Files
nysoure/frontend/src/pages/resource_details_page.tsx

494 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import {useNavigate, useParams} from "react-router";
import {createContext, useCallback, useContext, useEffect, useRef, useState} from "react";
import {ResourceDetails, RFile, Storage, Comment} from "../network/models.ts";
import {network} from "../network/network.ts";
import showToast from "../components/toast.ts";
import Markdown from "react-markdown";
import "../markdown.css";
import Loading from "../components/loading.tsx";
import {MdAdd, MdOutlineArticle, MdOutlineComment, MdOutlineDataset, MdOutlineDownload} from "react-icons/md";
import {app} from "../app.ts";
import {uploadingManager} from "../network/uploading.ts";
import {ErrorAlert} from "../components/alert.tsx";
import {useTranslation} from "react-i18next";
import Pagination from "../components/pagination.tsx";
export default function ResourcePage() {
const params = useParams()
const {t} = useTranslation();
const idStr = params.id
const id = idStr ? parseInt(idStr) : NaN
const [resource, setResource] = useState<ResourceDetails | null>(null)
const [page, setPage] = useState(0)
const reload = useCallback(async () => {
if (!isNaN(id)) {
setResource(null)
const res = await network.getResourceDetails(id)
if (res.success) {
setResource(res.data!)
} else {
showToast({message: res.message, type: "error"})
}
}
}, [id])
useEffect(() => {
if (!isNaN(id)) {
network.getResourceDetails(id).then((res) => {
if (res.success) {
setResource(res.data!)
} else {
showToast({message: res.message, type: "error"})
}
})
}
}, [id])
const navigate = useNavigate()
if (isNaN(id)) {
return <div className="alert alert-error shadow-lg">
<div>
<span>{t("Resource ID is required")}</span>
</div>
</div>
}
if (!resource) {
return <Loading/>
}
return <context.Provider value={reload}>
<div className={"pt-2"}>
<h1 className={"text-2xl font-bold px-4 py-2"}>{resource.title}</h1>
{
resource.alternativeTitles.map((e, i) => {
return <h2 key={i} className={"text-lg px-4 py-1 text-gray-700 dark:text-gray-300"}>{e}</h2>
})
}
<button
onClick={() => {
navigate(`/user/${resource.author.username}`)
}}
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="avatar">
<div className="w-6 rounded-full">
<img src={network.getUserAvatar(resource.author)} alt={"avatar"}/>
</div>
</div>
<div className="w-2"></div>
<div className="text-sm">{resource.author.username}</div>
</div>
</button>
<p className={"px-4 pt-2"}>
{
resource.tags.map((e) => {
return <span key={e.id} className="badge badge-primary mr-2 text-sm cursor-pointer" onClick={() => {
navigate(`/tag/${e.name}`);
}}>{e.name}</span>
})
}
</p>
<div className="tabs tabs-box my-4 mx-2 p-4">
<label className="tab transition-all">
<input type="radio" name="my_tabs" checked={page === 0} onChange={() => {
setPage(0)
}}/>
<MdOutlineArticle className="text-xl mr-2"/>
<span className="text-sm">
{t("Description")}
</span>
</label>
<div key={"article"} className="tab-content p-2">
<Article article={resource.article}/>
</div>
<label className="tab transition-all">
<input type="radio" name="my_tabs" checked={page === 1} onChange={() => {
setPage(1)
}}/>
<MdOutlineDataset className="text-xl mr-2"/>
<span className="text-sm">
{t("Files")}
</span>
</label>
<div key={"files"} className="tab-content p-2">
<Files files={resource.files} resourceID={resource.id}/>
</div>
<label className="tab transition-all">
<input type="radio" name="my_tabs" checked={page === 2} onChange={() => {
setPage(2)
}}/>
<MdOutlineComment className="text-xl mr-2"/>
<span className="text-sm">
{t("Comments")}
</span>
</label>
<div key={"comments"} className="tab-content p-2">
<Comments resourceId={resource.id}/>
</div>
</div>
<div className="h-4"></div>
</div>
</context.Provider>
}
const context = createContext<() => void>(() => {
})
function Article({article}: { article: string }) {
return <article>
<Markdown>{article}</Markdown>
</article>
}
function FileTile({file}: { file: RFile }) {
return <div className={"card card-border border-base-300 my-2"}>
<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"} onClick={() => {
const link = network.getFileDownloadLink(file.id);
window.open(link, "_blank");
}}>
<MdOutlineDownload size={24}/>
</button>
</div>
</div>
</div>
}
function Files({files, resourceID}: { files: RFile[], resourceID: number }) {
return <div>
{
files.map((file) => {
return <FileTile file={file} key={file.id}></FileTile>
})
}
<div className={"h-2"}></div>
{
app.isAdmin() && <div className={"flex flex-row-reverse"}>
<CreateFileDialog resourceId={resourceID}></CreateFileDialog>
</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) {
setSubmitting(false)
const dialog = document.getElementById("upload_dialog") as HTMLDialogElement
dialog.close()
showToast({message: t("File created successfully"), type: "success"})
reload()
} else {
setError(res.message)
setSubmitting(false)
}
} 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 (mounted.current) {
reload();
}
});
if (res.success) {
setSubmitting(false)
const dialog = document.getElementById("upload_dialog") as HTMLDialogElement
dialog.close()
showToast({message: t("Successfully create uploading task."), type: "success"})
} else {
setError(res.message)
setSubmitting(false)
}
}
}
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"}>
{t("Upload")}
</span>
</button>
<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)
}}/>
</>
}
{
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}({(s.currentSize / 1024 / 1024).toFixed(2)}/{s.maxSize / 1024 / 1024}MB)</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>
</>
}
function Comments({resourceId}: { resourceId: number }) {
const [page, setPage] = useState(1);
const [maxPage, setMaxPage] = useState(0);
const [listKey, setListKey] = useState(0);
const [commentContent, setCommentContent] = useState("");
const [isLoading, setLoading] = useState(false);
const reload = useCallback(() => {
setPage(1);
setMaxPage(0);
setListKey(prev => prev + 1);
}, [])
const sendComment = async () => {
if (isLoading) {
return;
}
if (commentContent === "") {
showToast({message: "Comment content cannot be empty", type: "error"});
return;
}
setLoading(true);
const res = await network.createComment(resourceId, commentContent);
if (res.success) {
setCommentContent("");
showToast({message: "Comment created successfully", type: "success"});
reload();
} else {
showToast({message: res.message, type: "error"});
}
setLoading(false);
}
return <div>
<div className={"mt-4 mb-6 textarea w-full p-4 h-28 flex flex-col"}>
<textarea placeholder={"Write down your comment"} className={"w-full resize-none grow"} value={commentContent}
onChange={(e) => setCommentContent(e.target.value)}/>
<div className={"flex flex-row-reverse"}>
<button onClick={sendComment}
className={`btn btn-primary h-8 text-sm mx-2 ${commentContent === "" && "btn-disabled"}`}>
{isLoading ? <span className={"loading loading-spinner loading-sm"}></span> : null}
Submit
</button>
</div>
</div>
<CommentsList resourceId={resourceId} page={page} maxPageCallback={setMaxPage} key={listKey}/>
{maxPage && <div className={"w-full flex justify-center"}>
<Pagination page={page} setPage={setPage} totalPages={maxPage}/>
</div>}
</div>
}
function CommentsList({resourceId, page, maxPageCallback}: {
resourceId: number,
page: number,
maxPageCallback: (maxPage: number) => void
}) {
const [comments, setComments] = useState<Comment[] | null>(null);
useEffect(() => {
network.listComments(resourceId, page).then((res) => {
if (res.success) {
setComments(res.data!);
maxPageCallback(res.totalPages || 1);
} else {
showToast({
message: res.message,
type: "error",
});
}
});
}, [maxPageCallback, page, resourceId]);
if (comments == null) {
return <div className={"w-full"}>
<Loading/>
</div>
}
return <>
{
comments.map((comment) => {
return <CommentTile comment={comment} key={comment.id}/>
})
}
</>
}
function CommentTile({comment}: { comment: Comment }) {
return <div className={"card card-border border-base-300 p-2 my-3"}>
<div className={"flex flex-row items-center my-1 mx-1"}>
<div className="avatar">
<div className="w-8 rounded-full">
<img src={network.getUserAvatar(comment.user)} alt={"avatar"}/>
</div>
</div>
<div className={"w-2"}></div>
<div className={"text-sm font-bold"}>{comment.user.username}</div>
<div className={"grow"}></div>
<div className={"text-sm text-gray-500"}>{new Date(comment.created_at).toLocaleString()}</div>
</div>
<div className={"text-sm p-2"}>
{comment.content}
</div>
</div>
}