Files
gerbeur/src/pages/Dump.tsx
khannurien f3f07ed1e3
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 49s
Build and Deploy Static Documentation Site / build-and-push (push) Successful in 22s
v3: every doc opens with the role, then the mechanism, then the details — the docs were written bottom-up: what a thing does first ("Declares the three contexts WSProvider fills…"), details that only make sense once its purpose is known next, and the reason it exists last ("…so that a feed doesn't re-render"), so a reader had nothing to hang the first sentences on. The README's rule caused it, since a module doc "opened with what the module does"; it now opens with the module's role in the app, and the value-side phrasing of a type ("Returned to Y by X") comes once the role is said, with WSContext as the before and after. WSContext.ts is rewritten that way throughout, and so are the docs elsewhere that buried their purpose in a trailing "so that" (the migrations, og, image-proxy, public-address, useFading, useFeedCache, the drafts), opened on a generic verb that hid it ("Holds", "Declares", "Loads", "Defines", "Types": the contexts and providers, the shared types, the models, the hooks), or were label fragments ("The page of a dump…", "A row of tabs."). A function whose verb is its action ("Stores session"), a props or result type, and a protocol message ("Sent by X when Y") already say their role and are left as they are. Only comments changed, and the catalogs only move their references; deno fmt, deno lint and doc:lint are clean.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 21:42:08 +00:00

605 lines
20 KiB
TypeScript

/**
* Renders a dump's page, with its preview, comments, related dumps and the
* playlists holding it.
*
* @module
*/
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/shared/urls.ts";
import { can } from "../utils/auth/permissions.ts";
import { AddToPlaylistModal } from "../components/playlist/AddToPlaylistModal.tsx";
import { API_URL } from "../config/api.ts";
import { VALIDATION } from "../../shared/validation.ts";
import type { Comment } from "../../shared/comment.ts";
import type { Dump, UpdateDumpRequest } from "../../shared/dump.ts";
import type { Playlist } from "../../shared/playlist.ts";
import type { PublicUser } from "../../shared/user.ts";
import type { Wire } from "../../shared/api.ts";
import {
deserializeComment,
deserializeDump,
deserializePlaylist,
deserializePublicUser,
} from "../utils/shared/deserialize.ts";
import { friendlyFetchError, parseAPIResponse } from "../utils/shared/api.ts";
import { useAuth } from "../hooks/auth/useAuth.ts";
import { useCategories } from "../hooks/category/useCategories.ts";
import { useDocumentTitle } from "../hooks/shared/useDocumentTitle.ts";
import { relativeTime } from "../utils/shared/relativeTime.ts";
import { useHeightTransition } from "../hooks/shared/useHeightTransition.ts";
import { useWS } from "../hooks/ws/useWS.ts";
import { Avatar } from "../components/shared/Avatar.tsx";
import { RichContentCard } from "../components/dump/RichContentCard.tsx";
import { FilePreview } from "../components/dump/FilePreview.tsx";
import { VoteButton } from "../components/social/VoteButton.tsx";
import { DumpCardList } from "../components/dump/DumpCardList.tsx";
import { PlaylistCardList } from "../components/playlist/PlaylistCardList.tsx";
import { PageShell } from "../components/layout/PageShell.tsx";
import { PageError } from "../components/layout/PageError.tsx";
import { Markdown } from "../components/editor/Markdown.tsx";
import { CommentThread } from "../components/comment/CommentThread.tsx";
import { Tooltip } from "../components/shared/Tooltip.tsx";
type DumpState =
| { status: "loading" }
| { status: "error"; error: string }
| { status: "loaded"; dump: Dump };
/**
* Renders `/dumps/:selectedDump`, for anyone who may see the dump: a private
* dump answers 404 to anyone but its poster. The dump comes from
* `GET /api/dumps/:dumpId`, or from the navigation's state when `DumpEdit` or
* a title edit here just saved it. Its comments, related dumps and playlists
* come from their own endpoints, and `WSContext`'s events keep them current.
* The poster and moderators can edit the title in place, and get a link to
* `DumpEdit`.
*/
export function Dump(): React.JSX.Element {
const { selectedDump } = useParams();
const location = useLocation();
const navigate = useNavigate();
// Taken once, since it stays in the history entry, where a reload or Back
// would show that snapshot forever. It is cleared from there below.
const [handedOver] = useState(
() => (location.state as { dump?: Dump } | null)?.dump ?? null,
);
const preloaded = handedOver &&
(handedOver.id === selectedDump || handedOver.slug === selectedDump)
? handedOver
: null;
useEffect(() => {
if (!(location.state as { dump?: Dump } | null)?.dump) return;
navigate(`${location.pathname}${location.search}${location.hash}`, {
replace: true,
state: null,
});
}, [location, navigate]);
const [dumpState, setDumpState] = useState<DumpState>(
preloaded ? { status: "loaded", dump: preloaded } : { status: "loading" },
);
const [op, setOp] = useState<PublicUser | null>(null);
const [playlistModalOpen, setPlaylistModalOpen] = useState(false);
useDocumentTitle(
dumpState.status === "loaded"
? dumpState.dump.title
: dumpState.status === "error"
? null
: undefined,
);
const [comments, setComments] = useState<Comment[]>([]);
const [relatedDumps, setRelatedDumps] = useState<Dump[]>([]);
const [collections, setCollections] = useState<Playlist[]>([]);
const [titleEditing, setTitleEditing] = useState(false);
const headerRightRef = useHeightTransition<HTMLDivElement>(titleEditing);
const [titleDraft, setTitleDraft] = useState("");
const [titleSaving, setTitleSaving] = useState(false);
const [titleError, setTitleError] = useState<string | null>(null);
const { user, mediaToken, authFetch } = useAuth();
const { byId } = useCategories();
const {
voteCounts,
myVotes,
castVote,
removeVote,
lastDumpEvent,
lastCommentEvent,
lastPlaylistEvent,
connectionEpoch,
} = 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 authFetch(`${API_URL}/api/dumps/${selectedDump}`, {
cache: "no-store",
signal: controller.signal,
});
const apiResponse = parseAPIResponse<Wire<Dump>>(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, authFetch]);
useEffect(() => {
if (!lastDumpEvent) return;
// Mirrors the socket's edits, which only an effect can observe.
// 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]);
useEffect(() => {
if (!selectedDump) return;
const controller = new AbortController();
authFetch(`${API_URL}/api/dumps/${selectedDump}/comments`, {
signal: controller.signal,
})
.then((r) => r.json())
.then((body) => {
if (body.success) {
setComments((body.data as Wire<Comment>[]).map(deserializeComment));
}
})
.catch(() => {});
return () => controller.abort();
// connectionEpoch refetches on reconnect, for the comments missed while the
// socket was down.
}, [selectedDump, authFetch, connectionEpoch]);
// The dumps that link to this one, or that it links to.
useEffect(() => {
if (!selectedDump) return;
const controller = new AbortController();
authFetch(`${API_URL}/api/dumps/${selectedDump}/related`, {
signal: controller.signal,
})
.then((r) => r.json())
.then((body) => {
if (body.success) {
setRelatedDumps((body.data as Wire<Dump>[]).map(deserializeDump));
}
})
.catch(() => {});
return () => controller.abort();
}, [selectedDump, authFetch]);
// The server leaves out the private playlists of others.
useEffect(() => {
if (!selectedDump) return;
const controller = new AbortController();
authFetch(`${API_URL}/api/dumps/${selectedDump}/playlists`, {
signal: controller.signal,
})
.then((r) => r.json())
.then((body) => {
if (body.success) {
setCollections(
(body.data as Wire<Playlist>[]).map(deserializePlaylist),
);
}
})
.catch(() => {});
return () => controller.abort();
// Any playlist event may change the list, and they are rare enough to
// refetch on each.
}, [selectedDump, authFetch, lastPlaylistEvent]);
// A notification links to `#comment-<id>`. Rerun when the comments arrive,
// since the element doesn't exist before.
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]);
const loadedDumpId = dumpState.status === "loaded" ? dumpState.dump.id : null;
useEffect(() => {
if (
!lastCommentEvent || !loadedDumpId ||
lastCommentEvent.dumpId !== loadedDumpId
) return;
// A comment of this user's arrives both from the socket and from
// CommentThread's callbacks below, hence the duplicate checks.
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 (
<PageShell>
<p className="page-loading">
<Trans>Loading dump…</Trans>
</p>
</PageShell>
);
}
if (dumpState.status === "error") {
return (
<PageError
message={dumpState.error}
actions={
<>
<button
className="btn-border"
type="button"
onClick={() => globalThis.location.reload()}
>
<Trans>Retry</Trans>
</button>
<button
className="btn-border"
type="button"
onClick={() => navigate("/")}
>
<Trans>← Back to all dumps</Trans>
</button>
</>
}
/>
);
}
const { dump } = dumpState;
const canEdit = !!user &&
(dump.userId === user.id || can(user, "dump:moderate"));
const dumpCategories = (dump.categoryIds ?? [])
.map((id) => byId.get(id))
.filter((c) => c !== undefined);
const startTitleEdit = () => {
setTitleDraft(dump.title);
setTitleError(null);
setTitleEditing(true);
};
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<Wire<Dump>>(await res.json());
if (!apiResponse.success) {
setTitleError(apiResponse.error.message);
return;
}
const updatedDump = deserializeDump(apiResponse.data);
setTitleEditing(false);
// The page stays mounted across the slug change below, and would keep
// the old title.
setDumpState({ status: "loaded", dump: updatedDump });
if (updatedDump.slug !== dump.slug) {
navigate(dumpUrl(updatedDump), {
replace: true,
state: { dump: updatedDump },
});
}
} catch (err) {
setTitleError(friendlyFetchError(err));
} finally {
setTitleSaving(false);
}
};
return (
<PageShell>
<div className="dump-detail">
<div className="dump-post-header">
<div className="dump-header-block">
<div className="dump-header-left">
<VoteButton
dumpId={dump.id}
count={voteCounts[dump.id] ?? dump.voteCount}
voted={myVotes.has(dump.id)}
disabled={!user}
onCast={castVote}
onRemove={removeVote}
/>
{user && (
<button
type="button"
className="btn-add-playlist"
onClick={() => setPlaylistModalOpen(true)}
>
<Trans>+ Playlist</Trans>
</button>
)}
</div>
<div className="dump-header-right" ref={headerRightRef}>
{titleEditing
? (
<div className="dump-title-editor animate-in">
<input
type="text"
className="dump-title-input"
value={titleDraft}
onChange={(e) => 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
/>
<button
type="button"
className="btn-primary"
onClick={handleTitleSave}
disabled={titleSaving || !titleDraft.trim()}
>
{titleSaving
? <Trans>Saving…</Trans>
: <Trans>Save</Trans>}
</button>
<button
type="button"
className="form-cancel"
onClick={() => setTitleEditing(false)}
disabled={titleSaving}
>
<Trans>Cancel</Trans>
</button>
{titleError && (
<p className="dump-title-error">{titleError}</p>
)}
</div>
)
: (
<div
className={`dump-title-view${
canEdit ? " dump-title-view--editable" : ""
}`}
onClick={canEdit ? startTitleEdit : undefined}
// Reachable from the keyboard too; not a button, so the
// title inside stays a heading.
tabIndex={canEdit ? 0 : undefined}
title={canEdit ? t`Edit title` : undefined}
onKeyDown={canEdit
? (e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
startTitleEdit();
}
}
: undefined}
>
<h1 className="dump-title">{dump.title}</h1>
{canEdit && (
<span className="dump-title-edit-btn" aria-hidden>
✎
</span>
)}
</div>
)}
<div className="dump-op">
<Avatar
userId={dump.userId}
username={op?.username ?? "?"}
hasAvatar={!!op?.avatarMime}
size={22}
/>
{op
? (
<Link to={`/users/${op.username}`} className="dump-op-link">
{op.username}
</Link>
)
: <span className="dump-op-link">…</span>}
<Tooltip text={dump.createdAt.toLocaleString()}>
<time
className="dump-card-date"
dateTime={dump.createdAt.toISOString()}
>
{relativeTime(dump.createdAt)}
</time>
</Tooltip>
{dump.updatedAt && (
<Tooltip text={t`Edited ${dump.updatedAt.toLocaleString()}`}>
<span className="dump-edited-label">
<Trans>edited {relativeTime(dump.updatedAt)}</Trans>
</span>
</Tooltip>
)}
{dump.isPrivate && (
<span className="dump-card-private-badge">
<Trans>private</Trans>
</span>
)}
</div>
{dumpCategories.length > 0 && (
<div className="dump-card-categories">
{dumpCategories.map((category) => (
<Link
key={category.id}
to={`/${category.slug}`}
className="dump-card-category-chip"
>
{category.name}
</Link>
))}
</div>
)}
</div>
</div>
{dump.comment && (
<Markdown className="dump-comment">{dump.comment}</Markdown>
)}
</div>
<div className="dump-rich-content">
{dump.kind === "file"
? <FilePreview dump={dump} global />
: dump.richContent
? (
<RichContentCard
richContent={dump.richContent}
dumpHref={dumpUrl(dump)}
thumbnailOverrideUrl={dump.thumbnailMime
? dumpThumbnailUrl(dump, mediaToken)
: undefined}
/>
)
: (
<a
href={dump.url}
target="_blank"
rel="noopener noreferrer"
className="dump-url-link"
>
{dump.url}
</a>
)}
</div>
<div className="dump-actions">
{canEdit && (
<Link to={`${dumpUrl(dump)}/edit`}>
<Trans>Edit</Trans>
</Link>
)}
<Link to="/">
<Trans>← Back to all dumps</Trans>
</Link>
</div>
{relatedDumps.length > 0 && (
<section className="related-section">
<h2 className="related-section-title">
<Trans>Related</Trans>
</h2>
<DumpCardList dumps={relatedDumps} />
</section>
)}
{collections.length > 0 && (
<section className="related-section collections-section">
<h2 className="related-section-title">
<Trans>In collections</Trans>
</h2>
<PlaylistCardList playlists={collections} />
</section>
)}
<CommentThread
dumpId={dump.id}
comments={comments}
currentUser={user}
onCommentCreated={(c) =>
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))
)}
/>
</div>
{playlistModalOpen && (
<AddToPlaylistModal
dumpId={dump.id}
onClose={() => setPlaylistModalOpen(false)}
/>
)}
</PageShell>
);
}