mirror of
https://github.com/wgh136/nysoure.git
synced 2025-09-27 12:17:24 +00:00
Add tag description.
This commit is contained in:
@@ -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 {
|
||||||
|
@@ -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 = []
|
||||||
}
|
}
|
||||||
|
@@ -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>
|
||||||
|
</>
|
||||||
|
}
|
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -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
|
||||||
|
}
|
||||||
|
@@ -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)
|
||||||
|
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -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
|
||||||
|
}
|
||||||
|
Reference in New Issue
Block a user