Files
nysoure/frontend/src/pages/resource_details_page.tsx
2025-07-04 13:23:56 +08:00

1307 lines
39 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 { useLocation, useNavigate, useParams } from "react-router";
import {
createContext,
createRef,
ReactElement,
ReactNode,
useCallback,
useContext,
useEffect,
useRef,
useState,
} from "react";
import {
ResourceDetails,
RFile,
Storage,
Comment,
Tag,
} 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,
MdOutlineDelete,
MdOutlineDownload,
MdOutlineEdit,
MdOutlineLink,
MdOutlineOpenInNew,
} 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";
import showPopup, { useClosePopup } from "../components/popup.tsx";
import { Turnstile } from "@marsidev/react-turnstile";
import Button from "../components/button.tsx";
import Badge, { BadgeAccent } from "../components/badge.tsx";
import Input, { TextArea } from "../components/input.tsx";
import { useAppContext } from "../components/AppContext.tsx";
import { BiLogoSteam } from "react-icons/bi";
import { CommentTile } from "../components/comment_tile.tsx";
import { CommentInput } from "../components/comment_input.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 location = useLocation();
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 (location.state?.resource) {
document.title = location.state?.resource.title;
} else {
document.title = t("Resource Details");
}
}, [location.state?.resource, t]);
useEffect(() => {
setResource(null);
if (!isNaN(id)) {
if (location.state) {
setResource(location.state.resource);
} else {
network.getResourceDetails(id).then((res) => {
if (res.success) {
setResource(res.data!);
document.title = res.data!.title;
} else {
showToast({ message: res.message, type: "error" });
}
});
}
}
}, [id, location.state]);
const navigate = useNavigate();
// 标签页与hash的映射
const tabHashList = ["description", "files", "comments"];
// 读取hash对应的tab索引
function getPageFromHash(hash: string) {
const h = hash.replace(/^#/, "");
const idx = tabHashList.indexOf(h);
return idx === -1 ? 0 : idx;
}
// 设置hash
function setHashByPage(idx: number) {
window.location.hash = "#" + tabHashList[idx];
}
// 初始状态读取hash
useEffect(() => {
setPage(getPageFromHash(window.location.hash));
// 监听hash变化
const onHashChange = () => {
setPage(getPageFromHash(window.location.hash));
};
window.addEventListener("hashchange", onHashChange);
return () => window.removeEventListener("hashchange", onHashChange);
// eslint-disable-next-line
}, []);
// 切换标签页时同步hash
const handleTabChange = (idx: number) => {
setPage(idx);
setHashByPage(idx);
};
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/${encodeURIComponent(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>
<Tags tags={resource.tags} />
{resource.links && (
<p className={"px-3 mt-2"}>
{resource.links.map((l) => {
return (
<a href={l.url} target={"_blank"}>
<span
className={
"py-1 px-3 inline-flex items-center m-1 border border-base-300 rounded-2xl hover:bg-base-200 transition-colors cursor-pointer select-none"
}
>
{l.url.includes("steampowered.com") ? (
<BiLogoSteam size={20} />
) : (
<MdOutlineLink size={20} />
)}
<span className={"ml-2 text-sm"}>{l.label}</span>
</span>
</a>
);
})}
</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={() => handleTabChange(0)}
/>
<MdOutlineArticle className="text-xl mr-2" />
<span className="text-sm">{t("Description")}</span>
</label>
<div key={"article"} className="tab-content p-2">
<Article resource={resource} />
</div>
<label className="tab transition-all">
<input
type="radio"
name="my_tabs"
checked={page === 1}
onChange={() => handleTabChange(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={() => handleTabChange(2)}
/>
<MdOutlineComment className="text-xl mr-2" />
<span className="text-sm">{t("Comments")}</span>
{resource.comments ? (
<span
className={`px-1 py-0.5 ml-1 rounded-full text-xs ${page === 2 ? "bg-accent text-accent-content" : "text-base-content/60"}`}
>
{resource.comments}
</span>
) : null}
</label>
<div key={"comments"} className="tab-content p-2">
<Comments resourceId={resource.id} />
</div>
<div className={"grow"}></div>
{app.isAdmin() || app.user?.id === resource.author.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>
</div>
</context.Provider>
);
}
function Tags({ tags }: { tags: Tag[] }) {
const tagsMap = new Map<string, Tag[]>();
const navigate = useNavigate();
const { t } = useTranslation();
for (const tag of tags || []) {
const type = tag.type;
if (!tagsMap.has(type)) {
tagsMap.set(type, []);
}
tagsMap.get(type)?.push(tag);
}
return (
<>
{Array.from(tagsMap.entries()).map(([type, tags]) => (
<p key={type} className={"px-4"}>
<Badge key={type}>{type == "" ? t("Other") : type}</Badge>
{tags.map((tag) => (
<Badge
key={tag.name}
className={"m-1 cursor-pointer badge-soft badge-primary"}
onClick={() => {
navigate(`/tag/${tag.name}`);
}}
>
{tag.name}
</Badge>
))}
</p>
))}
</>
);
}
function DeleteResourceDialog({
resourceId,
uploaderId,
}: {
resourceId: number;
uploaderId?: number;
}) {
const [isLoading, setLoading] = useState(false);
const navigate = useNavigate();
const { t } = useTranslation();
const context = useAppContext();
const handleDelete = async () => {
if (isLoading) {
return;
}
setLoading(true);
const res = await network.deleteResource(resourceId);
const dialog = document.getElementById(
"delete_resource_dialog",
) as HTMLDialogElement;
dialog.close();
if (res.success) {
showToast({
message: t("Resource deleted successfully"),
type: "success",
});
context.clear();
navigate("/", { replace: true });
} else {
showToast({ message: res.message, type: "error" });
}
setLoading(false);
};
if (!app.isAdmin() && app.user?.id !== uploaderId) {
return <></>;
}
return (
<>
<Button
className={"btn-error btn-ghost btn-circle"}
onClick={() => {
const dialog = document.getElementById(
"delete_resource_dialog",
) as HTMLDialogElement;
dialog.showModal();
}}
>
<MdOutlineDelete size={20} className={"inline-block"} />
</Button>
<dialog id={`delete_resource_dialog`} className="modal">
<div className="modal-box">
<h3 className="font-bold text-lg">{t("Delete Resource")}</h3>
<p className="py-4">
{t("Are you sure you want to delete the resource")}?{" "}
{t("This action cannot be undone.")}
</p>
<div className="modal-action">
<form method="dialog">
<button className="btn btn-ghost">{t("Close")}</button>
</form>
<Button
className="btn btn-error"
isLoading={isLoading}
onClick={handleDelete}
>
{t("Delete")}
</Button>
</div>
</div>
</dialog>
</>
);
}
const context = createContext<() => void>(() => {});
function Article({ resource }: { resource: ResourceDetails }) {
const navigate = useNavigate();
return (
<article>
<Markdown
components={{
p: ({ node, ...props }) => {
console.log(props.children);
if (
typeof props.children === "object" &&
(props.children as ReactElement).type === "strong"
) {
// @ts-ignore
const child = (
props.children as ReactElement
).props.children.toString() as string;
if (child.startsWith("<iframe")) {
// @ts-ignore
let html = child;
let splits = html.split(" ");
splits = splits.filter((s: string) => {
return !(
s.startsWith("width") ||
s.startsWith("height") ||
s.startsWith("class") ||
s.startsWith("style")
);
});
html = splits.join(" ");
return (
<div
className={`w-full my-3 max-w-xl rounded-xl overflow-clip ${html.includes("youtube") ? "aspect-video" : "h-48 sm:h-64"}`}
dangerouslySetInnerHTML={{
__html: html,
}}
></div>
);
}
} else if (
typeof props.children === "object" &&
// @ts-ignore
props.children?.props &&
// @ts-ignore
props.children?.props.href
) {
const a = props.children as ReactElement;
const childProps = a.props as any;
const href = childProps.href as string;
// @ts-ignore
if (childProps.children?.length === 2) {
// @ts-ignore
const first = childProps.children[0] as ReactNode;
// @ts-ignore
const second = childProps.children[1] as ReactNode;
if (
typeof first === "object" &&
(typeof second === "string" || typeof second === "object")
) {
const img = first as ReactElement;
// @ts-ignore
if (img.type === "img") {
return (
<a
className={
"inline-block card shadow bg-base-100 no-underline hover:shadow-md transition-shadow my-2"
}
target={"_blank"}
href={href}
>
<figure className={"max-h-96 min-w-48 min-h-24"}>
{img}
</figure>
<div className={"card-body text-base-content text-lg"}>
<div className={"flex items-center"}>
<span className={"flex-1"}>{second}</span>
<span>
<MdOutlineOpenInNew size={24} />
</span>
</div>
</div>
</a>
);
}
}
}
if (href.startsWith("https://store.steampowered.com/app/")) {
const appId = href
.substring("https://store.steampowered.com/app/".length)
.split("/")[0];
if (!Number.isNaN(Number(appId))) {
return (
<div className={"max-w-xl h-52 sm:h-48 my-2"}>
<iframe
className={"scheme-light"}
src={`https://store.steampowered.com/widget/${appId}/`}
></iframe>
</div>
);
}
}
}
return <p {...props}>{props.children}</p>;
},
a: ({ node, ...props }) => {
const href = props.href as string;
if (
href.startsWith(window.location.origin) ||
href.startsWith("/")
) {
let path = href;
if (path.startsWith(window.location.origin)) {
path = path.substring(window.location.origin.length);
}
const content = props.children?.toString();
if (path.startsWith("/resources/")) {
const id = path.substring("/resources/".length);
for (const r of resource.related ?? []) {
if (r.id.toString() === id) {
const imgHeight =
r.image && r.image.width > r.image.height ? 320 : 420;
const imgWidth = r.image
? (r.image.width / r.image.height) * imgHeight
: undefined;
return (
<span className={"inline-flex max-w-full"}>
<a
href={"/resources/" + r.id}
className={
"mr-2 mb-2 max-w-full cursor-pointer inline-flex min-w-0 flex-col bg-base-100 shadow hover:shadow-md transition-shadow rounded-xl no-underline"
}
onClick={(e) => {
e.preventDefault();
navigate(`/resources/${r.id}`);
}}
>
{r.image && (
<img
style={{
width: imgWidth,
height: imgHeight,
}}
className={"h-full min-h-0 object-cover min-w-0"}
alt={"cover"}
src={network.getImageUrl(r.image?.id)}
/>
)}
<span
className={"inline-flex flex-col p-4"}
style={{
width: imgWidth,
}}
>
<span
style={{
maxWidth: "100%",
textOverflow: "ellipsis",
lineBreak: "anywhere",
fontSize: "1.2rem",
fontWeight: "bold",
lineHeight: "1.5rem",
color: "var(--color-base-content)",
}}
>
{r.title}
</span>
<span className={"h-2"}></span>
<span
style={{
color: "var(--color-base-content)",
lineBreak: "anywhere",
}}
>
{content}
</span>
</span>
</a>
</span>
);
}
}
}
}
return <a target={"_blank"} {...props}></a>;
},
}}
>
{resource.article.replaceAll("\n", " \n")}
</Markdown>
</article>
);
}
function fileSizeToString(size: number) {
if (size < 1024) {
return size + "B";
} else if (size < 1024 * 1024) {
return (size / 1024).toFixed(2) + "KB";
} else if (size < 1024 * 1024 * 1024) {
return (size / 1024 / 1024).toFixed(2) + "MB";
} else {
return (size / 1024 / 1024 / 1024).toFixed(2) + "GB";
}
}
function FileTile({ file }: { file: RFile }) {
const buttonRef = createRef<HTMLButtonElement>();
const { t } = useTranslation();
return (
<div className={"card shadow bg-base-100 mb-4"}>
<div className={"p-4 flex flex-row items-center"}>
<div className={"grow"}>
<h4 className={"font-bold"}>{file.filename}</h4>
<p className={"text-sm my-1 whitespace-pre-wrap"}>
{file.description}
</p>
<p>
<BadgeAccent className={"mt-1"}>
{file.is_redirect ? t("Redirect") : fileSizeToString(file.size)}
</BadgeAccent>
</p>
</div>
<div className={"flex flex-row items-center"}>
<button
ref={buttonRef}
className={"btn btn-primary btn-soft btn-square"}
onClick={() => {
if (!app.cloudflareTurnstileSiteKey) {
const link = network.getFileDownloadLink(file.id, "");
window.open(link, "_blank");
} else {
showPopup(<CloudflarePopup file={file} />, buttonRef.current!);
}
}}
>
<MdOutlineDownload size={24} />
</button>
<DeleteFileDialog fileId={file.id} uploaderId={file.user_id} />
<UpdateFileInfoDialog file={file} />
</div>
</div>
</div>
);
}
function CloudflarePopup({ file }: { file: RFile }) {
const closePopup = useClosePopup();
const [isLoading, setLoading] = useState(true);
const { t } = useTranslation();
return (
<div
className={"menu bg-base-100 rounded-box z-1 w-80 p-2 shadow-sm relative"}
>
{isLoading ? (
<div
className={
"absolute top-0 bottom-0 left-0 right-0 flex items-center justify-center"
}
>
<span className={"loading loading-spinner loading-lg"}></span>
</div>
) : null}
<h3 className={"font-bold m-2"}>{t("Verifying your request")}</h3>
<div className={"h-20 w-full"}>
<Turnstile
siteKey={app.cloudflareTurnstileSiteKey!}
onWidgetLoad={() => {
setLoading(false);
}}
onSuccess={(token) => {
closePopup();
const link = network.getFileDownloadLink(file.id, token);
window.open(link, "_blank");
}}
></Turnstile>
</div>
<p className={"text-xs text-base-content/80 m-2"}>
{t(
"Please check your network if the verification takes too long or the captcha does not appear.",
)}
</p>
</div>
);
}
function Files({ files, resourceID }: { files: RFile[]; resourceID: number }) {
return (
<div className={"pt-3"}>
{files.map((file) => {
return <FileTile file={file} key={file.id}></FileTile>;
})}
<div className={"h-2"}></div>
{app.canUpload() && (
<div className={"flex flex-row-reverse"}>
<CreateFileDialog resourceId={resourceID}></CreateFileDialog>
</div>
)}
</div>
);
}
enum FileType {
redirect = "redirect",
upload = "upload",
serverTask = "server_task",
}
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 [fileUrl, setFileUrl] = 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 (fileType === FileType.upload) {
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);
}
} else if (fileType === FileType.serverTask) {
if (!fileUrl || !filename || !storage) {
setError(t("Please fill in all fields"));
setSubmitting(false);
return;
}
const res = await network.createServerDownloadTask(
fileUrl,
filename,
description,
resourceId,
storage.id,
);
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);
}
}
};
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);
}}
/>
<input
className="btn text-sm"
type="radio"
name="type"
aria-label={t("File Url")}
onInput={() => {
setFileType(FileType.serverTask);
}}
/>
</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);
}}
/>
<textarea
className="textarea 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]);
}
}}
/>
<textarea
className="textarea w-full my-2"
placeholder={t("Description")}
onChange={(e) => {
setDescription(e.target.value);
}}
/>
</>
)}
{fileType === FileType.serverTask && (
<>
<p className={"text-sm p-2"}>
{t(
"Provide a file url for the server to download, and 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="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("File URL")}
onChange={(e) => {
setFileUrl(e.target.value);
}}
/>
<textarea
className="textarea 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 UpdateFileInfoDialog({ file }: { file: RFile }) {
const [isLoading, setLoading] = useState(false);
const [filename, setFilename] = useState(file.filename);
const [description, setDescription] = useState(file.description);
const { t } = useTranslation();
const reload = useContext(context);
const handleUpdate = async () => {
if (isLoading) {
return;
}
setLoading(true);
const res = await network.updateFile(file.id, filename, description);
const dialog = document.getElementById(
`update_file_info_dialog_${file.id}`,
) as HTMLDialogElement;
dialog.close();
if (res.success) {
showToast({
message: t("File info updated successfully"),
type: "success",
});
reload();
} else {
showToast({ message: res.message, type: "error" });
}
setLoading(false);
};
if (!app.isAdmin() && app.user?.id !== file.user_id) {
return <></>;
}
return (
<>
<button
className={"btn btn-primary btn-ghost btn-circle ml-1"}
onClick={() => {
const dialog = document.getElementById(
`update_file_info_dialog_${file.id}`,
) as HTMLDialogElement;
dialog.showModal();
}}
>
<MdOutlineEdit size={20} className={"inline-block"} />
</button>
<dialog id={`update_file_info_dialog_${file.id}`} className="modal">
<div className="modal-box">
<h3 className="font-bold text-lg">{t("Update File Info")}</h3>
<Input
type={"text"}
label={t("File Name")}
value={filename}
onChange={(e) => setFilename(e.target.value)}
/>
<TextArea
label={t("Description")}
value={description}
onChange={(e) => setDescription(e.target.value)}
/>
<div className="modal-action">
<form method="dialog">
<button className="btn btn-ghost">{t("Close")}</button>
</form>
<button className="btn btn-primary" onClick={handleUpdate}>
{t("Update")}
</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 reload = useCallback(() => {
setPage(1);
setMaxPage(0);
setListKey((prev) => prev + 1);
}, []);
return (
<div>
<CommentInput resourceId={resourceId} reload={reload} />
<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>
) : null}
</div>
);
}
function CommentsList({
resourceId,
page,
maxPageCallback,
}: {
resourceId: number;
page: number;
maxPageCallback: (maxPage: number) => void;
}) {
const [comments, setComments] = useState<Comment[] | null>(null);
const reload = useContext(context);
useEffect(() => {
network.listResourceComments(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} onUpdated={reload} />
);
})}
</>
);
}
function DeleteFileDialog({
fileId,
uploaderId,
}: {
fileId: string;
uploaderId: number;
}) {
const [isLoading, setLoading] = useState(false);
const id = `delete_file_dialog_${fileId}`;
const reload = useContext(context);
const { t } = useTranslation();
const handleDelete = async () => {
if (isLoading) {
return;
}
setLoading(true);
const res = await network.deleteFile(fileId);
const dialog = document.getElementById(id) as HTMLDialogElement;
dialog.close();
if (res.success) {
showToast({ message: t("File deleted successfully"), type: "success" });
reload();
} else {
showToast({ message: res.message, type: "error" });
}
setLoading(false);
};
if (!app.isAdmin() && app.user?.id !== uploaderId) {
return <></>;
}
return (
<>
<button
className={"btn btn-error btn-ghost btn-circle ml-1"}
onClick={() => {
const dialog = document.getElementById(id) as HTMLDialogElement;
dialog.showModal();
}}
>
<MdOutlineDelete size={20} className={"inline-block"} />
</button>
<dialog id={id} className="modal">
<div className="modal-box">
<h3 className="font-bold text-lg">{t("Delete File")}</h3>
<p className="py-4">
{t(
"Are you sure you want to delete the file? This action cannot be undone.",
)}
</p>
<div className="modal-action">
<form method="dialog">
<button className="btn btn-ghost">{t("Close")}</button>
</form>
<button className="btn btn-error" onClick={handleDelete}>
{t("Delete")}
</button>
</div>
</div>
</dialog>
</>
);
}