mirror of
https://github.com/wgh136/nysoure.git
synced 2025-09-27 04:17:23 +00:00
Add edit resource functionality.
This commit is contained in:
@@ -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>
|
||||
|
@@ -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": "編輯資源",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@@ -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`, {
|
||||
|
394
frontend/src/pages/edit_resource_page.tsx
Normal file
394
frontend/src/pages/edit_resource_page.tsx
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
@@ -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()
|
||||
}}>
|
||||
|
@@ -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, ¶ms)
|
||||
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), ¶ms)
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
@@ -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
|
||||
}
|
||||
|
Reference in New Issue
Block a user