mirror of
https://github.com/muerwre/vault-frontend.git
synced 2025-04-25 04:46:40 +07:00
refactor node comments container
This commit is contained in:
parent
eea7095e65
commit
34797c2ac0
32 changed files with 9 additions and 9 deletions
|
@ -0,0 +1,42 @@
|
|||
import { FC } from 'react';
|
||||
|
||||
import { observer } from 'mobx-react-lite';
|
||||
|
||||
import { UploadDropzone } from '~/components/upload/UploadDropzone';
|
||||
import { UploadSubject, UploadTarget } from '~/constants/uploads';
|
||||
import { CommentForm } from '~/containers/comments/CommentForm';
|
||||
import { useUploader } from '~/hooks/data/useUploader';
|
||||
import { IComment, INode } from '~/types';
|
||||
import { UploaderContextProvider } from '~/utils/context/UploaderContextProvider';
|
||||
|
||||
interface CommentEditingFormProps {
|
||||
comment: IComment;
|
||||
nodeId: INode['id'];
|
||||
saveComment: (data: IComment) => Promise<IComment | undefined>;
|
||||
onCancelEdit?: () => void;
|
||||
}
|
||||
|
||||
const CommentEditingForm: FC<CommentEditingFormProps> = observer(
|
||||
({ saveComment, comment, onCancelEdit }) => {
|
||||
const uploader = useUploader(
|
||||
UploadSubject.Comment,
|
||||
UploadTarget.Comments,
|
||||
comment.files,
|
||||
);
|
||||
|
||||
return (
|
||||
<UploadDropzone onUpload={uploader.uploadFiles}>
|
||||
<UploaderContextProvider value={uploader}>
|
||||
<CommentForm
|
||||
saveComment={saveComment}
|
||||
comment={comment}
|
||||
onCancelEdit={onCancelEdit}
|
||||
allowUploads
|
||||
/>
|
||||
</UploaderContextProvider>
|
||||
</UploadDropzone>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export { CommentEditingForm };
|
|
@ -0,0 +1,48 @@
|
|||
import React, { FC, memo, useMemo } from 'react';
|
||||
|
||||
import { Icon } from '~/components/input/Icon';
|
||||
import { ICommentBlockProps } from '~/constants/comment';
|
||||
import { useYoutubeMetadata } from '~/hooks/metadata/useYoutubeMetadata';
|
||||
import { getYoutubeThumb } from '~/utils/dom';
|
||||
|
||||
import styles from './styles.module.scss';
|
||||
|
||||
|
||||
type Props = ICommentBlockProps & {};
|
||||
|
||||
const CommentEmbedBlock: FC<Props> = memo(({ block }) => {
|
||||
const id = useMemo(() => {
|
||||
const match = block.content.match(
|
||||
/https?:\/\/(?:www\.)?(?:youtube\.com|youtu\.be)\/(?:watch)?(?:\?v=)?([\w\-=]+)/
|
||||
);
|
||||
|
||||
return (match && match[1]) || '';
|
||||
}, [block.content]);
|
||||
|
||||
const url = useMemo(() => `https://youtube.com/watch?v=${id}`, [id]);
|
||||
|
||||
const preview = useMemo(() => getYoutubeThumb(block.content), [block.content]);
|
||||
|
||||
const metadata = useYoutubeMetadata(id);
|
||||
const title = metadata?.metadata?.title || '';
|
||||
|
||||
return (
|
||||
<div className={styles.embed}>
|
||||
<a href={url} target="_blank" rel="noreferrer" />
|
||||
|
||||
<div className={styles.preview}>
|
||||
<div style={{ backgroundImage: `url("${preview}")` }}>
|
||||
<div className={styles.backdrop}>
|
||||
<div className={styles.play}>
|
||||
<Icon icon="play" size={32} />
|
||||
</div>
|
||||
|
||||
<div className={styles.title}>{title}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export { CommentEmbedBlock };
|
|
@ -0,0 +1,100 @@
|
|||
@import 'src/styles/variables';
|
||||
|
||||
.embed {
|
||||
padding: 0 $gap;
|
||||
height: $comment_height;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
background: 50% 50% no-repeat;
|
||||
background-size: cover;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
margin: $gap * 0.25 0 !important;
|
||||
|
||||
&:last-child {
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
|
||||
&:first-child {
|
||||
margin-top: 0 !important;
|
||||
}
|
||||
|
||||
a {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
z-index: 5;
|
||||
}
|
||||
|
||||
svg {
|
||||
fill: white;
|
||||
position: relative;
|
||||
z-index: 6;
|
||||
}
|
||||
}
|
||||
|
||||
.backdrop {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: $content_bg_backdrop 50% 50%;
|
||||
background-size: cover;
|
||||
z-index: 15;
|
||||
border-radius: $radius;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
font: $font_16_medium;
|
||||
flex-direction: row;
|
||||
|
||||
@include outer_shadow();
|
||||
}
|
||||
|
||||
.preview {
|
||||
padding: 0 $gap * 0.5 0;
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
justify-content: stretch;
|
||||
box-sizing: border-box;
|
||||
z-index: 2;
|
||||
|
||||
& > div {
|
||||
width: 100%;
|
||||
border-radius: $radius;
|
||||
position: relative;
|
||||
background-position: 50% 50%;
|
||||
background-size: cover;
|
||||
}
|
||||
}
|
||||
|
||||
.play {
|
||||
flex: 0 0 $comment_height - $gap;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.title {
|
||||
flex: 1;
|
||||
text-align: left;
|
||||
box-sizing: border-box;
|
||||
font: $font_18_semibold;
|
||||
padding: 0 $gap 0 $gap * 1.5;
|
||||
text-transform: capitalize;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
|
@ -0,0 +1,49 @@
|
|||
import { FC } from 'react';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { Hoverable } from '~/components/common/Hoverable';
|
||||
import { Icon } from '~/components/input/Icon';
|
||||
import { imagePresets } from '~/constants/urls';
|
||||
import { IFile } from '~/types';
|
||||
import { getURL } from '~/utils/dom';
|
||||
import { getFileSrcSet } from '~/utils/srcset';
|
||||
|
||||
import styles from './styles.module.scss';
|
||||
|
||||
interface CommentImageGridProps {
|
||||
files: IFile[];
|
||||
onClick: (file: IFile) => void;
|
||||
}
|
||||
|
||||
const singleSrcSet = '(max-width: 1024px) 40vw, 20vw';
|
||||
const multipleSrcSet = '(max-width: 1024px) 50vw, 20vw';
|
||||
|
||||
const CommentImageGrid: FC<CommentImageGridProps> = ({ files, onClick }) => {
|
||||
return (
|
||||
<div
|
||||
className={classNames(styles.images, {
|
||||
[styles.multiple]: files.length > 1,
|
||||
})}
|
||||
>
|
||||
{files.map((file) => (
|
||||
<Hoverable
|
||||
key={file.id}
|
||||
onClick={() => onClick(file)}
|
||||
className={styles.item}
|
||||
icon={<Icon icon="zoom" size={30} />}
|
||||
>
|
||||
<img
|
||||
srcSet={getFileSrcSet(file)}
|
||||
src={getURL(file, imagePresets['300'])}
|
||||
alt={file.name}
|
||||
className={styles.image}
|
||||
sizes={files.length > 1 ? singleSrcSet : multipleSrcSet}
|
||||
/>
|
||||
</Hoverable>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export { CommentImageGrid };
|
|
@ -0,0 +1,37 @@
|
|||
@import 'src/styles/variables';
|
||||
@import '~flexbin/flexbin';
|
||||
|
||||
.images {
|
||||
cursor: pointer;
|
||||
overflow: visible !important;
|
||||
|
||||
&.multiple {
|
||||
// Desktop devices
|
||||
@include flexbin(25vh, $flexbin-space);
|
||||
|
||||
// Tablet devices
|
||||
@media (max-width: $flexbin-tablet-max) {
|
||||
@include flexbin($flexbin-row-height-tablet, $flexbin-space-tablet);
|
||||
}
|
||||
|
||||
// Phone devices
|
||||
@media (max-width: $flexbin-phone-max) {
|
||||
@include flexbin($flexbin-row-height-phone, $flexbin-space-phone);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.image {
|
||||
max-height: 400px;
|
||||
border-radius: $radius;
|
||||
max-width: 100%;
|
||||
|
||||
.multiple & {
|
||||
max-height: 250px;
|
||||
max-inline-size: 250px;
|
||||
}
|
||||
}
|
||||
|
||||
.item {
|
||||
border-radius: $radius;
|
||||
}
|
|
@ -0,0 +1,44 @@
|
|||
import React, { FC } from 'react';
|
||||
|
||||
import classnames from 'classnames';
|
||||
|
||||
import { Icon } from '~/components/input/Icon';
|
||||
|
||||
import styles from './styles.module.scss';
|
||||
|
||||
interface CommentLikeProps {
|
||||
className?: string;
|
||||
count?: number;
|
||||
active?: boolean;
|
||||
liked?: boolean;
|
||||
onLike?: () => void;
|
||||
}
|
||||
|
||||
const CommentLike: FC<CommentLikeProps> = ({
|
||||
className,
|
||||
count,
|
||||
active,
|
||||
liked,
|
||||
onLike,
|
||||
}) => {
|
||||
if (!active && !count) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
onClick={active ? onLike : undefined}
|
||||
className={classnames(styles.likes, className, {
|
||||
[styles.liked]: active && liked,
|
||||
[styles.active]: active,
|
||||
})}
|
||||
>
|
||||
<div className={styles.icon}>
|
||||
<Icon icon={count ? 'heart_full' : 'heart'} size={18} />
|
||||
</div>
|
||||
|
||||
{Boolean(count) && <span className={styles.count}>{count}</span>}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
export { CommentLike };
|
|
@ -0,0 +1,39 @@
|
|||
@import 'src/styles/variables';
|
||||
|
||||
.likes {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
position: relative;
|
||||
color: $gray_50;
|
||||
|
||||
&.active {
|
||||
cursor: pointer;
|
||||
|
||||
&::before {
|
||||
content: ' ';
|
||||
position: absolute;
|
||||
inset: -16px -10px -8px -40px;
|
||||
}
|
||||
|
||||
&:not(.liked):hover .icon {
|
||||
@include pulse;
|
||||
}
|
||||
}
|
||||
|
||||
&.liked {
|
||||
color: $color_like;
|
||||
}
|
||||
}
|
||||
|
||||
.icon {
|
||||
position: relative;
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
fill: currentColor;
|
||||
}
|
||||
|
||||
.count {
|
||||
font: $font_12_semibold;
|
||||
}
|
|
@ -0,0 +1,25 @@
|
|||
import React, { FC, useMemo } from 'react';
|
||||
|
||||
import { CornerMenu } from '~/components/common/CornerMenu';
|
||||
|
||||
interface IProps {
|
||||
onEdit: () => void;
|
||||
onDelete: () => void;
|
||||
}
|
||||
|
||||
const CommentMenu: FC<IProps> = ({ onEdit, onDelete }) => {
|
||||
const actions = useMemo(
|
||||
() => [
|
||||
{
|
||||
title: 'Редактировать',
|
||||
action: onEdit,
|
||||
},
|
||||
{ title: 'Удалить', action: onDelete },
|
||||
],
|
||||
[onEdit, onDelete]
|
||||
);
|
||||
|
||||
return <CornerMenu actions={actions} />;
|
||||
};
|
||||
|
||||
export { CommentMenu };
|
|
@ -0,0 +1,49 @@
|
|||
@import 'src/styles/variables';
|
||||
|
||||
.wrap {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@keyframes appear {
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.menu {
|
||||
position: absolute;
|
||||
right: 5px;
|
||||
top: 5px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
z-index: 6;
|
||||
white-space: nowrap;
|
||||
|
||||
animation: appear 0.25s forwards;
|
||||
}
|
||||
|
||||
.item {
|
||||
user-select: none;
|
||||
font: $font_12_semibold;
|
||||
text-transform: uppercase;
|
||||
padding: 8px $gap;
|
||||
background: $content_bg;
|
||||
cursor: pointer;
|
||||
|
||||
&:first-child {
|
||||
border-top-left-radius: $radius;
|
||||
border-top-right-radius: $radius;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
border-bottom-left-radius: $radius;
|
||||
border-bottom-right-radius: $radius;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: $color_primary;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,26 @@
|
|||
import React, { FC, useMemo } from 'react';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { ICommentBlockProps } from '~/constants/comment';
|
||||
import markdown from '~/styles/common/markdown.module.scss';
|
||||
import { formatText } from '~/utils/dom';
|
||||
|
||||
import styles from './styles.module.scss';
|
||||
|
||||
interface IProps extends ICommentBlockProps {}
|
||||
|
||||
const CommentTextBlock: FC<IProps> = ({ block }) => {
|
||||
const content = useMemo(() => formatText(block.content), [block.content]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames(styles.text, markdown.wrapper)}
|
||||
dangerouslySetInnerHTML={{
|
||||
__html: content,
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export { CommentTextBlock };
|
|
@ -0,0 +1,39 @@
|
|||
@import 'src/styles/variables';
|
||||
|
||||
.text {
|
||||
padding: 0 $gap;
|
||||
font-weight: 300;
|
||||
font: $font_16_medium;
|
||||
line-height: 20px;
|
||||
box-sizing: border-box;
|
||||
position: relative;
|
||||
color: #cccccc;
|
||||
word-break: break-word;
|
||||
width: 100%;
|
||||
|
||||
b {
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
i {
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
hr {
|
||||
height: 1px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border: none;
|
||||
}
|
||||
|
||||
:global(.green) {
|
||||
color: $color_primary;
|
||||
}
|
||||
|
||||
& > :last-child::after {
|
||||
display: inline-block;
|
||||
content: ' ';
|
||||
height: 1em;
|
||||
width: 120px;
|
||||
flex: 0 0 120px;
|
||||
}
|
||||
}
|
|
@ -0,0 +1,10 @@
|
|||
import { COMMENT_BLOCK_TYPES } from '~/constants/comment';
|
||||
|
||||
import { CommentEmbedBlock } from '../components/CommentEmbedBlock';
|
||||
import { CommentTextBlock } from '../components/CommentTextBlock';
|
||||
|
||||
export const COMMENT_BLOCK_RENDERERS = {
|
||||
[COMMENT_BLOCK_TYPES.TEXT]: CommentTextBlock,
|
||||
[COMMENT_BLOCK_TYPES.MARK]: CommentTextBlock,
|
||||
[COMMENT_BLOCK_TYPES.EMBED]: CommentEmbedBlock,
|
||||
};
|
|
@ -0,0 +1,167 @@
|
|||
import {
|
||||
createElement,
|
||||
FC,
|
||||
memo,
|
||||
ReactNode,
|
||||
useCallback,
|
||||
useMemo,
|
||||
useState,
|
||||
} from 'react';
|
||||
|
||||
import classnames from 'classnames';
|
||||
|
||||
import { Group } from '~/components/containers/Group';
|
||||
import { AudioPlayer } from '~/components/media/AudioPlayer';
|
||||
import { UploadType } from '~/constants/uploads';
|
||||
import { IComment, IFile } from '~/types';
|
||||
import { formatCommentText, getPrettyDate } from '~/utils/dom';
|
||||
import { append, assocPath, path, reduce } from '~/utils/ramda';
|
||||
|
||||
import { CommentEditingForm } from './components/CommentEditingForm';
|
||||
import { CommentImageGrid } from './components/CommentImageGrid';
|
||||
import { CommentLike } from './components/CommentLike';
|
||||
import { CommentMenu } from './components/CommentMenu';
|
||||
import { COMMENT_BLOCK_RENDERERS } from './constants';
|
||||
import styles from './styles.module.scss';
|
||||
|
||||
interface IProps {
|
||||
prefix?: ReactNode;
|
||||
nodeId: number;
|
||||
comment: IComment;
|
||||
canEdit: boolean;
|
||||
canLike: boolean;
|
||||
saveComment: (data: IComment) => Promise<IComment | undefined>;
|
||||
onDelete: (isLocked: boolean) => void;
|
||||
onLike: () => void;
|
||||
onShowImageModal: (images: IFile[], index: number) => void;
|
||||
}
|
||||
|
||||
const CommentContent: FC<IProps> = memo(
|
||||
({
|
||||
comment,
|
||||
nodeId,
|
||||
saveComment,
|
||||
canEdit,
|
||||
canLike,
|
||||
onLike,
|
||||
onDelete,
|
||||
onShowImageModal,
|
||||
prefix,
|
||||
}) => {
|
||||
const [isEditing, setIsEditing] = useState(false);
|
||||
|
||||
const startEditing = useCallback(() => setIsEditing(true), [setIsEditing]);
|
||||
const stopEditing = useCallback(() => setIsEditing(false), [setIsEditing]);
|
||||
|
||||
const groupped = useMemo<Record<UploadType, IFile[]>>(
|
||||
() =>
|
||||
reduce(
|
||||
(group, file) =>
|
||||
file.type
|
||||
? assocPath([file.type], append(file, group[file.type]), group)
|
||||
: group,
|
||||
{} as Record<UploadType, IFile[]>,
|
||||
comment.files,
|
||||
),
|
||||
[comment],
|
||||
);
|
||||
|
||||
const onLockClick = useCallback(() => {
|
||||
onDelete(!comment.deleted_at);
|
||||
}, [comment, onDelete]);
|
||||
|
||||
const onImageClick = useCallback(
|
||||
(file: IFile) =>
|
||||
onShowImageModal(groupped.image, groupped.image.indexOf(file)),
|
||||
[onShowImageModal, groupped],
|
||||
);
|
||||
|
||||
const menu = useMemo(
|
||||
() =>
|
||||
canEdit && (
|
||||
<div className={styles.menu}>
|
||||
<CommentMenu onDelete={onLockClick} onEdit={startEditing} />
|
||||
</div>
|
||||
),
|
||||
[canEdit, startEditing, onLockClick],
|
||||
);
|
||||
|
||||
const blocks = useMemo(
|
||||
() =>
|
||||
!!comment.text.trim()
|
||||
? formatCommentText(path(['user', 'username'], comment), comment.text)
|
||||
: [],
|
||||
[comment],
|
||||
);
|
||||
|
||||
if (isEditing) {
|
||||
return (
|
||||
<CommentEditingForm
|
||||
saveComment={saveComment}
|
||||
nodeId={nodeId}
|
||||
comment={comment}
|
||||
onCancelEdit={stopEditing}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.wrap}>
|
||||
{!!prefix && <div className={styles.prefix}>{prefix}</div>}
|
||||
|
||||
<div className={styles.content}>
|
||||
{menu}
|
||||
|
||||
<div>
|
||||
{comment.text.trim() && (
|
||||
<Group className={classnames(styles.block, styles.block_text)}>
|
||||
<Group className={styles.renderers}>
|
||||
{blocks.map(
|
||||
(block, key) =>
|
||||
COMMENT_BLOCK_RENDERERS[block.type] &&
|
||||
createElement(COMMENT_BLOCK_RENDERERS[block.type], {
|
||||
block,
|
||||
key,
|
||||
}),
|
||||
)}
|
||||
</Group>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
{groupped.image && groupped.image.length > 0 && (
|
||||
<div className={classnames(styles.block, styles.block_image)}>
|
||||
<CommentImageGrid
|
||||
files={groupped.image}
|
||||
onClick={onImageClick}
|
||||
/>
|
||||
</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>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.date}>
|
||||
{getPrettyDate(comment.created_at)}
|
||||
<CommentLike
|
||||
onLike={onLike}
|
||||
count={comment.like_count}
|
||||
active={canLike}
|
||||
liked={comment.liked}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export { CommentContent };
|
|
@ -0,0 +1,159 @@
|
|||
@import 'src/styles/variables';
|
||||
@import '~flexbin/flexbin';
|
||||
|
||||
.wrap {
|
||||
@include row_shadow;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.lock,
|
||||
.edit {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: $radius;
|
||||
transform: translate(10px, 0);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
touch-action: none;
|
||||
transition: opacity 0.25s, transform 0.25s;
|
||||
cursor: pointer;
|
||||
background: $color_danger;
|
||||
z-index: 2;
|
||||
|
||||
& > div {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 100%;
|
||||
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
@include tablet {
|
||||
right: 0;
|
||||
border-radius: 0 0 0 $radius;
|
||||
opacity: 1;
|
||||
transform: translate(0, 0);
|
||||
background: $content_bg_danger;
|
||||
}
|
||||
}
|
||||
|
||||
.edit {
|
||||
top: 28px;
|
||||
}
|
||||
|
||||
.content {
|
||||
width: 100%;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.block {
|
||||
@include row_shadow;
|
||||
|
||||
min-height: $comment_height;
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: flex-start;
|
||||
position: relative;
|
||||
padding-bottom: 10px;
|
||||
box-sizing: border-box;
|
||||
flex-direction: column;
|
||||
|
||||
&:first-child {
|
||||
border-top-right-radius: $radius;
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
border-bottom-right-radius: $radius;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
.lock,
|
||||
.edit {
|
||||
opacity: 1;
|
||||
pointer-events: all;
|
||||
touch-action: initial;
|
||||
transform: translate(0, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.block_audio {
|
||||
align-items: stretch;
|
||||
justify-content: center;
|
||||
padding-bottom: 0 !important;
|
||||
}
|
||||
|
||||
.block_image {
|
||||
padding: $gap / 2;
|
||||
}
|
||||
|
||||
.block_text {
|
||||
padding: $gap * 0.5 0;
|
||||
}
|
||||
|
||||
.date {
|
||||
position: absolute;
|
||||
bottom: 1px; // should not cover block shadow
|
||||
right: 0;
|
||||
font: $font_12_medium;
|
||||
color: $gray_75;
|
||||
fill: $gray_75;
|
||||
padding: 3px 5px 3px 8px;
|
||||
z-index: 2;
|
||||
background: $content_bg_light;
|
||||
border-radius: 4px;
|
||||
user-select: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.audios {
|
||||
& > div {
|
||||
height: $comment_height;
|
||||
border-radius: $radius;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
}
|
||||
|
||||
.renderers {
|
||||
width: 100%;
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
.prefix {
|
||||
@include row_shadow;
|
||||
|
||||
margin-top: 1px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.menu {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
z-index: 10;
|
||||
outline: none;
|
||||
}
|
|
@ -0,0 +1,34 @@
|
|||
import React, { FC, memo, useMemo } from 'react';
|
||||
|
||||
import { formatDistance } from 'date-fns';
|
||||
import ru from 'date-fns/locale/ru';
|
||||
|
||||
import styles from './styles.module.scss';
|
||||
|
||||
interface CommentDistanceProps {
|
||||
firstDate?: Date;
|
||||
secondDate?: Date;
|
||||
}
|
||||
|
||||
const CommentDistance: FC<CommentDistanceProps> = memo(
|
||||
({ firstDate, secondDate }) => {
|
||||
const distance = useMemo(() => {
|
||||
if (!firstDate || !secondDate) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return formatDistance(secondDate, firstDate, {
|
||||
locale: ru,
|
||||
addSuffix: false,
|
||||
});
|
||||
}, [firstDate, secondDate]);
|
||||
|
||||
if (!distance) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <div className={styles.bar}>прошло {distance}</div>;
|
||||
},
|
||||
);
|
||||
|
||||
export { CommentDistance };
|
|
@ -0,0 +1,10 @@
|
|||
@import 'src/styles/variables';
|
||||
|
||||
.bar {
|
||||
font: $font_12_regular;
|
||||
color: $gray_50;
|
||||
user-select: none;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
padding: 2px 0 4px;
|
||||
}
|
106
src/containers/node/NodeComments/components/Comment/index.tsx
Normal file
106
src/containers/node/NodeComments/components/Comment/index.tsx
Normal file
|
@ -0,0 +1,106 @@
|
|||
import React, { FC, HTMLAttributes, memo } from 'react';
|
||||
|
||||
import classNames from 'classnames';
|
||||
import { parseISO } from 'date-fns';
|
||||
|
||||
import { NEW_COMMENT_CLASSNAME } from '~/constants/comment';
|
||||
import { CommentWrapper } from '~/containers/comments/CommentWrapper';
|
||||
import { IComment, ICommentGroup, IFile } from '~/types';
|
||||
|
||||
import { CommendDeleted } from '../../../../../components/node/CommendDeleted';
|
||||
|
||||
import { CommentContent } from './components/CommentContent';
|
||||
import { CommentDistance } from './components/CommentDistance';
|
||||
import styles from './styles.module.scss';
|
||||
|
||||
type Props = HTMLAttributes<HTMLDivElement> & {
|
||||
nodeId: number;
|
||||
isEmpty?: boolean;
|
||||
isLoading?: boolean;
|
||||
group: ICommentGroup;
|
||||
isSame?: boolean;
|
||||
canEdit?: boolean;
|
||||
canLike?: boolean;
|
||||
highlighted?: boolean;
|
||||
saveComment: (data: IComment) => Promise<IComment | undefined>;
|
||||
onDelete: (id: IComment['id'], isLocked: boolean) => void;
|
||||
onLike: (id: IComment['id'], isLiked: boolean) => void;
|
||||
onShowImageModal: (images: IFile[], index: number) => void;
|
||||
};
|
||||
|
||||
const Comment: FC<Props> = memo(
|
||||
({
|
||||
group,
|
||||
nodeId,
|
||||
isEmpty,
|
||||
isSame,
|
||||
isLoading,
|
||||
className,
|
||||
highlighted,
|
||||
canEdit,
|
||||
canLike,
|
||||
onDelete,
|
||||
onLike,
|
||||
onShowImageModal,
|
||||
saveComment,
|
||||
...props
|
||||
}) => {
|
||||
return (
|
||||
<CommentWrapper
|
||||
className={classNames(styles.container, className, {
|
||||
[NEW_COMMENT_CLASSNAME]: group.hasNew,
|
||||
[styles.highlighted]: highlighted,
|
||||
})}
|
||||
isEmpty={isEmpty}
|
||||
isLoading={isLoading}
|
||||
user={group.user}
|
||||
isNew={group.hasNew && !isSame}
|
||||
{...props}
|
||||
>
|
||||
<div className={styles.wrap}>
|
||||
{group.comments.map((comment, index) => {
|
||||
if (comment.deleted_at) {
|
||||
return (
|
||||
<CommendDeleted
|
||||
id={comment.id}
|
||||
onDelete={onDelete}
|
||||
key={comment.id}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const prefix = Math.abs(group.distancesInDays[index]) > 14 && (
|
||||
<CommentDistance
|
||||
firstDate={
|
||||
comment?.created_at ? parseISO(comment.created_at) : undefined
|
||||
}
|
||||
secondDate={
|
||||
index > 0 && group.comments[index - 1]?.created_at
|
||||
? parseISO(group.comments[index - 1].created_at!)
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<CommentContent
|
||||
prefix={prefix}
|
||||
saveComment={saveComment}
|
||||
nodeId={nodeId}
|
||||
comment={comment}
|
||||
canEdit={!!canEdit}
|
||||
canLike={!!canLike}
|
||||
onLike={() => onLike(comment.id, !comment.liked)}
|
||||
onDelete={(val: boolean) => onDelete(comment.id, val)}
|
||||
onShowImageModal={onShowImageModal}
|
||||
key={comment.id}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</CommentWrapper>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export { Comment };
|
|
@ -0,0 +1,17 @@
|
|||
@import 'src/styles/variables';
|
||||
|
||||
@keyframes appear {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
.wrap {
|
||||
animation: appear 1s;
|
||||
}
|
||||
|
||||
.highlighted {
|
||||
box-shadow: $color_primary 0 0 0px 2px;
|
||||
}
|
89
src/containers/node/NodeComments/index.tsx
Normal file
89
src/containers/node/NodeComments/index.tsx
Normal file
|
@ -0,0 +1,89 @@
|
|||
import React, { FC, useMemo } from 'react';
|
||||
|
||||
import { observer } from 'mobx-react-lite';
|
||||
|
||||
import { LoadMoreButton } from '~/components/input/LoadMoreButton';
|
||||
import { ANNOUNCE_USER_ID, BORIS_NODE_ID } from '~/constants/boris/constants';
|
||||
import { Comment } from '~/containers/node/NodeComments/components/Comment';
|
||||
import { useGrouppedComments } from '~/hooks/node/useGrouppedComments';
|
||||
import { ICommentGroup } from '~/types';
|
||||
import { useCommentContext } from '~/utils/context/CommentContextProvider';
|
||||
import { useNodeContext } from '~/utils/context/NodeContextProvider';
|
||||
import { useUserContext } from '~/utils/context/UserContextProvider';
|
||||
import { canEditComment, canLikeComment } from '~/utils/node';
|
||||
|
||||
import styles from './styles.module.scss';
|
||||
|
||||
interface IProps {
|
||||
order: 'ASC' | 'DESC';
|
||||
}
|
||||
|
||||
const NodeComments: FC<IProps> = observer(({ order }) => {
|
||||
const user = useUserContext();
|
||||
const { node } = useNodeContext();
|
||||
|
||||
const {
|
||||
comments,
|
||||
hasMore,
|
||||
isLoading,
|
||||
isLoadingMore,
|
||||
lastSeenCurrent,
|
||||
onLike,
|
||||
onLoadMoreComments,
|
||||
onDeleteComment,
|
||||
onShowImageModal,
|
||||
onSaveComment,
|
||||
} = useCommentContext();
|
||||
|
||||
const groupped: ICommentGroup[] = useGrouppedComments(
|
||||
comments,
|
||||
order,
|
||||
lastSeenCurrent ?? undefined,
|
||||
);
|
||||
|
||||
const more = useMemo(
|
||||
() =>
|
||||
hasMore &&
|
||||
!isLoading && (
|
||||
<div className={styles.more}>
|
||||
<LoadMoreButton
|
||||
isLoading={isLoadingMore}
|
||||
onClick={onLoadMoreComments}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
[hasMore, onLoadMoreComments, isLoadingMore, isLoading],
|
||||
);
|
||||
|
||||
if (!node?.id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.wrap}>
|
||||
{order === 'DESC' && more}
|
||||
|
||||
{groupped.map((group) => (
|
||||
<Comment
|
||||
nodeId={node.id!}
|
||||
key={group.ids.join()}
|
||||
group={group}
|
||||
highlighted={
|
||||
node.id === BORIS_NODE_ID && group.user.id === ANNOUNCE_USER_ID
|
||||
}
|
||||
onLike={onLike}
|
||||
canLike={canLikeComment(group, user)}
|
||||
canEdit={canEditComment(group, user)}
|
||||
onDelete={onDeleteComment}
|
||||
onShowImageModal={onShowImageModal}
|
||||
isSame={group.user.id === user.id}
|
||||
saveComment={onSaveComment}
|
||||
/>
|
||||
))}
|
||||
|
||||
{order === 'ASC' && more}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
export { NodeComments };
|
15
src/containers/node/NodeComments/styles.module.scss
Normal file
15
src/containers/node/NodeComments/styles.module.scss
Normal file
|
@ -0,0 +1,15 @@
|
|||
@import "src/styles/variables";
|
||||
|
||||
.wrap {
|
||||
& > div {
|
||||
margin: 0 0 $gap 0;
|
||||
|
||||
&:last-child {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.more {
|
||||
margin-bottom: $gap;
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue