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, MdArrowDownward, MdArrowUpward, MdClose, MdOutlineArticle, MdOutlineComment, MdOutlineDataset, MdOutlineDelete, MdOutlineDownload, MdOutlineEdit, MdOutlineImage, MdOutlineLink, MdOutlineOpenInNew, } from "react-icons/md"; import { app } from "../app.ts"; import { uploadingManager } from "../network/uploading.ts"; import { ErrorAlert, InfoAlert } 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 { ImageGrid, SquareImage } from "../components/image.tsx"; import { BiLogoSteam } from "react-icons/bi"; export default function ResourcePage() { const params = useParams(); const { t } = useTranslation(); const idStr = params.id; const id = idStr ? parseInt(idStr) : NaN; const [resource, setResource] = useState(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 (
{t("Resource ID is required")}
); } if (!resource) { return ; } return (

{resource.title}

{resource.alternativeTitles.map((e, i) => { return (

{e}

); })} {resource.links && (

{resource.links.map((l) => { return ( {l.url.includes("steampowered.com") ? ( ) : ( )} {l.label} ); })}

)}
{app.isAdmin() || app.user?.id === resource.author.id ? ( ) : null}
); } function Tags({ tags }: { tags: Tag[] }) { const tagsMap = new Map(); 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]) => (

{type == "" ? t("Other") : type} {tags.map((tag) => ( { navigate(`/tag/${tag.name}`); }} > {tag.name} ))}

))} ); } 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 ( <>

{t("Delete Resource")}

{t("Are you sure you want to delete the resource")}?{" "} {t("This action cannot be undone.")}

); } const context = createContext<() => void>(() => {}); function Article({ resource }: { resource: ResourceDetails }) { const navigate = useNavigate(); return (
{ 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(" { return !( s.startsWith("width") || s.startsWith("height") || s.startsWith("class") || s.startsWith("style") ); }); html = splits.join(" "); return (
); } } 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 (
{img}
{second}
); } } } 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 (
); } } } return

{props.children}

; }, 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 ( { e.preventDefault(); navigate(`/resources/${r.id}`); }} > {r.image && ( {"cover"} )} {r.title} {content} ); } } } } return ; }, }} > {resource.article.replaceAll("\n", " \n")}
); } 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(); const { t } = useTranslation(); return (

{file.filename}

{file.description}

{file.is_redirect ? t("Redirect") : fileSizeToString(file.size)}

); } function CloudflarePopup({ file }: { file: RFile }) { const closePopup = useClosePopup(); const [isLoading, setLoading] = useState(true); const { t } = useTranslation(); return (
{isLoading ? (
) : null}

{t("Verifying your request")}

{ setLoading(false); }} onSuccess={(token) => { closePopup(); const link = network.getFileDownloadLink(file.id, token); window.open(link, "_blank"); }} >

{t( "Please check your network if the verification takes too long or the captcha does not appear.", )}

); } function Files({ files, resourceID }: { files: RFile[]; resourceID: number }) { return (
{files.map((file) => { return ; })}
{app.canUpload() && (
)}
); } 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(null); const mounted = useRef(true); const [fileType, setFileType] = useState(null); const [filename, setFilename] = useState(""); const [redirectUrl, setRedirectUrl] = useState(""); const [storage, setStorage] = useState(null); const [file, setFile] = useState(null); const [description, setDescription] = useState(""); const [fileUrl, setFileUrl] = useState(""); const reload = useContext(context); const [isSubmitting, setSubmitting] = useState(false); const [error, setError] = useState(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 ( <>

{t("Create File")}

{t("Type")}

{ setFileType(null); }} /> { setFileType(FileType.redirect); }} /> { setFileType(FileType.upload); }} /> { setFileType(FileType.serverTask); }} />
{fileType === FileType.redirect && ( <>

{t("User who click the file will be redirected to the URL")}

{ setFilename(e.target.value); }} /> { setRedirectUrl(e.target.value); }} />