1
0
Fork 0
mirror of https://github.com/muerwre/vault-frontend.git synced 2025-04-25 04:46:40 +07:00

comment menu

This commit is contained in:
Fedor Katurov 2019-12-03 15:02:03 +07:00
parent ab898cc40c
commit 1bf9fe6b83
14 changed files with 319 additions and 98 deletions

View file

@ -3,18 +3,34 @@ import { CommentWrapper } from '~/components/containers/CommentWrapper';
import { ICommentGroup, IComment } from '~/redux/types';
import { CommentContent } from '~/components/node/CommentContent';
import * as styles from './styles.scss';
import { nodeLockComment, nodeEditComment } from '~/redux/node/actions';
import { INodeState } from '~/redux/node/reducer';
import { CommentForm } from '../CommentForm';
type IProps = HTMLAttributes<HTMLDivElement> & {
is_empty?: boolean;
is_loading?: boolean;
comment_group?: ICommentGroup;
comment_data: INodeState['comment_data'];
is_same?: boolean;
can_edit?: boolean;
onDelete: (id: IComment['id'], is_deteted: boolean) => void;
onDelete: typeof nodeLockComment;
onEdit: typeof nodeEditComment;
};
const Comment: FC<IProps> = memo(
({ comment_group, is_empty, is_same, is_loading, className, can_edit, onDelete, ...props }) => {
({
comment_group,
comment_data,
is_empty,
is_same,
is_loading,
className,
can_edit,
onDelete,
onEdit,
...props
}) => {
return (
<CommentWrapper
className={className}
@ -25,18 +41,25 @@ const Comment: FC<IProps> = memo(
{...props}
>
<div className={styles.wrap}>
{comment_group.comments.map(comment =>
comment.deleted_at ? (
<div key={comment.id}>deleted</div>
) : (
{comment_group.comments.map(comment => {
if (comment.deleted_at) {
return <div key={comment.id}>deleted</div>;
}
if (Object.prototype.hasOwnProperty.call(comment_data, comment.id)) {
return <CommentForm id={comment.id} key={comment.id} />;
}
return (
<CommentContent
comment={comment}
key={comment.id}
can_edit={can_edit}
onDelete={onDelete}
onEdit={onEdit}
/>
)
)}
);
})}
</div>
</CommentWrapper>
);

View file

@ -13,14 +13,17 @@ import classnames from 'classnames';
import { PRESETS } from '~/constants/urls';
import { COMMENT_BLOCK_RENDERERS } from '~/constants/comment';
import { Icon } from '~/components/input/Icon';
import { nodeLockComment, nodeEditComment } from '~/redux/node/actions';
import { CommentMenu } from '../CommentMenu';
interface IProps {
comment: IComment;
can_edit: boolean;
onDelete: (id: IComment['id'], is_deteted: boolean) => void;
onDelete: typeof nodeLockComment;
onEdit: typeof nodeEditComment;
}
const CommentContent: FC<IProps> = memo(({ comment, can_edit, onDelete }) => {
const CommentContent: FC<IProps> = memo(({ comment, can_edit, onDelete, onEdit }) => {
const groupped = useMemo<Record<keyof typeof UPLOAD_TYPES, IFile[]>>(
() =>
reduce(
@ -35,23 +38,20 @@ const CommentContent: FC<IProps> = memo(({ comment, can_edit, onDelete }) => {
onDelete(comment.id, !comment.deleted_at);
}, [comment, onDelete]);
const lock = useMemo(
() =>
can_edit ? (
<div className={styles.lock} onClick={onLockClick}>
<div>
<Icon icon="close" />
</div>
</div>
) : null,
[can_edit, comment]
const onEditClick = useCallback(() => {
onEdit(comment.id);
}, [comment, onEdit]);
const menu = useMemo(
() => can_edit && <CommentMenu onDelete={onLockClick} onEdit={onEditClick} />,
[can_edit, comment, onEditClick, onLockClick]
);
return (
<div className={styles.wrap}>
{comment.text && (
<Group className={classnames(styles.block, styles.block_text)}>
{lock}
{menu}
{formatCommentText(path(['user', 'username'], comment), comment.text).map(
(block, key) =>
@ -65,7 +65,7 @@ const CommentContent: FC<IProps> = memo(({ comment, can_edit, onDelete }) => {
{groupped.image && groupped.image.length > 0 && (
<div className={classnames(styles.block, styles.block_image)}>
{lock}
{menu}
<div className={styles.images}>
{groupped.image.map(file => (
@ -83,6 +83,8 @@ const CommentContent: FC<IProps> = memo(({ comment, can_edit, onDelete }) => {
<>
{groupped.audio.map(file => (
<div className={classnames(styles.block, styles.block_audio)} key={file.id}>
{menu}
<AudioPlayer file={file} />
<div className={styles.date}>{getPrettyDate(comment.created_at)}</div>

View file

@ -4,7 +4,8 @@
position: relative;
}
.lock {
.lock,
.edit {
position: absolute;
right: 0;
top: 0;
@ -21,6 +22,7 @@
transition: opacity 0.25s, transform 0.25s;
cursor: pointer;
background: $red;
z-index: 2;
& > div {
width: 20px;
@ -46,6 +48,11 @@
}
}
.edit {
top: 28px;
background: blue;
}
.block {
@include outer_shadow();
min-height: $comment_height;
@ -68,7 +75,8 @@
}
&:hover {
.lock {
.lock,
.edit {
opacity: 1;
pointer-events: all;
touch-action: initial;

View file

@ -23,6 +23,7 @@ import { SortableImageGrid } from '~/components/editors/SortableImageGrid';
import { moveArrItem } from '~/utils/fn';
import { SortEnd } from 'react-sortable-hoc';
import { SortableAudioGrid } from '~/components/editors/SortableAudioGrid';
import { NodeCommentForm } from '../NodeCommentForm';
const mapStateToProps = (state: IState) => ({
node: selectNode(state),
@ -188,68 +189,66 @@ const CommentFormUnconnected: FC<IProps> = ({
);
return (
<CommentWrapper user={user}>
<form onSubmit={onSubmit} className={styles.wrap}>
<div className={styles.input}>
<Textarea
value={comment.text}
handler={onInput}
onKeyDown={onKeyDown}
disabled={is_sending_comment}
minRows={2}
/>
<form onSubmit={onSubmit} className={styles.wrap}>
<div className={styles.input}>
<Textarea
value={comment.text}
handler={onInput}
onKeyDown={onKeyDown}
disabled={is_sending_comment}
minRows={2}
/>
</div>
{(!!images.length || !!audios.length) && (
<div className={styles.attaches}>
{!!images.length && (
<SortableImageGrid
onDrop={onFileDrop}
onSortEnd={onImageMove}
axis="xy"
items={images}
locked={locked_images}
pressDelay={50}
helperClass={styles.helper}
size={120}
/>
)}
{!!audios.length && (
<SortableAudioGrid
items={audios}
onDrop={onFileDrop}
onSortEnd={onAudioMove}
axis="y"
locked={[]}
pressDelay={50}
helperClass={styles.helper}
/>
)}
</div>
)}
{(!!images.length || !!audios.length) && (
<div className={styles.attaches}>
{!!images.length && (
<SortableImageGrid
onDrop={onFileDrop}
onSortEnd={onImageMove}
axis="xy"
items={images}
locked={locked_images}
pressDelay={50}
helperClass={styles.helper}
size={120}
/>
)}
{!!audios.length && (
<SortableAudioGrid
items={audios}
onDrop={onFileDrop}
onSortEnd={onAudioMove}
axis="y"
locked={[]}
pressDelay={50}
helperClass={styles.helper}
/>
)}
</div>
)}
<Group horizontal className={styles.buttons}>
<ButtonGroup>
<Button iconLeft="image" size="small" color="gray" iconOnly>
<input type="file" onInput={onInputChange} multiple accept="image/*" />
</Button>
<Button iconRight="enter" size="small" color="gray" iconOnly>
<input type="file" onInput={onInputChange} multiple accept="audio/*" />
</Button>
</ButtonGroup>
<Filler />
{is_sending_comment && <LoaderCircle size={20} />}
<Button size="small" color="gray" iconRight="enter" disabled={is_sending_comment}>
Сказать
<Group horizontal className={styles.buttons}>
<ButtonGroup>
<Button iconLeft="image" size="small" color="gray" iconOnly>
<input type="file" onInput={onInputChange} multiple accept="image/*" />
</Button>
</Group>
</form>
</CommentWrapper>
<Button iconRight="enter" size="small" color="gray" iconOnly>
<input type="file" onInput={onInputChange} multiple accept="audio/*" />
</Button>
</ButtonGroup>
<Filler />
{is_sending_comment && <LoaderCircle size={20} />}
<Button size="small" color="gray" iconRight="enter" disabled={is_sending_comment}>
Сказать
</Button>
</Group>
</form>
);
};

View file

@ -0,0 +1,20 @@
import React, { FC } from 'react';
import styles from './styles.scss';
interface IProps {
onEdit: () => void;
onDelete: () => void;
}
const CommentMenu: FC<IProps> = ({ onEdit, onDelete }) => {
return (
<div className={styles.wrap}>
<div className={styles.menu}>
<div className={styles.item}>Редактировать</div>
<div className={styles.item}>Удалить</div>
</div>
</div>
);
};
export { CommentMenu };

View file

@ -0,0 +1,68 @@
.wrap {
position: absolute;
right: 0;
top: 0;
width: 48px;
height: 48px;
z-index: 3;
&:hover {
.menu {
display: flex;
}
}
&::before {
content: ' ';
width: 10px;
height: 10px;
border-radius: 100%;
background: transparentize(black, 0.7);
position: absolute;
right: 5px;
top: 5px;
}
}
@keyframes appear {
0% {
opacity: 0;
}
100% {
opacity: 1;
}
}
.menu {
position: absolute;
right: 0;
top: 0;
display: none;
flex-direction: column;
z-index: 6;
animation: appear 0.25s forwards;
}
.item {
user-select: none;
font: $font_12_semibold;
text-transform: uppercase;
padding: $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: $primary;
}
}

View file

@ -0,0 +1,36 @@
import React, { FC, useCallback, KeyboardEventHandler, useEffect, useMemo } from 'react';
import { Textarea } from '~/components/input/Textarea';
import { CommentWrapper } from '~/components/containers/CommentWrapper';
import * as styles from './styles.scss';
import { Filler } from '~/components/containers/Filler';
import { Button } from '~/components/input/Button';
import assocPath from 'ramda/es/assocPath';
import { InputHandler, IFileWithUUID, IFile } from '~/redux/types';
import { connect } from 'react-redux';
import * as NODE_ACTIONS from '~/redux/node/actions';
import { selectNode } from '~/redux/node/selectors';
import * as UPLOAD_ACTIONS from '~/redux/uploads/actions';
import { selectUploads } from '~/redux/uploads/selectors';
import { IState } from '~/redux/store';
import { selectUser, selectAuthUser } from '~/redux/auth/selectors';
import { CommentForm } from '../CommentForm';
const mapStateToProps = state => ({
user: selectAuthUser(state),
});
type IProps = ReturnType<typeof mapStateToProps> & {
is_before?: boolean;
};
const NodeCommentFormUnconnected: FC<IProps> = ({ user, is_before }) => {
return (
<CommentWrapper user={user}>
<CommentForm id={0} is_before={is_before} />
</CommentWrapper>
);
};
const NodeCommentForm = connect(mapStateToProps)(NodeCommentFormUnconnected);
export { NodeCommentForm };

View file

@ -7,14 +7,18 @@ import { ICommentGroup, IComment } from '~/redux/types';
import { groupCommentsByUser } from '~/utils/fn';
import { IUser } from '~/redux/auth/types';
import { canEditComment } from '~/utils/node';
import { nodeLockComment, nodeEditComment } from '~/redux/node/actions';
import { INodeState } from '~/redux/node/reducer';
interface IProps {
comments?: IComment[];
comment_data: INodeState['comment_data'];
user: IUser;
onDelete: (id: IComment['id'], is_deteted: boolean) => void;
onDelete: typeof nodeLockComment;
onEdit: typeof nodeEditComment;
}
const NodeComments: FC<IProps> = memo(({ comments, user, onDelete }) => {
const NodeComments: FC<IProps> = memo(({ comments, comment_data, user, onDelete, onEdit }) => {
const groupped: ICommentGroup[] = useMemo(() => comments.reduce(groupCommentsByUser, []), [
comments,
]);
@ -25,8 +29,10 @@ const NodeComments: FC<IProps> = memo(({ comments, user, onDelete }) => {
<Comment
key={group.ids.join()}
comment_group={group}
comment_data={comment_data}
can_edit={canEditComment(group, user)}
onDelete={onDelete}
onEdit={onEdit}
/>
))}

View file

@ -11,6 +11,7 @@ import { Group } from '~/components/containers/Group';
import boris from '~/sprites/boris_robot.svg';
import { NodeNoComments } from '~/components/node/NodeNoComments';
import { getRandomPhrase } from '~/constants/phrases';
import { NodeCommentForm } from '~/components/node/NodeCommentForm';
const mapStateToProps = state => ({
node: selectNode(state),
@ -20,6 +21,7 @@ const mapStateToProps = state => ({
const mapDispatchToProps = {
nodeLoadNode: NODE_ACTIONS.nodeLoadNode,
nodeLockComment: NODE_ACTIONS.nodeLockComment,
nodeEditComment: NODE_ACTIONS.nodeEditComment,
};
type IProps = ReturnType<typeof mapStateToProps> &
@ -29,11 +31,12 @@ type IProps = ReturnType<typeof mapStateToProps> &
const id = 696;
const BorisLayoutUnconnected: FC<IProps> = ({
node: { is_loading, is_loading_comments, comments = [] },
node: { is_loading, is_loading_comments, comments = [], comment_data },
user,
user: { is_user },
nodeLoadNode,
nodeLockComment,
nodeEditComment,
}) => {
const title = getRandomPhrase('BORIS_TITLE');
@ -77,12 +80,18 @@ const BorisLayoutUnconnected: FC<IProps> = ({
</div>
<Group className={styles.content}>
{is_user && <CommentForm id={0} is_before />}
{is_user && <NodeCommentForm is_before />}
{is_loading_comments ? (
<NodeNoComments is_loading />
) : (
<NodeComments comments={comments} user={user} onDelete={nodeLockComment} />
<NodeComments
comments={comments}
comment_data={comment_data}
user={user}
onDelete={nodeLockComment}
onEdit={nodeEditComment}
/>
)}
</Group>
</div>

View file

@ -20,7 +20,7 @@ import { selectUser } from '~/redux/auth/selectors';
import pick from 'ramda/es/pick';
import { NodeRelatedPlaceholder } from '~/components/node/NodeRelated/placeholder';
import { NodeDeletedBadge } from '~/components/node/NodeDeletedBadge';
import { IComment } from '~/redux/types';
import { NodeCommentForm } from '~/components/node/NodeCommentForm';
const mapStateToProps = state => ({
node: selectNode(state),
@ -36,6 +36,7 @@ const mapDispatchToProps = {
nodeStar: NODE_ACTIONS.nodeStar,
nodeLock: NODE_ACTIONS.nodeLock,
nodeLockComment: NODE_ACTIONS.nodeLockComment,
nodeEditComment: NODE_ACTIONS.nodeEditComment,
};
type IProps = ReturnType<typeof mapStateToProps> &
@ -47,7 +48,7 @@ const NodeLayoutUnconnected: FC<IProps> = memo(
match: {
params: { id },
},
node: { is_loading, is_loading_comments, comments = [], current: node, related },
node: { is_loading, is_loading_comments, comments = [], current: node, related, comment_data },
user,
user: { is_user },
nodeGotoNode,
@ -58,6 +59,7 @@ const NodeLayoutUnconnected: FC<IProps> = memo(
nodeLock,
nodeSetCoverImage,
nodeLockComment,
nodeEditComment,
}) => {
const [layout, setLayout] = useState({});
@ -131,10 +133,16 @@ const NodeLayoutUnconnected: FC<IProps> = memo(
{is_loading || is_loading_comments || (!comments.length && !inline_block) ? (
<NodeNoComments is_loading={is_loading_comments || is_loading} />
) : (
<NodeComments comments={comments} user={user} onDelete={nodeLockComment} />
<NodeComments
comments={comments}
comment_data={comment_data}
user={user}
onDelete={nodeLockComment}
onEdit={nodeEditComment}
/>
)}
{is_user && !is_loading && <CommentForm id={0} />}
{is_user && !is_loading && <NodeCommentForm />}
</Group>
<div className={styles.panel}>

View file

@ -2,6 +2,11 @@ import { INode, IValidationErrors, IComment, ITag, IFile } from '../types';
import { NODE_ACTIONS, NODE_TYPES } from './constants';
import { INodeState } from './reducer';
export const nodeSet = (node: Partial<INodeState>) => ({
node,
type: NODE_ACTIONS.SET,
});
export const nodeSave = (node: INode) => ({
node,
type: NODE_ACTIONS.SAVE,
@ -109,6 +114,11 @@ export const nodeLockComment = (id: IComment['id'], is_locked: boolean) => ({
is_locked,
});
export const nodeEditComment = (id: IComment['id']) => ({
type: NODE_ACTIONS.EDIT_COMMENT,
id,
});
export const nodeSetEditor = (editor: INode) => ({
type: NODE_ACTIONS.SET_EDITOR,
editor,

View file

@ -19,12 +19,14 @@ export const NODE_ACTIONS = {
SAVE: `${prefix}SAVE`,
LOAD_NODE: `${prefix}LOAD_NODE`,
GOTO_NODE: `${prefix}GOTO_NODE`,
SET: `${prefix}SET`,
EDIT: `${prefix}EDIT`,
LIKE: `${prefix}LIKE`,
STAR: `${prefix}STAR`,
LOCK: `${prefix}LOCK`,
LOCK_COMMENT: `${prefix}LOCK_COMMENT`,
EDIT_COMMENT: `${prefix}EDIT_COMMENT`,
CREATE: `${prefix}CREATE`,
SET_SAVE_ERRORS: `${prefix}SET_SAVE_ERRORS`,

View file

@ -12,9 +12,15 @@ import {
nodeSetEditor,
nodeSetCoverImage,
nodeSetRelated,
nodeSet,
} from './actions';
import { INodeState } from './reducer';
const setData = (state: INodeState, { node }: ReturnType<typeof nodeSet>) => ({
...state,
...node,
});
const setSaveErrors = (state: INodeState, { errors }: ReturnType<typeof nodeSetSaveErrors>) =>
assocPath(['errors'], errors, state);
@ -57,6 +63,7 @@ const setCoverImage = (
) => assocPath(['current_cover_image'], current_cover_image, state);
export const NODE_HANDLERS = {
[NODE_ACTIONS.SET]: setData,
[NODE_ACTIONS.SET_SAVE_ERRORS]: setSaveErrors,
[NODE_ACTIONS.SET_LOADING]: setLoading,
[NODE_ACTIONS.SET_LOADING_COMMENTS]: setLoadingComments,

View file

@ -1,5 +1,6 @@
import { takeLatest, call, put, select, delay, all } from 'redux-saga/effects';
import { push } from 'connected-react-router';
import omit from 'ramda/es/omit';
import { NODE_ACTIONS, EMPTY_NODE, EMPTY_COMMENT, NODE_EDITOR_DATA } from './constants';
import {
@ -23,6 +24,8 @@ import {
nodeGotoNode,
nodeLock,
nodeLockComment,
nodeEditComment,
nodeSet,
} from './actions';
import {
postNode,
@ -168,14 +171,23 @@ function* onPostComment({ id, is_before }: ReturnType<typeof nodePostComment>) {
const { current: current_node } = yield select(selectNode);
if (current_node && current_node.id === current.id) {
// if user still browsing that node
const { comments } = yield select(selectNode);
yield put(nodeSetCommentData(0, { ...EMPTY_COMMENT }));
const { comments, comment_data } = yield select(selectNode);
if (is_before) {
yield put(nodeSetComments([comment, ...comments]));
if (id === 0) {
yield put(nodeSetCommentData(0, { ...EMPTY_COMMENT }));
if (is_before) {
yield put(nodeSetComments([comment, ...comments]));
} else {
yield put(nodeSetComments([...comments, comment]));
}
} else {
yield put(nodeSetComments([...comments, comment]));
yield put(
nodeSet({
comment_data: omit([id.toString()], comment_data),
comments: comments.map(item => (item.id === id ? comment : item)),
})
);
}
}
}
@ -279,6 +291,16 @@ function* onLockCommentSaga({ id, is_locked }: ReturnType<typeof nodeLockComment
yield call(reqWrapper, postNodeLockComment, { current: current.id, id, is_locked });
}
function* onEditCommentSaga({ id }: ReturnType<typeof nodeEditComment>) {
const { comments } = yield select(selectNode);
const comment = comments.find(item => item.id === id);
if (!comment) return;
yield put(nodeSetCommentData(id, { ...EMPTY_COMMENT, ...comment }));
}
export default function* nodeSaga() {
yield takeLatest(NODE_ACTIONS.SAVE, onNodeSave);
yield takeLatest(NODE_ACTIONS.GOTO_NODE, onNodeGoto);
@ -291,4 +313,5 @@ export default function* nodeSaga() {
yield takeLatest(NODE_ACTIONS.STAR, onStarSaga);
yield takeLatest(NODE_ACTIONS.LOCK, onLockSaga);
yield takeLatest(NODE_ACTIONS.LOCK_COMMENT, onLockCommentSaga);
yield takeLatest(NODE_ACTIONS.EDIT_COMMENT, onEditCommentSaga);
}