mirror of
https://github.com/muerwre/vault-frontend.git
synced 2025-04-24 20:36:40 +07:00
refactor comment components
This commit is contained in:
parent
60da84aad9
commit
eea7095e65
42 changed files with 89 additions and 76 deletions
|
@ -1,106 +0,0 @@
|
|||
import React, { FC, HTMLAttributes, memo } from 'react';
|
||||
|
||||
import classNames from 'classnames';
|
||||
import { parseISO } from 'date-fns';
|
||||
|
||||
import { CommentContent } from '~/components/comment/CommentContent';
|
||||
import { CommentDistance } from '~/components/comment/CommentDistance';
|
||||
import { CommentWrapper } from '~/components/containers/CommentWrapper';
|
||||
import { NEW_COMMENT_CLASSNAME } from '~/constants/comment';
|
||||
import { IComment, ICommentGroup, IFile } from '~/types';
|
||||
|
||||
import { CommendDeleted } from '../../node/CommendDeleted';
|
||||
|
||||
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 };
|
|
@ -1,17 +0,0 @@
|
|||
@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;
|
||||
}
|
|
@ -1,35 +0,0 @@
|
|||
import React, { FC } from 'react';
|
||||
|
||||
import { Avatar } from '~/components/common/Avatar';
|
||||
import { MenuButton } from '~/components/menu/MenuButton';
|
||||
import { ProfileQuickInfo } from '~/containers/profile/ProfileQuickInfo';
|
||||
import { IUser } from '~/types/auth';
|
||||
import { path } from '~/utils/ramda';
|
||||
|
||||
interface Props {
|
||||
user?: IUser;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const CommentAvatar: FC<Props> = ({ user, className }) => {
|
||||
if (!user) {
|
||||
return <Avatar className={className} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<MenuButton
|
||||
position="auto"
|
||||
icon={
|
||||
<Avatar
|
||||
url={path(['photo', 'url'], user)}
|
||||
username={user.username}
|
||||
className={className}
|
||||
/>
|
||||
}
|
||||
>
|
||||
<ProfileQuickInfo user={user} />
|
||||
</MenuButton>
|
||||
);
|
||||
};
|
||||
|
||||
export { CommentAvatar };
|
|
@ -1,37 +0,0 @@
|
|||
@import 'src/styles/variables.scss';
|
||||
|
||||
@keyframes appear {
|
||||
0% {
|
||||
opacity: 0;
|
||||
}
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.popper {
|
||||
@include outer_shadow;
|
||||
|
||||
background-color: $content_bg_darker;
|
||||
padding: $gap;
|
||||
box-sizing: border-box;
|
||||
touch-action: none;
|
||||
pointer-events: none;
|
||||
border-radius: $radius;
|
||||
animation: appear forwards 250ms;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.username {
|
||||
font: $font_18_semibold;
|
||||
line-height: 1.25em;
|
||||
}
|
||||
|
||||
.description {
|
||||
@include clamp(2, 12px);
|
||||
|
||||
font: $font_12_regular;
|
||||
opacity: 0.5;
|
||||
margin-top: 3px;
|
||||
line-height: 1.25em;
|
||||
}
|
|
@ -1,168 +0,0 @@
|
|||
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 { COMMENT_BLOCK_RENDERERS } from '~/constants/comment';
|
||||
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 '../CommentEditingForm';
|
||||
import { CommentImageGrid } from '../CommentImageGrid';
|
||||
import { CommentLike } from '../CommentLike';
|
||||
import { CommentMenu } from '../CommentMenu';
|
||||
|
||||
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 };
|
|
@ -1,159 +0,0 @@
|
|||
@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;
|
||||
}
|
|
@ -1,34 +0,0 @@
|
|||
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 };
|
|
@ -1,10 +0,0 @@
|
|||
@import 'src/styles/variables';
|
||||
|
||||
.bar {
|
||||
font: $font_12_regular;
|
||||
color: $gray_50;
|
||||
user-select: none;
|
||||
width: 100%;
|
||||
text-align: center;
|
||||
padding: 2px 0 4px;
|
||||
}
|
|
@ -1,42 +0,0 @@
|
|||
import { FC } from 'react';
|
||||
|
||||
import { observer } from 'mobx-react-lite';
|
||||
|
||||
import { CommentForm } from '~/components/comment/CommentForm';
|
||||
import { UploadDropzone } from '~/components/upload/UploadDropzone';
|
||||
import { UploadSubject, UploadTarget } from '~/constants/uploads';
|
||||
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 };
|
|
@ -1,48 +0,0 @@
|
|||
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 };
|
|
@ -1,100 +0,0 @@
|
|||
@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;
|
||||
}
|
|
@ -1,49 +0,0 @@
|
|||
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 };
|
|
@ -1,37 +0,0 @@
|
|||
@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;
|
||||
}
|
|
@ -1,44 +0,0 @@
|
|||
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 };
|
|
@ -1,39 +0,0 @@
|
|||
@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;
|
||||
}
|
|
@ -1,25 +0,0 @@
|
|||
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 };
|
|
@ -1,49 +0,0 @@
|
|||
@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;
|
||||
}
|
||||
}
|
|
@ -1,26 +0,0 @@
|
|||
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 };
|
|
@ -1,39 +0,0 @@
|
|||
@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;
|
||||
}
|
||||
}
|
|
@ -1,36 +0,0 @@
|
|||
import React, { forwardRef, KeyboardEventHandler, TextareaHTMLAttributes, useCallback } from 'react';
|
||||
|
||||
import { Textarea } from '~/components/input/Textarea';
|
||||
import { useRandomPhrase } from '~/constants/phrases';
|
||||
import { useCommentFormContext } from '~/hooks/comments/useCommentFormFormik';
|
||||
|
||||
interface IProps extends TextareaHTMLAttributes<HTMLTextAreaElement> {
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
const LocalCommentFormTextarea = forwardRef<HTMLTextAreaElement, IProps>(({ ...rest }, ref) => {
|
||||
const { values, handleChange, handleSubmit, isSubmitting } = useCommentFormContext();
|
||||
|
||||
const onKeyDown = useCallback<KeyboardEventHandler<HTMLTextAreaElement>>(
|
||||
({ ctrlKey, key, metaKey }) => {
|
||||
if ((ctrlKey || metaKey) && key === 'Enter') handleSubmit(undefined);
|
||||
},
|
||||
[handleSubmit]
|
||||
);
|
||||
|
||||
const placeholder = useRandomPhrase('SIMPLE');
|
||||
|
||||
return (
|
||||
<Textarea
|
||||
{...rest}
|
||||
ref={ref}
|
||||
value={values.text}
|
||||
handler={handleChange('text')}
|
||||
onKeyDown={onKeyDown}
|
||||
disabled={isSubmitting}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
export { LocalCommentFormTextarea };
|
|
@ -0,0 +1,44 @@
|
|||
import React, {
|
||||
forwardRef,
|
||||
KeyboardEventHandler,
|
||||
TextareaHTMLAttributes,
|
||||
useCallback,
|
||||
} from 'react';
|
||||
|
||||
import { Textarea } from '~/components/input/Textarea';
|
||||
import { useRandomPhrase } from '~/constants/phrases';
|
||||
import { useCommentFormContext } from '~/hooks/comments/useCommentFormFormik';
|
||||
|
||||
interface IProps extends TextareaHTMLAttributes<HTMLTextAreaElement> {
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
const CommentFormTextarea = forwardRef<HTMLTextAreaElement, IProps>(
|
||||
({ ...rest }, ref) => {
|
||||
const { values, handleChange, handleSubmit, isSubmitting } =
|
||||
useCommentFormContext();
|
||||
|
||||
const onKeyDown = useCallback<KeyboardEventHandler<HTMLTextAreaElement>>(
|
||||
({ ctrlKey, key, metaKey }) => {
|
||||
if ((ctrlKey || metaKey) && key === 'Enter') handleSubmit(undefined);
|
||||
},
|
||||
[handleSubmit],
|
||||
);
|
||||
|
||||
const placeholder = useRandomPhrase('SIMPLE');
|
||||
|
||||
return (
|
||||
<Textarea
|
||||
{...rest}
|
||||
ref={ref}
|
||||
value={values.text}
|
||||
handler={handleChange('text')}
|
||||
onKeyDown={onKeyDown}
|
||||
disabled={isSubmitting}
|
||||
placeholder={placeholder}
|
||||
/>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
export { CommentFormTextarea };
|
|
@ -3,10 +3,6 @@ import { FC, useCallback, useState } from 'react';
|
|||
import { FormikProvider } from 'formik';
|
||||
import { observer } from 'mobx-react-lite';
|
||||
|
||||
import { CommentFormAttachButtons } from '~/components/comment/CommentFormAttachButtons';
|
||||
import { CommentFormAttaches } from '~/components/comment/CommentFormAttaches';
|
||||
import { CommentFormFormatButtons } from '~/components/comment/CommentFormFormatButtons';
|
||||
import { LocalCommentFormTextarea } from '~/components/comment/LocalCommentFormTextarea';
|
||||
import { Filler } from '~/components/containers/Filler';
|
||||
import { Button } from '~/components/input/Button';
|
||||
import { ERROR_LITERAL } from '~/constants/errors';
|
||||
|
@ -14,10 +10,12 @@ import { EMPTY_COMMENT } from '~/constants/node';
|
|||
import { useCommentFormFormik } from '~/hooks/comments/useCommentFormFormik';
|
||||
import { useInputPasteUpload } from '~/hooks/dom/useInputPasteUpload';
|
||||
import { IComment } from '~/types';
|
||||
import {
|
||||
useUploaderContext,
|
||||
} from '~/utils/context/UploaderContextProvider';
|
||||
import { useUploaderContext } from '~/utils/context/UploaderContextProvider';
|
||||
|
||||
import { CommentFormAttachButtons } from './components/CommentFormAttachButtons';
|
||||
import { CommentFormAttaches } from './components/CommentFormAttaches';
|
||||
import { CommentFormFormatButtons } from './components/CommentFormFormatButtons';
|
||||
import { CommentFormTextarea } from './components/CommentFormTextarea';
|
||||
import styles from './styles.module.scss';
|
||||
|
||||
interface IProps {
|
||||
|
@ -63,7 +61,7 @@ const CommentForm: FC<IProps> = observer(
|
|||
<form onSubmit={formik.handleSubmit} className={styles.wrap}>
|
||||
<FormikProvider value={formik}>
|
||||
<div className={styles.input}>
|
||||
<LocalCommentFormTextarea onPaste={onPaste} ref={setTextArea} />
|
||||
<CommentFormTextarea onPaste={onPaste} ref={setTextArea} />
|
||||
|
||||
{!!error && (
|
||||
<div className={styles.error} onClick={clearError}>
|
|
@ -1,8 +1,9 @@
|
|||
import React, { useCallback, useState, VFC } from 'react';
|
||||
|
||||
import styles from '~/components/comment/CommentMenu/styles.module.scss';
|
||||
import { MenuDots } from '~/components/common/MenuDots';
|
||||
|
||||
import styles from './styles.module.scss';
|
||||
|
||||
interface MenuAction {
|
||||
title: string;
|
||||
action: () => void;
|
||||
|
@ -18,7 +19,12 @@ const CornerMenu: VFC<CornerMenuProps> = ({ actions }) => {
|
|||
const onBlur = useCallback(() => setIsMenuOpened(false), [setIsMenuOpened]);
|
||||
|
||||
return (
|
||||
<div className={styles.wrap} onFocus={onFocus} onBlur={onBlur} tabIndex={-1}>
|
||||
<div
|
||||
className={styles.wrap}
|
||||
onFocus={onFocus}
|
||||
onBlur={onBlur}
|
||||
tabIndex={-1}
|
||||
>
|
||||
<MenuDots onClick={onFocus} />
|
||||
|
||||
{is_menu_opened && (
|
||||
|
|
|
@ -1,50 +0,0 @@
|
|||
import React, { FC } from 'react';
|
||||
|
||||
import classNames from 'classnames';
|
||||
|
||||
import { CommentAvatar } from '~/components/comment/CommentAvatar';
|
||||
import { IUser } from '~/types/auth';
|
||||
import { path } from '~/utils/ramda';
|
||||
import { DivProps } from '~/utils/types';
|
||||
|
||||
import styles from './styles.module.scss';
|
||||
|
||||
type IProps = DivProps & {
|
||||
user?: IUser;
|
||||
isEmpty?: boolean;
|
||||
isLoading?: boolean;
|
||||
isForm?: boolean;
|
||||
isNew?: boolean;
|
||||
};
|
||||
|
||||
const CommentWrapper: FC<IProps> = ({
|
||||
user,
|
||||
className,
|
||||
isEmpty,
|
||||
isLoading,
|
||||
isForm,
|
||||
children,
|
||||
isNew,
|
||||
...props
|
||||
}) => (
|
||||
<div
|
||||
className={classNames(styles.wrap, className, {
|
||||
[styles.is_empty]: isEmpty,
|
||||
[styles.is_loading]: isLoading,
|
||||
[styles.is_new]: isNew,
|
||||
})}
|
||||
{...props}
|
||||
>
|
||||
<div className={styles.thumb}>
|
||||
<CommentAvatar
|
||||
user={user}
|
||||
className={classNames(styles.thumb_image, { [styles.pointer]: user })}
|
||||
/>
|
||||
<div className={styles.thumb_user}>~{path(['username'], user)}</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.text}>{children}</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
export { CommentWrapper };
|
|
@ -1,124 +0,0 @@
|
|||
@import 'src/styles/variables';
|
||||
|
||||
@keyframes highlight {
|
||||
0% {
|
||||
opacity: 0.75;
|
||||
}
|
||||
25% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.75;
|
||||
}
|
||||
75% {
|
||||
opacity: 0;
|
||||
}
|
||||
100% {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.wrap {
|
||||
@include outer_shadow;
|
||||
|
||||
background: $content_bg_light;
|
||||
min-height: $comment_height;
|
||||
display: flex;
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
border-radius: $radius;
|
||||
|
||||
&.is_empty {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
&.is_same {
|
||||
margin: 0 !important;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
&.is_new::after {
|
||||
content: ' ';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
animation: highlight 1s 0.5s forwards;
|
||||
border-radius: $radius;
|
||||
pointer-events: none;
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
@include tablet {
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
|
||||
.text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
justify-content: center;
|
||||
|
||||
@include tablet {
|
||||
:global(.comment-author) {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.thumb {
|
||||
flex: 0 0 $comment_height;
|
||||
border-radius: $panel_radius 0 0 $panel_radius;
|
||||
background-color: $content_bg;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
box-sizing: border-box;
|
||||
|
||||
@include tablet {
|
||||
flex-direction: row;
|
||||
flex: 0 0 40px;
|
||||
padding: 8px;
|
||||
box-shadow: inset rgba(255, 255, 255, 0.05) 1px 1px,
|
||||
inset rgba(0, 0, 0, 0.1) -1px -1px;
|
||||
border-radius: $panel_radius $panel_radius 0 0;
|
||||
}
|
||||
}
|
||||
|
||||
div.thumb_image {
|
||||
height: $comment_height;
|
||||
background: $gray_90 no-repeat 50% 50%;
|
||||
border-radius: $panel_radius 0 0 $panel_radius;
|
||||
background-size: cover;
|
||||
flex: 0 0 $comment_height;
|
||||
will-change: transform;
|
||||
cursor: default;
|
||||
|
||||
@include tablet {
|
||||
height: 32px;
|
||||
width: 32px;
|
||||
flex: 0 0 32px;
|
||||
border-radius: $panel_radius;
|
||||
}
|
||||
}
|
||||
|
||||
.pointer {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.thumb_user {
|
||||
display: none;
|
||||
flex: 1;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
box-sizing: border-box;
|
||||
padding: 0 $gap;
|
||||
font: $font_14_medium;
|
||||
|
||||
@include tablet {
|
||||
display: flex;
|
||||
}
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue