diff --git a/frontend/src/app.tsx b/frontend/src/app.tsx index 24d5b8f..842b0c4 100644 --- a/frontend/src/app.tsx +++ b/frontend/src/app.tsx @@ -11,6 +11,7 @@ import TaggedResourcesPage from "./pages/tagged_resources_page.tsx"; import UserPage from "./pages/user_page.tsx"; import EditResourcePage from "./pages/edit_resource_page.tsx"; import AboutPage from "./pages/about_page.tsx"; +import TagsPage from "./pages/tags_page.tsx"; export default function App() { return ( @@ -28,6 +29,7 @@ export default function App() { }/> }/> }/> + }/> diff --git a/frontend/src/components/badge.tsx b/frontend/src/components/badge.tsx index 98285e8..1bb5700 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/navigator.tsx b/frontend/src/components/navigator.tsx index 8a02c0f..53c8d25 100644 --- a/frontend/src/components/navigator.tsx +++ b/frontend/src/components/navigator.tsx @@ -23,36 +23,77 @@ export default function Navigator() { }, }); + const { t } = useTranslation(); + return <>
-
-
+
+
+
+ +
+
    +
  • { + const menu = document.getElementById("navi_menu") as HTMLElement; + menu.blur(); + navigate("/"); + }}>{t("Home")}
  • +
  • { + const menu = document.getElementById("navi_menu") as HTMLElement; + menu.blur(); + navigate("/tags") + }}>{t("Tags")}
  • +
  • { + const menu = document.getElementById("navi_menu") as HTMLElement; + menu.blur(); + navigate("/about") + }}>{t("About")}
  • +
+
+
+
+ +
+
- - + + { app.isLoggedIn() && } { - app.isLoggedIn() ? : + app.isLoggedIn() ? : + }
diff --git a/frontend/src/i18n.ts b/frontend/src/i18n.ts index 384e363..ca4bb71 100644 --- a/frontend/src/i18n.ts +++ b/frontend/src/i18n.ts @@ -169,6 +169,8 @@ export const i18nData = { "Provide a file url for the server to download, and the file will be moved to the selected storage.": "Provide a file url for the server to download, and the file will be moved to the selected storage.", "Verifying your request": "Verifying your request", "Please check your network if the verification takes too long or the captcha does not appear.": "Please check your network if the verification takes too long or the captcha does not appear.", + "About": "About", + "Home": "Home", } }, "zh-CN": { @@ -341,6 +343,8 @@ export const i18nData = { "Provide a file url for the server to download, and the file will be moved to the selected storage.": "提供一个文件链接供服务器下载,文件将被移动到选定的存储中。", "Verifying your request": "正在验证您的请求", "Please check your network if the verification takes too long or the captcha does not appear.": "如果验证时间过长或验证码未出现, 请检查您的网络连接", + "About": "关于", + "Home": "首页", } }, "zh-TW": { @@ -513,6 +517,8 @@ export const i18nData = { "Provide a file url for the server to download, and the file will be moved to the selected storage.": "提供一個檔案連結供伺服器下載,檔案將被移動到選定的儲存中。", "Verifying your request": "正在驗證您的請求", "Please check your network if the verification takes too long or the captcha does not appear.": "如果驗證時間過長或驗證碼未出現,請檢查您的網絡連接。", + "About": "關於", + "Home": "首頁", } } } diff --git a/frontend/src/network/models.ts b/frontend/src/network/models.ts index 9239066..06276e7 100644 --- a/frontend/src/network/models.ts +++ b/frontend/src/network/models.ts @@ -35,6 +35,10 @@ export interface Tag { aliases: string[]; } +export interface TagWithCount extends Tag { + resources_count: number; +} + export interface CreateResourceParams { title: string; alternative_titles: string[]; diff --git a/frontend/src/network/network.ts b/frontend/src/network/network.ts index b038177..dadc3e3 100644 --- a/frontend/src/network/network.ts +++ b/frontend/src/network/network.ts @@ -14,7 +14,7 @@ import { UserWithToken, Comment, CommentWithResource, - ServerConfig, RSort + ServerConfig, RSort, TagWithCount } from "./models.ts"; class Network { @@ -284,6 +284,19 @@ class Network { } } + async getAllTags(): Promise> { + try { + const response = await axios.get(`${this.apiBaseUrl}/tag`) + return response.data + } catch (e: any) { + console.error(e) + return { + success: false, + message: e.toString(), + } + } + } + async searchTags(keyword: string, mainTag?: boolean): Promise> { try { const response = await axios.get(`${this.apiBaseUrl}/tag/search`, { diff --git a/frontend/src/pages/home_page.tsx b/frontend/src/pages/home_page.tsx index 656fd72..f5ddbbf 100644 --- a/frontend/src/pages/home_page.tsx +++ b/frontend/src/pages/home_page.tsx @@ -3,10 +3,7 @@ import ResourcesView from "../components/resources_view.tsx"; import {network} from "../network/network.ts"; import { app } from "../app.ts"; import {RSort} from "../network/models.ts"; -import Button from "../components/button.tsx"; -import {MdInfoOutline} from "react-icons/md"; import {useTranslation} from "react-i18next"; -import {useNavigate} from "react-router"; import {useAppContext} from "../components/AppContext.tsx"; export default function HomePage() { @@ -15,8 +12,6 @@ export default function HomePage() { }, []) const {t} = useTranslation() - - const navigate = useNavigate() const appContext = useAppContext() @@ -59,15 +54,6 @@ export default function HomePage() { - -
{ (tag?.aliases ?? []).map((e) => { - return {e} + return {e} }) }
diff --git a/frontend/src/pages/tags_page.tsx b/frontend/src/pages/tags_page.tsx new file mode 100644 index 0000000..4d0662a --- /dev/null +++ b/frontend/src/pages/tags_page.tsx @@ -0,0 +1,62 @@ +import {TagWithCount} from "../network/models.ts"; +import {useEffect, useState} from "react"; +import {network} from "../network/network.ts"; +import showToast from "../components/toast.ts"; +import Loading from "../components/loading.tsx"; +import Badge from "../components/badge.tsx"; +import {useNavigate} from "react-router"; + +export default function TagsPage() { + const [tags, setTags] = useState(null); + + useEffect(() => { + network.getAllTags().then((res) => { + if (res.success) { + setTags(res.data!); + } else { + showToast({ + type: "error", + message: res.message || "Failed to load tags" + }) + } + }) + }, []); + + const navigate = useNavigate() + + if (!tags) { + return + } + + const tagsMap = new Map(); + + for (const tag of tags || []) { + const type = tag.type + if (!tagsMap.has(type)) { + tagsMap.set(type, []); + } + tagsMap.get(type)?.push(tag); + } + + for (const [_, tags] of tagsMap) { + tags.sort((a, b) => b.resources_count - a.resources_count); + } + + return
+

Tags

+ {Array.from(tagsMap.entries()).map(([type, tags]) => ( +
+

{type == "" ? "Other" : type}

+

+ {tags.map(tag => ( + { + navigate(`/tag/${tag.name}`); + }} key={tag.name} className={"m-1 cursor-pointer badge-soft badge-primary"}> + {tag.name + (tag.resources_count > 0 ? ` (${tag.resources_count})` : "")} + + ))} +

+
+ ))} +
+} \ No newline at end of file diff --git a/server/api/tag.go b/server/api/tag.go index a31c67c..db0907e 100644 --- a/server/api/tag.go +++ b/server/api/tag.go @@ -122,6 +122,21 @@ func handleGetTagByName(c fiber.Ctx) error { }) } +func getAllTags(c fiber.Ctx) error { + tags, err := service.GetTagList() + if err != nil { + return err + } + if tags == nil { + tags = []model.TagViewWithCount{} + } + return c.Status(fiber.StatusOK).JSON(model.Response[*[]model.TagViewWithCount]{ + Success: true, + Data: &tags, + Message: "All tags retrieved successfully", + }) +} + func AddTagRoutes(api fiber.Router) { tag := api.Group("/tag") { @@ -130,5 +145,6 @@ func AddTagRoutes(api fiber.Router) { tag.Delete("/:id", handleDeleteTag) tag.Put("/:id/info", handleSetTagInfo) tag.Get("/:name", handleGetTagByName) + tag.Get("/", getAllTags) } } diff --git a/server/dao/resource.go b/server/dao/resource.go index 188c6dd..a4d405b 100644 --- a/server/dao/resource.go +++ b/server/dao/resource.go @@ -268,6 +268,36 @@ func GetResourceByTag(tagID uint, page int, pageSize int) ([]model.Resource, int return resources, int(totalPages), nil } +// CountResourcesByTag counts the number of resources associated with a specific tag. +func CountResourcesByTag(tagID uint) (int64, error) { + tag, err := GetTagByID(tagID) + if err != nil { + return 0, err + } + if tag.AliasOf != nil { + tag, err = GetTagByID(*tag.AliasOf) + if err != nil { + return 0, err + } + } + var tagIds []uint + tagIds = append(tagIds, tag.ID) + for _, alias := range tag.Aliases { + tagIds = append(tagIds, alias.ID) + } + var count int64 + subQuery := db.Table("resource_tags"). + Select("resource_id"). + Where("tag_id IN ?", tagIds). + Group("resource_id") + if err := db.Model(&model.Resource{}). + Where("id IN (?)", subQuery). + Count(&count).Error; err != nil { + return 0, err + } + return count, nil +} + func ExistsResource(id uint) (bool, error) { var r model.Resource if err := db.Model(&model.Resource{}).Where("id = ?", id).First(&r).Error; err != nil { diff --git a/server/dao/tag.go b/server/dao/tag.go index 3db0904..6b96559 100644 --- a/server/dao/tag.go +++ b/server/dao/tag.go @@ -82,3 +82,13 @@ func SetTagInfo(id uint, description string, aliasOf *uint, tagType string) erro } return nil } + +// ListTags retrieves all tags from the database. +// Only returns the ID, name, and type of each tag. +func ListTags() ([]model.Tag, error) { + var tags []model.Tag + if err := db.Select("id", "name", "type").Where("alias_of is null").Find(&tags).Error; err != nil { + return nil, err + } + return tags, nil +} diff --git a/server/model/tag.go b/server/model/tag.go index 75dd380..cb54ad2 100644 --- a/server/model/tag.go +++ b/server/model/tag.go @@ -33,3 +33,15 @@ func (t *Tag) ToView() *TagView { Aliases: aliases, } } + +type TagViewWithCount struct { + TagView + ResourceCount int `json:"resources_count"` // Count of resources associated with the tag +} + +func (t *TagView) WithCount(count int) *TagViewWithCount { + return &TagViewWithCount{ + TagView: *t, + ResourceCount: count, + } +} diff --git a/server/service/resource.go b/server/service/resource.go index 87108af..8e8d1ed 100644 --- a/server/service/resource.go +++ b/server/service/resource.go @@ -56,6 +56,10 @@ func CreateResource(uid uint, params *ResourceCreateParams) (uint, error) { if r, err = dao.CreateResource(r); err != nil { return 0, err } + err = updateCachedTagList() + if err != nil { + log.Error("Error updating cached tag list:", err) + } return r.ID, nil } @@ -169,6 +173,10 @@ func DeleteResource(uid, id uint) error { if err := dao.DeleteResource(id); err != nil { return err } + err = updateCachedTagList() + if err != nil { + log.Error("Error updating cached tag list:", err) + } return nil } @@ -241,5 +249,9 @@ func EditResource(uid, rid uint, params *ResourceCreateParams) error { log.Error("UpdateResource error: ", err) return model.NewInternalServerError("Failed to update resource") } + err = updateCachedTagList() + if err != nil { + log.Error("Error updating cached tag list:", err) + } return nil } diff --git a/server/service/tag.go b/server/service/tag.go index f4b9ac8..1748ac0 100644 --- a/server/service/tag.go +++ b/server/service/tag.go @@ -19,6 +19,10 @@ func CreateTag(uid uint, name string) (*model.TagView, error) { if err != nil { return nil, err } + err = updateCachedTagList() + if err != nil { + log.Error("Error updating cached tag list:", err) + } return t.ToView(), nil } @@ -63,6 +67,10 @@ func SearchTag(name string, mainTag bool) ([]model.TagView, error) { } func DeleteTag(id uint) error { + err := updateCachedTagList() + if err != nil { + log.Error("Error updating cached tag list:", err) + } return dao.DeleteTag(id) } @@ -88,5 +96,36 @@ func SetTagInfo(uid uint, id uint, description string, aliasOf *uint, tagType st return nil, err } } + err = updateCachedTagList() + if err != nil { + log.Error("Error updating cached tag list:", err) + } return t.ToView(), nil } + +var cachedTagList []model.TagViewWithCount + +func updateCachedTagList() error { + tags, err := dao.ListTags() + if err != nil { + return err + } + cachedTagList = make([]model.TagViewWithCount, 0, len(tags)) + for _, tag := range tags { + count, err := dao.CountResourcesByTag(tag.ID) + if err != nil { + return err + } + cachedTagList = append(cachedTagList, *tag.ToView().WithCount(int(count))) + } + return nil +} + +func GetTagList() ([]model.TagViewWithCount, error) { + if cachedTagList == nil { + if err := updateCachedTagList(); err != nil { + return nil, err + } + } + return cachedTagList, nil +}