implement creating files.

This commit is contained in:
2025-05-11 22:08:40 +08:00
parent d97247159f
commit 5af84dbcf3
3 changed files with 303 additions and 36 deletions

View File

@@ -88,6 +88,24 @@ export const i18nData = {
"User set as user successfully": "User set as user successfully", "User set as user successfully": "User set as user successfully",
"User set as upload permission successfully": "User set as upload permission successfully", "User set as upload permission successfully": "User set as upload permission successfully",
"User removed upload permission successfully": "User removed upload permission successfully", "User removed upload permission successfully": "User removed upload permission successfully",
// Resource details page
"Resource ID is required": "Resource ID is required",
"Files": "Files",
"Comments": "Comments",
"Upload": "Upload",
"Create File": "Create File",
"Please select a file type": "Please select a file type",
"Please fill in all fields": "Please fill in all fields",
"File created successfully": "File created successfully",
"Successfully create uploading task.": "Successfully create uploading task.",
"Please select a file and storage": "Please select a file and storage",
"Redirect": "Redirect",
"User who click the file will be redirected to the URL": "User who click the file will be redirected to the URL",
"File Name": "File Name",
"URL": "URL",
"Upload a file to server, then the file will be moved to the selected storage.": "Upload a file to server, then the file will be moved to the selected storage.",
"Select Storage": "Select Storage",
} }
}, },
"zh-CN": { "zh-CN": {
@@ -179,6 +197,24 @@ export const i18nData = {
"User set as user successfully": "用户已成功设为普通用户", "User set as user successfully": "用户已成功设为普通用户",
"User set as upload permission successfully": "用户已成功授予上传权限", "User set as upload permission successfully": "用户已成功授予上传权限",
"User removed upload permission successfully": "用户已成功移除上传权限", "User removed upload permission successfully": "用户已成功移除上传权限",
// Resource details page
"Resource ID is required": "资源ID是必需的",
"Files": "文件",
"Comments": "评论",
"Upload": "上传",
"Create File": "创建文件",
"Please select a file type": "请选择文件类型",
"Please fill in all fields": "请填写所有字段",
"File created successfully": "文件创建成功",
"Successfully create uploading task.": "成功创建上传任务。",
"Please select a file and storage": "请选择文件和存储",
"Redirect": "重定向",
"User who click the file will be redirected to the URL": "点击文件的用户将被重定向到URL",
"File Name": "文件名",
"URL": "URL",
"Upload a file to server, then the file will be moved to the selected storage.": "将文件上传到服务器,然后文件将被移动到选定的存储中。",
"Select Storage": "选择存储",
} }
}, },
"zh-TW": { "zh-TW": {
@@ -270,6 +306,24 @@ export const i18nData = {
"User set as user successfully": "用戶已成功設為普通用戶", "User set as user successfully": "用戶已成功設為普通用戶",
"User set as upload permission successfully": "用戶已成功授予上傳權限", "User set as upload permission successfully": "用戶已成功授予上傳權限",
"User removed upload permission successfully": "用戶已成功移除上傳權限", "User removed upload permission successfully": "用戶已成功移除上傳權限",
// Resource details page
"Resource ID is required": "資源ID是必需的",
"Files": "檔案",
"Comments": "評論",
"Upload": "上傳",
"Create File": "創建檔案",
"Please select a file type": "請選擇檔案類型",
"Please fill in all fields": "請填寫所有欄位",
"File created successfully": "檔案創建成功",
"Successfully create uploading task.": "成功創建上傳任務。",
"Please select a file and storage": "請選擇檔案和儲存",
"Redirect": "重定向",
"User who click the file will be redirected to the URL": "點擊檔案的用戶將被重定向到URL",
"File Name": "檔案名",
"URL": "URL",
"Upload a file to server, then the file will be moved to the selected storage.": "將檔案上傳到伺服器,然後檔案將被移動到選定的儲存中。",
"Select Storage": "選擇儲存",
} }
} }
} }

View File

@@ -0,0 +1,10 @@
import {Response} from "./models.ts";
class UploadingManager {
async addTask(file: File, resourceID: number, storageID: number, description: string): Promise<Response<void>> {
// TODO: implement this
throw new Error("Not implemented");
}
}
export const uploadingManager = new UploadingManager();

View File

@@ -1,16 +1,20 @@
import {useParams} from "react-router"; import {useParams} from "react-router";
import {createContext, useCallback, useEffect, useState} from "react"; import {createContext, useCallback, useContext, useEffect, useRef, useState} from "react";
import {ResourceDetails, RFile} from "../network/models.ts"; import {ResourceDetails, RFile, Storage} from "../network/models.ts";
import {network} from "../network/network.ts"; import {network} from "../network/network.ts";
import showToast from "../components/toast.ts"; import showToast from "../components/toast.ts";
import Markdown from "react-markdown"; import Markdown from "react-markdown";
import "../markdown.css"; import "../markdown.css";
import Loading from "../components/loading.tsx"; import Loading from "../components/loading.tsx";
import {MdAdd, MdOutlineArticle, MdOutlineComment, MdOutlineDataset} from "react-icons/md"; import {MdAdd, MdOutlineArticle, MdOutlineComment, MdOutlineDataset, MdOutlineDownload} from "react-icons/md";
import {app} from "../app.ts"; import {app} from "../app.ts";
import {uploadingManager} from "../network/uploading.ts";
import {ErrorAlert} from "../components/alert.tsx";
import { useTranslation } from "react-i18next";
export default function ResourcePage() { export default function ResourcePage() {
const params = useParams() const params = useParams()
const { t } = useTranslation();
const idStr = params.id const idStr = params.id
@@ -45,7 +49,7 @@ export default function ResourcePage() {
if (isNaN(id)) { if (isNaN(id)) {
return <div className="alert alert-error shadow-lg"> return <div className="alert alert-error shadow-lg">
<div> <div>
<span>Resource ID is required</span> <span>{t("Resource ID is required")}</span>
</div> </div>
</div> </div>
} }
@@ -58,11 +62,12 @@ export default function ResourcePage() {
<div className={"pt-2"}> <div className={"pt-2"}>
<h1 className={"text-2xl font-bold px-4 py-2"}>{resource.title}</h1> <h1 className={"text-2xl font-bold px-4 py-2"}>{resource.title}</h1>
{ {
resource.alternativeTitles.map((e) => { resource.alternativeTitles.map((e, i) => {
return <h2 className={"text-lg px-4 py-1 text-gray-700 dark:text-gray-300"}>{e}</h2> return <h2 key={i} className={"text-lg px-4 py-1 text-gray-700 dark:text-gray-300"}>{e}</h2>
}) })
} }
<button className="border-b-2 mx-4 py-1 cursor-pointer border-transparent hover:border-primary transition-colors duration-200 ease-in-out"> <button
className="border-b-2 mx-4 py-1 cursor-pointer border-transparent hover:border-primary transition-colors duration-200 ease-in-out">
<div className="flex items-center "> <div className="flex items-center ">
<div className="avatar"> <div className="avatar">
<div className="w-6 rounded-full"> <div className="w-6 rounded-full">
@@ -76,7 +81,7 @@ export default function ResourcePage() {
<p className={"px-4 pt-2"}> <p className={"px-4 pt-2"}>
{ {
resource.tags.map((e) => { resource.tags.map((e) => {
return <span className="badge badge-primary mr-2">{e.name}</span> return <span key={e.id} className="badge badge-primary mr-2 text-sm">{e.name}</span>
}) })
} }
</p> </p>
@@ -85,10 +90,10 @@ export default function ResourcePage() {
<input type="radio" name="my_tabs" defaultChecked/> <input type="radio" name="my_tabs" defaultChecked/>
<MdOutlineArticle className="text-xl mr-2"/> <MdOutlineArticle className="text-xl mr-2"/>
<span className="text-sm"> <span className="text-sm">
Description {t("Description")}
</span> </span>
</label> </label>
<div className="tab-content p-2"> <div key={"article"} className="tab-content p-2">
<Article article={resource.article}/> <Article article={resource.article}/>
</div> </div>
@@ -96,28 +101,29 @@ export default function ResourcePage() {
<input type="radio" name="my_tabs"/> <input type="radio" name="my_tabs"/>
<MdOutlineDataset className="text-xl mr-2"/> <MdOutlineDataset className="text-xl mr-2"/>
<span className="text-sm"> <span className="text-sm">
Files {t("Files")}
</span> </span>
</label> </label>
<div className="tab-content p-2"> <div key={"files"} className="tab-content p-2">
<Files files={resource.files} /> <Files files={resource.files} resourceID={resource.id}/>
</div> </div>
<label className="tab"> <label className="tab">
<input type="radio" name="my_tabs"/> <input type="radio" name="my_tabs"/>
<MdOutlineComment className="text-xl mr-2"/> <MdOutlineComment className="text-xl mr-2"/>
<span className="text-sm"> <span className="text-sm">
Comments {t("Comments")}
</span> </span>
</label> </label>
<div className="tab-content p-2">Comments</div> <div key={"comments"} className="tab-content p-2">{t("Comments")}</div>
</div> </div>
<div className="h-4"></div> <div className="h-4"></div>
</div> </div>
</context.Provider> </context.Provider>
} }
const context = createContext<() => void>(() => {}) const context = createContext<() => void>(() => {
})
function Article({article}: { article: string }) { function Article({article}: { article: string }) {
return <article> return <article>
@@ -126,26 +132,223 @@ function Article({ article }: { article: string }) {
} }
function FileTile({file}: { file: RFile }) { function FileTile({file}: { file: RFile }) {
// TODO: implement file tile return <div className={"card card-border border-base-300 my-2"}>
return <div></div> <div className={"p-4 flex flex-row items-center"}>
<div className={"grow"}>
<h4 className={"font-bold py-1"}>{file.filename}</h4>
<p className={"text-sm"}>{file.description}</p>
</div>
<div>
<button className={"btn btn-primary btn-soft btn-square"}>
<MdOutlineDownload size={24}/>
</button>
</div>
</div>
</div>
} }
function Files({files}: { files: RFile[]}) { function Files({files, resourceID}: { files: RFile[], resourceID: number }) {
return <div> return <div>
{ {
files.map((file) => { files.map((file) => {
return <FileTile file={file} key={file.id}></FileTile> return <FileTile file={file} key={file.id}></FileTile>
}) })
} }
<div className={"h-2"}></div>
{ {
app.isAdmin() && <div className={"flex flex-row-reverse"}> app.isAdmin() && <div className={"flex flex-row-reverse"}>
<button className={"btn btn-accent shadow"}> <CreateFileDialog resourceId={resourceID}></CreateFileDialog>
<MdAdd size={24}/> </div>
}
</div>
}
enum FileType {
redirect = "redirect",
upload = "upload",
}
function CreateFileDialog({resourceId}: { resourceId: number }) {
const { t } = useTranslation();
const [isLoading, setLoading] = useState(false)
const storages = useRef<Storage[] | null>(null)
const mounted = useRef(true)
const [fileType, setFileType] = useState<FileType | null>(null)
const [filename, setFilename] = useState<string>("")
const [redirectUrl, setRedirectUrl] = useState<string>("")
const [storage, setStorage] = useState<Storage | null>(null)
const [file, setFile] = useState<File | null>(null)
const [description, setDescription] = useState<string>("")
const reload = useContext(context)
const [isSubmitting, setSubmitting] = useState(false)
const [error, setError] = useState<string | null>(null)
useEffect(() => {
mounted.current = true
return () => {
mounted.current = false
}
}, []);
const submit = async () => {
if (isSubmitting) {
return
}
if (!fileType) {
setError(t("Please select a file type"))
return
}
setSubmitting(true)
if (fileType === FileType.redirect) {
if (!redirectUrl || !filename || !description) {
setError(t("Please fill in all fields"));
setSubmitting(false);
return;
}
const res = await network.createRedirectFile(filename, description, resourceId, redirectUrl);
if (res.success) {
const dialog = document.getElementById("upload_dialog") as HTMLDialogElement
dialog.close()
showToast({message: t("File created successfully"), type: "success"})
reload()
} else {
setError(res.message)
}
} else {
if (!file || !storage) {
setError(t("Please select a file and storage"))
setSubmitting(false)
return
}
const res = await uploadingManager.addTask(file, resourceId, storage.id, description);
if (res.success) {
const dialog = document.getElementById("upload_dialog") as HTMLDialogElement
dialog.close()
showToast({message: t("Successfully create uploading task."), type: "success"})
reload()
} else {
setError(res.message)
}
}
}
return <>
<button className={"btn btn-accent shadow"} onClick={() => {
if (isLoading) {
return;
}
if (storages.current == null) {
setLoading(true);
network.listStorages().then((res) => {
if (!mounted.current) {
return;
}
if (!res.success) {
showToast({message: res.message, type: "error"})
} else {
storages.current = res.data!
setLoading(false)
const dialog = document.getElementById("upload_dialog") as HTMLDialogElement
dialog.showModal()
}
});
return;
}
const dialog = document.getElementById("upload_dialog") as HTMLDialogElement
dialog.showModal()
}}>
{
isLoading ? <span className={"loading loading-spinner loading-sm"}></span> : <MdAdd size={24}/>
}
<span className={"text-sm"}> <span className={"text-sm"}>
Upload {t("Upload")}
</span> </span>
</button> </button>
</div> <dialog id="upload_dialog" className="modal">
<div className="modal-box">
<h3 className="font-bold text-lg mb-2">{t("Create File")}</h3>
<p className={"text-sm font-bold p-2"}>{t("Type")}</p>
<form className="filter mb-2">
<input className="btn btn-square" type="reset" value="×" onClick={() => {
setFileType(null);
}}/>
<input className="btn text-sm" type="radio" name="type" aria-label={t("Redirect")} onInput={() => {
setFileType(FileType.redirect);
}}/>
<input className="btn text-sm" type="radio" name="type" aria-label={t("Upload")} onInput={() => {
setFileType(FileType.upload);
}}/>
</form>
{
fileType === FileType.redirect && <>
<p className={"text-sm p-2"}>{t("User who click the file will be redirected to the URL")}</p>
<input type="text" className="input w-full my-2" placeholder={t("File Name")} onChange={(e) => {
setFilename(e.target.value)
}}/>
<input type="text" className="input w-full my-2" placeholder={t("URL")} onChange={(e) => {
setRedirectUrl(e.target.value)
}}/>
<input type="text" className="input w-full my-2" placeholder={t("Description")} onChange={(e) => {
setDescription(e.target.value)
}}/>
</>
} }
</div>
{
fileType === FileType.upload && <>
<p className={"text-sm p-2"}>{t("Upload a file to server, then the file will be moved to the selected storage.")}</p>
<select className="select select-primary w-full my-2" defaultValue={""} onChange={(e) => {
const id = parseInt(e.target.value)
if (isNaN(id)) {
setStorage(null)
} else {
const s = storages.current?.find((s) => s.id == id)
if (s) {
setStorage(s)
} }
}
}}>
<option value={""} disabled>{t("Select Storage")}</option>
{
storages.current?.map((s) => {
return <option key={s.id} value={s.id}>{s.name}</option>
})
}
</select>
<input
type="file" className="file-input w-full my-2" onChange={(e) => {
if (e.target.files) {
setFile(e.target.files[0])
}
}}/>
<input type="text" className="input w-full my-2" placeholder={t("Description")} onChange={(e) => {
setDescription(e.target.value)
}}/>
</>
}
{error && <ErrorAlert className={"my-2"} message={error}/>}
<div className="modal-action">
<form method="dialog">
<button className="btn text-sm">{t("Cancel")}</button>
</form>
<button className={"btn btn-primary text-sm"} onClick={submit}>
{isSubmitting ? <span className={"loading loading-spinner loading-sm"}></span> : null}
{t("Submit")}
</button>
</div>
</div>
</dialog>
</>
}