Add tags page.

This commit is contained in:
2025-05-30 17:39:57 +08:00
parent d4bfb52ef9
commit 4f99bff2f5
15 changed files with 262 additions and 29 deletions

View File

@@ -11,6 +11,7 @@ import TaggedResourcesPage from "./pages/tagged_resources_page.tsx";
import UserPage from "./pages/user_page.tsx"; import UserPage from "./pages/user_page.tsx";
import EditResourcePage from "./pages/edit_resource_page.tsx"; import EditResourcePage from "./pages/edit_resource_page.tsx";
import AboutPage from "./pages/about_page.tsx"; import AboutPage from "./pages/about_page.tsx";
import TagsPage from "./pages/tags_page.tsx";
export default function App() { export default function App() {
return ( return (
@@ -28,6 +29,7 @@ export default function App() {
<Route path={"/user/:username"} element={<UserPage/>}/> <Route path={"/user/:username"} element={<UserPage/>}/>
<Route path={"/resource/edit/:rid"} element={<EditResourcePage/>}/> <Route path={"/resource/edit/:rid"} element={<EditResourcePage/>}/>
<Route path={"/about"} element={<AboutPage/>}/> <Route path={"/about"} element={<AboutPage/>}/>
<Route path={"/tags"} element={<TagsPage/>}/>
</Route> </Route>
</Routes> </Routes>
</BrowserRouter> </BrowserRouter>

View File

@@ -1,7 +1,7 @@
import {ReactNode} from "react"; import {ReactNode} from "react";
export default function Badge({children, className, onClick }: { children: ReactNode, className?: string, onClick?: () => void }) { export default function Badge({children, className, onClick }: { children: ReactNode, className?: string, onClick?: () => void }) {
return <span className={`badge ${!className?.includes("badge-") && "badge-primary"} ${className}`} onClick={onClick}>{children}</span> return <span className={`badge ${!className?.includes("badge-") && "badge-primary"} select-none ${className}`} onClick={onClick}>{children}</span>
} }
export function BadgeAccent({children, className, onClick }: { children: ReactNode, className?: string, onClick?: () => void }) { export function BadgeAccent({children, className, onClick }: { children: ReactNode, className?: string, onClick?: () => void }) {

View File

@@ -23,36 +23,77 @@ export default function Navigator() {
}, },
}); });
const { t } = useTranslation();
return <> return <>
<div className="navbar bg-base-100 shadow-sm fixed top-0 z-1 lg:z-10" key={key}> <div className="navbar bg-base-100 shadow-sm fixed top-0 z-1 lg:z-10" key={key}>
<div className={"flex-1 max-w-7xl mx-auto flex"}> <div className={"flex-1 max-w-7xl mx-auto flex items-center"}>
<div className="flex-1"> <div className="dropdown">
<div tabIndex={0} role="button" className="btn btn-ghost btn-circle lg:hidden">
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"> <path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 6h16M4 12h16M4 18h7" /> </svg>
</div>
<ul id={"navi_menu"}
tabIndex={0}
className="menu menu-md dropdown-content bg-base-100 rounded-box z-1 mt-3 w-52 p-2 shadow">
<li onClick={() => {
const menu = document.getElementById("navi_menu") as HTMLElement;
menu.blur();
navigate("/");
}}><a>{t("Home")}</a></li>
<li onClick={() => {
const menu = document.getElementById("navi_menu") as HTMLElement;
menu.blur();
navigate("/tags")
}}><a>{t("Tags")}</a></li>
<li onClick={() => {
const menu = document.getElementById("navi_menu") as HTMLElement;
menu.blur();
navigate("/about")
}}><a>{t("About")}</a></li>
</ul>
</div>
<div>
<button className="btn btn-ghost text-xl" onClick={() => { <button className="btn btn-ghost text-xl" onClick={() => {
appContext.clear() appContext.clear()
navigate(`/`); navigate(`/`, { replace: true});
}}>{app.appName}</button> }}>{app.appName}</button>
</div> </div>
<div className="hidden lg:flex">
<ul className="menu menu-horizontal px-1">
<li onClick={() => {
navigate("/");
}}><a>{t("Home")}</a></li>
<li onClick={() => {
navigate("/tags")
}}><a>{t("Tags")}</a></li>
<li onClick={() => {
navigate("/about")
}}><a>{t("About")}</a></li>
</ul>
</div>
<div className={"flex-1"}></div>
<div className="flex gap-2"> <div className="flex gap-2">
<SearchBar /> <SearchBar/>
<UploadingSideBar /> <UploadingSideBar/>
{ {
app.isLoggedIn() && <button className={"btn btn-circle btn-ghost"} onClick={() => { app.isLoggedIn() && <button className={"btn btn-circle btn-ghost"} onClick={() => {
navigate("/manage"); navigate("/manage");
}}> }}>
<MdSettings size={24} /> <MdSettings size={24}/>
</button> </button>
} }
<button className={"btn btn-circle btn-ghost"} onClick={() => { <button className={"btn btn-circle btn-ghost"} onClick={() => {
window.open("https://github.com/wgh136/nysoure", "_blank"); window.open("https://github.com/wgh136/nysoure", "_blank");
}}> }}>
<IoLogoGithub size={24} /> <IoLogoGithub size={24}/>
</button> </button>
{ {
app.isLoggedIn() ? <UserButton /> : <button className={"btn btn-primary btn-square btn-soft"} onClick={() => { app.isLoggedIn() ? <UserButton/> :
navigate("/login"); <button className={"btn btn-primary btn-square btn-soft"} onClick={() => {
}}> navigate("/login");
<MdOutlinePerson size={24}></MdOutlinePerson> }}>
</button> <MdOutlinePerson size={24}></MdOutlinePerson>
</button>
} }
</div> </div>
</div> </div>

View File

@@ -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.", "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", "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.", "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": { "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.": "提供一个文件链接供服务器下载,文件将被移动到选定的存储中。", "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": "关于",
"Home": "首页",
} }
}, },
"zh-TW": { "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.": "提供一個檔案連結供伺服器下載,檔案將被移動到選定的儲存中。", "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": "關於",
"Home": "首頁",
} }
} }
} }

View File

@@ -35,6 +35,10 @@ export interface Tag {
aliases: string[]; aliases: string[];
} }
export interface TagWithCount extends Tag {
resources_count: number;
}
export interface CreateResourceParams { export interface CreateResourceParams {
title: string; title: string;
alternative_titles: string[]; alternative_titles: string[];

View File

@@ -14,7 +14,7 @@ import {
UserWithToken, UserWithToken,
Comment, Comment,
CommentWithResource, CommentWithResource,
ServerConfig, RSort ServerConfig, RSort, TagWithCount
} from "./models.ts"; } from "./models.ts";
class Network { class Network {
@@ -284,6 +284,19 @@ class Network {
} }
} }
async getAllTags(): Promise<Response<TagWithCount[]>> {
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<Response<Tag[]>> { async searchTags(keyword: string, mainTag?: boolean): Promise<Response<Tag[]>> {
try { try {
const response = await axios.get(`${this.apiBaseUrl}/tag/search`, { const response = await axios.get(`${this.apiBaseUrl}/tag/search`, {

View File

@@ -3,10 +3,7 @@ import ResourcesView from "../components/resources_view.tsx";
import {network} from "../network/network.ts"; import {network} from "../network/network.ts";
import { app } from "../app.ts"; import { app } from "../app.ts";
import {RSort} from "../network/models.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 {useTranslation} from "react-i18next";
import {useNavigate} from "react-router";
import {useAppContext} from "../components/AppContext.tsx"; import {useAppContext} from "../components/AppContext.tsx";
export default function HomePage() { export default function HomePage() {
@@ -16,8 +13,6 @@ export default function HomePage() {
const {t} = useTranslation() const {t} = useTranslation()
const navigate = useNavigate()
const appContext = useAppContext() const appContext = useAppContext()
const [order, setOrder] = useState(() => { const [order, setOrder] = useState(() => {
@@ -59,15 +54,6 @@ export default function HomePage() {
<option value="4">{t("Downloads Ascending")}</option> <option value="4">{t("Downloads Ascending")}</option>
<option value="5">{t("Downloads Descending")}</option> <option value="5">{t("Downloads Descending")}</option>
</select> </select>
<span className={"flex-1"}/>
<Button onClick={() => {
navigate("/about");
}}>
<div className={"flex items-center"}>
<MdInfoOutline size={24} className={"inline-block mr-2"}/>
<span>{t("About this site")}</span>
</div>
</Button>
</div> </div>
<ResourcesView <ResourcesView
key={`home_page_${order}`} key={`home_page_${order}`}

View File

@@ -55,7 +55,7 @@ export default function TaggedResourcesPage() {
<div className={"px-3"}> <div className={"px-3"}>
{ {
(tag?.aliases ?? []).map((e) => { (tag?.aliases ?? []).map((e) => {
return <Badge className={"m-1 badge-outline badge-soft"}>{e}</Badge> return <Badge className={"m-1 badge-primary badge-soft"}>{e}</Badge>
}) })
} }
</div> </div>

View File

@@ -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<TagWithCount[] | null>(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 <Loading/>
}
const tagsMap = new Map<string, TagWithCount[]>();
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 <div className="flex flex-col gap-4 p-4">
<h1 className={"text-2xl font-bold py-2"}>Tags</h1>
{Array.from(tagsMap.entries()).map(([type, tags]) => (
<div key={type} className="flex flex-col gap-2">
<h2 className="text-lg font-bold pl-1">{type == "" ? "Other" : type}</h2>
<p>
{tags.map(tag => (
<Badge onClick={() => {
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})` : "")}
</Badge>
))}
</p>
</div>
))}
</div>
}

View File

@@ -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) { func AddTagRoutes(api fiber.Router) {
tag := api.Group("/tag") tag := api.Group("/tag")
{ {
@@ -130,5 +145,6 @@ func AddTagRoutes(api fiber.Router) {
tag.Delete("/:id", handleDeleteTag) tag.Delete("/:id", handleDeleteTag)
tag.Put("/:id/info", handleSetTagInfo) tag.Put("/:id/info", handleSetTagInfo)
tag.Get("/:name", handleGetTagByName) tag.Get("/:name", handleGetTagByName)
tag.Get("/", getAllTags)
} }
} }

View File

@@ -268,6 +268,36 @@ func GetResourceByTag(tagID uint, page int, pageSize int) ([]model.Resource, int
return resources, int(totalPages), nil 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) { func ExistsResource(id uint) (bool, error) {
var r model.Resource var r model.Resource
if err := db.Model(&model.Resource{}).Where("id = ?", id).First(&r).Error; err != nil { if err := db.Model(&model.Resource{}).Where("id = ?", id).First(&r).Error; err != nil {

View File

@@ -82,3 +82,13 @@ func SetTagInfo(id uint, description string, aliasOf *uint, tagType string) erro
} }
return nil 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
}

View File

@@ -33,3 +33,15 @@ func (t *Tag) ToView() *TagView {
Aliases: aliases, 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,
}
}

View File

@@ -56,6 +56,10 @@ func CreateResource(uid uint, params *ResourceCreateParams) (uint, error) {
if r, err = dao.CreateResource(r); err != nil { if r, err = dao.CreateResource(r); err != nil {
return 0, err return 0, err
} }
err = updateCachedTagList()
if err != nil {
log.Error("Error updating cached tag list:", err)
}
return r.ID, nil return r.ID, nil
} }
@@ -169,6 +173,10 @@ func DeleteResource(uid, id uint) error {
if err := dao.DeleteResource(id); err != nil { if err := dao.DeleteResource(id); err != nil {
return err return err
} }
err = updateCachedTagList()
if err != nil {
log.Error("Error updating cached tag list:", err)
}
return nil return nil
} }
@@ -241,5 +249,9 @@ func EditResource(uid, rid uint, params *ResourceCreateParams) error {
log.Error("UpdateResource error: ", err) log.Error("UpdateResource error: ", err)
return model.NewInternalServerError("Failed to update resource") return model.NewInternalServerError("Failed to update resource")
} }
err = updateCachedTagList()
if err != nil {
log.Error("Error updating cached tag list:", err)
}
return nil return nil
} }

View File

@@ -19,6 +19,10 @@ func CreateTag(uid uint, name string) (*model.TagView, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
err = updateCachedTagList()
if err != nil {
log.Error("Error updating cached tag list:", err)
}
return t.ToView(), nil return t.ToView(), nil
} }
@@ -63,6 +67,10 @@ func SearchTag(name string, mainTag bool) ([]model.TagView, error) {
} }
func DeleteTag(id uint) error { func DeleteTag(id uint) error {
err := updateCachedTagList()
if err != nil {
log.Error("Error updating cached tag list:", err)
}
return dao.DeleteTag(id) return dao.DeleteTag(id)
} }
@@ -88,5 +96,36 @@ func SetTagInfo(uid uint, id uint, description string, aliasOf *uint, tagType st
return nil, err return nil, err
} }
} }
err = updateCachedTagList()
if err != nil {
log.Error("Error updating cached tag list:", err)
}
return t.ToView(), nil 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
}