v3: comments say what the code can't — the documentation pass wanted JSDoc on every export and every field of an exported interface, which filled the code with restatements ("The dump.", "Whether it is loading.", "The props of X.", "Renders the X.") and module docs that told a reader nothing ("The hot and new feeds, and the props every main feed of the index takes."); some were wrong as well as vague. Every comment in api/, shared/ and src/ is rewritten under one rule, now stated in the README's "Comments" section: every module and export still has a JSDoc, and it gives context (who uses it and where), usage (what a caller must know: a precondition, what it leaves to the caller, a side effect) or its place in the chain (where its input comes from, where its output goes); a field, parameter, private function or local gets one only when it adds to its parent's, and context about a whole type goes on the type; a type's doc is phrased from the value's side ("Returned to Y by X when Z", "Passed to Y by X"); full sentences, as short as they can be, no label-colon-list; // goes wherever the code isn't obvious and says why, and the section banners and narration go. Every claim was checked against the code, which turned up some forty comments that were wrong: DumpCard's claimed it was only used by the hot and new feeds, the WebSocket welcome claimed to be a socket's first message (presence comes first), UpdateUserRequest said null removes a description (it keeps it), deletedDumpIds leaves out that it also holds dumps made private, THEME_COLOR claimed to mirror index.html's, and the like. The routers' JSDoc now says where they are mounted and what becomes of a thrown APIException. doc-lint.ts lets a field without a doc through; a module or export without one still fails. No code changes; 204 backend and 628 frontend tests pass, and doc:lint, deno check, deno lint and tsc are clean.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* The dump creation modal.
|
||||
* The modal that posts a dump from a link or a file, then offers to add it to
|
||||
* the poster's playlists. Every "new dump" button opens it.
|
||||
*
|
||||
* @module
|
||||
*/
|
||||
@@ -57,16 +58,15 @@ import { LocalFilePreview } from "./LocalFilePreview.tsx";
|
||||
import { type Step, StepIndicator } from "./StepIndicator.tsx";
|
||||
import { TitleField } from "./TitleField.tsx";
|
||||
|
||||
/** Stable empty result, so rendering doesn't churn on a fresh array each pass. */
|
||||
/** Kept stable so `DuplicateNotice` doesn't re-render on every keystroke. */
|
||||
const NO_DUPLICATES: DumpUrlMatch[] = [];
|
||||
|
||||
/** The link's preview, as fetched for the form. */
|
||||
/** `reached` is false when the server couldn't fetch the page at all. */
|
||||
type UrlPreview =
|
||||
| { status: "idle" }
|
||||
| { status: "loading" }
|
||||
| { status: "done"; reached: boolean; richContent: RichContent | null };
|
||||
|
||||
/** The form's fields. */
|
||||
interface CreateValues {
|
||||
url: string;
|
||||
file: File | null;
|
||||
@@ -76,31 +76,34 @@ interface CreateValues {
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether the pointer is coarse — a touch device, where autofocusing the URL
|
||||
* input raises a keyboard that covers most of the modal before the user has
|
||||
* seen it.
|
||||
* Checks whether the pointer is coarse. On a touch device, focusing a field
|
||||
* raises a keyboard that covers most of the modal before the user has seen it.
|
||||
*/
|
||||
function prefersNoAutoFocus(): boolean {
|
||||
return globalThis.matchMedia?.("(pointer: coarse)").matches ?? false;
|
||||
}
|
||||
|
||||
/** The props of {@link DumpCreateModal}. */
|
||||
/**
|
||||
* Passed to {@link DumpCreateModal} by the button that opens it. The parent
|
||||
* unmounts the modal on `onClose`, which the modal also calls after a
|
||||
* confirmed dismissal.
|
||||
*/
|
||||
export interface DumpCreateModalProps {
|
||||
/** Called to close it. */
|
||||
onClose: () => void;
|
||||
/**
|
||||
* A link to start from (the Android share sheet); it replaces any saved
|
||||
* draft.
|
||||
* A link shared from Android's share sheet, which `AppHeader` passes on from
|
||||
* `Index`. It replaces any saved draft.
|
||||
*/
|
||||
initialUrl?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the modal that posts a dump, in three panels: the link (previewed as
|
||||
* it is typed, with a hint when it was already dumped) or the file, and the
|
||||
* title; the note, categories and visibility; then, once posted, the playlists
|
||||
* to add it to. Everything but the file is kept as a draft until it is posted,
|
||||
* and closing with unsaved work asks first.
|
||||
* Renders the dump creation modal. The first panel takes a link, previewed
|
||||
* through `GET /api/preview` and checked against `GET /api/dumps/by-url`, or a
|
||||
* file. The second takes the "why", categories and visibility, and posts to
|
||||
* `POST /api/dumps`. The third adds the new dump to playlists. The new dump is
|
||||
* handed to `WSContext`'s `injectDump` so the feeds show it at once. Everything
|
||||
* but the file is saved as a draft (`utils/dump/draft.ts`) until it is posted.
|
||||
*/
|
||||
export function DumpCreateModal(
|
||||
{ onClose, initialUrl = "" }: DumpCreateModalProps,
|
||||
@@ -109,7 +112,6 @@ export function DumpCreateModal(
|
||||
const { injectDump } = useWS();
|
||||
|
||||
const [step, setStep] = useState<Step>("what");
|
||||
// Which way the next panel should come in from. Purely presentational.
|
||||
const [direction, setDirection] = useState<"forward" | "back">("forward");
|
||||
const [createdDump, setCreatedDump] = useState<Dump | null>(null);
|
||||
|
||||
@@ -128,17 +130,18 @@ export function DumpCreateModal(
|
||||
const draft = restoredDraft ?? EMPTY_DUMP_DRAFT;
|
||||
const [draftNoticeVisible, setDraftNoticeVisible] = useState(!!restoredDraft);
|
||||
|
||||
// Composition state
|
||||
const [titleEditing, setTitleEditing] = useState(false);
|
||||
const [titleFromUser, setTitleFromUser] = useState(draft.titleFromUser);
|
||||
const [urlFetchState, setUrlFetchState] = useState<UrlPreview>({
|
||||
status: "idle",
|
||||
});
|
||||
// Tagged with the URL they describe, so a result never lingers over the next
|
||||
// link during the debounce (and so clearing them needs no effect).
|
||||
// link during the debounce, and clearing them needs no effect.
|
||||
const [duplicateState, setDuplicateState] = useState<
|
||||
{ url: string; matches: DumpUrlMatch[] }
|
||||
>({ url: "", matches: [] });
|
||||
// Undefined when no upload runs, null once the body is sent and the server
|
||||
// is still processing it, else the fraction sent.
|
||||
const [uploadProgress, setUploadProgress] = useState<number | null>();
|
||||
const [confirmClose, setConfirmClose] = useState(false);
|
||||
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
@@ -177,7 +180,6 @@ export function DumpCreateModal(
|
||||
return next;
|
||||
});
|
||||
|
||||
// Playlist step state
|
||||
const [memberships, setMemberships] = useState<PlaylistMembership[]>([]);
|
||||
const [playlistsLoading, setPlaylistsLoading] = useState(false);
|
||||
|
||||
@@ -190,10 +192,9 @@ export function DumpCreateModal(
|
||||
setTitleEditing(false);
|
||||
}, [setValue]);
|
||||
|
||||
// Canonical form of `url`, used (rather than the raw input text) to decide
|
||||
// whether the preview actually needs to re-fetch — e.g. "example.com" and
|
||||
// "https://example.com" (set later by onBlur's normalizeUrl) canonicalize
|
||||
// to the same value, so they shouldn't trigger two separate fetches.
|
||||
// The effects below key on this rather than on the raw input, so that
|
||||
// "example.com" and the "https://example.com" onBlur rewrites it to don't
|
||||
// fetch the preview twice.
|
||||
let canonicalUrl: string | null;
|
||||
try {
|
||||
const u = new URL(normalizeUrl(url));
|
||||
@@ -204,8 +205,8 @@ export function DumpCreateModal(
|
||||
canonicalUrl = null;
|
||||
}
|
||||
|
||||
// Idle state is derived below from canonicalUrl rather than set here, so
|
||||
// this effect only ever needs to set loading/done (inside the timeout).
|
||||
// Deriving "idle" here spares the effect below from resetting the state when
|
||||
// the link is cleared.
|
||||
const urlPreview: UrlPreview = canonicalUrl
|
||||
? urlFetchState
|
||||
: { status: "idle" };
|
||||
@@ -214,20 +215,18 @@ export function DumpCreateModal(
|
||||
? duplicateState.matches
|
||||
: NO_DUPLICATES;
|
||||
|
||||
// Debounced preview + "already dumped?" lookup. Both answer questions about
|
||||
// the same link and settle at the same time, so they share one debounce.
|
||||
// The preview and the duplicate lookup concern the same link, so they share
|
||||
// one debounce.
|
||||
useEffect(() => {
|
||||
if (debounceRef.current) clearTimeout(debounceRef.current);
|
||||
if (!canonicalUrl) return;
|
||||
|
||||
// Deferred to a microtask (rather than called directly) so the immediate
|
||||
// "loading" feedback doesn't fire synchronously within the effect body —
|
||||
// same timing for the user, just not in the same tick.
|
||||
// Deferred: the set-state-in-effect lint rule forbids setting state
|
||||
// synchronously in an effect.
|
||||
queueMicrotask(() => setUrlFetchState({ status: "loading" }));
|
||||
|
||||
// A preview can take seconds (the server fetches the page), so the link
|
||||
// may have changed by the time one lands: the answer for the old link must
|
||||
// not show, or suggest its title, under the new one.
|
||||
// A preview can take seconds, since the server fetches the page. An answer
|
||||
// for a link that has since changed must not show, or set the title.
|
||||
const controller = new AbortController();
|
||||
const { signal } = controller;
|
||||
|
||||
@@ -254,8 +253,7 @@ export function DumpCreateModal(
|
||||
});
|
||||
});
|
||||
|
||||
// Signed in, so that a private dump of the poster's own counts as a
|
||||
// duplicate for them and for nobody else.
|
||||
// Signed in, so that the poster's own private dumps count as duplicates.
|
||||
authFetch(`${API_URL}/api/dumps/by-url?url=${encoded}`, { signal })
|
||||
.then((res) => res.json())
|
||||
.then((body) => {
|
||||
@@ -280,9 +278,8 @@ export function DumpCreateModal(
|
||||
};
|
||||
}, [canonicalUrl, authFetch]);
|
||||
|
||||
// Until the poster takes the field over, the title *is* the current page's
|
||||
// title — including when the new link suggests nothing, which is why this
|
||||
// writes an empty suggestion too rather than leaving the previous link's.
|
||||
// Until the poster edits it, the title follows the preview. An empty
|
||||
// suggestion is written too, or the previous link's title would stay.
|
||||
const suggestedTitle = urlPreview.status === "done"
|
||||
? urlPreview.richContent?.title ?? ""
|
||||
: "";
|
||||
@@ -291,8 +288,7 @@ export function DumpCreateModal(
|
||||
setValue("title", suggestedTitle);
|
||||
}, [mode, titleFromUser, suggestedTitle, setValue]);
|
||||
|
||||
// Mirror the draft on every change so a dismissal — deliberate or not — never
|
||||
// costs more than the attached file, which cannot be stored.
|
||||
// Once posted, the draft is cleared and must not be written back.
|
||||
useEffect(() => {
|
||||
if (step === "playlist") return;
|
||||
saveDumpDraft({
|
||||
@@ -328,34 +324,30 @@ export function DumpCreateModal(
|
||||
setDraftNoticeVisible(false);
|
||||
};
|
||||
|
||||
// Land the caret in the "why" box on arrival, so the second panel is
|
||||
// immediately typeable — except on touch, where it would raise a keyboard
|
||||
// over the categories this panel exists to show.
|
||||
// Not on touch, where the keyboard would cover the categories.
|
||||
useEffect(() => {
|
||||
if (step !== "details" || prefersNoAutoFocus()) return;
|
||||
commentEditorRef.current?.focus();
|
||||
}, [step]);
|
||||
|
||||
// Only the attached file is unrecoverable, so it is the only thing worth
|
||||
// interrupting a dismissal for — the text comes back on its own.
|
||||
// Only the attached file is lost on close, since the draft keeps the rest,
|
||||
// so only a file makes the modal ask first.
|
||||
const handleBeforeClose = useCallback(() => {
|
||||
if (step === "playlist" || submitting || !file || confirmClose) return true;
|
||||
setConfirmClose(true);
|
||||
return false;
|
||||
}, [step, submitting, file, confirmClose]);
|
||||
|
||||
/** Dismissal from inside the form (Cancel), guarded like Escape and the ✕. */
|
||||
// Cancel asks first too, like Escape and the ✕.
|
||||
const requestClose = () => {
|
||||
if (handleBeforeClose()) onClose();
|
||||
};
|
||||
|
||||
// Paste handler
|
||||
// A paste anywhere in the page takes a file or a link.
|
||||
useEffect(() => {
|
||||
const handler = (e: ClipboardEvent) => {
|
||||
// Fields own their paste: the comment editor uploads pasted images as
|
||||
// inline attachments, and the URL input has its own file handler below.
|
||||
// Without this guard, pasting an image into the description would flip
|
||||
// the whole modal over to a file dump.
|
||||
// Fields handle their own paste: the "why" editor uploads a pasted image
|
||||
// as an attachment, and the link field has its own handler below.
|
||||
const tag = (e.target as HTMLElement).tagName;
|
||||
if (tag === "INPUT" || tag === "TEXTAREA") return;
|
||||
const pastedFile = e.clipboardData?.files[0];
|
||||
@@ -381,9 +373,8 @@ export function DumpCreateModal(
|
||||
|
||||
const [dragging, setDragging] = useState(false);
|
||||
|
||||
// Dropping is accepted over the whole modal, not just the drop zone: once a
|
||||
// link is being typed the zone is hidden, and aiming at it was never the
|
||||
// point of dragging a file into a "new dump" dialog.
|
||||
// The whole modal takes a drop, since the drop zone is hidden while a link
|
||||
// is typed.
|
||||
const handleDrop = (e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setDragging(false);
|
||||
@@ -408,8 +399,8 @@ export function DumpCreateModal(
|
||||
setValue("url", normalizedUrl);
|
||||
const body: CreateUrlDumpRequest = {
|
||||
url: normalizedUrl,
|
||||
// Only when the poster typed it: otherwise the server would be pinned
|
||||
// to whatever the preview happened to scrape a moment ago.
|
||||
// Left to the server unless typed, so it takes the page's title
|
||||
// itself.
|
||||
title: titleFromUser && title.trim() ? title.trim() : undefined,
|
||||
comment: comment.trim() || undefined,
|
||||
isPrivate,
|
||||
@@ -466,8 +457,7 @@ export function DumpCreateModal(
|
||||
const toggleMembership = async (membership: PlaylistMembership) => {
|
||||
if (!createdDump) return;
|
||||
const { playlist, hasDump } = membership;
|
||||
// The box only changes once the server agrees; a refusal or a dropped
|
||||
// connection leaves it showing the truth.
|
||||
// Not optimistic: the box changes only once the server agrees.
|
||||
try {
|
||||
const res = await authFetch(
|
||||
`${API_URL}/api/playlists/${playlist.id}/dumps/${createdDump.id}`,
|
||||
@@ -486,14 +476,12 @@ export function DumpCreateModal(
|
||||
|
||||
const canProceed = !!file || !!canonicalUrl;
|
||||
|
||||
// What the second panel's choices apply to, in one line.
|
||||
const recap = title.trim() || file?.name || normalizeUrl(url);
|
||||
const uploadPercentLabel = t`Uploading ${
|
||||
Math.round((uploadProgress ?? 0) * 100)
|
||||
}%`;
|
||||
|
||||
// Enter in the link field advances rather than posts, so the panels behave
|
||||
// like a form the whole way through.
|
||||
// Enter on the first panel moves on to the second rather than posting.
|
||||
const onFormSubmit = (e: React.FormEvent<HTMLFormElement>) => {
|
||||
if (step === "what") {
|
||||
e.preventDefault();
|
||||
@@ -598,7 +586,7 @@ export function DumpCreateModal(
|
||||
<form onSubmit={onFormSubmit} className="form">
|
||||
<FormError title={t`Failed to post`} />
|
||||
|
||||
{/* Keyed so each panel re-enters from the side it came from. */}
|
||||
{/* Keyed so a new panel remounts and plays its slide-in. */}
|
||||
<div
|
||||
key={step}
|
||||
className={`dump-panel dump-panel--${direction}`}
|
||||
@@ -687,8 +675,7 @@ export function DumpCreateModal(
|
||||
)}
|
||||
|
||||
{
|
||||
/* Hidden only while a link is being written: an empty
|
||||
panel offers both ways in at once, no mode to pick. */
|
||||
/* An empty panel offers both a link and a file. */
|
||||
}
|
||||
{!(mode === "url" && url.trim()) && (
|
||||
<FileField<CreateValues>
|
||||
@@ -718,8 +705,7 @@ export function DumpCreateModal(
|
||||
: (
|
||||
<>
|
||||
{
|
||||
/* The link itself is a panel back, so name the thing
|
||||
these choices are about. */
|
||||
/* The link or file is a panel back, so recall it. */
|
||||
}
|
||||
<p className="dump-panel-recap" title={recap}>
|
||||
<span aria-hidden="true">
|
||||
|
||||
Reference in New Issue
Block a user