import { useEffect, useState } from "react"; import { Link, useLocation, useNavigate, useParams } from "react-router"; import { t } from "@lingui/core/macro"; import { Trans } from "@lingui/react/macro"; import { dumpThumbnailUrl, dumpUrl } from "../utils/urls.ts"; import { can } from "../utils/permissions.ts"; import { AddToPlaylistModal } from "../components/AddToPlaylistModal.tsx"; import { API_URL, VALIDATION } from "../config/api.ts"; import type { Comment, Dump, PublicUser, RawComment, RawDump, UpdateDumpRequest, } from "../model.ts"; import { deserializeComment, deserializeDump, deserializePublicUser, parseAPIResponse, } from "../model.ts"; import { useAuth } from "../hooks/useAuth.ts"; import { useDocumentTitle } from "../hooks/useDocumentTitle.ts"; import { relativeTime } from "../utils/relativeTime.ts"; import { useWS } from "../hooks/useWS.ts"; import { Avatar } from "../components/Avatar.tsx"; import RichContentCard from "../components/RichContentCard.tsx"; import FilePreview from "../components/FilePreview.tsx"; import { VoteButton } from "../components/VoteButton.tsx"; import { DumpCard } from "../components/DumpCard.tsx"; import { PageShell } from "../components/PageShell.tsx"; import { PageError } from "../components/PageError.tsx"; import { Markdown } from "../components/Markdown.tsx"; import { CommentThread } from "../components/CommentThread.tsx"; import { Tooltip } from "../components/Tooltip.tsx"; import { friendlyFetchError } from "../utils/apiError.ts"; type DumpState = | { status: "loading" } | { status: "error"; error: string } | { status: "loaded"; dump: Dump }; export function Dump() { const { selectedDump } = useParams(); const location = useLocation(); const navigate = useNavigate(); const preloaded = (location.state as { dump?: Dump } | null)?.dump ?? null; const [dumpState, setDumpState] = useState( preloaded ? { status: "loaded", dump: preloaded } : { status: "loading" }, ); const [op, setOp] = useState(null); const [playlistModalOpen, setPlaylistModalOpen] = useState(false); useDocumentTitle( dumpState.status === "loaded" ? dumpState.dump.title : dumpState.status === "error" ? null : undefined, ); const [comments, setComments] = useState([]); const [relatedDumps, setRelatedDumps] = useState([]); const [titleEditing, setTitleEditing] = useState(false); const [titleDraft, setTitleDraft] = useState(""); const [titleSaving, setTitleSaving] = useState(false); const [titleError, setTitleError] = useState(null); const { user, token, authFetch } = useAuth(); const { voteCounts, myVotes, castVote, removeVote, lastDumpEvent, lastCommentEvent, } = useWS(); useEffect(() => { if (!selectedDump) return; const controller = new AbortController(); if (preloaded) { fetch(`${API_URL}/api/users/by-id/${preloaded.userId}`, { signal: controller.signal, }) .then((r) => r.json()) .then((r) => r.success && setOp(deserializePublicUser(r.data))) .catch(() => {}); return () => controller.abort(); } (async () => { setDumpState({ status: "loading" }); setOp(null); try { const res = await fetch(`${API_URL}/api/dumps/${selectedDump}`, { cache: "no-store", signal: controller.signal, headers: token ? { Authorization: `Bearer ${token}` } : {}, }); const apiResponse = parseAPIResponse(await res.json()); if (!apiResponse.success) { throw new Error(apiResponse.error.message); } const dump: Dump = deserializeDump(apiResponse.data); setDumpState({ status: "loaded", dump }); fetch(`${API_URL}/api/users/by-id/${dump.userId}`, { signal: controller.signal, }) .then((r) => r.json()) .then((r) => r.success && setOp(deserializePublicUser(r.data))) .catch(() => {}); } catch (err) { if ((err as Error).name === "AbortError") return; setDumpState({ status: "error", error: friendlyFetchError(err) }); } })(); return () => controller.abort(); }, [selectedDump, preloaded, token]); useEffect(() => { if (!lastDumpEvent) return; // Subscribing to the WS-pushed dump-update stream — setState here mirrors // external state into local state, no synchronous alternative. // eslint-disable-next-line react-hooks/set-state-in-effect setDumpState((prev) => { if (prev.status !== "loaded" || prev.dump.id !== lastDumpEvent.id) { return prev; } return { status: "loaded", dump: lastDumpEvent }; }); }, [lastDumpEvent]); // Fetch comments when dump loads useEffect(() => { if (!selectedDump) return; const controller = new AbortController(); fetch(`${API_URL}/api/dumps/${selectedDump}/comments`, { signal: controller.signal, headers: token ? { Authorization: `Bearer ${token}` } : {}, }) .then((r) => r.json()) .then((body) => { if (body.success) { setComments((body.data as RawComment[]).map(deserializeComment)); } }) .catch(() => {}); return () => controller.abort(); }, [selectedDump, token]); // Fetch related dumps (backlinks) when dump loads useEffect(() => { if (!selectedDump) return; const controller = new AbortController(); fetch(`${API_URL}/api/dumps/${selectedDump}/related`, { signal: controller.signal, headers: token ? { Authorization: `Bearer ${token}` } : {}, }) .then((r) => r.json()) .then((body) => { if (body.success) { setRelatedDumps((body.data as RawDump[]).map(deserializeDump)); } }) .catch(() => {}); return () => controller.abort(); }, [selectedDump, token]); // Scroll to and highlight a comment when navigating to #comment-{id} useEffect(() => { if (!location.hash.startsWith("#comment-")) return; const id = location.hash.slice(1); const el = document.getElementById(id); if (!el) return; el.scrollIntoView({ behavior: "smooth", block: "start" }); el.classList.add("comment-node--highlight"); const timer = setTimeout( () => el.classList.remove("comment-node--highlight"), 2000, ); return () => clearTimeout(timer); }, [comments, location.hash]); // React to WS comment events const loadedDumpId = dumpState.status === "loaded" ? dumpState.dump.id : null; useEffect(() => { if ( !lastCommentEvent || !loadedDumpId || lastCommentEvent.dumpId !== loadedDumpId ) return; // Subscribing to the WS-pushed comment-event stream — setState here // mirrors external state into local state, no synchronous alternative. if (lastCommentEvent.type === "created" && lastCommentEvent.comment) { // eslint-disable-next-line react-hooks/set-state-in-effect setComments((prev) => { if (prev.some((c) => c.id === lastCommentEvent.comment!.id)) { return prev; } return [...prev, lastCommentEvent.comment!]; }); } else if ( lastCommentEvent.type === "deleted" && lastCommentEvent.commentId ) { setComments((prev) => prev.map((c) => c.id === lastCommentEvent.commentId ? { ...c, deleted: true, body: "" } : c ) ); } else if ( lastCommentEvent.type === "updated" && lastCommentEvent.comment ) { setComments((prev) => prev.map((c) => c.id === lastCommentEvent.comment!.id ? lastCommentEvent.comment! : c ) ); } }, [lastCommentEvent, loadedDumpId]); if (dumpState.status === "loading") { return (

Loading dump…

); } if (dumpState.status === "error") { return ( } /> ); } const { dump } = dumpState; const canEdit = !!user && (dump.userId === user.id || can(user, "dump:moderate")); const handleTitleSave = async () => { const trimmed = titleDraft.trim(); if (!trimmed || trimmed.length > VALIDATION.DUMP_TITLE_MAX) return; setTitleSaving(true); setTitleError(null); try { const res = await authFetch(`${API_URL}/api/dumps/${dump.id}`, { method: "PUT", body: JSON.stringify( { title: trimmed } satisfies UpdateDumpRequest, ), }); const apiResponse = parseAPIResponse(await res.json()); if (!apiResponse.success) { setTitleError(apiResponse.error.message); return; } const updatedDump = deserializeDump(apiResponse.data); setTitleEditing(false); if (updatedDump.slug !== dump.slug) { navigate(dumpUrl(updatedDump), { replace: true, state: { dump: updatedDump }, }); } else { setDumpState({ status: "loaded", dump: updatedDump }); } } catch (err) { setTitleError(friendlyFetchError(err)); } finally { setTitleSaving(false); } }; return (
{/* Post header */}
{user && ( )}
{titleEditing ? (
setTitleDraft(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); handleTitleSave(); } else if (e.key === "Escape") { setTitleEditing(false); } }} maxLength={VALIDATION.DUMP_TITLE_MAX} disabled={titleSaving} autoFocus /> {titleError && (

{titleError}

)}
) : (
{ setTitleDraft(dump.title); setTitleError(null); setTitleEditing(true); } : undefined} >

{dump.title}

{canEdit && ( )}
)}
{op ? ( {op.username} ) : } {dump.updatedAt && ( edited {relativeTime(dump.updatedAt)} )} {dump.isPrivate && ( private )}
{dump.comment && ( {dump.comment} )}
{/* Main content */}
{dump.kind === "file" ? : dump.richContent ? ( ) : ( {dump.url} )}
{/* Actions */}
{canEdit && ( Edit )} ← Back to all dumps
{/* Related (backlinks) */} {relatedDumps.length > 0 && (

Related

    {relatedDumps.map((related) => ( ))}
)} {/* Comments */} setComments((prev) => prev.some((x) => x.id === c.id) ? prev : [...prev, c] )} onCommentDeleted={(id) => setComments((prev) => prev.map((c) => c.id === id ? { ...c, deleted: true, body: "" } : c ) )} onCommentUpdated={(updated) => setComments((prev) => prev.map((c) => (c.id === updated.id ? updated : c)) )} />
{playlistModalOpen && ( setPlaylistModalOpen(false)} /> )}
); }