mirror of
https://github.com/muerwre/vault-frontend.git
synced 2025-04-24 20:36:40 +07:00
let users like comments
This commit is contained in:
parent
822f51f5de
commit
bd802ede10
22 changed files with 332 additions and 154 deletions
|
@ -12,7 +12,7 @@
|
||||||
"@tippyjs/react": "^4.2.6",
|
"@tippyjs/react": "^4.2.6",
|
||||||
"@v9v/ts-react-telegram-login": "^1.1.1",
|
"@v9v/ts-react-telegram-login": "^1.1.1",
|
||||||
"autosize": "^4.0.2",
|
"autosize": "^4.0.2",
|
||||||
"axios": "^0.21.2",
|
"axios": "^0.21.4",
|
||||||
"body-scroll-lock": "^2.6.4",
|
"body-scroll-lock": "^2.6.4",
|
||||||
"classnames": "^2.2.6",
|
"classnames": "^2.2.6",
|
||||||
"color2k": "^1.2.4",
|
"color2k": "^1.2.4",
|
||||||
|
|
|
@ -1,4 +1,4 @@
|
||||||
import axios, { AxiosRequestConfig } from 'axios';
|
import axios, { AxiosRequestConfig, CancelToken } from 'axios';
|
||||||
|
|
||||||
import { API } from '~/constants/api';
|
import { API } from '~/constants/api';
|
||||||
import { COMMENTS_DISPLAY } from '~/constants/node';
|
import { COMMENTS_DISPLAY } from '~/constants/node';
|
||||||
|
@ -10,6 +10,7 @@ import {
|
||||||
ApiGetNodeRelatedResult,
|
ApiGetNodeRelatedResult,
|
||||||
ApiGetNodeRequest,
|
ApiGetNodeRequest,
|
||||||
ApiGetNodeResponse,
|
ApiGetNodeResponse,
|
||||||
|
ApiLikeCommentRequest,
|
||||||
ApiLockCommentRequest,
|
ApiLockCommentRequest,
|
||||||
ApiLockcommentResult,
|
ApiLockcommentResult,
|
||||||
ApiLockNodeRequest,
|
ApiLockNodeRequest,
|
||||||
|
@ -97,6 +98,20 @@ export const apiGetNodeWithCancel = ({ id }: ApiGetNodeRequest) => {
|
||||||
export const apiPostComment = ({ id, data }: ApiPostCommentRequest) =>
|
export const apiPostComment = ({ id, data }: ApiPostCommentRequest) =>
|
||||||
api.post<ApiPostCommentResult>(API.NODES.COMMENT(id), data).then(cleanResult);
|
api.post<ApiPostCommentResult>(API.NODES.COMMENT(id), data).then(cleanResult);
|
||||||
|
|
||||||
|
export const apiLikeComment = (
|
||||||
|
nodeId: number,
|
||||||
|
commentId: number,
|
||||||
|
data: ApiLikeCommentRequest,
|
||||||
|
options?: { cancelToken?: CancelToken },
|
||||||
|
) =>
|
||||||
|
api
|
||||||
|
.post<ApiPostCommentResult>(
|
||||||
|
API.NODES.COMMENT_LIKES(nodeId, commentId),
|
||||||
|
data,
|
||||||
|
{ cancelToken: options?.cancelToken },
|
||||||
|
)
|
||||||
|
.then(cleanResult);
|
||||||
|
|
||||||
export const apiGetNodeComments = ({
|
export const apiGetNodeComments = ({
|
||||||
id,
|
id,
|
||||||
take = COMMENTS_DISPLAY,
|
take = COMMENTS_DISPLAY,
|
||||||
|
|
|
@ -13,20 +13,22 @@ import { CommendDeleted } from '../../node/CommendDeleted';
|
||||||
|
|
||||||
import styles from './styles.module.scss';
|
import styles from './styles.module.scss';
|
||||||
|
|
||||||
type IProps = HTMLAttributes<HTMLDivElement> & {
|
type Props = HTMLAttributes<HTMLDivElement> & {
|
||||||
nodeId: number;
|
nodeId: number;
|
||||||
isEmpty?: boolean;
|
isEmpty?: boolean;
|
||||||
isLoading?: boolean;
|
isLoading?: boolean;
|
||||||
group: ICommentGroup;
|
group: ICommentGroup;
|
||||||
isSame?: boolean;
|
isSame?: boolean;
|
||||||
canEdit?: boolean;
|
canEdit?: boolean;
|
||||||
|
canLike?: boolean;
|
||||||
highlighted?: boolean;
|
highlighted?: boolean;
|
||||||
saveComment: (data: IComment) => Promise<IComment | undefined>;
|
saveComment: (data: IComment) => Promise<IComment | undefined>;
|
||||||
onDelete: (id: IComment['id'], isLocked: boolean) => void;
|
onDelete: (id: IComment['id'], isLocked: boolean) => void;
|
||||||
|
onLike: (id: IComment['id'], isLiked: boolean) => void;
|
||||||
onShowImageModal: (images: IFile[], index: number) => void;
|
onShowImageModal: (images: IFile[], index: number) => void;
|
||||||
};
|
};
|
||||||
|
|
||||||
const Comment: FC<IProps> = memo(
|
const Comment: FC<Props> = memo(
|
||||||
({
|
({
|
||||||
group,
|
group,
|
||||||
nodeId,
|
nodeId,
|
||||||
|
@ -36,7 +38,9 @@ const Comment: FC<IProps> = memo(
|
||||||
className,
|
className,
|
||||||
highlighted,
|
highlighted,
|
||||||
canEdit,
|
canEdit,
|
||||||
|
canLike,
|
||||||
onDelete,
|
onDelete,
|
||||||
|
onLike,
|
||||||
onShowImageModal,
|
onShowImageModal,
|
||||||
saveComment,
|
saveComment,
|
||||||
...props
|
...props
|
||||||
|
@ -84,10 +88,12 @@ const Comment: FC<IProps> = memo(
|
||||||
saveComment={saveComment}
|
saveComment={saveComment}
|
||||||
nodeId={nodeId}
|
nodeId={nodeId}
|
||||||
comment={comment}
|
comment={comment}
|
||||||
key={comment.id}
|
|
||||||
canEdit={!!canEdit}
|
canEdit={!!canEdit}
|
||||||
onDelete={onDelete}
|
canLike={!!canLike}
|
||||||
|
onLike={() => onLike(comment.id, !comment.liked)}
|
||||||
|
onDelete={(val: boolean) => onDelete(comment.id, val)}
|
||||||
onShowImageModal={onShowImageModal}
|
onShowImageModal={onShowImageModal}
|
||||||
|
key={comment.id}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|
|
@ -1,7 +1,6 @@
|
||||||
import React, {
|
import {
|
||||||
createElement,
|
createElement,
|
||||||
FC,
|
FC,
|
||||||
Fragment,
|
|
||||||
memo,
|
memo,
|
||||||
ReactNode,
|
ReactNode,
|
||||||
useCallback,
|
useCallback,
|
||||||
|
@ -11,7 +10,6 @@ import React, {
|
||||||
|
|
||||||
import classnames from 'classnames';
|
import classnames from 'classnames';
|
||||||
|
|
||||||
import { Authorized } from '~/components/containers/Authorized';
|
|
||||||
import { Group } from '~/components/containers/Group';
|
import { Group } from '~/components/containers/Group';
|
||||||
import { AudioPlayer } from '~/components/media/AudioPlayer';
|
import { AudioPlayer } from '~/components/media/AudioPlayer';
|
||||||
import { COMMENT_BLOCK_RENDERERS } from '~/constants/comment';
|
import { COMMENT_BLOCK_RENDERERS } from '~/constants/comment';
|
||||||
|
@ -22,6 +20,7 @@ import { append, assocPath, path, reduce } from '~/utils/ramda';
|
||||||
|
|
||||||
import { CommentEditingForm } from '../CommentEditingForm';
|
import { CommentEditingForm } from '../CommentEditingForm';
|
||||||
import { CommentImageGrid } from '../CommentImageGrid';
|
import { CommentImageGrid } from '../CommentImageGrid';
|
||||||
|
import { CommentLike } from '../CommentLike';
|
||||||
import { CommentMenu } from '../CommentMenu';
|
import { CommentMenu } from '../CommentMenu';
|
||||||
|
|
||||||
import styles from './styles.module.scss';
|
import styles from './styles.module.scss';
|
||||||
|
@ -31,17 +30,21 @@ interface IProps {
|
||||||
nodeId: number;
|
nodeId: number;
|
||||||
comment: IComment;
|
comment: IComment;
|
||||||
canEdit: boolean;
|
canEdit: boolean;
|
||||||
|
canLike: boolean;
|
||||||
saveComment: (data: IComment) => Promise<IComment | undefined>;
|
saveComment: (data: IComment) => Promise<IComment | undefined>;
|
||||||
onDelete: (id: IComment['id'], isLocked: boolean) => void;
|
onDelete: (isLocked: boolean) => void;
|
||||||
|
onLike: () => void;
|
||||||
onShowImageModal: (images: IFile[], index: number) => void;
|
onShowImageModal: (images: IFile[], index: number) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const CommentContent: FC<IProps> = memo(
|
const CommentContent: FC<IProps> = memo(
|
||||||
({
|
({
|
||||||
comment,
|
comment,
|
||||||
canEdit,
|
|
||||||
nodeId,
|
nodeId,
|
||||||
saveComment,
|
saveComment,
|
||||||
|
canEdit,
|
||||||
|
canLike,
|
||||||
|
onLike,
|
||||||
onDelete,
|
onDelete,
|
||||||
onShowImageModal,
|
onShowImageModal,
|
||||||
prefix,
|
prefix,
|
||||||
|
@ -65,7 +68,7 @@ const CommentContent: FC<IProps> = memo(
|
||||||
);
|
);
|
||||||
|
|
||||||
const onLockClick = useCallback(() => {
|
const onLockClick = useCallback(() => {
|
||||||
onDelete(comment.id, !comment.deleted_at);
|
onDelete(!comment.deleted_at);
|
||||||
}, [comment, onDelete]);
|
}, [comment, onDelete]);
|
||||||
|
|
||||||
const onImageClick = useCallback(
|
const onImageClick = useCallback(
|
||||||
|
@ -75,13 +78,10 @@ const CommentContent: FC<IProps> = memo(
|
||||||
);
|
);
|
||||||
|
|
||||||
const menu = useMemo(
|
const menu = useMemo(
|
||||||
() => (
|
() =>
|
||||||
<div>
|
canEdit && (
|
||||||
{canEdit && (
|
<div className={styles.menu}>
|
||||||
<Authorized>
|
|
||||||
<CommentMenu onDelete={onLockClick} onEdit={startEditing} />
|
<CommentMenu onDelete={onLockClick} onEdit={startEditing} />
|
||||||
</Authorized>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
[canEdit, startEditing, onLockClick],
|
[canEdit, startEditing, onLockClick],
|
||||||
|
@ -110,10 +110,12 @@ const CommentContent: FC<IProps> = memo(
|
||||||
<div className={styles.wrap}>
|
<div className={styles.wrap}>
|
||||||
{!!prefix && <div className={styles.prefix}>{prefix}</div>}
|
{!!prefix && <div className={styles.prefix}>{prefix}</div>}
|
||||||
|
|
||||||
{comment.text.trim() && (
|
<div className={styles.content}>
|
||||||
<Group className={classnames(styles.block, styles.block_text)}>
|
|
||||||
{menu}
|
{menu}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
{comment.text.trim() && (
|
||||||
|
<Group className={classnames(styles.block, styles.block_text)}>
|
||||||
<Group className={styles.renderers}>
|
<Group className={styles.renderers}>
|
||||||
{blocks.map(
|
{blocks.map(
|
||||||
(block, key) =>
|
(block, key) =>
|
||||||
|
@ -124,44 +126,41 @@ const CommentContent: FC<IProps> = memo(
|
||||||
}),
|
}),
|
||||||
)}
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<div className={styles.date}>
|
|
||||||
{getPrettyDate(comment.created_at)}
|
|
||||||
</div>
|
|
||||||
</Group>
|
</Group>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{groupped.image && groupped.image.length > 0 && (
|
{groupped.image && groupped.image.length > 0 && (
|
||||||
<div className={classnames(styles.block, styles.block_image)}>
|
<div className={classnames(styles.block, styles.block_image)}>
|
||||||
{menu}
|
<CommentImageGrid
|
||||||
|
files={groupped.image}
|
||||||
<CommentImageGrid files={groupped.image} onClick={onImageClick} />
|
onClick={onImageClick}
|
||||||
|
/>
|
||||||
<div className={styles.date}>
|
|
||||||
{getPrettyDate(comment.created_at)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{groupped.audio && groupped.audio.length > 0 && (
|
{groupped.audio &&
|
||||||
<Fragment>
|
groupped.audio.length > 0 &&
|
||||||
{groupped.audio.map((file) => (
|
groupped.audio.map((file) => (
|
||||||
<div
|
<div
|
||||||
className={classnames(styles.block, styles.block_audio)}
|
className={classnames(styles.block, styles.block_audio)}
|
||||||
key={file.id}
|
key={file.id}
|
||||||
>
|
>
|
||||||
{menu}
|
|
||||||
|
|
||||||
<AudioPlayer file={file} />
|
<AudioPlayer file={file} />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className={styles.date}>
|
<div className={styles.date}>
|
||||||
{getPrettyDate(comment.created_at)}
|
{getPrettyDate(comment.created_at)}
|
||||||
|
<CommentLike
|
||||||
|
onLike={onLike}
|
||||||
|
count={comment.like_count}
|
||||||
|
active={canLike}
|
||||||
|
liked={comment.liked}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
|
||||||
</Fragment>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
|
@ -54,7 +54,14 @@
|
||||||
top: 28px;
|
top: 28px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
width: 100%;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
.block {
|
.block {
|
||||||
|
@include row_shadow;
|
||||||
|
|
||||||
min-height: $comment_height;
|
min-height: $comment_height;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-start;
|
align-items: flex-start;
|
||||||
|
@ -90,13 +97,7 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
.block_image {
|
.block_image {
|
||||||
padding-bottom: 0 !important;
|
padding: $gap / 2;
|
||||||
|
|
||||||
.date {
|
|
||||||
background: $content_bg;
|
|
||||||
border-radius: $radius 0 $radius 0;
|
|
||||||
color: $gray_25;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.block_text {
|
.block_text {
|
||||||
|
@ -105,16 +106,20 @@
|
||||||
|
|
||||||
.date {
|
.date {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
bottom: 1px;
|
bottom: 1px; // should not cover block shadow
|
||||||
right: 0;
|
right: 0;
|
||||||
font: $font_12_regular;
|
font: $font_12_medium;
|
||||||
color: $gray_75;
|
color: $gray_75;
|
||||||
padding: 0 6px 2px;
|
fill: $gray_75;
|
||||||
|
padding: 3px 6px;
|
||||||
z-index: 2;
|
z-index: 2;
|
||||||
background: $content_bg_light;
|
background: $content_bg_light;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
pointer-events: none;
|
user-select: none;
|
||||||
touch-action: none;
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.audios {
|
.audios {
|
||||||
|
@ -141,3 +146,13 @@
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.menu {
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
top: 0;
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
z-index: 10;
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
44
src/components/comment/CommentLike/index.tsx
Normal file
44
src/components/comment/CommentLike/index.tsx
Normal file
|
@ -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={14} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{Boolean(count) && <span className={styles.count}>{count}</span>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
export { CommentLike };
|
34
src/components/comment/CommentLike/styles.module.scss
Normal file
34
src/components/comment/CommentLike/styles.module.scss
Normal file
|
@ -0,0 +1,34 @@
|
||||||
|
@import 'src/styles/variables';
|
||||||
|
|
||||||
|
.likes {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 2px;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: ' ';
|
||||||
|
position: absolute;
|
||||||
|
inset: -10px -10px -8px -16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:not(.liked):hover .icon {
|
||||||
|
@include pulse;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.liked {
|
||||||
|
color: $color_like;
|
||||||
|
fill: currentColor;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon {
|
||||||
|
position: relative;
|
||||||
|
width: 14px;
|
||||||
|
height: 14px;
|
||||||
|
}
|
|
@ -1,13 +1,6 @@
|
||||||
@import 'src/styles/variables';
|
@import 'src/styles/variables';
|
||||||
|
|
||||||
.wrap {
|
.wrap {
|
||||||
position: absolute;
|
|
||||||
right: -3px;
|
|
||||||
top: -3px;
|
|
||||||
width: 48px;
|
|
||||||
height: 48px;
|
|
||||||
z-index: 10;
|
|
||||||
outline: none;
|
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -25,7 +25,7 @@ const NodeLikeButton: FC<NodeLikeButtonProps> = ({
|
||||||
[styles.is_liked]: active,
|
[styles.is_liked]: active,
|
||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
{active ? (
|
{count ? (
|
||||||
<Icon icon="heart_full" size={24} onClick={onClick} />
|
<Icon icon="heart_full" size={24} onClick={onClick} />
|
||||||
) : (
|
) : (
|
||||||
<Icon icon="heart" size={24} onClick={onClick} />
|
<Icon icon="heart" size={24} onClick={onClick} />
|
||||||
|
|
|
@ -1,31 +1,5 @@
|
||||||
@import 'src/styles/variables';
|
@import 'src/styles/variables';
|
||||||
|
|
||||||
@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 {
|
.like {
|
||||||
transition: fill, stroke 0.25s;
|
transition: fill, stroke 0.25s;
|
||||||
will-change: transform;
|
will-change: transform;
|
||||||
|
@ -46,8 +20,9 @@
|
||||||
}
|
}
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
|
@include pulse;
|
||||||
|
|
||||||
fill: $color_like;
|
fill: $color_like;
|
||||||
animation: pulse 0.75s infinite;
|
|
||||||
|
|
||||||
.count {
|
.count {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
|
|
|
@ -41,6 +41,8 @@ export const API = {
|
||||||
`/nodes/${id}/tags/${tagId}`,
|
`/nodes/${id}/tags/${tagId}`,
|
||||||
|
|
||||||
COMMENT: (id: INode['id'] | string) => `/nodes/${id}/comments`,
|
COMMENT: (id: INode['id'] | string) => `/nodes/${id}/comments`,
|
||||||
|
COMMENT_LIKES: (id: INode['id'] | string, cid: number) =>
|
||||||
|
`/nodes/${id}/comments/${cid}/likes`,
|
||||||
LOCK_COMMENT: (id: INode['id'], comment_id: IComment['id']) =>
|
LOCK_COMMENT: (id: INode['id'], comment_id: IComment['id']) =>
|
||||||
`/nodes/${id}/comments/${comment_id}`,
|
`/nodes/${id}/comments/${comment_id}`,
|
||||||
},
|
},
|
||||||
|
|
|
@ -1,4 +1,6 @@
|
||||||
import React, { FC, memo, useMemo } from 'react';
|
import React, { FC, useMemo } from 'react';
|
||||||
|
|
||||||
|
import { observer } from 'mobx-react-lite';
|
||||||
|
|
||||||
import { Comment } from '~/components/comment/Comment';
|
import { Comment } from '~/components/comment/Comment';
|
||||||
import { LoadMoreButton } from '~/components/input/LoadMoreButton';
|
import { LoadMoreButton } from '~/components/input/LoadMoreButton';
|
||||||
|
@ -8,7 +10,7 @@ import { ICommentGroup } from '~/types';
|
||||||
import { useCommentContext } from '~/utils/context/CommentContextProvider';
|
import { useCommentContext } from '~/utils/context/CommentContextProvider';
|
||||||
import { useNodeContext } from '~/utils/context/NodeContextProvider';
|
import { useNodeContext } from '~/utils/context/NodeContextProvider';
|
||||||
import { useUserContext } from '~/utils/context/UserContextProvider';
|
import { useUserContext } from '~/utils/context/UserContextProvider';
|
||||||
import { canEditComment } from '~/utils/node';
|
import { canEditComment, canLikeComment } from '~/utils/node';
|
||||||
|
|
||||||
import styles from './styles.module.scss';
|
import styles from './styles.module.scss';
|
||||||
|
|
||||||
|
@ -16,7 +18,7 @@ interface IProps {
|
||||||
order: 'ASC' | 'DESC';
|
order: 'ASC' | 'DESC';
|
||||||
}
|
}
|
||||||
|
|
||||||
const NodeComments: FC<IProps> = memo(({ order }) => {
|
const NodeComments: FC<IProps> = observer(({ order }) => {
|
||||||
const user = useUserContext();
|
const user = useUserContext();
|
||||||
const { node } = useNodeContext();
|
const { node } = useNodeContext();
|
||||||
|
|
||||||
|
@ -26,6 +28,7 @@ const NodeComments: FC<IProps> = memo(({ order }) => {
|
||||||
isLoading,
|
isLoading,
|
||||||
isLoadingMore,
|
isLoadingMore,
|
||||||
lastSeenCurrent,
|
lastSeenCurrent,
|
||||||
|
onLike,
|
||||||
onLoadMoreComments,
|
onLoadMoreComments,
|
||||||
onDeleteComment,
|
onDeleteComment,
|
||||||
onShowImageModal,
|
onShowImageModal,
|
||||||
|
@ -68,6 +71,8 @@ const NodeComments: FC<IProps> = memo(({ order }) => {
|
||||||
highlighted={
|
highlighted={
|
||||||
node.id === BORIS_NODE_ID && group.user.id === ANNOUNCE_USER_ID
|
node.id === BORIS_NODE_ID && group.user.id === ANNOUNCE_USER_ID
|
||||||
}
|
}
|
||||||
|
onLike={onLike}
|
||||||
|
canLike={canLikeComment(group, user)}
|
||||||
canEdit={canEditComment(group, user)}
|
canEdit={canEditComment(group, user)}
|
||||||
onDelete={onDeleteComment}
|
onDelete={onDeleteComment}
|
||||||
onShowImageModal={onShowImageModal}
|
onShowImageModal={onShowImageModal}
|
||||||
|
|
|
@ -1,11 +1,33 @@
|
||||||
import { useCallback } from 'react';
|
import { useCallback, useRef } from 'react';
|
||||||
|
|
||||||
import { apiLockComment, apiPostComment } from '~/api/node';
|
import { CancelTokenSource } from 'axios';
|
||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
|
import { apiLikeComment, apiLockComment, apiPostComment } from '~/api/node';
|
||||||
import { useGetComments } from '~/hooks/comments/useGetComments';
|
import { useGetComments } from '~/hooks/comments/useGetComments';
|
||||||
import { IComment } from '~/types';
|
import { IComment } from '~/types';
|
||||||
import { showErrorToast } from '~/utils/errors/showToast';
|
import { showErrorToast } from '~/utils/errors/showToast';
|
||||||
|
|
||||||
|
const updateComment =
|
||||||
|
(id: number, data: Partial<IComment>) => (pages?: IComment[][]) =>
|
||||||
|
pages?.map((comments) =>
|
||||||
|
comments.map((comment) =>
|
||||||
|
comment.id === id ? { ...comment, ...data } : comment,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const transformComment =
|
||||||
|
(id: number, cb: (val: IComment) => IComment) => (pages?: IComment[][]) =>
|
||||||
|
pages?.map((comments) =>
|
||||||
|
comments.map((comment) => (comment.id === id ? cb(comment) : comment)),
|
||||||
|
);
|
||||||
|
|
||||||
|
const insertComment = (data: IComment) => (pages?: IComment[][]) =>
|
||||||
|
pages?.map((list, index) => (index === 0 ? [data, ...list] : list));
|
||||||
|
|
||||||
export const useNodeComments = (nodeId: number, fallbackData?: IComment[]) => {
|
export const useNodeComments = (nodeId: number, fallbackData?: IComment[]) => {
|
||||||
|
const likeAbortController = useRef<CancelTokenSource>();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
comments,
|
comments,
|
||||||
isLoading,
|
isLoading,
|
||||||
|
@ -17,7 +39,7 @@ export const useNodeComments = (nodeId: number, fallbackData?: IComment[]) => {
|
||||||
} = useGetComments(nodeId, fallbackData);
|
} = useGetComments(nodeId, fallbackData);
|
||||||
|
|
||||||
const onDelete = useCallback(
|
const onDelete = useCallback(
|
||||||
async (id: IComment['id'], isLocked: boolean) => {
|
async (id: number, isLocked: boolean) => {
|
||||||
try {
|
try {
|
||||||
const { deleted_at } = await apiLockComment({ id, nodeId, isLocked });
|
const { deleted_at } = await apiLockComment({ id, nodeId, isLocked });
|
||||||
|
|
||||||
|
@ -25,15 +47,7 @@ export const useNodeComments = (nodeId: number, fallbackData?: IComment[]) => {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await mutate(
|
await mutate(updateComment(id, { deleted_at }), false);
|
||||||
(prev) =>
|
|
||||||
prev?.map((list) =>
|
|
||||||
list.map((comment) =>
|
|
||||||
comment.id === id ? { ...comment, deleted_at } : comment,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
showErrorToast(error);
|
showErrorToast(error);
|
||||||
}
|
}
|
||||||
|
@ -51,34 +65,57 @@ export const useNodeComments = (nodeId: number, fallbackData?: IComment[]) => {
|
||||||
|
|
||||||
// Comment was created
|
// Comment was created
|
||||||
if (!comment.id) {
|
if (!comment.id) {
|
||||||
await mutate(
|
await mutate(insertComment(result.comment), false);
|
||||||
data.map((list, index) =>
|
} else {
|
||||||
index === 0 ? [result.comment, ...list] : list,
|
await mutate(updateComment(comment.id, result.comment), false);
|
||||||
),
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
|
|
||||||
return result.comment;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await mutate(
|
|
||||||
(prev) =>
|
|
||||||
prev?.map((list) =>
|
|
||||||
list.map((it) =>
|
|
||||||
it.id === result.comment.id ? { ...it, ...result.comment } : it,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
false,
|
|
||||||
);
|
|
||||||
|
|
||||||
return result.comment;
|
return result.comment;
|
||||||
},
|
},
|
||||||
[data, mutate, nodeId],
|
[data, mutate, nodeId],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const sendLikeRequest = useCallback(
|
||||||
|
async (id: number, liked: boolean) => {
|
||||||
|
if (likeAbortController.current) {
|
||||||
|
likeAbortController.current.cancel();
|
||||||
|
}
|
||||||
|
|
||||||
|
likeAbortController.current = axios.CancelToken.source();
|
||||||
|
|
||||||
|
await apiLikeComment(
|
||||||
|
nodeId,
|
||||||
|
id,
|
||||||
|
{ liked },
|
||||||
|
{ cancelToken: likeAbortController.current?.token },
|
||||||
|
);
|
||||||
|
|
||||||
|
likeAbortController.current = undefined;
|
||||||
|
},
|
||||||
|
[nodeId],
|
||||||
|
);
|
||||||
|
|
||||||
|
const onLike = useCallback(
|
||||||
|
async (id: number, liked: boolean) => {
|
||||||
|
const increment = liked ? 1 : -1;
|
||||||
|
await mutate(
|
||||||
|
transformComment(id, (val) => ({
|
||||||
|
...val,
|
||||||
|
liked,
|
||||||
|
like_count: (val.like_count ?? 0) + increment,
|
||||||
|
})),
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
|
||||||
|
sendLikeRequest(id, liked).catch(showErrorToast);
|
||||||
|
},
|
||||||
|
[mutate, sendLikeRequest],
|
||||||
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
onLoadMoreComments,
|
onLoadMoreComments,
|
||||||
onDelete,
|
onDelete,
|
||||||
|
onLike,
|
||||||
comments,
|
comments,
|
||||||
hasMore,
|
hasMore,
|
||||||
isLoading,
|
isLoading,
|
||||||
|
|
|
@ -22,6 +22,7 @@ const BorisPage: VFC = observer(() => {
|
||||||
onLoadMoreComments,
|
onLoadMoreComments,
|
||||||
onDelete: onDeleteComment,
|
onDelete: onDeleteComment,
|
||||||
onEdit: onSaveComment,
|
onEdit: onSaveComment,
|
||||||
|
onLike: onLikeComment,
|
||||||
comments,
|
comments,
|
||||||
hasMore,
|
hasMore,
|
||||||
isLoading: isLoadingComments,
|
isLoading: isLoadingComments,
|
||||||
|
@ -36,6 +37,7 @@ const BorisPage: VFC = observer(() => {
|
||||||
onSaveComment={onSaveComment}
|
onSaveComment={onSaveComment}
|
||||||
comments={comments}
|
comments={comments}
|
||||||
hasMore={hasMore}
|
hasMore={hasMore}
|
||||||
|
onLike={onLikeComment}
|
||||||
isLoading={isLoadingComments}
|
isLoading={isLoadingComments}
|
||||||
isLoadingMore={isLoadingMore}
|
isLoadingMore={isLoadingMore}
|
||||||
onShowImageModal={onShowImageModal}
|
onShowImageModal={onShowImageModal}
|
||||||
|
|
|
@ -91,7 +91,7 @@ export const getStaticProps = async (
|
||||||
revalidate: 7 * 86400, // every week
|
revalidate: 7 * 86400, // every week
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('[NEXT] can\'t generate node: ', error);
|
console.warn("[NEXT] can't generate node: ", error);
|
||||||
return {
|
return {
|
||||||
notFound: true,
|
notFound: true,
|
||||||
};
|
};
|
||||||
|
@ -112,6 +112,7 @@ const NodePage: FC<Props> = observer((props) => {
|
||||||
|
|
||||||
const {
|
const {
|
||||||
onLoadMoreComments,
|
onLoadMoreComments,
|
||||||
|
onLike: onLikeComment,
|
||||||
onDelete: onDeleteComment,
|
onDelete: onDeleteComment,
|
||||||
onEdit: onSaveComment,
|
onEdit: onSaveComment,
|
||||||
comments,
|
comments,
|
||||||
|
@ -141,6 +142,7 @@ const NodePage: FC<Props> = observer((props) => {
|
||||||
>
|
>
|
||||||
<NodeRelatedProvider id={parseInt(id, 10)} tags={node.tags}>
|
<NodeRelatedProvider id={parseInt(id, 10)} tags={node.tags}>
|
||||||
<CommentContextProvider
|
<CommentContextProvider
|
||||||
|
onLike={onLikeComment}
|
||||||
onSaveComment={onSaveComment}
|
onSaveComment={onSaveComment}
|
||||||
comments={comments}
|
comments={comments}
|
||||||
hasMore={hasMore}
|
hasMore={hasMore}
|
||||||
|
|
29
src/styles/_animations.scss
Normal file
29
src/styles/_animations.scss
Normal file
|
@ -0,0 +1,29 @@
|
||||||
|
@keyframes pulse_animation {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@mixin pulse {
|
||||||
|
animation: pulse_animation 0.75s infinite;
|
||||||
|
}
|
|
@ -2,6 +2,7 @@
|
||||||
@import 'inputs';
|
@import 'inputs';
|
||||||
@import 'fonts';
|
@import 'fonts';
|
||||||
@import 'mixins';
|
@import 'mixins';
|
||||||
|
@import 'animations';
|
||||||
|
|
||||||
$header_height: 64px;
|
$header_height: 64px;
|
||||||
$cell: 250px;
|
$cell: 250px;
|
||||||
|
|
|
@ -122,6 +122,8 @@ export interface IComment {
|
||||||
text: string;
|
text: string;
|
||||||
files: IFile[];
|
files: IFile[];
|
||||||
user?: IUser;
|
user?: IUser;
|
||||||
|
like_count?: number;
|
||||||
|
liked?: boolean;
|
||||||
|
|
||||||
created_at?: string;
|
created_at?: string;
|
||||||
update_at?: string;
|
update_at?: string;
|
||||||
|
|
|
@ -47,6 +47,10 @@ export type ApiPostCommentRequest = {
|
||||||
id: INode['id'];
|
id: INode['id'];
|
||||||
data: IComment;
|
data: IComment;
|
||||||
};
|
};
|
||||||
|
export type ApiLikeCommentRequest = {
|
||||||
|
liked: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
export type ApiPostCommentResult = {
|
export type ApiPostCommentResult = {
|
||||||
comment: IComment;
|
comment: IComment;
|
||||||
};
|
};
|
||||||
|
|
|
@ -12,6 +12,7 @@ export interface CommentProviderProps {
|
||||||
onLoadMoreComments: () => void;
|
onLoadMoreComments: () => void;
|
||||||
onSaveComment: (comment: IComment) => Promise<IComment | undefined>;
|
onSaveComment: (comment: IComment) => Promise<IComment | undefined>;
|
||||||
onDeleteComment: (id: IComment['id'], isLocked: boolean) => void;
|
onDeleteComment: (id: IComment['id'], isLocked: boolean) => void;
|
||||||
|
onLike: (id: number, liked: boolean) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const CommentContext = createContext<CommentProviderProps>({
|
const CommentContext = createContext<CommentProviderProps>({
|
||||||
|
@ -20,6 +21,7 @@ const CommentContext = createContext<CommentProviderProps>({
|
||||||
lastSeenCurrent: null,
|
lastSeenCurrent: null,
|
||||||
isLoading: false,
|
isLoading: false,
|
||||||
isLoadingMore: false,
|
isLoadingMore: false,
|
||||||
|
onLike: () => {},
|
||||||
onSaveComment: async () => undefined,
|
onSaveComment: async () => undefined,
|
||||||
onShowImageModal: () => {},
|
onShowImageModal: () => {},
|
||||||
onLoadMoreComments: () => {},
|
onLoadMoreComments: () => {},
|
||||||
|
|
|
@ -18,6 +18,17 @@ export const canEditComment = (
|
||||||
path(['role'], user) === Role.Admin ||
|
path(['role'], user) === Role.Admin ||
|
||||||
path(['user', 'id'], comment) === path(['id'], user);
|
path(['user', 'id'], comment) === path(['id'], user);
|
||||||
|
|
||||||
|
export const canLikeComment = (
|
||||||
|
comment?: Partial<ICommentGroup>,
|
||||||
|
user?: Partial<IUser>,
|
||||||
|
): boolean =>
|
||||||
|
Boolean(
|
||||||
|
user?.role &&
|
||||||
|
user?.id &&
|
||||||
|
user?.role !== Role.Guest &&
|
||||||
|
user.id !== comment?.user?.id,
|
||||||
|
);
|
||||||
|
|
||||||
export const canLikeNode = (
|
export const canLikeNode = (
|
||||||
node?: Partial<INode>,
|
node?: Partial<INode>,
|
||||||
user?: Partial<IUser>,
|
user?: Partial<IUser>,
|
||||||
|
|
|
@ -710,7 +710,7 @@ autosize@^4.0.2:
|
||||||
resolved "https://registry.yarnpkg.com/autosize/-/autosize-4.0.4.tgz#924f13853a466b633b9309330833936d8bccce03"
|
resolved "https://registry.yarnpkg.com/autosize/-/autosize-4.0.4.tgz#924f13853a466b633b9309330833936d8bccce03"
|
||||||
integrity sha512-5yxLQ22O0fCRGoxGfeLSNt3J8LB1v+umtpMnPW6XjkTWXKoN0AmXAIhelJcDtFT/Y/wYWmfE+oqU10Q0b8FhaQ==
|
integrity sha512-5yxLQ22O0fCRGoxGfeLSNt3J8LB1v+umtpMnPW6XjkTWXKoN0AmXAIhelJcDtFT/Y/wYWmfE+oqU10Q0b8FhaQ==
|
||||||
|
|
||||||
axios@^0.21.2:
|
axios@^0.21.100:
|
||||||
version "0.21.4"
|
version "0.21.4"
|
||||||
resolved "https://registry.yarnpkg.com/axios/-/axios-0.21.4.tgz#c67b90dc0568e5c1cf2b0b858c43ba28e2eda575"
|
resolved "https://registry.yarnpkg.com/axios/-/axios-0.21.4.tgz#c67b90dc0568e5c1cf2b0b858c43ba28e2eda575"
|
||||||
integrity sha512-ut5vewkiu8jjGBdqpM44XxjuCjq9LAKeHVmoVfHVzy8eHgxxq8SbAVQNovDA8mVi05kP0Ea/n/UzcSHcTJQfNg==
|
integrity sha512-ut5vewkiu8jjGBdqpM44XxjuCjq9LAKeHVmoVfHVzy8eHgxxq8SbAVQNovDA8mVi05kP0Ea/n/UzcSHcTJQfNg==
|
||||||
|
@ -1484,9 +1484,9 @@ flexbin@^0.2.0:
|
||||||
integrity sha1-ASYwbT1ZX8t9/LhxSbnJWZ/49Ok=
|
integrity sha1-ASYwbT1ZX8t9/LhxSbnJWZ/49Ok=
|
||||||
|
|
||||||
follow-redirects@^1.14.0:
|
follow-redirects@^1.14.0:
|
||||||
version "1.14.8"
|
version "1.15.3"
|
||||||
resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.14.8.tgz#016996fb9a11a100566398b1c6839337d7bfa8fc"
|
resolved "https://registry.yarnpkg.com/follow-redirects/-/follow-redirects-1.15.3.tgz#fe2f3ef2690afce7e82ed0b44db08165b207123a"
|
||||||
integrity sha512-1x0S9UVJHsQprFcEC/qnNzBLcIxsjAV905f/UkQxbclCsoTWlacCNOpQa/anodLl2uaEKFhfWOvM2Qg77+15zA==
|
integrity sha512-1VzOtuEM8pC9SFU1E+8KfTjZyMztRsgEfwQl44z8A25uy13jSzTj6dyK2Df52iV0vgHCfBwLhDWevLn95w5v6Q==
|
||||||
|
|
||||||
formik@^2.2.6:
|
formik@^2.2.6:
|
||||||
version "2.2.9"
|
version "2.2.9"
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue