Add edit resource functionality.

This commit is contained in:
2025-05-15 20:10:33 +08:00
parent f8271161cb
commit 9597aa5687
7 changed files with 496 additions and 2 deletions

View File

@@ -9,6 +9,7 @@ import ResourcePage from "./pages/resource_details_page.tsx";
import ManagePage from "./pages/manage_page.tsx";
import TaggedResourcesPage from "./pages/tagged_resources_page.tsx";
import UserPage from "./pages/user_page.tsx";
import EditResourcePage from "./pages/edit_resource_page.tsx";
export default function App() {
return (
@@ -24,6 +25,7 @@ export default function App() {
<Route path={"/manage"} element={<ManagePage/>}/>
<Route path={"/tag/:tag"} element={<TaggedResourcesPage/>}/>
<Route path={"/user/:username"} element={<UserPage/>}/>
<Route path={"/resource/edit/:rid"} element={<EditResourcePage/>}/>
</Route>
</Routes>
</BrowserRouter>

View File

@@ -147,6 +147,7 @@ export const i18nData = {
"Searching...": "Searching...",
"Create Tag": "Create Tag",
"Search Tags": "Search Tags",
"Edit Resource": "Edit Resource",
}
},
"zh-CN": {
@@ -297,6 +298,7 @@ export const i18nData = {
"Searching...": "搜索中...",
"Create Tag": "创建标签",
"Search Tags": "搜索标签",
"Edit Resource": "编辑资源",
}
},
"zh-TW": {
@@ -447,6 +449,7 @@ export const i18nData = {
"Searching...": "搜尋中...",
"Create Tag": "創建標籤",
"Search Tags": "搜尋標籤",
"Edit Resource": "編輯資源",
}
}
}

View File

@@ -340,6 +340,19 @@ class Network {
}
}
async editResource(id: number, params: CreateResourceParams): Promise<Response<void>> {
try {
const response = await axios.post(`${this.apiBaseUrl}/resource/${id}`, params)
return response.data
} catch (e: any) {
console.error(e)
return {
success: false,
message: e.toString(),
}
}
}
async getResources(page: number): Promise<PageResponse<Resource>> {
try {
const response = await axios.get(`${this.apiBaseUrl}/resource`, {

View File

@@ -0,0 +1,394 @@
import { useEffect, useRef, useState } from "react";
import {MdAdd, MdClose, MdDelete, MdOutlineInfo, MdSearch} 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";
export default function EditResourcePage() {
const [title, setTitle] = useState<string>("")
const [altTitles, setAltTitles] = useState<string[]>([])
const [tags, setTags] = useState<Tag[]>([])
const [article, setArticle] = useState<string>("")
const [images, setImages] = useState<number[]>([])
const [isUploading, setUploading] = useState(false)
const [error, setError] = useState<string | null>(null)
const [isSubmitting, setSubmitting] = useState(false)
const [isLoading, setLoading] = useState(true)
const navigate = useNavigate()
const { t } = useTranslation();
useEffect(() => {
document.title = t("Edit Resource");
}, [t])
const {rid} = useParams()
const id = parseInt(rid || "")
useEffect(() => {
if (isNaN(id)) {
return
}
network.getResourceDetails(id).then((res) => {
if (res.success) {
const data = res.data!
setTitle(data.title)
setAltTitles(data.alternativeTitles)
setTags(data.tags)
setArticle(data.article)
setImages(data.images.map(i => i.id))
setLoading(false)
} else {
showToast({ message: t("Failed to load resource"), type: "error" })
}
})
}, [id, t]);
const handleSubmit = async () => {
if (isSubmitting) {
return
}
if (!title) {
setError(t("Title cannot be empty"))
return
}
for (let i = 0; i < altTitles.length; i++) {
if (!altTitles[i]) {
setError(t("Alternative title cannot be empty"))
return
}
}
if (!tags || tags.length === 0) {
setError(t("At least one tag required"))
return
}
if (!article) {
setError(t("Description cannot be empty"))
return
}
const res = await network.editResource(id, {
title: title,
alternative_titles: altTitles,
tags: tags.map((tag) => tag.id),
article: article,
images: images,
})
if (res.success) {
setSubmitting(false)
navigate("/resources/" + id.toString(), { replace: true })
} else {
setSubmitting(false)
setError(res.message)
}
}
const addImage = () => {
if (isUploading) {
return
}
const input = document.createElement("input")
input.type = "file"
input.accept = "image/*"
input.onchange = async () => {
const files = input.files
if (!files || files.length === 0) {
return
}
const image = files[0]
setUploading(true)
const res = await network.uploadImage(image)
if (res.success) {
setUploading(false)
setImages([...images, res.data!])
} else {
setUploading(false)
showToast({ message: t("Failed to upload image"), type: "error" })
}
}
input.click()
}
if (isNaN(id)) {
return <ErrorAlert className={"m-4"} message={t("Invalid resource ID")} />
}
if (!app.user) {
return <ErrorAlert className={"m-4"} message={t("You are not logged in. Please log in to access this page.")} />
}
if (isLoading) {
return <Loading/>
}
return <div className={"p-4"}>
<h1 className={"text-2xl font-bold my-4"}>{t("Edit Resource")}</h1>
<div role="alert" className="alert alert-info mb-2 alert-dash">
<MdOutlineInfo size={24} />
<span>{t("All information can be modified after publishing")}</span>
</div>
<p className={"my-1"}>{t("Title")}</p>
<input type="text" className="input w-full" value={title} onChange={(e) => setTitle(e.target.value)} />
<div className={"h-4"}></div>
<p className={"my-1"}>{t("Alternative Titles")}</p>
{
altTitles.map((title, index) => {
return <div key={index} className={"flex items-center my-2"}>
<input type="text" className="input w-full" value={title} onChange={(e) => {
const newAltTitles = [...altTitles]
newAltTitles[index] = e.target.value
setAltTitles(newAltTitles)
}} />
<button className={"btn btn-square btn-error ml-2"} type={"button"} onClick={() => {
const newAltTitles = [...altTitles]
newAltTitles.splice(index, 1)
setAltTitles(newAltTitles)
}}>
<MdDelete size={24} />
</button>
</div>
})
}
<button className={"btn my-2"} type={"button"} onClick={() => {
setAltTitles([...altTitles, ""])
}}>
<MdAdd />
{t("Add Alternative Title")}
</button>
<div className={"h-2"}></div>
<p className={"my-1"}>{t("Tags")}</p>
<p className={"my-1 pb-1"}>
{
tags.map((tag, index) => {
return <span key={index} className={"badge badge-primary mr-2 text-sm"}>
{tag.name}
<span onClick={() => {
const newTags = [...tags]
newTags.splice(index, 1)
setTags(newTags)
}}>
<MdClose size={18}/>
</span>
</span>
})
}
</p>
<TagInput onAdd={(tag) => {
setTags([...tags, tag])
}} />
<div className={"h-4"}></div>
<p className={"my-1"}>{t("Description")}</p>
<textarea className="textarea w-full min-h-80 p-4" value={article} onChange={(e) => setArticle(e.target.value)} />
<div className={"flex items-center py-1 "}>
<MdOutlineInfo className={"inline mr-1"} />
<span className={"text-sm"}>{t("Use Markdown format")}</span>
</div>
<div className={"h-4"}></div>
<p className={"my-1"}>{t("Images")}</p>
<div role="alert" className="alert alert-info alert-soft my-2">
<MdOutlineInfo size={24} />
<div>
<p>{t("Images will not be displayed automatically, you need to reference them in the description")}</p>
<p>{t("The first image will be used as the cover image")}</p>
</div>
</div>
<div className={`rounded-box border border-base-content/5 bg-base-100 ${images.length === 0 ? "hidden" : ""}`}>
<table className={"table"}>
<thead>
<tr>
<td>{t("Preview")}</td>
<td>{t("Link")}</td>
<td>{t("Action")}</td>
</tr>
</thead>
<tbody>
{
images.map((image, index) => {
return <tr key={index} className={"hover"}>
<td>
<img src={network.getImageUrl(image)} className={"w-16 h-16 object-cover card"} alt={"image"} />
</td>
<td>
{network.getImageUrl(image)}
</td>
<td>
<button className={"btn btn-square"} type={"button"} onClick={() => {
const id = images[index]
const newImages = [...images]
newImages.splice(index, 1)
setImages(newImages)
network.deleteImage(id)
}}>
<MdDelete size={24} />
</button>
</td>
</tr>
})
}
</tbody>
</table>
</div>
<button className={"btn my-2"} type={"button"} onClick={addImage}>
{isUploading ? <span className="loading loading-spinner"></span> : <MdAdd />}
{t("Upload Image")}
</button>
<div className={"h-4"}></div>
{
error && <div role="alert" className="alert alert-error my-2 shadow">
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6 shrink-0 stroke-current" fill="none"
viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span>{t("Error")}: {error}</span>
</div>
}
<div className={"flex flex-row-reverse mt-4"}>
<button className={"btn btn-accent shadow"} onClick={handleSubmit}>
{isSubmitting && <span className="loading loading-spinner"></span>}
{t("Publish")}
</button>
</div>
</div>
}
function TagInput({ onAdd }: { onAdd: (tag: Tag) => void }) {
const [keyword, setKeyword] = useState<string>("")
const [tags, setTags] = useState<Tag[]>([])
const [error, setError] = useState<string | null>(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 = <div className="alert alert-error my-2">
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6 shrink-0 stroke-current" fill="none"
viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<span>{error}</span>
</div>
} else if (!keyword) {
dropdownContent = <div className="flex flex-row py-2 px-4">
<LuInfo size={20} />
<span className={"w-2"} />
<span className={"flex-1"}>{t("Please enter a search keyword")}</span>
</div>
} else if (isLoading) {
dropdownContent = <div className="flex flex-row py-2 px-4">
<span className={"loading loading-spinner loading-sm"}></span>
<span className={"w-2"} />
<span className={"flex-1"}>{t("Searching...")}</span>
</div>
} else {
const haveExactMatch = tags.find((t) => t.name === keyword) !== undefined
dropdownContent = <>
{
tags.map((t) => {
return <li key={t.id} onClick={() => {
onAdd(t);
setKeyword("")
setTags([])
const input = document.getElementById("search_tags_input") as HTMLInputElement
input.blur()
}}><a>{t.name}</a></li>
})
}
{
!haveExactMatch && <li onClick={() => {
handleCreateTag(keyword)
}}><a>{t("Create Tag")}: {keyword}</a></li>
}
</>
}
return <div className={"dropdown dropdown-end"}>
<label className="input">
<MdSearch size={18}/>
<input autoComplete={"off"} id={"search_tags_input"} tabIndex={0} type="text" className="grow" placeholder={t("Search Tags")} value={keyword} onChange={(e) => handleChange(e.target.value)} />
</label>
<ul tabIndex={0} className="dropdown-content menu bg-base-100 rounded-box z-1 w-52 p-2 shadow mt-2 border border-base-300">
{dropdownContent}
</ul>
</div>
}
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
}
}
}

View File

@@ -12,7 +12,7 @@ import {
MdOutlineComment,
MdOutlineDataset,
MdOutlineDelete,
MdOutlineDownload
MdOutlineDownload, MdOutlineEdit
} from "react-icons/md";
import {app} from "../app.ts";
import {uploadingManager} from "../network/uploading.ts";
@@ -151,6 +151,13 @@ export default function ResourcePage() {
</div>
<div className={"grow"}></div>
{
app.isAdmin() || app.user?.id === resource.id ? <Button className={"btn-ghost btn-circle"} onClick={() => {
navigate(`/resource/edit/${resource.id}`, {replace: true})
}}>
<MdOutlineEdit size={20}/>
</Button> : null
}
<DeleteResourceDialog resourceId={resource.id} uploaderId={resource.author.id}/>
</div>
<div className="h-4"></div>
@@ -187,7 +194,7 @@ function DeleteResourceDialog({resourceId, uploaderId}: { resourceId: number, up
}
return <>
<Button className={"btn-error btn-ghost"} onClick={() => {
<Button className={"btn-error btn-ghost btn-circle"} onClick={() => {
const dialog = document.getElementById("delete_resource_dialog") as HTMLDialogElement
dialog.showModal()
}}>

View File

@@ -183,6 +183,36 @@ func handleGetResourcesWithUser(c fiber.Ctx) error {
})
}
func handleUpdateResource(c fiber.Ctx) error {
idStr := c.Params("id")
if idStr == "" {
return model.NewRequestError("Resource ID is required")
}
id, err := strconv.Atoi(idStr)
if err != nil {
return model.NewRequestError("Invalid resource ID")
}
var params service.ResourceCreateParams
body := c.Body()
err = json.Unmarshal(body, &params)
if err != nil {
return model.NewRequestError("Invalid request body")
}
uid, ok := c.Locals("uid").(uint)
if !ok {
return model.NewUnAuthorizedError("You must be logged in to update a resource")
}
err = service.EditResource(uid, uint(id), &params)
if err != nil {
return err
}
return c.Status(fiber.StatusOK).JSON(model.Response[any]{
Success: true,
Data: nil,
Message: "Resource updated successfully",
})
}
func AddResourceRoutes(api fiber.Router) {
resource := api.Group("/resource")
{
@@ -193,5 +223,6 @@ func AddResourceRoutes(api fiber.Router) {
resource.Delete("/:id", handleDeleteResource)
resource.Get("/tag/:tag", handleListResourcesWithTag)
resource.Get("/user/:username", handleGetResourcesWithUser)
resource.Post("/:id", handleUpdateResource)
}
}

View File

@@ -1,6 +1,7 @@
package service
import (
"github.com/gofiber/fiber/v3/log"
"nysoure/server/dao"
"nysoure/server/model"
@@ -136,3 +137,46 @@ func GetResourcesWithUser(username string, page int) ([]model.ResourceView, int,
}
return views, totalPages, nil
}
func EditResource(uid, rid uint, params *ResourceCreateParams) error {
isAdmin, err := checkUserCanUpload(uid)
if err != nil {
log.Error("checkUserCanUpload error: ", err)
return model.NewInternalServerError("Failed to check user permission")
}
r, err := dao.GetResourceByID(rid)
if err != nil {
return err
}
if r.UserID != uid && !isAdmin {
return model.NewUnAuthorizedError("You have not permission to edit this resource")
}
r.Title = params.Title
r.AlternativeTitles = params.AlternativeTitles
r.Article = params.Article
images := make([]model.Image, len(params.Images))
for i, id := range params.Images {
images[i] = model.Image{
Model: gorm.Model{
ID: id,
},
}
}
tags := make([]model.Tag, len(params.Tags))
for i, id := range params.Tags {
tags[i] = model.Tag{
Model: gorm.Model{
ID: id,
},
}
}
r.Images = images
r.Tags = tags
if err := dao.UpdateResource(r); err != nil {
log.Error("UpdateResource error: ", err)
return model.NewInternalServerError("Failed to update resource")
}
return nil
}