mirror of
https://github.com/muerwre/vault-frontend.git
synced 2025-04-25 21:06:42 +07:00
Merge branch 'master' of https://github.com/muerwre/vault-frontend
This commit is contained in:
commit
d6ff3bcdca
142 changed files with 6877 additions and 3841 deletions
|
@ -1,75 +1,36 @@
|
|||
import React, { FC, HTMLAttributes, useMemo } from 'react';
|
||||
import React, { FC, HTMLAttributes, memo } from 'react';
|
||||
import { CommentWrapper } from '~/components/containers/CommentWrapper';
|
||||
import { IComment, IFile } from '~/redux/types';
|
||||
import { ICommentGroup } from '~/redux/types';
|
||||
import { getURL } from '~/utils/dom';
|
||||
import { CommentContent } from '~/components/node/CommentContent';
|
||||
import * as styles from './styles.scss';
|
||||
import { formatCommentText, getURL, getPrettyDate } from '~/utils/dom';
|
||||
import { Group } from '~/components/containers/Group';
|
||||
import assocPath from 'ramda/es/assocPath';
|
||||
import append from 'ramda/es/append';
|
||||
import reduce from 'ramda/es/reduce';
|
||||
import { UPLOAD_TYPES } from '~/redux/uploads/constants';
|
||||
import { AudioPlayer } from '~/components/media/AudioPlayer';
|
||||
|
||||
type IProps = HTMLAttributes<HTMLDivElement> & {
|
||||
is_empty?: boolean;
|
||||
is_loading?: boolean;
|
||||
comment?: IComment;
|
||||
comment_group?: ICommentGroup;
|
||||
is_same?: boolean;
|
||||
};
|
||||
|
||||
const Comment: FC<IProps> = ({ comment, is_empty, is_same, is_loading, className, ...props }) => {
|
||||
const groupped = useMemo<Record<keyof typeof UPLOAD_TYPES, IFile[]>>(
|
||||
() =>
|
||||
reduce(
|
||||
(group, file) => assocPath([file.type], append(file, group[file.type]), group),
|
||||
{},
|
||||
comment.files
|
||||
),
|
||||
[comment]
|
||||
);
|
||||
|
||||
return (
|
||||
<CommentWrapper
|
||||
className={className}
|
||||
is_empty={is_empty}
|
||||
is_loading={is_loading}
|
||||
photo={getURL(comment.user.photo)}
|
||||
is_same={is_same}
|
||||
{...props}
|
||||
>
|
||||
{comment.text && (
|
||||
<Group
|
||||
className={styles.text}
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: formatCommentText(
|
||||
!is_same && comment.user && comment.user.username,
|
||||
comment.text
|
||||
),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className={styles.date}>{getPrettyDate(comment.created_at)}</div>
|
||||
|
||||
{groupped.image && (
|
||||
<div className={styles.images}>
|
||||
{groupped.image.map(file => (
|
||||
<div key={file.id}>
|
||||
<img src={getURL(file)} alt={file.name} />
|
||||
</div>
|
||||
const Comment: FC<IProps> = memo(
|
||||
({ comment_group, is_empty, is_same, is_loading, className, ...props }) => {
|
||||
return (
|
||||
<CommentWrapper
|
||||
className={className}
|
||||
is_empty={is_empty}
|
||||
is_loading={is_loading}
|
||||
user={comment_group.user}
|
||||
is_same={is_same}
|
||||
{...props}
|
||||
>
|
||||
<div className={styles.wrap}>
|
||||
{comment_group.comments.map(comment => (
|
||||
<CommentContent comment={comment} key={comment.id} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{groupped.audio && (
|
||||
<div className={styles.audios}>
|
||||
{groupped.audio.map(file => (
|
||||
<AudioPlayer key={file.id} file={file} />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CommentWrapper>
|
||||
);
|
||||
};
|
||||
</CommentWrapper>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export { Comment };
|
||||
|
|
|
@ -1,48 +1,11 @@
|
|||
@import 'flexbin/flexbin.scss';
|
||||
|
||||
.text {
|
||||
// @include outer_shadow();
|
||||
|
||||
padding: $gap;
|
||||
font-weight: 300;
|
||||
font: $font_16_medium;
|
||||
min-height: $comment_height;
|
||||
box-sizing: border-box;
|
||||
position: relative;
|
||||
color: #cccccc;
|
||||
|
||||
b {
|
||||
font-weight: 600;
|
||||
@keyframes appear {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.date {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
font: $font_12_regular;
|
||||
color: transparentize($color: white, $amount: 0.8);
|
||||
padding: 2px 4px;
|
||||
border-radius: 0 0 $radius 0;
|
||||
}
|
||||
|
||||
.images {
|
||||
@include flexbin(240px, 5px);
|
||||
|
||||
img {
|
||||
border-radius: $radius;
|
||||
}
|
||||
}
|
||||
|
||||
.audios {
|
||||
& > div {
|
||||
@include outer_shadow();
|
||||
|
||||
height: $comment_height;
|
||||
border-radius: $radius;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
.wrap {
|
||||
animation: appear 1s;
|
||||
}
|
||||
|
|
84
src/components/node/CommentContent/index.tsx
Normal file
84
src/components/node/CommentContent/index.tsx
Normal file
|
@ -0,0 +1,84 @@
|
|||
import React, { FC, useMemo, memo } from 'react';
|
||||
import { IComment, IFile } from '~/redux/types';
|
||||
import path from 'ramda/es/path';
|
||||
import { formatCommentText, getURL, getPrettyDate } from '~/utils/dom';
|
||||
import { Group } from '~/components/containers/Group';
|
||||
import * as styles from './styles.scss';
|
||||
import { UPLOAD_TYPES } from '~/redux/uploads/constants';
|
||||
import assocPath from 'ramda/es/assocPath';
|
||||
import append from 'ramda/es/append';
|
||||
import reduce from 'ramda/es/reduce';
|
||||
import { AudioPlayer } from '~/components/media/AudioPlayer';
|
||||
import classnames from 'classnames';
|
||||
|
||||
interface IProps {
|
||||
comment: IComment;
|
||||
}
|
||||
|
||||
const CommentContent: FC<IProps> = memo(({ comment }) => {
|
||||
const groupped = useMemo<Record<keyof typeof UPLOAD_TYPES, IFile[]>>(
|
||||
() =>
|
||||
reduce(
|
||||
(group, file) => assocPath([file.type], append(file, group[file.type]), group),
|
||||
{},
|
||||
comment.files
|
||||
),
|
||||
[comment]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{comment.text && (
|
||||
<div className={styles.block}>
|
||||
<Group
|
||||
className={styles.text}
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: formatCommentText(path(['user', 'username'], comment), comment.text),
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className={styles.date}>{getPrettyDate(comment.created_at)}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{groupped.image && groupped.image.length > 0 && (
|
||||
<div className={classnames(styles.block, styles.block_image)}>
|
||||
<div className={styles.images}>
|
||||
{groupped.image.map(file => (
|
||||
<div key={file.id}>
|
||||
<img src={getURL(file)} alt={file.name} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className={styles.date}>{getPrettyDate(comment.created_at)}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{groupped.audio && groupped.audio.length > 0 && (
|
||||
<>
|
||||
{groupped.audio.map(file => (
|
||||
<div className={classnames(styles.block, styles.block_audio)} key={file.id}>
|
||||
<AudioPlayer file={file} />
|
||||
|
||||
<div className={styles.date}>{getPrettyDate(comment.created_at)}</div>
|
||||
</div>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export { CommentContent };
|
||||
|
||||
/*
|
||||
{comment.text && (
|
||||
|
||||
)}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
*/
|
80
src/components/node/CommentContent/styles.scss
Normal file
80
src/components/node/CommentContent/styles.scss
Normal file
|
@ -0,0 +1,80 @@
|
|||
@import 'flexbin/flexbin.scss';
|
||||
|
||||
.block {
|
||||
@include outer_shadow();
|
||||
min-height: $comment_height;
|
||||
// box-shadow: inset rgba(255, 255, 255, 0.05) 1px 1px, inset rgba(0, 0, 0, 0.1) -1px -1px;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: flex-start;
|
||||
position: relative;
|
||||
padding-bottom: 10px;
|
||||
box-sizing: border-box;
|
||||
|
||||
&:first-child {
|
||||
border-top-right-radius: $radius;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
border-bottom-right-radius: $radius;
|
||||
}
|
||||
}
|
||||
|
||||
.block_audio {
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding-bottom: 0 !important;
|
||||
}
|
||||
|
||||
.block_image {
|
||||
padding-bottom: 0 !important;
|
||||
|
||||
.date {
|
||||
background: transparentize($color: $content_bg, $amount: 0.2);
|
||||
border-radius: $radius 0 $radius 0;
|
||||
color: transparentize(white, 0.2);
|
||||
}
|
||||
}
|
||||
|
||||
.text {
|
||||
padding: $gap;
|
||||
font-weight: 300;
|
||||
font: $font_16_medium;
|
||||
line-height: 20px;
|
||||
box-sizing: border-box;
|
||||
position: relative;
|
||||
color: #cccccc;
|
||||
|
||||
b {
|
||||
font-weight: 600;
|
||||
}
|
||||
}
|
||||
|
||||
.date {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
font: $font_12_regular;
|
||||
color: transparentize($color: white, $amount: 0.8);
|
||||
padding: 4px 6px 4px 6px;
|
||||
border-radius: 0 0 $radius 0;
|
||||
}
|
||||
|
||||
.images {
|
||||
@include flexbin(240px, 5px);
|
||||
|
||||
img {
|
||||
border-radius: $radius;
|
||||
}
|
||||
}
|
||||
|
||||
.audios {
|
||||
& > div {
|
||||
height: $comment_height;
|
||||
border-radius: $radius;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
|
@ -42,7 +42,7 @@ type IProps = ReturnType<typeof mapStateToProps> &
|
|||
const CommentFormUnconnected: FC<IProps> = ({
|
||||
node: { comment_data, is_sending_comment },
|
||||
uploads: { statuses, files },
|
||||
user: { photo },
|
||||
user,
|
||||
id,
|
||||
nodePostComment,
|
||||
nodeSetCommentData,
|
||||
|
@ -122,7 +122,7 @@ const CommentFormUnconnected: FC<IProps> = ({
|
|||
const comment = comment_data[id];
|
||||
|
||||
return (
|
||||
<CommentWrapper photo={getURL(photo)}>
|
||||
<CommentWrapper user={user}>
|
||||
<form onSubmit={onSubmit} className={styles.wrap}>
|
||||
<div className={styles.input}>
|
||||
<Textarea
|
||||
|
@ -134,6 +134,22 @@ const CommentFormUnconnected: FC<IProps> = ({
|
|||
/>
|
||||
</div>
|
||||
|
||||
{comment.temp_ids.map(
|
||||
temp_id =>
|
||||
statuses[temp_id] &&
|
||||
statuses[temp_id].is_uploading && (
|
||||
<div key={statuses[temp_id].temp_id}>{statuses[temp_id].progress}</div>
|
||||
)
|
||||
)}
|
||||
|
||||
{comment.files.map(file => {
|
||||
if (file.type === UPLOAD_TYPES.AUDIO) {
|
||||
return <AudioPlayer file={file} />;
|
||||
}
|
||||
|
||||
return <div>file.name</div>;
|
||||
})}
|
||||
|
||||
<Group horizontal className={styles.buttons}>
|
||||
<ButtonGroup>
|
||||
<Button iconLeft="image" size="small" grey iconOnly>
|
||||
|
@ -154,22 +170,6 @@ const CommentFormUnconnected: FC<IProps> = ({
|
|||
</Button>
|
||||
</Group>
|
||||
</form>
|
||||
|
||||
{comment.temp_ids.map(
|
||||
temp_id =>
|
||||
statuses[temp_id] &&
|
||||
statuses[temp_id].is_uploading && (
|
||||
<div key={statuses[temp_id].temp_id}>{statuses[temp_id].progress}</div>
|
||||
)
|
||||
)}
|
||||
|
||||
{comment.files.map(file => {
|
||||
if (file.type === UPLOAD_TYPES.AUDIO) {
|
||||
return <AudioPlayer file={file} />;
|
||||
}
|
||||
|
||||
return <div>file.name</div>;
|
||||
})}
|
||||
</CommentWrapper>
|
||||
);
|
||||
};
|
||||
|
|
26
src/components/node/NodeAudioBlock/index.tsx
Normal file
26
src/components/node/NodeAudioBlock/index.tsx
Normal file
|
@ -0,0 +1,26 @@
|
|||
import React, { FC, useMemo } from 'react';
|
||||
import { INode } from '~/redux/types';
|
||||
import { UPLOAD_TYPES } from '~/redux/uploads/constants';
|
||||
import { AudioPlayer } from '~/components/media/AudioPlayer';
|
||||
import * as styles from './styles.scss';
|
||||
|
||||
interface IProps {
|
||||
node: INode;
|
||||
}
|
||||
|
||||
const NodeAudioBlock: FC<IProps> = ({ node }) => {
|
||||
const audios = useMemo(
|
||||
() => node.files.filter(file => file && file.type === UPLOAD_TYPES.AUDIO),
|
||||
[node.files]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={styles.wrap}>
|
||||
{audios.map(file => (
|
||||
<AudioPlayer key={file.id} file={file} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export { NodeAudioBlock };
|
17
src/components/node/NodeAudioBlock/styles.scss
Normal file
17
src/components/node/NodeAudioBlock/styles.scss
Normal file
|
@ -0,0 +1,17 @@
|
|||
.wrap {
|
||||
background: $content_bg;
|
||||
border-radius: $radius;
|
||||
|
||||
& > div {
|
||||
@include outer_shadow();
|
||||
|
||||
&:first-child {
|
||||
border-top-left-radius: $radius;
|
||||
border-top-right-radius: $radius;
|
||||
}
|
||||
&:last-child {
|
||||
border-bottom-left-radius: $radius;
|
||||
border-bottom-right-radius: $radius;
|
||||
}
|
||||
}
|
||||
}
|
28
src/components/node/NodeAudioImageBlock/index.tsx
Normal file
28
src/components/node/NodeAudioImageBlock/index.tsx
Normal file
|
@ -0,0 +1,28 @@
|
|||
import React, { FC, useMemo } from 'react';
|
||||
import { INode } from '~/redux/types';
|
||||
import * as styles from './styles.scss';
|
||||
import { UPLOAD_TYPES } from '~/redux/uploads/constants';
|
||||
import path from 'ramda/es/path';
|
||||
import { getURL } from '~/utils/dom';
|
||||
|
||||
interface IProps {
|
||||
node: INode;
|
||||
}
|
||||
|
||||
const NodeAudioImageBlock: FC<IProps> = ({ node }) => {
|
||||
const images = useMemo(
|
||||
() => node.files.filter(file => file && file.type === UPLOAD_TYPES.IMAGE),
|
||||
[node.files]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={styles.wrap}>
|
||||
<div
|
||||
className={styles.slide}
|
||||
style={{ backgroundImage: `url("${getURL(path([0], images))}")` }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export { NodeAudioImageBlock };
|
33
src/components/node/NodeAudioImageBlock/styles.scss
Normal file
33
src/components/node/NodeAudioImageBlock/styles.scss
Normal file
|
@ -0,0 +1,33 @@
|
|||
.wrap {
|
||||
@include outer_shadow();
|
||||
padding-bottom: 33vh;
|
||||
position: relative;
|
||||
border-radius: $radius $radius 0 0;
|
||||
|
||||
&::after {
|
||||
border-radius: $radius $radius 0 0;
|
||||
content: ' ';
|
||||
z-index: 3;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: rgba(0, 0, 0, 0.5) url('~/sprites/dots.svg');
|
||||
}
|
||||
}
|
||||
|
||||
.slide {
|
||||
@include outer_shadow();
|
||||
|
||||
border-radius: $radius $radius 0 0;
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
top: 0;
|
||||
left: 0;
|
||||
background: no-repeat 50% 30%;
|
||||
background-size: cover;
|
||||
z-index: 1;
|
||||
will-change: transform;
|
||||
}
|
|
@ -1,24 +1,29 @@
|
|||
import React, { FC } from 'react';
|
||||
import React, { FC, useMemo, memo } from 'react';
|
||||
import { Comment } from '../Comment';
|
||||
import { Filler } from '~/components/containers/Filler';
|
||||
|
||||
import * as styles from './styles.scss';
|
||||
import { ICommentGroup, IComment } from '~/redux/types';
|
||||
import { groupCommentsByUser } from '~/utils/fn';
|
||||
|
||||
interface IProps {
|
||||
comments?: any;
|
||||
comments?: IComment[];
|
||||
}
|
||||
|
||||
const isSameComment = (comments, index) =>
|
||||
comments[index - 1] && comments[index - 1].user.id === comments[index].user.id;
|
||||
const NodeComments: FC<IProps> = memo(({ comments }) => {
|
||||
const groupped: ICommentGroup[] = useMemo(() => comments.reduce(groupCommentsByUser, []), [
|
||||
comments,
|
||||
]);
|
||||
|
||||
const NodeComments: FC<IProps> = ({ comments }) => (
|
||||
<div className={styles.wrap}>
|
||||
{comments.map((comment, index) => (
|
||||
<Comment key={comment.id} comment={comment} is_same={isSameComment(comments, index)} />
|
||||
))}
|
||||
return (
|
||||
<div className={styles.wrap}>
|
||||
{groupped.map(group => (
|
||||
<Comment key={group.ids.join()} comment_group={group} />
|
||||
))}
|
||||
|
||||
<Filler />
|
||||
</div>
|
||||
);
|
||||
<Filler />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export { NodeComments };
|
||||
|
|
|
@ -1,20 +1,9 @@
|
|||
.wrap {
|
||||
& > div {
|
||||
margin: $gap 0 0 0;
|
||||
margin: 0 0 $gap 0;
|
||||
|
||||
&:last-child {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
// display: flex;
|
||||
// flex-direction: column !important;
|
||||
|
||||
// & > div {
|
||||
// margin: ($gap / 2) 0;
|
||||
|
||||
// &:last-child {
|
||||
// margin-top: 0;
|
||||
// }
|
||||
|
||||
// &:first-child {
|
||||
// margin-bottom: 0;
|
||||
// }
|
||||
// }
|
||||
}
|
||||
|
|
217
src/components/node/NodeImageSlideBlock/index.tsx
Normal file
217
src/components/node/NodeImageSlideBlock/index.tsx
Normal file
|
@ -0,0 +1,217 @@
|
|||
import React, { FC, useMemo, useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { ImageSwitcher } from '../ImageSwitcher';
|
||||
import * as styles from './styles.scss';
|
||||
import { INode } from '~/redux/types';
|
||||
import classNames from 'classnames';
|
||||
import { getImageSize } from '~/utils/dom';
|
||||
import { UPLOAD_TYPES } from '~/redux/uploads/constants';
|
||||
import { NODE_SETTINGS } from '~/redux/node/constants';
|
||||
|
||||
interface IProps {
|
||||
is_loading: boolean;
|
||||
node: INode;
|
||||
layout: {};
|
||||
updateLayout: () => void;
|
||||
}
|
||||
|
||||
const getX = event => (event.touches ? event.touches[0].clientX : event.clientX);
|
||||
|
||||
const NodeImageSlideBlock: FC<IProps> = ({ node, is_loading, updateLayout }) => {
|
||||
const [current, setCurrent] = useState(0);
|
||||
const [height, setHeight] = useState(320);
|
||||
const [max_height, setMaxHeight] = useState(960);
|
||||
const [loaded, setLoaded] = useState<Record<number, boolean>>({});
|
||||
const refs = useRef<Record<number, HTMLDivElement>>({});
|
||||
const [heights, setHeights] = useState({});
|
||||
|
||||
const [initial_offset, setInitialOffset] = useState(0);
|
||||
const [initial_x, setInitialX] = useState(0);
|
||||
const [offset, setOffset] = useState(0);
|
||||
const [is_dragging, setIsDragging] = useState(false);
|
||||
const slide = useRef<HTMLDivElement>();
|
||||
const wrap = useRef<HTMLDivElement>();
|
||||
|
||||
const images = useMemo(
|
||||
() =>
|
||||
(node && node.files && node.files.filter(({ type }) => type === UPLOAD_TYPES.IMAGE)) || [],
|
||||
[node]
|
||||
);
|
||||
|
||||
const updateSizes = useCallback(() => {
|
||||
const values = Object.keys(refs.current).reduce((obj, key) => {
|
||||
const ref = refs.current[key];
|
||||
|
||||
if (!ref || !ref.getBoundingClientRect) return 0;
|
||||
|
||||
return { ...obj, [key]: ref.getBoundingClientRect().height };
|
||||
}, {});
|
||||
|
||||
setHeights(values);
|
||||
}, [refs]);
|
||||
|
||||
const setRef = useCallback(
|
||||
index => el => {
|
||||
refs.current[index] = el;
|
||||
},
|
||||
[refs, heights, setHeights]
|
||||
);
|
||||
|
||||
const onImageLoad = useCallback(index => () => setLoaded({ ...loaded, [index]: true }), [
|
||||
setLoaded,
|
||||
loaded,
|
||||
]);
|
||||
|
||||
// update outside hooks
|
||||
useEffect(() => updateLayout(), [loaded, height]);
|
||||
useEffect(() => updateSizes(), [refs, current, loaded]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!wrap || !wrap.current) return;
|
||||
|
||||
const { width } = wrap.current.getBoundingClientRect();
|
||||
const selected = Math.abs(-offset / width);
|
||||
const prev = Math.max(heights[Math.floor(selected)] || 320, 320);
|
||||
const next = Math.max(heights[Math.ceil(selected)] || 320, 320);
|
||||
const now = prev - (prev - next) * (selected % 1);
|
||||
|
||||
if (current !== Math.round(selected)) setCurrent(Math.round(selected));
|
||||
|
||||
setHeight(now);
|
||||
}, [offset, heights, max_height]);
|
||||
|
||||
const onDrag = useCallback(
|
||||
event => {
|
||||
if (
|
||||
!is_dragging ||
|
||||
!slide.current ||
|
||||
!wrap.current ||
|
||||
(event.touches && event.clientY > event.clientX)
|
||||
)
|
||||
return;
|
||||
|
||||
const { width: slide_width } = slide.current.getBoundingClientRect();
|
||||
const { width: wrap_width } = wrap.current.getBoundingClientRect();
|
||||
|
||||
setOffset(
|
||||
Math.min(Math.max(initial_offset + getX(event) - initial_x, wrap_width - slide_width), 0)
|
||||
);
|
||||
},
|
||||
[is_dragging, initial_x, setOffset, initial_offset]
|
||||
);
|
||||
|
||||
const normalizeOffset = useCallback(() => {
|
||||
const { width: wrap_width } = wrap.current.getBoundingClientRect();
|
||||
const { width: slide_width } = slide.current.getBoundingClientRect();
|
||||
|
||||
const shift = (initial_offset - offset) / wrap_width; // percent / 100
|
||||
const diff = initial_offset - (shift > 0 ? Math.ceil(shift) : Math.floor(shift)) * wrap_width;
|
||||
const new_offset =
|
||||
Math.abs(shift) > 0.25
|
||||
? Math.min(Math.max(diff, wrap_width - slide_width), 0) // next or prev slide
|
||||
: Math.round(offset / wrap_width) * wrap_width; // back to this one
|
||||
|
||||
setOffset(new_offset);
|
||||
}, [wrap, offset, initial_offset]);
|
||||
|
||||
const updateMaxHeight = useCallback(() => {
|
||||
if (!wrap.current) return;
|
||||
const { width } = wrap.current.getBoundingClientRect();
|
||||
setMaxHeight(width * NODE_SETTINGS.MAX_IMAGE_ASPECT);
|
||||
normalizeOffset();
|
||||
}, [wrap, setMaxHeight, normalizeOffset]);
|
||||
|
||||
const stopDragging = useCallback(() => {
|
||||
if (!is_dragging) return;
|
||||
|
||||
normalizeOffset();
|
||||
setIsDragging(false);
|
||||
}, [setIsDragging, is_dragging, normalizeOffset]);
|
||||
|
||||
const startDragging = useCallback(
|
||||
event => {
|
||||
setIsDragging(true);
|
||||
setInitialX(getX(event));
|
||||
setInitialOffset(offset);
|
||||
},
|
||||
[setIsDragging, setInitialX, offset, setInitialOffset]
|
||||
);
|
||||
|
||||
useEffect(() => updateMaxHeight(), []);
|
||||
|
||||
useEffect(() => {
|
||||
window.addEventListener('resize', updateSizes);
|
||||
window.addEventListener('resize', updateMaxHeight);
|
||||
|
||||
window.addEventListener('mousemove', onDrag);
|
||||
window.addEventListener('touchmove', onDrag);
|
||||
|
||||
window.addEventListener('mouseup', stopDragging);
|
||||
window.addEventListener('touchend', stopDragging);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('resize', updateSizes);
|
||||
window.removeEventListener('resize', updateMaxHeight);
|
||||
|
||||
window.removeEventListener('mousemove', onDrag);
|
||||
window.removeEventListener('touchmove', onDrag);
|
||||
|
||||
window.removeEventListener('mouseup', stopDragging);
|
||||
window.removeEventListener('touchend', stopDragging);
|
||||
};
|
||||
}, [onDrag, stopDragging, updateMaxHeight, updateSizes]);
|
||||
|
||||
const changeCurrent = useCallback(
|
||||
(item: number) => {
|
||||
const { width } = wrap.current.getBoundingClientRect();
|
||||
setOffset(-1 * item * width);
|
||||
},
|
||||
[wrap]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={classNames(styles.wrap, { is_loading })} ref={wrap}>
|
||||
<ImageSwitcher
|
||||
total={images.length}
|
||||
current={current}
|
||||
onChange={changeCurrent}
|
||||
loaded={loaded}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={styles.image_container}
|
||||
style={{
|
||||
transition: is_dragging ? 'none' : 'transform 500ms',
|
||||
height,
|
||||
transform: `translate(${offset}px, 0)`,
|
||||
width: `${images.length * 100}%`,
|
||||
}}
|
||||
onMouseDown={startDragging}
|
||||
onTouchStart={startDragging}
|
||||
ref={slide}
|
||||
>
|
||||
{(is_loading || !loaded[0] || !images.length) && <div className={styles.placeholder} />}
|
||||
|
||||
{images.map((file, index) => (
|
||||
<div
|
||||
className={classNames(styles.image_wrap, {
|
||||
is_active: index === current && loaded[index],
|
||||
})}
|
||||
ref={setRef(index)}
|
||||
key={file.id}
|
||||
>
|
||||
<img
|
||||
className={styles.image}
|
||||
src={getImageSize(file, 'node')}
|
||||
alt=""
|
||||
key={file.id}
|
||||
onLoad={onImageLoad(index)}
|
||||
style={{ maxHeight: max_height }}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export { NodeImageSlideBlock };
|
49
src/components/node/NodeImageSlideBlock/styles.scss
Normal file
49
src/components/node/NodeImageSlideBlock/styles.scss
Normal file
|
@ -0,0 +1,49 @@
|
|||
.wrap {
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
transition: height 0.25s;
|
||||
border-radius: $radius $radius 0 0;
|
||||
}
|
||||
|
||||
.image_container {
|
||||
background: $node_image_bg;
|
||||
border-radius: $panel_radius 0 0 $panel_radius;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
user-select: none;
|
||||
will-change: transform, height;
|
||||
|
||||
.image {
|
||||
max-height: 960px;
|
||||
max-width: 100%;
|
||||
opacity: 1;
|
||||
border-radius: $radius $radius 0 0;
|
||||
}
|
||||
}
|
||||
|
||||
.image_wrap {
|
||||
width: 100%;
|
||||
// top: 0;
|
||||
// left: 0;
|
||||
// opacity: 0;
|
||||
pointer-events: none;
|
||||
touch-action: none;
|
||||
z-index: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
&:global(.is_active) {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.placeholder {
|
||||
background: red;
|
||||
height: 320px;
|
||||
}
|
|
@ -1,48 +1,79 @@
|
|||
import React, { FC, useCallback, useEffect, useRef, useState } from 'react';
|
||||
import React, { FC, useCallback, useEffect, useRef, useState, memo } from 'react';
|
||||
import * as styles from './styles.scss';
|
||||
import { INode } from '~/redux/types';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { NodePanelInner } from '~/components/node/NodePanelInner';
|
||||
import pick from 'ramda/es/pick';
|
||||
|
||||
interface IProps {
|
||||
node: INode;
|
||||
node: Partial<INode>;
|
||||
layout: {};
|
||||
|
||||
can_edit: boolean;
|
||||
can_like: boolean;
|
||||
can_star: boolean;
|
||||
|
||||
onEdit: () => void;
|
||||
onLike: () => void;
|
||||
onStar: () => void;
|
||||
}
|
||||
|
||||
const NodePanel: FC<IProps> = ({ node, layout }) => {
|
||||
const [stack, setStack] = useState(false);
|
||||
const NodePanel: FC<IProps> = memo(
|
||||
({ node, layout, can_edit, can_like, can_star, onEdit, onLike, onStar }) => {
|
||||
const [stack, setStack] = useState(false);
|
||||
|
||||
const ref = useRef(null);
|
||||
const getPlace = useCallback(() => {
|
||||
if (!ref.current) return;
|
||||
const ref = useRef(null);
|
||||
const getPlace = useCallback(() => {
|
||||
if (!ref.current) return;
|
||||
|
||||
const { offsetTop } = ref.current;
|
||||
const { height } = ref.current.getBoundingClientRect();
|
||||
const { scrollY, innerHeight } = window;
|
||||
const { offsetTop } = ref.current;
|
||||
const { height } = ref.current.getBoundingClientRect();
|
||||
const { scrollY, innerHeight } = window;
|
||||
|
||||
setStack(offsetTop > scrollY + innerHeight - height);
|
||||
}, [ref]);
|
||||
setStack(offsetTop > scrollY + innerHeight - height);
|
||||
}, [ref]);
|
||||
|
||||
useEffect(() => {
|
||||
getPlace();
|
||||
window.addEventListener('scroll', getPlace);
|
||||
window.addEventListener('resize', getPlace);
|
||||
useEffect(() => {
|
||||
getPlace();
|
||||
window.addEventListener('scroll', getPlace);
|
||||
window.addEventListener('resize', getPlace);
|
||||
|
||||
return () => {
|
||||
window.removeEventListener('scroll', getPlace);
|
||||
window.removeEventListener('resize', getPlace);
|
||||
};
|
||||
}, [layout]);
|
||||
return () => {
|
||||
window.removeEventListener('scroll', getPlace);
|
||||
window.removeEventListener('resize', getPlace);
|
||||
};
|
||||
}, [layout]);
|
||||
|
||||
return (
|
||||
<div className={styles.place} ref={ref}>
|
||||
{stack ? (
|
||||
createPortal(<NodePanelInner node={node} stack />, document.body)
|
||||
) : (
|
||||
<NodePanelInner node={node} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
return (
|
||||
<div className={styles.place} ref={ref}>
|
||||
{stack ? (
|
||||
createPortal(
|
||||
<NodePanelInner
|
||||
node={node}
|
||||
stack
|
||||
onEdit={onEdit}
|
||||
onLike={onLike}
|
||||
onStar={onStar}
|
||||
can_edit={can_edit}
|
||||
can_like={can_like}
|
||||
can_star={can_star}
|
||||
/>,
|
||||
document.body
|
||||
)
|
||||
) : (
|
||||
<NodePanelInner
|
||||
node={node}
|
||||
onEdit={onEdit}
|
||||
onLike={onLike}
|
||||
onStar={onStar}
|
||||
can_edit={can_edit}
|
||||
can_like={can_like}
|
||||
can_star={can_star}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export { NodePanel };
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import React, { FC, useCallback, useEffect, useRef, useState } from 'react';
|
||||
import React, { FC } from 'react';
|
||||
import * as styles from './styles.scss';
|
||||
import { Group } from '~/components/containers/Group';
|
||||
import { Filler } from '~/components/containers/Filler';
|
||||
|
@ -7,27 +7,61 @@ import { INode } from '~/redux/types';
|
|||
import classNames from 'classnames';
|
||||
|
||||
interface IProps {
|
||||
node: INode;
|
||||
node: Partial<INode>;
|
||||
stack?: boolean;
|
||||
|
||||
can_edit: boolean;
|
||||
can_like: boolean;
|
||||
can_star: boolean;
|
||||
onEdit: () => void;
|
||||
onLike: () => void;
|
||||
onStar: () => void;
|
||||
}
|
||||
|
||||
const NodePanelInner: FC<IProps> = ({ node: { title, user }, stack }) => {
|
||||
const NodePanelInner: FC<IProps> = ({
|
||||
node: { title, user, is_liked, is_heroic },
|
||||
stack,
|
||||
can_star,
|
||||
can_edit,
|
||||
can_like,
|
||||
onStar,
|
||||
onEdit,
|
||||
onLike,
|
||||
}) => {
|
||||
return (
|
||||
<div className={classNames(styles.wrap, { stack })}>
|
||||
<div className={styles.content}>
|
||||
<Group horizontal className={styles.panel}>
|
||||
<Filler>
|
||||
<div className={styles.title}>{title || '...'}</div>
|
||||
{user && user.username && <div className={styles.name}>~ {user.username}</div>}
|
||||
{user && user.username && <div className={styles.name}>~{user.username}</div>}
|
||||
</Filler>
|
||||
</Group>
|
||||
|
||||
<div className={styles.buttons}>
|
||||
<Icon icon="edit" size={24} />
|
||||
|
||||
<div className={styles.sep} />
|
||||
|
||||
<Icon icon="heart" size={24} />
|
||||
{can_star && (
|
||||
<div className={classNames(styles.star, { is_heroic })}>
|
||||
{is_heroic ? (
|
||||
<Icon icon="star_full" size={24} onClick={onStar} />
|
||||
) : (
|
||||
<Icon icon="star" size={24} onClick={onStar} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{can_edit && (
|
||||
<div>
|
||||
<Icon icon="edit" size={24} onClick={onEdit} />
|
||||
</div>
|
||||
)}
|
||||
{can_like && (
|
||||
<div className={classNames(styles.like, { is_liked })}>
|
||||
{is_liked ? (
|
||||
<Icon icon="heart_full" size={24} onClick={onLike} />
|
||||
) : (
|
||||
<Icon icon="heart" size={24} onClick={onLike} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
@ -27,6 +27,7 @@
|
|||
padding: $gap;
|
||||
background: $node_bg;
|
||||
height: 72px;
|
||||
@include outer_shadow();
|
||||
}
|
||||
|
||||
.title {
|
||||
|
@ -65,22 +66,43 @@
|
|||
|
||||
& > * {
|
||||
margin: 0 $gap;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
|
||||
svg {
|
||||
fill: darken(white, 50%);
|
||||
transition: fill 0.25s;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
svg {
|
||||
fill: $red;
|
||||
}
|
||||
}
|
||||
|
||||
&::after {
|
||||
content: ' ';
|
||||
flex: 0 0 6px;
|
||||
height: $gap;
|
||||
width: 6px;
|
||||
border-radius: 4px;
|
||||
background: transparentize(black, 0.7);
|
||||
margin-left: $gap * 2;
|
||||
}
|
||||
|
||||
&:first-child {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
margin-right: 0;
|
||||
&::after {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
}
|
||||
//height: 54px;
|
||||
//border-radius: $radius $radius 0 0;
|
||||
//background: linear-gradient(176deg, #f42a00, #5c1085);
|
||||
//position: absolute;
|
||||
//bottom: 0;
|
||||
//right: 10px;
|
||||
//width: 270px;
|
||||
//display: flex;
|
||||
}
|
||||
|
||||
.mark {
|
||||
|
@ -94,16 +116,68 @@
|
|||
right: 4px;
|
||||
width: 24px;
|
||||
height: 52px;
|
||||
background: $green_gradient;
|
||||
background: $main_gradient;
|
||||
box-shadow: transparentize(black, 0.8) 4px 2px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
}
|
||||
|
||||
.sep {
|
||||
flex: 0 0 6px;
|
||||
height: 6px;
|
||||
width: 6px;
|
||||
border-radius: 4px;
|
||||
background: transparentize(black, 0.7);
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0% {
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
45% {
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
60% {
|
||||
transform: scale(1.4);
|
||||
}
|
||||
|
||||
75% {
|
||||
transform: scale(1);
|
||||
}
|
||||
|
||||
90% {
|
||||
transform: scale(1.4);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
.like {
|
||||
transition: fill, stroke 0.25s;
|
||||
will-change: transform;
|
||||
|
||||
&:global(.is_liked) {
|
||||
svg {
|
||||
fill: $red;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
fill: $red;
|
||||
animation: pulse 0.75s infinite;
|
||||
}
|
||||
}
|
||||
|
||||
.star {
|
||||
transition: fill, stroke 0.25s;
|
||||
will-change: transform;
|
||||
|
||||
&:global(.is_heroic) {
|
||||
svg {
|
||||
fill: $orange;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover {
|
||||
fill: $orange;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,25 +1,29 @@
|
|||
import React, { FC, HTMLAttributes } from 'react';
|
||||
import { range } from 'ramda';
|
||||
import React, { FC } from 'react';
|
||||
import * as styles from './styles.scss';
|
||||
import { Group } from '~/components/containers/Group';
|
||||
import { INode } from '~/redux/types';
|
||||
import { NodeRelatedItem } from '~/components/node/NodeRelatedItem';
|
||||
|
||||
type IProps = HTMLAttributes<HTMLDivElement> & {}
|
||||
interface IProps {
|
||||
title: string;
|
||||
items: Partial<INode>[];
|
||||
}
|
||||
|
||||
const NodeRelated: FC<IProps> = ({
|
||||
title,
|
||||
}) => (
|
||||
<Group className={styles.wrap}>
|
||||
<div className={styles.title}>
|
||||
<div className={styles.line} />
|
||||
<div className={styles.text}>{title}</div>
|
||||
<div className={styles.line} />
|
||||
</div>
|
||||
<div className={styles.grid}>
|
||||
{
|
||||
range(1, 7).map(el => (<div className={styles.item} key={el} />))
|
||||
}
|
||||
</div>
|
||||
</Group>
|
||||
);
|
||||
const NodeRelated: FC<IProps> = ({ title, items }) => {
|
||||
return (
|
||||
<Group className={styles.wrap}>
|
||||
<div className={styles.title}>
|
||||
<div className={styles.line} />
|
||||
<div className={styles.text}>{title}</div>
|
||||
<div className={styles.line} />
|
||||
</div>
|
||||
<div className={styles.grid}>
|
||||
{items.map(item => (
|
||||
<NodeRelatedItem item={item} key={item.id} />
|
||||
))}
|
||||
</div>
|
||||
</Group>
|
||||
);
|
||||
};
|
||||
|
||||
export { NodeRelated };
|
||||
|
|
|
@ -7,19 +7,16 @@
|
|||
|
||||
.grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(64px, 1fr));
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
grid-template-rows: auto;
|
||||
grid-auto-rows: auto;
|
||||
grid-column-gap: $gap;
|
||||
grid-row-gap: $gap;
|
||||
}
|
||||
|
||||
.item {
|
||||
background: darken($content_bg, 2%);
|
||||
padding-bottom: 100%;
|
||||
border-radius: $cell_radius;
|
||||
@include tablet {
|
||||
grid-template-columns: repeat(6, 1fr);
|
||||
}
|
||||
}
|
||||
|
||||
.title {
|
||||
font: $font_14_semibold;
|
||||
text-transform: uppercase;
|
||||
|
|
35
src/components/node/NodeRelatedItem/index.tsx
Normal file
35
src/components/node/NodeRelatedItem/index.tsx
Normal file
|
@ -0,0 +1,35 @@
|
|||
import React, { FC, memo, useCallback, useState } from 'react';
|
||||
import * as styles from './styles.scss';
|
||||
import classNames from 'classnames';
|
||||
import { INode } from '~/redux/types';
|
||||
import { URLS } from '~/constants/urls';
|
||||
import { RouteComponentProps, withRouter } from 'react-router';
|
||||
import { getURL } from '~/utils/dom';
|
||||
|
||||
type IProps = RouteComponentProps & {
|
||||
item: Partial<INode>;
|
||||
};
|
||||
|
||||
const NodeRelatedItemUnconnected: FC<IProps> = memo(({ item, history }) => {
|
||||
const [is_loaded, setIsLoaded] = useState(false);
|
||||
const onClick = useCallback(() => history.push(URLS.NODE_URL(item.id)), [item, history]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames(styles.item, { [styles.is_loaded]: is_loaded })}
|
||||
key={item.id}
|
||||
onClick={onClick}
|
||||
>
|
||||
<div
|
||||
className={styles.thumb}
|
||||
style={{ backgroundImage: `url("${getURL({ url: item.thumbnail })}")` }}
|
||||
/>
|
||||
|
||||
<img src={getURL({ url: item.thumbnail })} alt="loader" onLoad={() => setIsLoaded(true)} />
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
const NodeRelatedItem = withRouter(NodeRelatedItemUnconnected);
|
||||
|
||||
export { NodeRelatedItem };
|
30
src/components/node/NodeRelatedItem/styles.scss
Normal file
30
src/components/node/NodeRelatedItem/styles.scss
Normal file
|
@ -0,0 +1,30 @@
|
|||
.item {
|
||||
background: lighten($content_bg, 2%) 50% 50% no-repeat;
|
||||
padding-bottom: 100%;
|
||||
border-radius: $cell_radius;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
|
||||
img {
|
||||
position: absolute;
|
||||
width: 0;
|
||||
height: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.thumb {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: $cell_radius;
|
||||
background: lighten($content_bg, 2%) 50% 50% no-repeat;
|
||||
background-size: cover;
|
||||
opacity: 0;
|
||||
transition: opacity 0.5s;
|
||||
will-change: opacity;
|
||||
|
||||
.is_loaded & {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
|
@ -1,4 +1,4 @@
|
|||
import React, { FC } from 'react';
|
||||
import React, { FC, memo } from 'react';
|
||||
import { Tags } from '../Tags';
|
||||
import { ITag } from '~/redux/types';
|
||||
|
||||
|
@ -8,8 +8,8 @@ interface IProps {
|
|||
onChange?: (tags: string[]) => void;
|
||||
}
|
||||
|
||||
const NodeTags: FC<IProps> = ({ is_editable, tags, onChange }) => (
|
||||
const NodeTags: FC<IProps> = memo(({ is_editable, tags, onChange }) => (
|
||||
<Tags tags={tags} is_editable={is_editable} onTagsChange={onChange} />
|
||||
);
|
||||
));
|
||||
|
||||
export { NodeTags };
|
||||
|
|
20
src/components/node/NodeTextBlock/index.tsx
Normal file
20
src/components/node/NodeTextBlock/index.tsx
Normal file
|
@ -0,0 +1,20 @@
|
|||
import React, { FC } from 'react';
|
||||
import { INode } from '~/redux/types';
|
||||
import path from 'ramda/es/path';
|
||||
import { formatText } from '~/utils/dom';
|
||||
import * as styles from './styles.scss';
|
||||
|
||||
interface IProps {
|
||||
node: INode;
|
||||
}
|
||||
|
||||
const NodeTextBlock: FC<IProps> = ({ node }) => (
|
||||
<div
|
||||
className={styles.text}
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: formatText(path(['blocks', 0, 'text'], node)),
|
||||
}}
|
||||
/>
|
||||
);
|
||||
|
||||
export { NodeTextBlock };
|
13
src/components/node/NodeTextBlock/styles.scss
Normal file
13
src/components/node/NodeTextBlock/styles.scss
Normal file
|
@ -0,0 +1,13 @@
|
|||
.text {
|
||||
@include outer_shadow();
|
||||
|
||||
background: $content_bg;
|
||||
padding: $gap * 4;
|
||||
border-radius: $radius;
|
||||
|
||||
p {
|
||||
margin: $gap 0;
|
||||
font-size: 18px;
|
||||
line-height: 24px;
|
||||
}
|
||||
}
|
36
src/components/node/NodeVideoBlock/index.tsx
Normal file
36
src/components/node/NodeVideoBlock/index.tsx
Normal file
|
@ -0,0 +1,36 @@
|
|||
import React, { FC, useMemo } from 'react';
|
||||
import { INode } from '~/redux/types';
|
||||
import * as styles from './styles.scss';
|
||||
import path from 'ramda/es/path';
|
||||
|
||||
interface IProps {
|
||||
node: INode;
|
||||
}
|
||||
|
||||
const NodeVideoBlock: FC<IProps> = ({ node }) => {
|
||||
const video = useMemo(() => {
|
||||
const url: string = path(['blocks', 0, 'url'], node);
|
||||
const match =
|
||||
url &&
|
||||
url.match(
|
||||
/http(?:s?):\/\/(?:www\.)?youtu(?:be\.com\/watch\?v=|\.be\/)([\w\-\_]*)(&(amp;)?[\w\?=]*)?/
|
||||
);
|
||||
|
||||
return match && match[1];
|
||||
}, [node]);
|
||||
|
||||
return (
|
||||
<div className={styles.wrap}>
|
||||
<iframe
|
||||
width="560"
|
||||
height="315"
|
||||
src={`https://www.youtube.com/embed/${video}`}
|
||||
frameBorder="0"
|
||||
allowFullScreen
|
||||
title="video"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export { NodeVideoBlock };
|
12
src/components/node/NodeVideoBlock/styles.scss
Normal file
12
src/components/node/NodeVideoBlock/styles.scss
Normal file
|
@ -0,0 +1,12 @@
|
|||
.wrap {
|
||||
padding-bottom: 56.25%;
|
||||
position: relative;
|
||||
|
||||
iframe {
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
top: 0;
|
||||
left: 0;
|
||||
}
|
||||
}
|
|
@ -1,4 +1,6 @@
|
|||
.tag {
|
||||
@include outer_shadow();
|
||||
|
||||
height: $tag_height;
|
||||
background: $tag_bg;
|
||||
display: flex;
|
||||
|
@ -9,8 +11,8 @@
|
|||
font: $font_14_semibold;
|
||||
align-self: flex-start;
|
||||
padding: 0 8px 0 0;
|
||||
box-shadow: $shadow_depth_2;
|
||||
margin: ($gap / 2) $gap ($gap / 2) 0;
|
||||
// box-shadow: $shadow_depth_2;
|
||||
margin: 0 $gap $gap 0;
|
||||
position: relative;
|
||||
|
||||
&:global(.is_hoverable) {
|
||||
|
@ -64,7 +66,7 @@
|
|||
top: 0;
|
||||
bottom: 0;
|
||||
width: 100%;
|
||||
padding-left: 23px;
|
||||
padding-left: $tag_height;
|
||||
padding-right: 5px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
@ -74,10 +76,10 @@
|
|||
width: $tag_height;
|
||||
height: $tag_height;
|
||||
display: flex;
|
||||
margin-right: 3px;
|
||||
// padding-right: 0px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex: 0 0 22px;
|
||||
flex: 0 0 $tag_height;
|
||||
|
||||
&::after {
|
||||
content: ' ';
|
||||
|
|
|
@ -12,6 +12,7 @@ import { TagField } from '~/components/containers/TagField';
|
|||
import { ITag } from '~/redux/types';
|
||||
import { Tag } from '~/components/node/Tag';
|
||||
import uniq from 'ramda/es/uniq';
|
||||
import assocPath from 'ramda/es/assocPath';
|
||||
|
||||
type IProps = HTMLAttributes<HTMLDivElement> & {
|
||||
tags: Partial<ITag>[];
|
||||
|
@ -65,9 +66,14 @@ export const Tags: FC<IProps> = ({ tags, is_editable, onTagsChange, ...props })
|
|||
);
|
||||
|
||||
const onSubmit = useCallback(() => {
|
||||
if (!data.length) return;
|
||||
onTagsChange(uniq([...tags, ...data]).map(tag => tag.title));
|
||||
}, [tags, data, onTagsChange]);
|
||||
const title = input && input.trim();
|
||||
const items = title ? [...data, { title }] : data;
|
||||
|
||||
if (!items.length) return;
|
||||
setData(items);
|
||||
setInput('');
|
||||
onTagsChange(uniq([...tags, ...items]).map(tag => tag.title));
|
||||
}, [tags, data, onTagsChange, input, setInput]);
|
||||
|
||||
useEffect(() => {
|
||||
setData(data.filter(({ title }) => !tags.some(tag => tag.title.trim() === title.trim())));
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue