mirror of
https://github.com/muerwre/vault-frontend.git
synced 2025-04-24 20:36:40 +07:00
added tag deletion interface
This commit is contained in:
parent
0a75feef8d
commit
04a7b28a53
16 changed files with 195 additions and 39 deletions
|
@ -27,7 +27,7 @@ const LabTags: FC<IProps> = ({ tags, isLoading }) => {
|
|||
return (
|
||||
<div className={styles.tags}>
|
||||
{tags.slice(0, 10).map(tag => (
|
||||
<Tag tag={tag} key={tag.id} />
|
||||
<Tag tag={tag} key={tag.ID} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
|
|
|
@ -16,6 +16,7 @@ import { NodeAuthorBlock } from '~/components/node/NodeAuthorBlock';
|
|||
|
||||
interface IProps {
|
||||
node: INode;
|
||||
canEdit: boolean;
|
||||
isLoading: boolean;
|
||||
commentsOrder: 'ASC' | 'DESC';
|
||||
comments: IComment[];
|
||||
|
@ -26,6 +27,7 @@ interface IProps {
|
|||
|
||||
const NodeBottomBlock: FC<IProps> = ({
|
||||
node,
|
||||
canEdit,
|
||||
isLoading,
|
||||
isLoadingComments,
|
||||
comments,
|
||||
|
@ -66,7 +68,7 @@ const NodeBottomBlock: FC<IProps> = ({
|
|||
<NodeAuthorBlock node={node} />
|
||||
</div>
|
||||
<div className={styles.left_item}>
|
||||
<NodeTagsBlock node={node} isLoading={isLoading} />
|
||||
<NodeTagsBlock node={node} canEdit={canEdit} isLoading={isLoading} />
|
||||
</div>
|
||||
<div className={styles.left_item}>
|
||||
<NodeRelatedBlock isLoading={isLoading} node={node} related={related} />
|
||||
|
|
|
@ -3,16 +3,27 @@ import { ITag } from '~/redux/types';
|
|||
import { Tags } from '~/components/tags/Tags';
|
||||
|
||||
interface IProps {
|
||||
is_deletable?: boolean;
|
||||
is_editable?: boolean;
|
||||
tags: ITag[];
|
||||
onChange?: (tags: string[]) => void;
|
||||
onTagClick?: (tag: Partial<ITag>) => void;
|
||||
onTagDelete?: (id: ITag['ID']) => void;
|
||||
}
|
||||
|
||||
const NodeTags: FC<IProps> = memo(({ is_editable, tags, onChange, onTagClick }) => {
|
||||
const NodeTags: FC<IProps> = memo(
|
||||
({ is_editable, is_deletable, tags, onChange, onTagClick, onTagDelete }) => {
|
||||
return (
|
||||
<Tags tags={tags} is_editable={is_editable} onTagsChange={onChange} onTagClick={onTagClick} />
|
||||
<Tags
|
||||
tags={tags}
|
||||
is_editable={is_editable}
|
||||
onTagsChange={onChange}
|
||||
onTagClick={onTagClick}
|
||||
onTagDelete={onTagDelete}
|
||||
is_deletable={is_deletable}
|
||||
/>
|
||||
);
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
export { NodeTags };
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
import React, { FC, useCallback } from 'react';
|
||||
import { INode, ITag } from '~/redux/types';
|
||||
import { URLS } from '~/constants/urls';
|
||||
import { nodeUpdateTags } from '~/redux/node/actions';
|
||||
import { nodeDeleteTag, nodeUpdateTags } from '~/redux/node/actions';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { useHistory } from 'react-router';
|
||||
import { NodeTags } from '~/components/node/NodeTags';
|
||||
|
@ -9,10 +9,11 @@ import { useUser } from '~/utils/hooks/user/userUser';
|
|||
|
||||
interface IProps {
|
||||
node: INode;
|
||||
canEdit: boolean;
|
||||
isLoading: boolean;
|
||||
}
|
||||
|
||||
const NodeTagsBlock: FC<IProps> = ({ node, isLoading }) => {
|
||||
const NodeTagsBlock: FC<IProps> = ({ node, canEdit, isLoading }) => {
|
||||
const dispatch = useDispatch();
|
||||
const history = useHistory();
|
||||
const { is_user } = useUser();
|
||||
|
@ -35,6 +36,13 @@ const NodeTagsBlock: FC<IProps> = ({ node, isLoading }) => {
|
|||
[history, node]
|
||||
);
|
||||
|
||||
const onTagDelete = useCallback(
|
||||
(tagId: ITag['ID']) => {
|
||||
dispatch(nodeDeleteTag(node.id, tagId));
|
||||
},
|
||||
[dispatch, node.id]
|
||||
);
|
||||
|
||||
if (isLoading) {
|
||||
return null;
|
||||
}
|
||||
|
@ -42,9 +50,11 @@ const NodeTagsBlock: FC<IProps> = ({ node, isLoading }) => {
|
|||
return (
|
||||
<NodeTags
|
||||
is_editable={is_user}
|
||||
is_deletable={canEdit}
|
||||
tags={node.tags}
|
||||
onChange={onTagsChange}
|
||||
onTagClick={onTagClick}
|
||||
onTagDelete={onTagDelete}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -12,26 +12,46 @@ interface IProps {
|
|||
tag: Partial<ITag>;
|
||||
size?: 'normal' | 'big';
|
||||
|
||||
is_deletable?: boolean;
|
||||
is_hoverable?: boolean;
|
||||
is_editing?: boolean;
|
||||
|
||||
onBlur?: FocusEventHandler<HTMLInputElement>;
|
||||
onClick?: (tag: Partial<ITag>) => void;
|
||||
onDelete?: (id: ITag['ID']) => void;
|
||||
}
|
||||
|
||||
const Tag: FC<IProps> = ({ tag, is_hoverable, is_editing, size = 'normal', onBlur, onClick }) => {
|
||||
const Tag: FC<IProps> = ({
|
||||
tag,
|
||||
is_deletable,
|
||||
is_hoverable,
|
||||
is_editing,
|
||||
size = 'normal',
|
||||
onClick,
|
||||
onDelete,
|
||||
}) => {
|
||||
const onClickHandler = useCallback(() => {
|
||||
if (!onClick) return;
|
||||
onClick(tag);
|
||||
}, [tag, onClick]);
|
||||
|
||||
const onDeleteHandler = useCallback(() => {
|
||||
if (!onDelete) {
|
||||
return;
|
||||
}
|
||||
|
||||
onDelete(tag.ID!);
|
||||
}, [onDelete, tag]);
|
||||
|
||||
return (
|
||||
<TagWrapper
|
||||
feature={getTagFeature(tag)}
|
||||
size={size}
|
||||
is_deletable={is_deletable}
|
||||
is_hoverable={is_hoverable}
|
||||
is_editing={is_editing}
|
||||
onClick={onClick && onClickHandler}
|
||||
onDelete={onDeleteHandler}
|
||||
title={tag.title}
|
||||
/>
|
||||
);
|
||||
|
|
|
@ -1,14 +1,18 @@
|
|||
import React, { FC } from 'react';
|
||||
import React, { FC, useCallback, useState } from 'react';
|
||||
import classNames from 'classnames';
|
||||
import styles from './styles.module.scss';
|
||||
import { Manager, Popper, Reference } from 'react-popper';
|
||||
import { Icon } from '~/components/input/Icon';
|
||||
|
||||
interface IProps {
|
||||
feature?: string;
|
||||
size?: string;
|
||||
is_deletable?: boolean;
|
||||
is_hoverable?: boolean;
|
||||
is_editing?: boolean;
|
||||
has_input?: boolean;
|
||||
onClick?: () => void;
|
||||
onDelete?: () => void;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
|
@ -16,25 +20,39 @@ const TagWrapper: FC<IProps> = ({
|
|||
children,
|
||||
feature,
|
||||
size,
|
||||
is_deletable,
|
||||
is_hoverable,
|
||||
is_editing,
|
||||
has_input,
|
||||
onClick,
|
||||
onDelete,
|
||||
title = '',
|
||||
}) => (
|
||||
}) => {
|
||||
const deletable = is_deletable && !is_editing && !has_input;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames(styles.tag, feature, size, {
|
||||
is_hoverable,
|
||||
is_editing,
|
||||
deletable,
|
||||
input: has_input,
|
||||
clickable: onClick,
|
||||
})}
|
||||
onClick={onClick}
|
||||
>
|
||||
<div className={styles.content} onClick={onClick}>
|
||||
<div className={styles.hole} />
|
||||
<div className={styles.title}>{title}</div>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{deletable && (
|
||||
<button type="button" className={styles.delete} onClick={onDelete}>
|
||||
<Icon icon="close" size={20} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export { TagWrapper };
|
||||
|
|
|
@ -5,21 +5,21 @@ $big: 1.2;
|
|||
.tag {
|
||||
@include outer_shadow();
|
||||
|
||||
overflow: hidden;
|
||||
cursor: default;
|
||||
height: $tag_height;
|
||||
background: $tag_bg;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: stretch;
|
||||
border-radius: ($tag_height / 2) 3px 3px ($tag_height / 2);
|
||||
font: $font_14_semibold;
|
||||
align-self: flex-start;
|
||||
padding: 0 8px 0 0;
|
||||
//margin: 0 $gap $gap 0;
|
||||
position: relative;
|
||||
z-index: 12;
|
||||
|
||||
&:hover {
|
||||
z-index: 40;
|
||||
}
|
||||
|
||||
&:global(.big) {
|
||||
height: $tag_height * $big;
|
||||
font: $font_16_semibold;
|
||||
|
@ -98,6 +98,19 @@ $big: 1.2;
|
|||
}
|
||||
}
|
||||
|
||||
.content {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
align-items: center;
|
||||
justify-content: stretch;
|
||||
width: 100%;
|
||||
transition: transform 250ms;
|
||||
|
||||
:hover:global(.deletable) > & {
|
||||
transform: translate(-32px, 0);
|
||||
}
|
||||
}
|
||||
|
||||
.hole {
|
||||
width: $tag_height;
|
||||
height: $tag_height;
|
||||
|
@ -123,3 +136,28 @@ $big: 1.2;
|
|||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
button.delete {
|
||||
@include inner_shadow;
|
||||
|
||||
width: 32px;
|
||||
height: 100%;
|
||||
z-index: 24;
|
||||
background: $red;
|
||||
border: none;
|
||||
padding: 0;
|
||||
margin: 0 0 0 -5px;
|
||||
border-radius: 0;
|
||||
position: absolute;
|
||||
right: -32px;
|
||||
top: 0;
|
||||
transition: transform 250ms;
|
||||
transform: translate(0, 0);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
|
||||
:hover > & {
|
||||
transform: translate(-32px, 0);
|
||||
}
|
||||
}
|
||||
|
|
|
@ -8,12 +8,22 @@ import { separateTags } from '~/utils/tag';
|
|||
|
||||
type IProps = HTMLAttributes<HTMLDivElement> & {
|
||||
tags: Partial<ITag>[];
|
||||
is_deletable?: boolean;
|
||||
is_editable?: boolean;
|
||||
onTagsChange?: (tags: string[]) => void;
|
||||
onTagClick?: (tag: Partial<ITag>) => void;
|
||||
onTagDelete?: (id: ITag['ID']) => void;
|
||||
};
|
||||
|
||||
export const Tags: FC<IProps> = ({ tags, is_editable, onTagsChange, onTagClick, ...props }) => {
|
||||
export const Tags: FC<IProps> = ({
|
||||
tags,
|
||||
is_deletable,
|
||||
is_editable,
|
||||
onTagsChange,
|
||||
onTagClick,
|
||||
onTagDelete,
|
||||
...props
|
||||
}) => {
|
||||
const [data, setData] = useState<string[]>([]);
|
||||
|
||||
const [catTags, ordinaryTags] = useMemo(() => separateTags(tags), [tags]);
|
||||
|
@ -56,11 +66,23 @@ export const Tags: FC<IProps> = ({ tags, is_editable, onTagsChange, onTagClick,
|
|||
return (
|
||||
<TagField {...props}>
|
||||
{catTags.map(tag => (
|
||||
<Tag key={tag.title} tag={tag} onClick={onTagClick} />
|
||||
<Tag
|
||||
key={tag.title}
|
||||
tag={tag}
|
||||
onClick={onTagClick}
|
||||
is_deletable={is_deletable}
|
||||
onDelete={onTagDelete}
|
||||
/>
|
||||
))}
|
||||
|
||||
{ordinaryTags.map(tag => (
|
||||
<Tag key={tag.title} tag={tag} onClick={onTagClick} />
|
||||
<Tag
|
||||
key={tag.title}
|
||||
tag={tag}
|
||||
onClick={onTagClick}
|
||||
is_deletable={is_deletable}
|
||||
onDelete={onTagDelete}
|
||||
/>
|
||||
))}
|
||||
|
||||
{data.map(title => (
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { IComment, INode } from '~/redux/types';
|
||||
import { IComment, INode, ITag } from '~/redux/types';
|
||||
import { ISocialProvider } from '~/redux/auth/types';
|
||||
|
||||
export const API = {
|
||||
|
@ -30,6 +30,7 @@ export const API = {
|
|||
COMMENT: (id: INode['id']) => `/node/${id}/comment`,
|
||||
RELATED: (id: INode['id']) => `/node/${id}/related`,
|
||||
UPDATE_TAGS: (id: INode['id']) => `/node/${id}/tags`,
|
||||
DELETE_TAG: (id: INode['id'], tagId: ITag['ID']) => `/node/${id}/tags/${tagId}`,
|
||||
POST_LIKE: (id: INode['id']) => `/node/${id}/like`,
|
||||
POST_HEROIC: (id: INode['id']) => `/node/${id}/heroic`,
|
||||
POST_LOCK: (id: INode['id']) => `/node/${id}/lock`,
|
||||
|
|
|
@ -18,6 +18,8 @@ import { useLoadNode } from '~/utils/hooks/node/useLoadNode';
|
|||
import { URLS } from '~/constants/urls';
|
||||
import { EditorEditDialog } from '~/containers/dialogs/EditorEditDialog';
|
||||
import { useOnNodeSeen } from '~/utils/hooks/node/useOnNodeSeen';
|
||||
import { canEditNode } from '~/utils/node';
|
||||
import { useNodePermissions } from '~/utils/hooks/node/useNodePermissions';
|
||||
|
||||
type IProps = RouteComponentProps<{ id: string }> & {};
|
||||
|
||||
|
@ -42,6 +44,7 @@ const NodeLayout: FC<IProps> = memo(
|
|||
useOnNodeSeen(current);
|
||||
|
||||
const { head, block } = useNodeBlocks(current, is_loading);
|
||||
const [canEdit] = useNodePermissions(current);
|
||||
|
||||
return (
|
||||
<div className={styles.wrap}>
|
||||
|
@ -54,13 +57,14 @@ const NodeLayout: FC<IProps> = memo(
|
|||
<NodePanel node={current} isLoading={is_loading} />
|
||||
|
||||
<NodeBottomBlock
|
||||
canEdit={canEdit}
|
||||
node={current}
|
||||
isLoadingComments={is_loading_comments}
|
||||
comments={comments}
|
||||
isLoading={is_loading}
|
||||
commentsCount={comment_count}
|
||||
commentsOrder="DESC"
|
||||
related={related}
|
||||
isLoadingComments={is_loading_comments}
|
||||
isLoading={is_loading}
|
||||
/>
|
||||
|
||||
<Footer />
|
||||
|
|
|
@ -86,6 +86,12 @@ export const nodeUpdateTags = (id: INode['id'], tags: string[]) => ({
|
|||
tags,
|
||||
});
|
||||
|
||||
export const nodeDeleteTag = (id: INode['id'], tagId: ITag['ID']) => ({
|
||||
type: NODE_ACTIONS.DELETE_TAG,
|
||||
id: id!,
|
||||
tagId,
|
||||
});
|
||||
|
||||
export const nodeSetTags = (tags: ITag[]) => ({
|
||||
type: NODE_ACTIONS.SET_TAGS,
|
||||
tags,
|
||||
|
|
|
@ -3,6 +3,8 @@ import { IComment, INode } from '../types';
|
|||
import { API } from '~/constants/api';
|
||||
import { COMMENTS_DISPLAY } from './constants';
|
||||
import {
|
||||
ApiDeleteNodeTagsRequest,
|
||||
ApiDeleteNodeTagsResult,
|
||||
ApiGetNodeRelatedRequest,
|
||||
ApiGetNodeRelatedResult,
|
||||
ApiGetNodeRequest,
|
||||
|
@ -101,6 +103,9 @@ export const apiPostNodeTags = ({ id, tags }: ApiPostNodeTagsRequest) =>
|
|||
.post<ApiPostNodeTagsResult>(API.NODE.UPDATE_TAGS(id), { tags })
|
||||
.then(cleanResult);
|
||||
|
||||
export const apiDeleteNodeTag = ({ id, tagId }: ApiDeleteNodeTagsRequest) =>
|
||||
api.delete<ApiDeleteNodeTagsResult>(API.NODE.DELETE_TAG(id, tagId)).then(cleanResult);
|
||||
|
||||
export const apiPostNodeLike = ({ id }: ApiPostNodeLikeRequest) =>
|
||||
api.post<ApiPostNodeLikeResult>(API.NODE.POST_LIKE(id)).then(cleanResult);
|
||||
|
||||
|
|
|
@ -52,6 +52,7 @@ export const NODE_ACTIONS = {
|
|||
SET_RELATED: `${prefix}SET_RELATED`,
|
||||
|
||||
UPDATE_TAGS: `${prefix}UPDATE_TAGS`,
|
||||
DELETE_TAG: `${prefix}DELETE_TAG`,
|
||||
SET_TAGS: `${prefix}SET_TAGS`,
|
||||
SET_COVER_IMAGE: `${prefix}SET_COVER_IMAGE`,
|
||||
};
|
||||
|
|
|
@ -10,6 +10,7 @@ import {
|
|||
} from './constants';
|
||||
import {
|
||||
nodeCreate,
|
||||
nodeDeleteTag,
|
||||
nodeEdit,
|
||||
nodeGotoNode,
|
||||
nodeLike,
|
||||
|
@ -30,6 +31,7 @@ import {
|
|||
nodeUpdateTags,
|
||||
} from './actions';
|
||||
import {
|
||||
apiDeleteNodeTag,
|
||||
apiGetNode,
|
||||
apiGetNodeComments,
|
||||
apiGetNodeRelated,
|
||||
|
@ -246,6 +248,13 @@ function* onUpdateTags({ id, tags }: ReturnType<typeof nodeUpdateTags>) {
|
|||
} catch {}
|
||||
}
|
||||
|
||||
function* onDeleteTag({ id, tagId }: ReturnType<typeof nodeDeleteTag>) {
|
||||
try {
|
||||
const { tags }: Unwrap<typeof apiDeleteNodeTag> = yield call(apiDeleteNodeTag, { id, tagId });
|
||||
yield put(nodeSetTags(tags));
|
||||
} catch {}
|
||||
}
|
||||
|
||||
function* onCreateSaga({ node_type: type, isLab }: ReturnType<typeof nodeCreate>) {
|
||||
if (!type || !has(type, NODE_EDITOR_DIALOGS)) return;
|
||||
|
||||
|
@ -385,6 +394,7 @@ export default function* nodeSaga() {
|
|||
yield takeLatest(NODE_ACTIONS.LOAD_NODE, onNodeLoad);
|
||||
yield takeLatest(NODE_ACTIONS.POST_COMMENT, onPostComment);
|
||||
yield takeLatest(NODE_ACTIONS.UPDATE_TAGS, onUpdateTags);
|
||||
yield takeLatest(NODE_ACTIONS.DELETE_TAG, onDeleteTag);
|
||||
yield takeLatest(NODE_ACTIONS.CREATE, onCreateSaga);
|
||||
yield takeLatest(NODE_ACTIONS.EDIT, onEditSaga);
|
||||
yield takeLatest(NODE_ACTIONS.LIKE, onLikeSaga);
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import { IComment, INode } from '~/redux/types';
|
||||
import { IComment, INode, ITag } from '~/redux/types';
|
||||
import { INodeState } from '~/redux/node/reducer';
|
||||
|
||||
export interface IEditorComponentProps {}
|
||||
|
@ -56,6 +56,14 @@ export type ApiPostNodeTagsResult = {
|
|||
node: INode;
|
||||
};
|
||||
|
||||
export type ApiDeleteNodeTagsRequest = {
|
||||
id: INode['id'];
|
||||
tagId: ITag['ID'];
|
||||
};
|
||||
export type ApiDeleteNodeTagsResult = {
|
||||
tags: ITag[];
|
||||
};
|
||||
|
||||
export type ApiPostNodeLikeRequest = { id: INode['id'] };
|
||||
export type ApiPostNodeLikeResult = { is_liked: boolean };
|
||||
|
||||
|
|
|
@ -6,7 +6,7 @@ import { CallEffect } from 'redux-saga/effects';
|
|||
import { AxiosResponse } from 'axios';
|
||||
|
||||
export interface ITag {
|
||||
id: number;
|
||||
ID: number;
|
||||
title: string;
|
||||
|
||||
data: Record<string, string>;
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue