Add tag description.

This commit is contained in:
2025-05-24 20:38:05 +08:00
parent 73815560b4
commit c55dc709e0
8 changed files with 231 additions and 20 deletions

View File

@@ -30,6 +30,7 @@ export interface PageResponse<T> {
export interface Tag { export interface Tag {
id: number; id: number;
name: string; name: string;
description: string;
} }
export interface CreateResourceParams { export interface CreateResourceParams {

View File

@@ -316,6 +316,34 @@ class Network {
} }
} }
async getTagByName(name: string): Promise<Response<Tag>> {
try {
const response = await axios.get(`${this.apiBaseUrl}/tag/${name}`)
return response.data
} catch (e: any) {
console.error(e)
return {
success: false,
message: e.toString(),
}
}
}
async setTagDescription(tagId: number, description: string): Promise<Response<Tag>> {
try {
const response = await axios.putForm(`${this.apiBaseUrl}/tag/${tagId}/description`, {
description
})
return response.data
} catch (e: any) {
console.error(e)
return {
success: false,
message: e.toString(),
}
}
}
/** /**
* Upload image and return the image id * Upload image and return the image id
*/ */
@@ -453,7 +481,7 @@ class Network {
async getResourceDetails(id: number): Promise<Response<ResourceDetails>> { async getResourceDetails(id: number): Promise<Response<ResourceDetails>> {
try { try {
const response = await axios.get(`${this.apiBaseUrl}/resource/${id}`) const response = await axios.get(`${this.apiBaseUrl}/resource/${id}`)
let data = response.data const data = response.data
if (!data.related) { if (!data.related) {
data.related = [] data.related = []
} }

View File

@@ -2,30 +2,118 @@ import { useParams } from "react-router";
import { ErrorAlert } from "../components/alert.tsx"; import { ErrorAlert } from "../components/alert.tsx";
import ResourcesView from "../components/resources_view.tsx"; import ResourcesView from "../components/resources_view.tsx";
import { network } from "../network/network.ts"; import { network } from "../network/network.ts";
import { useEffect } from "react"; import {useEffect, useState} from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import {Tag} from "../network/models.ts";
import Button from "../components/button.tsx";
import Markdown from "react-markdown";
import {app} from "../app.ts";
export default function TaggedResourcesPage() { export default function TaggedResourcesPage() {
const { tag } = useParams() const { tag: tagName } = useParams()
const { t } = useTranslation(); const { t } = useTranslation();
if (!tag) { const [tag, setTag] = useState<Tag | null>(null);
return <div>
useEffect(() => {
document.title = t("Tag: " + tagName);
}, [t, tagName])
useEffect(() => {
if (!tagName) {
return;
}
network.getTagByName(tagName).then((res) => {
if (res.success) {
setTag(res.data!);
}
});
}, [tagName]);
if (!tagName) {
return <div className={"m-4"}>
<ErrorAlert message={"Tag not found"} /> <ErrorAlert message={"Tag not found"} />
</div> </div>
} }
useEffect(() => {
document.title = t("Tag: " + tag);
}, [tag])
return <div> return <div>
<h1 className={"text-2xl pt-6 pb-2 px-4 font-bold"}> <div className={"flex items-center"}>
Tag: {tag} <h1 className={"text-2xl pt-6 pb-2 px-4 font-bold flex-1"}>
</h1> {tagName}
</h1>
{
tag && <EditTagButton tag={tag} onEdited={(t) => {
setTag(t)
}} />
}
</div>
{
(tag?.description && app.canUpload()) && <article className={"px-4 py-2"}>
<Markdown>
{tag.description}
</Markdown>
</article>
}
<ResourcesView loader={(page) => { <ResourcesView loader={(page) => {
return network.getResourcesByTag(tag, page) return network.getResourcesByTag(tagName, page)
}}></ResourcesView> }}></ResourcesView>
</div> </div>
} }
function EditTagButton({tag, onEdited}: { tag: Tag, onEdited: (t: Tag) => void }) {
const [description, setDescription] = useState(tag.description);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
setDescription(tag.description)
}, [tag.description]);
const submit = async () => {
if (description === tag.description) {
return;
}
if (description && description.length > 256) {
setError("Description is too long");
return;
}
setIsLoading(true);
setError(null);
const res = await network.setTagDescription(tag.id, description);
setIsLoading(false);
if (res.success) {
const dialog = document.getElementById("edit_tag_dialog") as HTMLDialogElement;
dialog.close();
onEdited(res.data!);
} else {
setError(res.message || "Unknown error");
}
};
return <>
<Button onClick={()=> {
const dialog = document.getElementById("edit_tag_dialog") as HTMLDialogElement;
dialog.showModal();
}}>Edit</Button>
<dialog id="edit_tag_dialog" className="modal">
<div className="modal-box">
<h3 className="font-bold text-lg">Edit Tag</h3>
<p className="py-2 text-sm">Set the description of the tag.</p>
<p className="pb-3 text-sm">Use markdown format.</p>
<textarea className="textarea h-24 w-full resize-none" value={description} onChange={(e) => setDescription(e.target.value)}/>
{error && <ErrorAlert className={"mt-2"} message={error} />}
<div className="modal-action">
<form method="dialog">
<Button className="btn">Close</Button>
</form>
<Button isLoading={isLoading} className={"btn-primary"} onClick={submit}>
Save
</Button>
</div>
</div>
</dialog>
</>
}

View File

@@ -2,6 +2,7 @@ package api
import ( import (
"github.com/gofiber/fiber/v3" "github.com/gofiber/fiber/v3"
"net/url"
"nysoure/server/model" "nysoure/server/model"
"nysoure/server/service" "nysoure/server/service"
"strconv" "strconv"
@@ -65,11 +66,59 @@ func handleDeleteTag(c fiber.Ctx) error {
}) })
} }
func handleSetTagDescription(c fiber.Ctx) error {
uid, ok := c.Locals("uid").(uint)
if !ok {
return model.NewUnAuthorizedError("You must be logged in to set tag description")
}
id, err := strconv.Atoi(c.Params("id"))
if err != nil {
return model.NewRequestError("Invalid tag ID")
}
description := c.FormValue("description")
if description == "" {
return model.NewRequestError("Description is required")
}
description = strings.TrimSpace(description)
t, err := service.SetTagDescription(uid, uint(id), description)
if err != nil {
return err
}
return c.Status(fiber.StatusOK).JSON(model.Response[model.TagView]{
Success: true,
Data: *t,
Message: "Tag description updated successfully",
})
}
func handleGetTagByName(c fiber.Ctx) error {
name := c.Params("name")
if name == "" {
return model.NewRequestError("Tag name is required")
}
name, err := url.PathUnescape(name)
if err != nil {
return model.NewRequestError("Invalid tag name format")
}
name = strings.TrimSpace(name)
t, err := service.GetTagByName(name)
if err != nil {
return err
}
return c.Status(fiber.StatusOK).JSON(model.Response[model.TagView]{
Success: true,
Data: *t,
Message: "Tag retrieved successfully",
})
}
func AddTagRoutes(api fiber.Router) { func AddTagRoutes(api fiber.Router) {
tag := api.Group("/tag") tag := api.Group("/tag")
{ {
tag.Post("/", handleCreateTag) tag.Post("/", handleCreateTag)
tag.Get("/search", handleSearchTag) tag.Get("/search", handleSearchTag)
tag.Delete("/:id", handleDeleteTag) tag.Delete("/:id", handleDeleteTag)
tag.Put("/:id/description", handleSetTagDescription)
tag.Get("/:name", handleGetTagByName)
} }
} }

View File

@@ -58,3 +58,10 @@ func GetTagByName(name string) (model.Tag, error) {
} }
return t, nil return t, nil
} }
func SetTagDescription(id uint, description string) error {
if err := db.Model(model.Tag{}).Where("id = ?", id).Update("description", description).Error; err != nil {
return err
}
return nil
}

View File

@@ -40,7 +40,7 @@ func handleRobotsTxt(c fiber.Ctx) error {
c.Set("Content-Type", "text/plain; charset=utf-8") c.Set("Content-Type", "text/plain; charset=utf-8")
c.Set("Cache-Control", "no-cache") c.Set("Cache-Control", "no-cache")
c.Set("X-Robots-Tag", "noindex") c.Set("X-Robots-Tag", "noindex")
return c.SendString("User-agent: *\nDisallow: /api/\nDisallow: /admin/\n") return c.SendString("User-agent: *\nDisallow: /api/\n\nSitemap: " + c.BaseURL() + "/sitemap.xml\n")
} }
func handleSiteMap(c fiber.Ctx) error { func handleSiteMap(c fiber.Ctx) error {
@@ -92,6 +92,13 @@ func serveIndexHtml(c fiber.Ctx) error {
title = u.Username title = u.Username
description = "User " + u.Username + "'s profile" description = "User " + u.Username + "'s profile"
} }
} else if strings.HasPrefix(path, "/tag/") {
tagName := strings.TrimPrefix(path, "/tag/")
t, err := service.GetTagByName(tagName)
if err == nil {
title = "Tag: " + t.Name
description = utils.ArticleToDescription(t.Description, 256)
}
} }
content = strings.ReplaceAll(content, "{{SiteName}}", siteName) content = strings.ReplaceAll(content, "{{SiteName}}", siteName)

View File

@@ -4,18 +4,21 @@ import "gorm.io/gorm"
type Tag struct { type Tag struct {
gorm.Model gorm.Model
Name string `gorm:"unique"` Name string `gorm:"unique"`
Resources []Resource `gorm:"many2many:resource_tags;"` Description string
Resources []Resource `gorm:"many2many:resource_tags;"`
} }
type TagView struct { type TagView struct {
ID uint `json:"id"` ID uint `json:"id"`
Name string `json:"name"` Name string `json:"name"`
Description string `json:"description"`
} }
func (t *Tag) ToView() *TagView { func (t *Tag) ToView() *TagView {
return &TagView{ return &TagView{
ID: t.ID, ID: t.ID,
Name: t.Name, Name: t.Name,
Description: t.Description,
} }
} }

View File

@@ -30,6 +30,14 @@ func GetTag(id uint) (*model.TagView, error) {
return t.ToView(), nil return t.ToView(), nil
} }
func GetTagByName(name string) (*model.TagView, error) {
t, err := dao.GetTagByName(name)
if err != nil {
return nil, err
}
return t.ToView(), nil
}
func SearchTag(name string) ([]model.TagView, error) { func SearchTag(name string) ([]model.TagView, error) {
tags, err := dao.SearchTag(name) tags, err := dao.SearchTag(name)
if err != nil { if err != nil {
@@ -45,3 +53,23 @@ func SearchTag(name string) ([]model.TagView, error) {
func DeleteTag(id uint) error { func DeleteTag(id uint) error {
return dao.DeleteTag(id) return dao.DeleteTag(id)
} }
func SetTagDescription(uid uint, id uint, description string) (*model.TagView, error) {
canUpload, err := checkUserCanUpload(uid)
if err != nil {
log.Error("Error checking user permissions:", err)
return nil, model.NewInternalServerError("Error checking user permissions")
}
if !canUpload {
return nil, model.NewUnAuthorizedError("User cannot set tag description")
}
t, err := dao.GetTagByID(id)
if err != nil {
return nil, err
}
t.Description = description
if err := dao.SetTagDescription(id, description); err != nil {
return nil, err
}
return t.ToView(), nil
}