From 5ef2816f98657a28096228220c5334ce3780d812 Mon Sep 17 00:00:00 2001 From: nyne Date: Fri, 30 May 2025 16:08:58 +0800 Subject: [PATCH] Add tag type and tag alias. --- frontend/src/components/badge.tsx | 2 +- frontend/src/components/tag_input.tsx | 142 +++++++++++++++++++ frontend/src/network/models.ts | 2 + frontend/src/network/network.ts | 13 +- frontend/src/pages/edit_resource_page.tsx | 142 +------------------ frontend/src/pages/publish_page.tsx | 142 +------------------ frontend/src/pages/tagged_resources_page.tsx | 52 +++++-- server/api/tag.go | 24 +++- server/dao/resource.go | 81 ++++++++--- server/dao/tag.go | 22 ++- server/model/tag.go | 17 ++- server/service/tag.go | 29 +++- 12 files changed, 336 insertions(+), 332 deletions(-) create mode 100644 frontend/src/components/tag_input.tsx diff --git a/frontend/src/components/badge.tsx b/frontend/src/components/badge.tsx index 4ca285c..98285e8 100644 --- a/frontend/src/components/badge.tsx +++ b/frontend/src/components/badge.tsx @@ -1,7 +1,7 @@ import {ReactNode} from "react"; export default function Badge({children, className, onClick }: { children: ReactNode, className?: string, onClick?: () => void }) { - return {children} + return {children} } export function BadgeAccent({children, className, onClick }: { children: ReactNode, className?: string, onClick?: () => void }) { diff --git a/frontend/src/components/tag_input.tsx b/frontend/src/components/tag_input.tsx new file mode 100644 index 0000000..a426adf --- /dev/null +++ b/frontend/src/components/tag_input.tsx @@ -0,0 +1,142 @@ +import {Tag} from "../network/models.ts"; +import {useRef, useState} from "react"; +import {useTranslation} from "react-i18next"; +import {network} from "../network/network.ts"; +import {LuInfo} from "react-icons/lu"; +import {MdSearch} from "react-icons/md"; + +export default function TagInput({ onAdd, mainTag }: { onAdd: (tag: Tag) => void, mainTag?: boolean }) { + const [keyword, setKeyword] = useState("") + const [tags, setTags] = useState([]) + const [error, setError] = useState(null) + const [isLoading, setLoading] = useState(false) + + const debounce = useRef(new Debounce(500)) + + const { t } = useTranslation(); + + const searchTags = async (keyword: string) => { + if (keyword.length === 0) { + return + } + setLoading(true) + setTags([]) + setError(null) + const res = await network.searchTags(keyword, mainTag) + if (!res.success) { + setError(res.message) + setLoading(false) + return + } + setTags(res.data!) + setLoading(false) + } + + const handleChange = async (v: string) => { + setKeyword(v) + setTags([]) + setError(null) + if (v.length !== 0) { + setLoading(true) + debounce.current.run(() => searchTags(v)) + } else { + setLoading(false) + debounce.current.cancel() + } + } + + const handleCreateTag = async (name: string) => { + setLoading(true) + const res = await network.createTag(name) + if (!res.success) { + setError(res.message) + setLoading(false) + return + } + onAdd(res.data!) + setKeyword("") + setTags([]) + setLoading(false) + const input = document.getElementById("search_tags_input") as HTMLInputElement + input.blur() + } + + let dropdownContent + if (error) { + dropdownContent =
+ + + + {error} +
+ } else if (!keyword) { + dropdownContent =
+ + + {t("Please enter a search keyword")} +
+ } else if (isLoading) { + dropdownContent =
+ + + {t("Searching...")} +
+ } else { + const haveExactMatch = tags.find((t) => t.name === keyword) !== undefined + dropdownContent = <> + { + tags.map((t) => { + return
  • { + onAdd(t); + setKeyword("") + setTags([]) + const input = document.getElementById("search_tags_input") as HTMLInputElement + input.blur() + }}>{t.name}
  • + }) + } + { + !haveExactMatch &&
  • { + handleCreateTag(keyword) + }}>{t("Create Tag")}: {keyword}
  • + } + + } + + return
    + +
      + {dropdownContent} +
    +
    +} + +class Debounce { + private timer: number | null = null + private readonly delay: number + + constructor(delay: number) { + this.delay = delay + } + + run(callback: () => void) { + if (this.timer) { + clearTimeout(this.timer) + } + this.timer = setTimeout(() => { + callback() + }, this.delay) + } + + cancel() { + if (this.timer) { + clearTimeout(this.timer) + this.timer = null + } + } +} \ No newline at end of file diff --git a/frontend/src/network/models.ts b/frontend/src/network/models.ts index 4d03107..9239066 100644 --- a/frontend/src/network/models.ts +++ b/frontend/src/network/models.ts @@ -31,6 +31,8 @@ export interface Tag { id: number; name: string; description: string; + type: string; + aliases: string[]; } export interface CreateResourceParams { diff --git a/frontend/src/network/network.ts b/frontend/src/network/network.ts index 36b969b..b038177 100644 --- a/frontend/src/network/network.ts +++ b/frontend/src/network/network.ts @@ -284,11 +284,12 @@ class Network { } } - async searchTags(keyword: string): Promise> { + async searchTags(keyword: string, mainTag?: boolean): Promise> { try { const response = await axios.get(`${this.apiBaseUrl}/tag/search`, { params: { - keyword + keyword, + mainTag } }) return response.data @@ -329,10 +330,12 @@ class Network { } } - async setTagDescription(tagId: number, description: string): Promise> { + async setTagInfo(tagId: number, description: string, aliasOf: number | null, type: string): Promise> { try { - const response = await axios.putForm(`${this.apiBaseUrl}/tag/${tagId}/description`, { - description + const response = await axios.putForm(`${this.apiBaseUrl}/tag/${tagId}/info`, { + description, + alias_of: aliasOf, + type, }) return response.data } catch (e: any) { diff --git a/frontend/src/pages/edit_resource_page.tsx b/frontend/src/pages/edit_resource_page.tsx index fb2db5d..7f703bd 100644 --- a/frontend/src/pages/edit_resource_page.tsx +++ b/frontend/src/pages/edit_resource_page.tsx @@ -1,14 +1,14 @@ -import { useEffect, useRef, useState } from "react"; -import {MdAdd, MdClose, MdDelete, MdOutlineInfo, MdSearch} from "react-icons/md"; +import { useEffect, useState } from "react"; +import {MdAdd, MdClose, MdDelete, MdOutlineInfo} from "react-icons/md"; import { Tag } from "../network/models.ts"; import { network } from "../network/network.ts"; -import { LuInfo } from "react-icons/lu"; import {useNavigate, useParams} from "react-router"; import showToast from "../components/toast.ts"; import { useTranslation } from "react-i18next"; import { app } from "../app.ts"; import { ErrorAlert } from "../components/alert.tsx"; import Loading from "../components/loading.tsx"; +import TagInput from "../components/tag_input.tsx"; export default function EditResourcePage() { const [title, setTitle] = useState("") @@ -256,139 +256,3 @@ export default function EditResourcePage() { } - -function TagInput({ onAdd }: { onAdd: (tag: Tag) => void }) { - const [keyword, setKeyword] = useState("") - const [tags, setTags] = useState([]) - const [error, setError] = useState(null) - const [isLoading, setLoading] = useState(false) - - const debounce = useRef(new Debounce(500)) - - const { t } = useTranslation(); - - const searchTags = async (keyword: string) => { - if (keyword.length === 0) { - return - } - setLoading(true) - setTags([]) - setError(null) - const res = await network.searchTags(keyword) - if (!res.success) { - setError(res.message) - setLoading(false) - return - } - setTags(res.data!) - setLoading(false) - } - - const handleChange = async (v: string) => { - setKeyword(v) - setTags([]) - setError(null) - if (v.length !== 0) { - setLoading(true) - debounce.current.run(() => searchTags(v)) - } else { - setLoading(false) - debounce.current.cancel() - } - } - - const handleCreateTag = async (name: string) => { - setLoading(true) - const res = await network.createTag(name) - if (!res.success) { - setError(res.message) - setLoading(false) - return - } - onAdd(res.data!) - setKeyword("") - setTags([]) - setLoading(false) - const input = document.getElementById("search_tags_input") as HTMLInputElement - input.blur() - } - - let dropdownContent - if (error) { - dropdownContent =
    - - - - {error} -
    - } else if (!keyword) { - dropdownContent =
    - - - {t("Please enter a search keyword")} -
    - } else if (isLoading) { - dropdownContent =
    - - - {t("Searching...")} -
    - } else { - const haveExactMatch = tags.find((t) => t.name === keyword) !== undefined - dropdownContent = <> - { - tags.map((t) => { - return
  • { - onAdd(t); - setKeyword("") - setTags([]) - const input = document.getElementById("search_tags_input") as HTMLInputElement - input.blur() - }}>{t.name}
  • - }) - } - { - !haveExactMatch &&
  • { - handleCreateTag(keyword) - }}>{t("Create Tag")}: {keyword}
  • - } - - } - - return
    - -
      - {dropdownContent} -
    -
    -} - -class Debounce { - private timer: number | null = null - private readonly delay: number - - constructor(delay: number) { - this.delay = delay - } - - run(callback: () => void) { - if (this.timer) { - clearTimeout(this.timer) - } - this.timer = setTimeout(() => { - callback() - }, this.delay) - } - - cancel() { - if (this.timer) { - clearTimeout(this.timer) - this.timer = null - } - } -} \ No newline at end of file diff --git a/frontend/src/pages/publish_page.tsx b/frontend/src/pages/publish_page.tsx index 3c50938..fad0336 100644 --- a/frontend/src/pages/publish_page.tsx +++ b/frontend/src/pages/publish_page.tsx @@ -1,14 +1,14 @@ -import { useEffect, useRef, useState } from "react"; -import {MdAdd, MdClose, MdDelete, MdOutlineInfo, MdSearch} from "react-icons/md"; +import { useEffect, useState } from "react"; +import {MdAdd, MdClose, MdDelete, MdOutlineInfo} from "react-icons/md"; import { Tag } from "../network/models.ts"; import { network } from "../network/network.ts"; -import { LuInfo } from "react-icons/lu"; import { useNavigate } from "react-router"; import showToast from "../components/toast.ts"; import { useTranslation } from "react-i18next"; import { app } from "../app.ts"; import { ErrorAlert } from "../components/alert.tsx"; import {useAppContext} from "../components/AppContext.tsx"; +import TagInput from "../components/tag_input.tsx"; export default function PublishPage() { const [title, setTitle] = useState("") @@ -231,140 +231,4 @@ export default function PublishPage() { -} - -function TagInput({ onAdd }: { onAdd: (tag: Tag) => void }) { - const [keyword, setKeyword] = useState("") - const [tags, setTags] = useState([]) - const [error, setError] = useState(null) - const [isLoading, setLoading] = useState(false) - - const debounce = useRef(new Debounce(500)) - - const { t } = useTranslation(); - - const searchTags = async (keyword: string) => { - if (keyword.length === 0) { - return - } - setLoading(true) - setTags([]) - setError(null) - const res = await network.searchTags(keyword) - if (!res.success) { - setError(res.message) - setLoading(false) - return - } - setTags(res.data!) - setLoading(false) - } - - const handleChange = async (v: string) => { - setKeyword(v) - setTags([]) - setError(null) - if (v.length !== 0) { - setLoading(true) - debounce.current.run(() => searchTags(v)) - } else { - setLoading(false) - debounce.current.cancel() - } - } - - const handleCreateTag = async (name: string) => { - setLoading(true) - const res = await network.createTag(name) - if (!res.success) { - setError(res.message) - setLoading(false) - return - } - onAdd(res.data!) - setKeyword("") - setTags([]) - setLoading(false) - const input = document.getElementById("search_tags_input") as HTMLInputElement - input.blur() - } - - let dropdownContent - if (error) { - dropdownContent =
    - - - - {error} -
    - } else if (!keyword) { - dropdownContent =
    - - - {t("Please enter a search keyword")} -
    - } else if (isLoading) { - dropdownContent =
    - - - {t("Searching...")} -
    - } else { - const haveExactMatch = tags.find((t) => t.name === keyword) !== undefined - dropdownContent = <> - { - tags.map((t) => { - return
  • { - onAdd(t); - setKeyword("") - setTags([]) - const input = document.getElementById("search_tags_input") as HTMLInputElement - input.blur() - }}>{t.name}
  • - }) - } - { - !haveExactMatch &&
  • { - handleCreateTag(keyword) - }}>{t("Create Tag")}: {keyword}
  • - } - - } - - return
    - -
      - {dropdownContent} -
    -
    -} - -class Debounce { - private timer: number | null = null - private readonly delay: number - - constructor(delay: number) { - this.delay = delay - } - - run(callback: () => void) { - if (this.timer) { - clearTimeout(this.timer) - } - this.timer = setTimeout(() => { - callback() - }, this.delay) - } - - cancel() { - if (this.timer) { - clearTimeout(this.timer) - this.timer = null - } - } } \ No newline at end of file diff --git a/frontend/src/pages/tagged_resources_page.tsx b/frontend/src/pages/tagged_resources_page.tsx index 1c01265..af7b9ba 100644 --- a/frontend/src/pages/tagged_resources_page.tsx +++ b/frontend/src/pages/tagged_resources_page.tsx @@ -8,6 +8,9 @@ import {Tag} from "../network/models.ts"; import Button from "../components/button.tsx"; import Markdown from "react-markdown"; import {app} from "../app.ts"; +import Input, {TextArea} from "../components/input.tsx"; +import TagInput from "../components/tag_input.tsx"; +import Badge from "../components/badge.tsx"; export default function TaggedResourcesPage() { const { tag: tagName } = useParams() @@ -40,7 +43,7 @@ export default function TaggedResourcesPage() { return

    - {tagName} + {tag?.name ?? tagName}

    { tag && { @@ -48,6 +51,14 @@ export default function TaggedResourcesPage() { }} /> }
    + {tag?.type &&

    {tag.type}

    } +
    + { + (tag?.aliases ?? []).map((e) => { + return {e} + }) + } +
    { (tag?.description && app.canUpload()) &&
    @@ -63,6 +74,9 @@ export default function TaggedResourcesPage() { function EditTagButton({tag, onEdited}: { tag: Tag, onEdited: (t: Tag) => void }) { const [description, setDescription] = useState(tag.description); + const [isAlias, setIsAlias] = useState(false); + const [aliasOf, setAliasOf] = useState(null); + const [type, setType] = useState(tag.type); const [isLoading, setIsLoading] = useState(false); const [error, setError] = useState(null); const { t } = useTranslation(); @@ -72,16 +86,13 @@ function EditTagButton({tag, onEdited}: { tag: Tag, onEdited: (t: Tag) => void } }, [tag.description]); const submit = async () => { - if (description === tag.description) { - return; - } if (description && description.length > 256) { setError(t("Description is too long")); return; } setIsLoading(true); setError(null); - const res = await network.setTagDescription(tag.id, description); + const res = await network.setTagInfo(tag.id, description, aliasOf?.id ?? null, type); setIsLoading(false); if (res.success) { const dialog = document.getElementById("edit_tag_dialog") as HTMLDialogElement; @@ -98,11 +109,34 @@ function EditTagButton({tag, onEdited}: { tag: Tag, onEdited: (t: Tag) => void } dialog.showModal(); }}>{t("Edit")} -
    +

    {t("Edit Tag")}

    -

    {t("Set the description of the tag.")}

    -

    {t("Use markdown format.")}

    -