feat: create tag dialog (#814)

This commit is contained in:
boojack
2022-12-21 23:59:03 +08:00
committed by GitHub
parent e4a8a4d708
commit 68a77b6e1f
16 changed files with 361 additions and 47 deletions

View File

@@ -1,7 +1,7 @@
import dayjs from "dayjs";
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useMemoStore, useShortcutStore } from "../store/module";
import { useShortcutStore, useTagStore } from "../store/module";
import { filterConsts, getDefaultFilter, relationConsts } from "../helpers/filter";
import useLoading from "../hooks/useLoading";
import Icon from "./Icon";
@@ -162,9 +162,9 @@ interface MemoFilterInputerProps {
const MemoFilterInputer: React.FC<MemoFilterInputerProps> = (props: MemoFilterInputerProps) => {
const { index, filter, handleFilterChange, handleFilterRemove } = props;
const { t } = useTranslation();
const memoStore = useMemoStore();
const tagStore = useTagStore();
const [value, setValue] = useState<string>(filter.value.value);
const tags = Array.from(memoStore.getState().tags);
const tags = Array.from(tagStore.getState().tags);
const { type } = filter;
const typeDataSource = Object.values(filterConsts).map(({ text, value }) => ({ text: t(text), value }));

View File

@@ -0,0 +1,140 @@
import { TextField } from "@mui/joy";
import { useEffect, useState } from "react";
import { useTagStore } from "../store/module";
import { getTagSuggestionList } from "../helpers/api";
import Tag from "../labs/marked/parser/Tag";
import Icon from "./Icon";
import toastHelper from "./Toast";
import { generateDialog } from "./Dialog";
type Props = DialogProps;
const validateTagName = (tagName: string): boolean => {
const matchResult = Tag.matcher(`#${tagName}`);
if (!matchResult || matchResult[1] !== tagName) {
return false;
}
return true;
};
const CreateTagDialog: React.FC<Props> = (props: Props) => {
const { destroy } = props;
const tagStore = useTagStore();
const [tagName, setTagName] = useState<string>("");
const [suggestTagNameList, setSuggestTagNameList] = useState<string[]>([]);
const tagNameList = tagStore.state.tags;
useEffect(() => {
getTagSuggestionList().then(({ data }) => {
setSuggestTagNameList(data.data.filter((tag) => !tagNameList.includes(tag) && validateTagName(tag)));
});
}, [tagNameList]);
const handleTagNameChanged = (e: React.ChangeEvent<HTMLInputElement>) => {
const tagName = e.target.value as string;
setTagName(tagName.trim());
};
const handleRemoveSuggestTag = (tag: string) => {
setSuggestTagNameList(suggestTagNameList.filter((item) => item !== tag));
};
const handleSaveBtnClick = async () => {
if (!validateTagName(tagName)) {
toastHelper.error("Invalid tag name");
return;
}
try {
await tagStore.upsertTag(tagName);
} catch (error: any) {
console.error(error);
toastHelper.error(error.response.data.message);
}
};
const handleDeleteTag = async (tag: string) => {
await tagStore.deleteTag(tag);
};
const handleSaveSuggestTagList = async () => {
for (const tagName of suggestTagNameList) {
if (validateTagName(tagName)) {
await tagStore.upsertTag(tagName);
}
}
};
return (
<>
<div className="dialog-header-container">
<p className="title-text">Create Tag</p>
<button className="btn close-btn" onClick={() => destroy()}>
<Icon.X />
</button>
</div>
<div className="dialog-content-container !w-80">
<TextField
className="mb-2"
placeholder="TAG_NAME"
value={tagName}
onChange={handleTagNameChanged}
fullWidth
startDecorator={<Icon.Hash className="w-4 h-auto" />}
endDecorator={<Icon.CheckCircle onClick={handleSaveBtnClick} className="w-4 h-auto" />}
/>
{tagNameList.length > 0 && (
<>
<p className="w-full mt-2 mb-1 text-sm text-gray-400">All tags</p>
<div className="w-full flex flex-row justify-start items-start flex-wrap">
{tagNameList.map((tag) => (
<span
className="text-sm mr-2 mt-1 font-mono cursor-pointer truncate hover:opacity-60 hover:line-through"
key={tag}
onClick={() => handleDeleteTag(tag)}
>
#{tag}
</span>
))}
</div>
</>
)}
{suggestTagNameList.length > 0 && (
<>
<p className="w-full mt-2 mb-1 text-sm text-gray-400">Tag suggestions</p>
<div className="w-full flex flex-row justify-start items-start flex-wrap">
{suggestTagNameList.map((tag) => (
<span
className="text-sm mr-2 mt-1 font-mono cursor-pointer truncate hover:opacity-60 hover:line-through"
key={tag}
onClick={() => handleRemoveSuggestTag(tag)}
>
#{tag}
</span>
))}
</div>
<button
className="mt-2 text-sm border px-2 leading-6 rounded cursor-pointer hover:opacity-80 hover:shadow"
onClick={handleSaveSuggestTagList}
>
Save all
</button>
</>
)}
</div>
</>
);
};
function showCreateTagDialog() {
generateDialog(
{
className: "create-tag-dialog",
dialogName: "create-tag-dialog",
},
CreateTagDialog
);
}
export default showCreateTagDialog;

View File

@@ -3,7 +3,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"
import { useTranslation } from "react-i18next";
import { deleteMemoResource, upsertMemoResource } from "../helpers/api";
import { TAB_SPACE_WIDTH, UNKNOWN_ID, VISIBILITY_SELECTOR_ITEMS } from "../helpers/consts";
import { useEditorStore, useLocationStore, useMemoStore, useResourceStore, useUserStore } from "../store/module";
import { useEditorStore, useLocationStore, useMemoStore, useResourceStore, useTagStore, useUserStore } from "../store/module";
import * as storage from "../helpers/storage";
import Icon from "./Icon";
import toastHelper from "./Toast";
@@ -44,6 +44,7 @@ const MemoEditor = () => {
const editorStore = useEditorStore();
const locationStore = useLocationStore();
const memoStore = useMemoStore();
const tagStore = useTagStore();
const resourceStore = useResourceStore();
const [state, setState] = useState<State>({
@@ -57,7 +58,7 @@ const MemoEditor = () => {
const tagSelectorRef = useRef<HTMLDivElement>(null);
const user = userStore.state.user as User;
const setting = user.setting;
const tags = memoStore.state.tags;
const tags = tagStore.state.tags;
const memoVisibilityOptionSelectorItems = VISIBILITY_SELECTOR_ITEMS.map((item) => {
return {
value: item.value,

View File

@@ -1,8 +1,9 @@
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useLocationStore, useMemoStore, useUserStore } from "../store/module";
import { useLocationStore, useTagStore, useUserStore } from "../store/module";
import useToggle from "../hooks/useToggle";
import Icon from "./Icon";
import showCreateTagDialog from "./CreateTagDialog";
import "../less/tag-list.less";
interface Tag {
@@ -15,16 +16,14 @@ const TagList = () => {
const { t } = useTranslation();
const locationStore = useLocationStore();
const userStore = useUserStore();
const memoStore = useMemoStore();
const { memos, tags: tagsText } = memoStore.state;
const tagStore = useTagStore();
const tagsText = tagStore.state.tags;
const query = locationStore.state.query;
const [tags, setTags] = useState<Tag[]>([]);
useEffect(() => {
if (memos.length > 0) {
memoStore.updateTagsState();
}
}, [memos]);
tagStore.fetchTags();
}, []);
useEffect(() => {
const sortedTags = Array.from(tagsText).sort();
@@ -72,7 +71,15 @@ const TagList = () => {
return (
<div className="tags-wrapper">
<p className="title-text">{t("common.tags")}</p>
<div className="w-full flex flex-row justify-start items-center px-4 mb-1">
<span className="text-sm leading-6 font-mono text-gray-400">{t("common.tags")}</span>
<button
onClick={() => showCreateTagDialog()}
className="flex flex-col justify-center items-center w-5 h-5 bg-gray-200 dark:bg-zinc-700 rounded ml-2 hover:shadow"
>
<Icon.Plus className="w-4 h-4 text-gray-400" />
</button>
</div>
<div className="tags-container">
{tags.map((t, idx) => (
<TagItemContainer key={t.text + "-" + idx} tag={t} tagQuery={query?.tag} />

View File

@@ -1,7 +1,7 @@
import { useCallback, useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { useLocationStore, useMemoStore, useUserStore } from "../store/module";
import { useLocationStore, useMemoStore, useTagStore, useUserStore } from "../store/module";
import { getMemoStats } from "../helpers/api";
import * as utils from "../helpers/utils";
import Icon from "./Icon";
@@ -17,8 +17,10 @@ const UserBanner = () => {
const locationStore = useLocationStore();
const userStore = useUserStore();
const memoStore = useMemoStore();
const tagStore = useTagStore();
const { user, owner } = userStore.state;
const { memos, tags } = memoStore.state;
const { memos } = memoStore.state;
const tags = tagStore.state.tags;
const [username, setUsername] = useState("Memos");
const [memoAmount, setMemoAmount] = useState(0);
const [createdDays, setCreatedDays] = useState(0);

View File

@@ -187,6 +187,20 @@ export function getTagList(tagFind?: TagFind) {
return axios.get<ResponseObject<string[]>>(`/api/tag?${queryList.join("&")}`);
}
export function getTagSuggestionList() {
return axios.get<ResponseObject<string[]>>(`/api/tag/suggestion`);
}
export function upsertTag(tagName: string) {
return axios.post<ResponseObject<string>>(`/api/tag`, {
name: tagName,
});
}
export function deleteTag(tagName: string) {
return axios.delete<ResponseObject<string>>(`/api/tag/${tagName}`);
}
export async function getRepoStarCount() {
const { data } = await axios.get(`https://api.github.com/repos/usememos/memos`, {
headers: {

View File

@@ -1,10 +1,6 @@
.tags-wrapper {
@apply flex flex-col justify-start items-start px-2 w-full h-auto flex-nowrap pb-4 mt-2 grow hide-scrollbar;
> .title-text {
@apply w-full px-4 text-sm leading-6 font-mono text-gray-400;
}
> .tags-container {
@apply flex flex-col justify-start items-start relative w-full h-auto flex-nowrap mb-2 mt-1;

View File

@@ -8,12 +8,14 @@ import shortcutReducer from "./reducer/shortcut";
import locationReducer from "./reducer/location";
import resourceReducer from "./reducer/resource";
import dialogReducer from "./reducer/dialog";
import tagReducer from "./reducer/tag";
const store = configureStore({
reducer: {
global: globalReducer,
user: userReducer,
memo: memoReducer,
tag: tagReducer,
editor: editorReducer,
shortcut: shortcutReducer,
location: locationReducer,

View File

@@ -2,6 +2,7 @@ export * from "./editor";
export * from "./global";
export * from "./location";
export * from "./memo";
export * from "./tag";
export * from "./resource";
export * from "./shortcut";
export * from "./user";

View File

@@ -3,7 +3,7 @@ import * as api from "../../helpers/api";
import { DEFAULT_MEMO_LIMIT } from "../../helpers/consts";
import { useUserStore } from "./";
import store, { useAppSelector } from "../";
import { createMemo, deleteMemo, patchMemo, setIsFetching, setMemos, setTags } from "../reducer/memo";
import { createMemo, deleteMemo, patchMemo, setIsFetching, setMemos } from "../reducer/memo";
const convertResponseModelMemo = (memo: Memo): Memo => {
return {
@@ -85,14 +85,6 @@ export const useMemoStore = () => {
return await fetchMemoById(memoId);
},
updateTagsState: async () => {
const tagFind: TagFind = {};
if (userStore.isVisitorMode()) {
tagFind.creatorId = userStore.getUserIdFromPath();
}
const { data } = (await api.getTagList(tagFind)).data;
store.dispatch(setTags(data));
},
getLinkedMemos: async (memoId: MemoId): Promise<Memo[]> => {
const regex = new RegExp(`[@(.+?)](${memoId})`);
return state.memos.filter((m) => m.content.match(regex));

View File

@@ -0,0 +1,31 @@
import store, { useAppSelector } from "..";
import * as api from "../../helpers/api";
import { deleteTag, setTags, upsertTag } from "../reducer/tag";
import { useUserStore } from "./";
export const useTagStore = () => {
const state = useAppSelector((state) => state.tag);
const userStore = useUserStore();
return {
state,
getState: () => {
return store.getState().tag;
},
fetchTags: async () => {
const tagFind: TagFind = {};
if (userStore.isVisitorMode()) {
tagFind.creatorId = userStore.getUserIdFromPath();
}
const { data } = (await api.getTagList(tagFind)).data;
store.dispatch(setTags(data));
},
upsertTag: async (tagName: string) => {
await api.upsertTag(tagName);
store.dispatch(upsertTag(tagName));
},
deleteTag: async (tagName: string) => {
await api.deleteTag(tagName);
store.dispatch(deleteTag(tagName));
},
};
};

View File

@@ -2,7 +2,6 @@ import { createSlice, PayloadAction } from "@reduxjs/toolkit";
interface State {
memos: Memo[];
tags: string[];
isFetching: boolean;
}
@@ -10,7 +9,6 @@ const memoSlice = createSlice({
name: "memo",
initialState: {
memos: [],
tags: [],
// isFetching flag should starts with true.
isFetching: true,
} as State,
@@ -52,12 +50,6 @@ const memoSlice = createSlice({
}),
};
},
setTags: (state, action: PayloadAction<string[]>) => {
return {
...state,
tags: action.payload,
};
},
setIsFetching: (state, action: PayloadAction<boolean>) => {
return {
...state,
@@ -67,6 +59,6 @@ const memoSlice = createSlice({
},
});
export const { setMemos, createMemo, patchMemo, deleteMemo, setTags, setIsFetching } = memoSlice.actions;
export const { setMemos, createMemo, patchMemo, deleteMemo, setIsFetching } = memoSlice.actions;
export default memoSlice.reducer;

View File

@@ -0,0 +1,42 @@
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
interface State {
tags: string[];
}
const tagSlice = createSlice({
name: "tag",
initialState: {
tags: [],
} as State,
reducers: {
setTags: (state, action: PayloadAction<string[]>) => {
return {
...state,
tags: action.payload,
};
},
upsertTag: (state, action: PayloadAction<string>) => {
if (state.tags.includes(action.payload)) {
return state;
}
return {
...state,
tags: state.tags.concat(action.payload),
};
},
deleteTag: (state, action: PayloadAction<string>) => {
return {
...state,
tags: state.tags.filter((tag) => {
return tag !== action.payload;
}),
};
},
},
});
export const { setTags, upsertTag, deleteTag } = tagSlice.actions;
export default tagSlice.reducer;