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, Resource, Collection, } 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, MdOutlineArchive, MdOutlineArticle, MdOutlineComment, MdOutlineDataset, MdOutlineDelete, MdOutlineDownload, MdOutlineEdit, MdOutlineFolderSpecial, 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 "../utils/i18n"; 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 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"; import { useNavigator } from "../components/navigator.tsx"; import KunApi, { kunLanguageToString, KunPatchResourceResponse, KunPatchResponse, kunPlatformToString, kunResourceTypeToString, } from "../network/kun.ts"; import { Debounce } from "../utils/debounce.ts"; 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 [visitedTabs, setVisitedTabs] = useState>(new Set([])); const location = useLocation(); const navigator = useNavigator(); 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 { const preFetchData = app.getPreFetchData(); if (preFetchData?.resource?.id === id) { setResource(preFetchData.resource); } else { network.getResourceDetails(id).then((res) => { if (res.success) { setResource(res.data!); } else { showToast({ message: res.message, type: "error" }); } }); } } } }, [id, location.state]); useEffect(() => { if (resource) { document.title = resource.title; if (resource.images.length > 0) { navigator.setBackground( network.getResampledImageUrl(resource.images[0].id), ); } } }, [resource]); 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)); setVisitedTabs(new Set([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); // Mark tab as visited when switched to setVisitedTabs((prev) => new Set(prev).add(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.map((l) => { return ( {l.url.includes("steampowered.com") ? ( ) : ( )} {l.label} ); })}

{visitedTabs.has(0) &&
}
{visitedTabs.has(1) && ( )}
{visitedTabs.has(2) && }
{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 }) { return (
{ 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; const origin = window.location.origin; if (href.startsWith(origin) || href.startsWith("/")) { let path = href; if (path.startsWith(origin)) { path = path.substring(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) { return ; } } } } return ; }, }} > {resource.article.replaceAll("\n", " \n")}
); } function RelatedResourceCard({ r, content, }: { r: Resource; content?: string; }) { const navigate = useNavigate(); const [articleWidth, setArticleWidth] = useState(null); useEffect(() => { const observer = new ResizeObserver((entries) => { for (const entry of entries) { if (entry.contentRect.width !== articleWidth) { setArticleWidth(entry.contentRect.width); } } }); const articleElement = document.querySelector("article"); if (articleElement) { observer.observe(articleElement); } }, []); const imgHeight = r.image && r.image.width > r.image.height ? 320 : 420; let imgWidth = r.image ? (r.image.width / r.image.height) * imgHeight : undefined; if (articleWidth && imgWidth && imgWidth > articleWidth) { imgWidth = articleWidth; } if (!articleWidth) { return <>; } return ( { e.preventDefault(); navigate(`/resources/${r.id}`); }} > {r.image && ( {"cover"} )} {r.title} {content} ); } 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(); const userLink = `/user/${encodeURIComponent(file.user.username)}`; const navigate = useNavigate(); return (

{file.filename}

{file.description.replaceAll("\n", " \n")}

{ e.preventDefault(); navigate(userLink); }} > {"avatar"} {file.user.username} {file.is_redirect ? t("Redirect") : fileSizeToString(file.size)}

{file.size > 10 * 1024 * 1024 ? ( ) : ( )}
); } 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, resource, }: { files: RFile[]; resource: ResourceDetails; }) { return (
{files.map((file) => { return ; })}
{(app.canUpload() || (app.allowNormalUserUpload && app.isLoggedIn())) && (
)}
); } 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")}

{app.uploadPrompt && (

{app.uploadPrompt}

)}

{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); }} />