mirror of
https://github.com/usememos/memos.git
synced 2025-03-22 05:30:16 +01:00
feat: create tag dialog (#814)
This commit is contained in:
parent
e4a8a4d708
commit
68a77b6e1f
106
server/tag.go
106
server/tag.go
@ -2,20 +2,85 @@ package server
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"regexp"
|
"regexp"
|
||||||
"sort"
|
"sort"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"github.com/usememos/memos/api"
|
"github.com/usememos/memos/api"
|
||||||
|
"github.com/usememos/memos/common"
|
||||||
|
metric "github.com/usememos/memos/plugin/metrics"
|
||||||
|
|
||||||
"github.com/labstack/echo/v4"
|
"github.com/labstack/echo/v4"
|
||||||
)
|
)
|
||||||
|
|
||||||
var tagRegexpList = []*regexp.Regexp{regexp.MustCompile(`^#([^\s#]+?) `), regexp.MustCompile(`^#([^\s#]+?)\s`), regexp.MustCompile(`[^\S]#([^\s#]+?)$`), regexp.MustCompile(`[^\S]#([^\s#]+?) `), regexp.MustCompile(` #([^\s#]+?) `)}
|
|
||||||
|
|
||||||
func (s *Server) registerTagRoutes(g *echo.Group) {
|
func (s *Server) registerTagRoutes(g *echo.Group) {
|
||||||
|
g.POST("/tag", func(c echo.Context) error {
|
||||||
|
ctx := c.Request().Context()
|
||||||
|
userID, ok := c.Get(getUserIDContextKey()).(int)
|
||||||
|
if !ok {
|
||||||
|
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
|
||||||
|
}
|
||||||
|
|
||||||
|
tagUpsert := &api.TagUpsert{
|
||||||
|
CreatorID: userID,
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(c.Request().Body).Decode(tagUpsert); err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusBadRequest, "Malformatted post tag request").SetInternal(err)
|
||||||
|
}
|
||||||
|
if tagUpsert.Name == "" {
|
||||||
|
return echo.NewHTTPError(http.StatusBadRequest, "Tag name shouldn't be empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
tag, err := s.Store.UpsertTag(ctx, tagUpsert)
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to upsert tag").SetInternal(err)
|
||||||
|
}
|
||||||
|
s.Collector.Collect(ctx, &metric.Metric{
|
||||||
|
Name: "tag created",
|
||||||
|
})
|
||||||
|
|
||||||
|
c.Response().Header().Set(echo.HeaderContentType, echo.MIMEApplicationJSONCharsetUTF8)
|
||||||
|
if err := json.NewEncoder(c.Response().Writer).Encode(composeResponse(tag.Name)); err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to encode tag response").SetInternal(err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
g.GET("/tag", func(c echo.Context) error {
|
g.GET("/tag", func(c echo.Context) error {
|
||||||
|
ctx := c.Request().Context()
|
||||||
|
tagFind := &api.TagFind{}
|
||||||
|
if userID, err := strconv.Atoi(c.QueryParam("creatorId")); err == nil {
|
||||||
|
tagFind.CreatorID = userID
|
||||||
|
}
|
||||||
|
|
||||||
|
if tagFind.CreatorID == 0 {
|
||||||
|
currentUserID, ok := c.Get(getUserIDContextKey()).(int)
|
||||||
|
if !ok {
|
||||||
|
return echo.NewHTTPError(http.StatusBadRequest, "Missing user id to find tag")
|
||||||
|
}
|
||||||
|
tagFind.CreatorID = currentUserID
|
||||||
|
}
|
||||||
|
|
||||||
|
tagList, err := s.Store.FindTagList(ctx, tagFind)
|
||||||
|
if err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to find tag list").SetInternal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
tagNameList := []string{}
|
||||||
|
for _, tag := range tagList {
|
||||||
|
tagNameList = append(tagNameList, tag.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
c.Response().Header().Set(echo.HeaderContentType, echo.MIMEApplicationJSONCharsetUTF8)
|
||||||
|
if err := json.NewEncoder(c.Response().Writer).Encode(composeResponse(tagNameList)); err != nil {
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, "Failed to encode tags response").SetInternal(err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
g.GET("/tag/suggestion", func(c echo.Context) error {
|
||||||
ctx := c.Request().Context()
|
ctx := c.Request().Context()
|
||||||
contentSearch := "#"
|
contentSearch := "#"
|
||||||
normalRowStatus := api.Normal
|
normalRowStatus := api.Normal
|
||||||
@ -65,15 +130,42 @@ func (s *Server) registerTagRoutes(g *echo.Group) {
|
|||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
})
|
})
|
||||||
|
|
||||||
|
g.DELETE("/tag/:tagName", func(c echo.Context) error {
|
||||||
|
ctx := c.Request().Context()
|
||||||
|
userID, ok := c.Get(getUserIDContextKey()).(int)
|
||||||
|
if !ok {
|
||||||
|
return echo.NewHTTPError(http.StatusUnauthorized, "Missing user in session")
|
||||||
|
}
|
||||||
|
|
||||||
|
tagName := c.Param("tagName")
|
||||||
|
if tagName == "" {
|
||||||
|
return echo.NewHTTPError(http.StatusBadRequest, "Tag name cannot be empty")
|
||||||
|
}
|
||||||
|
|
||||||
|
tagDelete := &api.TagDelete{
|
||||||
|
Name: tagName,
|
||||||
|
CreatorID: userID,
|
||||||
|
}
|
||||||
|
if err := s.Store.DeleteTag(ctx, tagDelete); err != nil {
|
||||||
|
if common.ErrorCode(err) == common.NotFound {
|
||||||
|
return echo.NewHTTPError(http.StatusNotFound, fmt.Sprintf("Tag name not found: %s", tagName))
|
||||||
|
}
|
||||||
|
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("Failed to delete tag name: %v", tagName)).SetInternal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return c.JSON(http.StatusOK, true)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var tagRegexp = regexp.MustCompile(`#([^\s#]+)`)
|
||||||
|
|
||||||
func findTagListFromMemoContent(memoContent string) []string {
|
func findTagListFromMemoContent(memoContent string) []string {
|
||||||
tagMapSet := make(map[string]bool)
|
tagMapSet := make(map[string]bool)
|
||||||
for _, tagRegexp := range tagRegexpList {
|
matches := tagRegexp.FindAllStringSubmatch(memoContent, -1)
|
||||||
for _, rawTag := range tagRegexp.FindAllString(memoContent, -1) {
|
for _, v := range matches {
|
||||||
tag := tagRegexp.ReplaceAllString(rawTag, "$1")
|
tagName := v[1]
|
||||||
tagMapSet[tag] = true
|
tagMapSet[tagName] = true
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
tagList := []string{}
|
tagList := []string{}
|
||||||
|
@ -31,11 +31,11 @@ func TestFindTagListFromMemoContent(t *testing.T) {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
memoContent: "#tag1 123123#tag2 \n#tag3 #tag4 ",
|
memoContent: "#tag1 123123#tag2 \n#tag3 #tag4 ",
|
||||||
want: []string{"tag1", "tag3", "tag4"},
|
want: []string{"tag1", "tag2", "tag3", "tag4"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
memoContent: "#tag1 http://123123.com?123123#tag2 \n#tag3 #tag4 http://123123.com?123123#tag2) ",
|
memoContent: "#tag1 http://123123.com?123123#tag2 \n#tag3 #tag4 http://123123.com?123123#tag2) ",
|
||||||
want: []string{"tag1", "tag3", "tag4"},
|
want: []string{"tag1", "tag2", "tag2)", "tag3", "tag4"},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
for _, test := range tests {
|
for _, test := range tests {
|
||||||
|
@ -87,7 +87,9 @@ func upsertTag(ctx context.Context, tx *sql.Tx, upsert *api.TagUpsert) (*tagRaw,
|
|||||||
name, creator_id
|
name, creator_id
|
||||||
)
|
)
|
||||||
VALUES (?, ?)
|
VALUES (?, ?)
|
||||||
ON CONFLICT(name, creator_id) DO NOTHING
|
ON CONFLICT(name, creator_id) DO UPDATE
|
||||||
|
SET
|
||||||
|
name = EXCLUDED.name
|
||||||
RETURNING name, creator_id
|
RETURNING name, creator_id
|
||||||
`
|
`
|
||||||
var tagRaw tagRaw
|
var tagRaw tagRaw
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
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 { filterConsts, getDefaultFilter, relationConsts } from "../helpers/filter";
|
||||||
import useLoading from "../hooks/useLoading";
|
import useLoading from "../hooks/useLoading";
|
||||||
import Icon from "./Icon";
|
import Icon from "./Icon";
|
||||||
@ -162,9 +162,9 @@ interface MemoFilterInputerProps {
|
|||||||
const MemoFilterInputer: React.FC<MemoFilterInputerProps> = (props: MemoFilterInputerProps) => {
|
const MemoFilterInputer: React.FC<MemoFilterInputerProps> = (props: MemoFilterInputerProps) => {
|
||||||
const { index, filter, handleFilterChange, handleFilterRemove } = props;
|
const { index, filter, handleFilterChange, handleFilterRemove } = props;
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const memoStore = useMemoStore();
|
const tagStore = useTagStore();
|
||||||
const [value, setValue] = useState<string>(filter.value.value);
|
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 { type } = filter;
|
||||||
|
|
||||||
const typeDataSource = Object.values(filterConsts).map(({ text, value }) => ({ text: t(text), value }));
|
const typeDataSource = Object.values(filterConsts).map(({ text, value }) => ({ text: t(text), value }));
|
||||||
|
140
web/src/components/CreateTagDialog.tsx
Normal file
140
web/src/components/CreateTagDialog.tsx
Normal 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;
|
@ -3,7 +3,7 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { deleteMemoResource, upsertMemoResource } from "../helpers/api";
|
import { deleteMemoResource, upsertMemoResource } from "../helpers/api";
|
||||||
import { TAB_SPACE_WIDTH, UNKNOWN_ID, VISIBILITY_SELECTOR_ITEMS } from "../helpers/consts";
|
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 * as storage from "../helpers/storage";
|
||||||
import Icon from "./Icon";
|
import Icon from "./Icon";
|
||||||
import toastHelper from "./Toast";
|
import toastHelper from "./Toast";
|
||||||
@ -44,6 +44,7 @@ const MemoEditor = () => {
|
|||||||
const editorStore = useEditorStore();
|
const editorStore = useEditorStore();
|
||||||
const locationStore = useLocationStore();
|
const locationStore = useLocationStore();
|
||||||
const memoStore = useMemoStore();
|
const memoStore = useMemoStore();
|
||||||
|
const tagStore = useTagStore();
|
||||||
const resourceStore = useResourceStore();
|
const resourceStore = useResourceStore();
|
||||||
|
|
||||||
const [state, setState] = useState<State>({
|
const [state, setState] = useState<State>({
|
||||||
@ -57,7 +58,7 @@ const MemoEditor = () => {
|
|||||||
const tagSelectorRef = useRef<HTMLDivElement>(null);
|
const tagSelectorRef = useRef<HTMLDivElement>(null);
|
||||||
const user = userStore.state.user as User;
|
const user = userStore.state.user as User;
|
||||||
const setting = user.setting;
|
const setting = user.setting;
|
||||||
const tags = memoStore.state.tags;
|
const tags = tagStore.state.tags;
|
||||||
const memoVisibilityOptionSelectorItems = VISIBILITY_SELECTOR_ITEMS.map((item) => {
|
const memoVisibilityOptionSelectorItems = VISIBILITY_SELECTOR_ITEMS.map((item) => {
|
||||||
return {
|
return {
|
||||||
value: item.value,
|
value: item.value,
|
||||||
|
@ -1,8 +1,9 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
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 useToggle from "../hooks/useToggle";
|
||||||
import Icon from "./Icon";
|
import Icon from "./Icon";
|
||||||
|
import showCreateTagDialog from "./CreateTagDialog";
|
||||||
import "../less/tag-list.less";
|
import "../less/tag-list.less";
|
||||||
|
|
||||||
interface Tag {
|
interface Tag {
|
||||||
@ -15,16 +16,14 @@ const TagList = () => {
|
|||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const locationStore = useLocationStore();
|
const locationStore = useLocationStore();
|
||||||
const userStore = useUserStore();
|
const userStore = useUserStore();
|
||||||
const memoStore = useMemoStore();
|
const tagStore = useTagStore();
|
||||||
const { memos, tags: tagsText } = memoStore.state;
|
const tagsText = tagStore.state.tags;
|
||||||
const query = locationStore.state.query;
|
const query = locationStore.state.query;
|
||||||
const [tags, setTags] = useState<Tag[]>([]);
|
const [tags, setTags] = useState<Tag[]>([]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (memos.length > 0) {
|
tagStore.fetchTags();
|
||||||
memoStore.updateTagsState();
|
}, []);
|
||||||
}
|
|
||||||
}, [memos]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const sortedTags = Array.from(tagsText).sort();
|
const sortedTags = Array.from(tagsText).sort();
|
||||||
@ -72,7 +71,15 @@ const TagList = () => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="tags-wrapper">
|
<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">
|
<div className="tags-container">
|
||||||
{tags.map((t, idx) => (
|
{tags.map((t, idx) => (
|
||||||
<TagItemContainer key={t.text + "-" + idx} tag={t} tagQuery={query?.tag} />
|
<TagItemContainer key={t.text + "-" + idx} tag={t} tagQuery={query?.tag} />
|
||||||
|
@ -1,7 +1,7 @@
|
|||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useNavigate } from "react-router-dom";
|
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 { getMemoStats } from "../helpers/api";
|
||||||
import * as utils from "../helpers/utils";
|
import * as utils from "../helpers/utils";
|
||||||
import Icon from "./Icon";
|
import Icon from "./Icon";
|
||||||
@ -17,8 +17,10 @@ const UserBanner = () => {
|
|||||||
const locationStore = useLocationStore();
|
const locationStore = useLocationStore();
|
||||||
const userStore = useUserStore();
|
const userStore = useUserStore();
|
||||||
const memoStore = useMemoStore();
|
const memoStore = useMemoStore();
|
||||||
|
const tagStore = useTagStore();
|
||||||
const { user, owner } = userStore.state;
|
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 [username, setUsername] = useState("Memos");
|
||||||
const [memoAmount, setMemoAmount] = useState(0);
|
const [memoAmount, setMemoAmount] = useState(0);
|
||||||
const [createdDays, setCreatedDays] = useState(0);
|
const [createdDays, setCreatedDays] = useState(0);
|
||||||
|
@ -187,6 +187,20 @@ export function getTagList(tagFind?: TagFind) {
|
|||||||
return axios.get<ResponseObject<string[]>>(`/api/tag?${queryList.join("&")}`);
|
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() {
|
export async function getRepoStarCount() {
|
||||||
const { data } = await axios.get(`https://api.github.com/repos/usememos/memos`, {
|
const { data } = await axios.get(`https://api.github.com/repos/usememos/memos`, {
|
||||||
headers: {
|
headers: {
|
||||||
|
@ -1,10 +1,6 @@
|
|||||||
.tags-wrapper {
|
.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;
|
@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 {
|
> .tags-container {
|
||||||
@apply flex flex-col justify-start items-start relative w-full h-auto flex-nowrap mb-2 mt-1;
|
@apply flex flex-col justify-start items-start relative w-full h-auto flex-nowrap mb-2 mt-1;
|
||||||
|
|
||||||
|
@ -8,12 +8,14 @@ import shortcutReducer from "./reducer/shortcut";
|
|||||||
import locationReducer from "./reducer/location";
|
import locationReducer from "./reducer/location";
|
||||||
import resourceReducer from "./reducer/resource";
|
import resourceReducer from "./reducer/resource";
|
||||||
import dialogReducer from "./reducer/dialog";
|
import dialogReducer from "./reducer/dialog";
|
||||||
|
import tagReducer from "./reducer/tag";
|
||||||
|
|
||||||
const store = configureStore({
|
const store = configureStore({
|
||||||
reducer: {
|
reducer: {
|
||||||
global: globalReducer,
|
global: globalReducer,
|
||||||
user: userReducer,
|
user: userReducer,
|
||||||
memo: memoReducer,
|
memo: memoReducer,
|
||||||
|
tag: tagReducer,
|
||||||
editor: editorReducer,
|
editor: editorReducer,
|
||||||
shortcut: shortcutReducer,
|
shortcut: shortcutReducer,
|
||||||
location: locationReducer,
|
location: locationReducer,
|
||||||
|
@ -2,6 +2,7 @@ export * from "./editor";
|
|||||||
export * from "./global";
|
export * from "./global";
|
||||||
export * from "./location";
|
export * from "./location";
|
||||||
export * from "./memo";
|
export * from "./memo";
|
||||||
|
export * from "./tag";
|
||||||
export * from "./resource";
|
export * from "./resource";
|
||||||
export * from "./shortcut";
|
export * from "./shortcut";
|
||||||
export * from "./user";
|
export * from "./user";
|
||||||
|
@ -3,7 +3,7 @@ import * as api from "../../helpers/api";
|
|||||||
import { DEFAULT_MEMO_LIMIT } from "../../helpers/consts";
|
import { DEFAULT_MEMO_LIMIT } from "../../helpers/consts";
|
||||||
import { useUserStore } from "./";
|
import { useUserStore } from "./";
|
||||||
import store, { useAppSelector } 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 => {
|
const convertResponseModelMemo = (memo: Memo): Memo => {
|
||||||
return {
|
return {
|
||||||
@ -85,14 +85,6 @@ export const useMemoStore = () => {
|
|||||||
|
|
||||||
return await fetchMemoById(memoId);
|
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[]> => {
|
getLinkedMemos: async (memoId: MemoId): Promise<Memo[]> => {
|
||||||
const regex = new RegExp(`[@(.+?)](${memoId})`);
|
const regex = new RegExp(`[@(.+?)](${memoId})`);
|
||||||
return state.memos.filter((m) => m.content.match(regex));
|
return state.memos.filter((m) => m.content.match(regex));
|
||||||
|
31
web/src/store/module/tag.ts
Normal file
31
web/src/store/module/tag.ts
Normal 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));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
@ -2,7 +2,6 @@ import { createSlice, PayloadAction } from "@reduxjs/toolkit";
|
|||||||
|
|
||||||
interface State {
|
interface State {
|
||||||
memos: Memo[];
|
memos: Memo[];
|
||||||
tags: string[];
|
|
||||||
isFetching: boolean;
|
isFetching: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -10,7 +9,6 @@ const memoSlice = createSlice({
|
|||||||
name: "memo",
|
name: "memo",
|
||||||
initialState: {
|
initialState: {
|
||||||
memos: [],
|
memos: [],
|
||||||
tags: [],
|
|
||||||
// isFetching flag should starts with true.
|
// isFetching flag should starts with true.
|
||||||
isFetching: true,
|
isFetching: true,
|
||||||
} as State,
|
} as State,
|
||||||
@ -52,12 +50,6 @@ const memoSlice = createSlice({
|
|||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
setTags: (state, action: PayloadAction<string[]>) => {
|
|
||||||
return {
|
|
||||||
...state,
|
|
||||||
tags: action.payload,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
setIsFetching: (state, action: PayloadAction<boolean>) => {
|
setIsFetching: (state, action: PayloadAction<boolean>) => {
|
||||||
return {
|
return {
|
||||||
...state,
|
...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;
|
export default memoSlice.reducer;
|
||||||
|
42
web/src/store/reducer/tag.ts
Normal file
42
web/src/store/reducer/tag.ts
Normal 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;
|
Loading…
x
Reference in New Issue
Block a user