v3: src/components sorted into feature folders, the four largest files split, and the component tests mirror the new layout — 48 components sat in one flat folder, ChatModal ran to 1301 lines, DumpCreateModal to 983, and the tests were grouped by theme, so there was no telling which component had none. Components now live in category/, chat/, comment/, dump/ (with dump/create/ for the new-dump wizard), editor/, form/, layout/ (header, page shell, floating buttons), player/, playlist/, profile/, social/ (vote, like, follow, the who-voted popover) and ui/; FeedTabBar and PresenceRow, used by the index page alone, move to pages/index/. ChatModal gives up ChatMessageItem, PendingItem, ChatEditBox and ReplyRef, the message limits and text helpers (chat/message.ts), the day and clock labels (chat/time.ts) and the draft storage (utils/chatDraft.ts, next to dumpDraft.ts). DumpCreateModal gives up StepIndicator, LocalFilePreview, TitleField and DuplicateNotice; CommentThread gives up CommentForm and CommentNode; TextEditor gives up the emoji picker popup and its skin-tone storage; MediaPlayer gives up its icons (which FilePreview imported from it), Waveform, SeekBar and the remembered volume. NewPlaylistForm, a button that opens a modal holding the form, is NewPlaylistButton now, and Thumbnail, ThumbnailPlaceholder, FilePreview and RichContentCard use named exports like everything else. The tests are split one file per component under src/test/components/<folder>/ (and src/test/pages/index/), the same 625 of them. No behaviour changes; the catalogues only move their source references.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
khannurien
2026-09-27 19:30:28 +00:00
parent de5abeceb2
commit 07d0344be7
131 changed files with 3893 additions and 3596 deletions

View File

@@ -0,0 +1,802 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { Link } from "react-router";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
import { API_URL, VALIDATION } from "../../../config/api.ts";
import type {
CreateUrlDumpRequest,
Dump,
DumpUrlMatch,
PlaylistMembership,
RawDump,
RawDumpUrlMatch,
RawPlaylistMembership,
UrlPreviewResponse,
} from "../../../model.ts";
import {
deserializeDump,
deserializeDumpUrlMatch,
deserializePlaylistMembership,
} from "../../../model.ts";
import { useAuth } from "../../../hooks/useAuth.ts";
import { useWS } from "../../../hooks/useWS.ts";
import { dumpUrl, normalizeUrl } from "../../../utils/urls.ts";
import { titleFromFileName } from "../../../utils/format.ts";
import {
clearDumpDraft,
type DumpDraft,
EMPTY_DUMP_DRAFT,
loadDumpDraft,
saveDumpDraft,
} from "../../../utils/dumpDraft.ts";
import { MAX_FILE_SIZE } from "../../../config/upload.ts";
import { RichContentCard } from "../RichContentCard.tsx";
import type { RichContent } from "../../../model.ts";
import { Modal } from "../../ui/Modal.tsx";
import type { TextEditorHandle } from "../../editor/TextEditor.tsx";
import { CategorySelect } from "../../category/CategorySelect.tsx";
import { PlaylistMembershipPanel } from "../../playlist/PlaylistMembershipPanel.tsx";
import {
expectOk,
FileField,
FormError,
FormProvider,
RichTextField,
SubmitButton,
useApiForm,
VisibilityToggle,
} from "../../form/index.ts";
import { DuplicateNotice } from "./DuplicateNotice.tsx";
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. */
const NO_DUPLICATES: DumpUrlMatch[] = [];
type UrlPreview =
| { status: "idle" }
| { status: "loading" }
| { status: "done"; reached: boolean; richContent: RichContent | null };
interface CreateValues {
url: string;
file: File | null;
title: string;
comment: string;
isPublic: boolean;
}
/**
* 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.
*/
function prefersNoAutoFocus(): boolean {
return globalThis.matchMedia?.("(pointer: coarse)").matches ?? false;
}
interface DumpCreateModalProps {
onClose: () => void;
initialUrl?: string;
}
export function DumpCreateModal(
{ onClose, initialUrl = "" }: DumpCreateModalProps,
) {
const { authFetch, authUpload } = useAuth();
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);
const goToStep = (next: Step, dir: "forward" | "back") => {
setDirection(dir);
setStep(next);
};
// Arriving with a URL (the Android share sheet) is a request to post *that*,
// so an abandoned draft is dropped whole rather than half-merged: pairing
// someone's old "why" with a link they just shared from another app would be
// more confusing than starting them clean.
const [restoredDraft] = useState<DumpDraft | null>(() =>
initialUrl ? null : loadDumpDraft()
);
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).
const [duplicateState, setDuplicateState] = useState<
{ url: string; matches: DumpUrlMatch[] }
>({ url: "", matches: [] });
const [uploadProgress, setUploadProgress] = useState<number | null>();
const [confirmClose, setConfirmClose] = useState(false);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const commentEditorRef = useRef<TextEditorHandle | null>(null);
const form = useApiForm<CreateValues>({
defaultValues: {
url: initialUrl || draft.url,
file: null,
title: draft.title,
comment: draft.comment,
isPublic: draft.isPublic,
},
});
const { register, setValue, watch, clearErrors, reset } = form;
const submitting = form.formState.isSubmitting;
const url = watch("url");
const file = watch("file");
const title = watch("title");
const comment = watch("comment");
const isPublic = watch("isPublic");
// The dump's kind is whatever the user actually provided, so there is no mode
// to pick: attaching a file makes it a file dump, typing a link makes it a
// link dump, and clearing either offers both again.
const mode: "url" | "file" = file ? "file" : "url";
const [selectedCategoryIds, setSelectedCategoryIds] = useState<Set<string>>(
() => new Set(draft.categoryIds),
);
const toggleCategory = (id: string) =>
setSelectedCategoryIds((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
// Playlist step state
const [memberships, setMemberships] = useState<PlaylistMembership[]>([]);
const [playlistsLoading, setPlaylistsLoading] = useState(false);
const selectFile = useCallback((f: File | null) => {
setValue("file", f);
if (f) {
setValue("title", titleFromFileName(f.name));
setTitleFromUser(false);
}
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.
let canonicalUrl: string | null;
try {
const u = new URL(normalizeUrl(url));
canonicalUrl = (u.protocol === "http:" || u.protocol === "https:")
? u.toString()
: null;
} catch {
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).
const urlPreview: UrlPreview = canonicalUrl
? urlFetchState
: { status: "idle" };
const duplicates = canonicalUrl && duplicateState.url === canonicalUrl
? 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.
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.
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.
const controller = new AbortController();
const { signal } = controller;
debounceRef.current = setTimeout(() => {
const encoded = encodeURIComponent(canonicalUrl);
authFetch(`${API_URL}/api/preview?url=${encoded}`, { signal })
.then((res) => res.json())
.then((body) => {
if (signal.aborted) return;
const data = body.success
? body.data as UrlPreviewResponse
: undefined;
setUrlFetchState({
status: "done",
reached: data?.reached ?? false,
richContent: data?.richContent ?? null,
});
})
.catch(() => {
if (signal.aborted) return;
setUrlFetchState({
status: "done",
reached: false,
richContent: null,
});
});
// Signed in, so that a private dump of the poster's own counts as a
// duplicate for them and for nobody else.
authFetch(`${API_URL}/api/dumps/by-url?url=${encoded}`, { signal })
.then((res) => res.json())
.then((body) => {
if (signal.aborted) return;
setDuplicateState({
url: canonicalUrl,
matches: body.success
? (body.data as RawDumpUrlMatch[]).map(deserializeDumpUrlMatch)
: [],
});
})
.catch(() => {
if (!signal.aborted) {
setDuplicateState({ url: canonicalUrl, matches: [] });
}
});
}, 600);
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
controller.abort();
};
}, [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.
const suggestedTitle = urlPreview.status === "done"
? urlPreview.richContent?.title ?? ""
: "";
useEffect(() => {
if (mode !== "url" || titleFromUser) return;
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.
useEffect(() => {
if (step === "playlist") return;
saveDumpDraft({
url,
title,
titleFromUser,
comment,
isPublic,
categoryIds: [...selectedCategoryIds],
});
}, [
step,
url,
title,
titleFromUser,
comment,
isPublic,
selectedCategoryIds,
]);
const discardDraft = () => {
clearDumpDraft();
reset({
url: "",
file: null,
title: "",
comment: "",
isPublic: true,
});
setSelectedCategoryIds(new Set());
setTitleFromUser(false);
setTitleEditing(false);
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.
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.
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 ✕. */
const requestClose = () => {
if (handleBeforeClose()) onClose();
};
// Paste handler
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.
const tag = (e.target as HTMLElement).tagName;
if (tag === "INPUT" || tag === "TEXTAREA") return;
const pastedFile = e.clipboardData?.files[0];
if (pastedFile) {
setValue("url", "");
selectFile(pastedFile);
clearErrors("root");
return;
}
const text = e.clipboardData?.getData("text") ?? "";
try {
const u = new URL(normalizeUrl(text));
if (u.protocol === "http:" || u.protocol === "https:") {
selectFile(null);
setValue("url", u.toString());
clearErrors("root");
}
} catch { /* not a URL */ }
};
globalThis.addEventListener("paste", handler);
return () => globalThis.removeEventListener("paste", handler);
}, [selectFile, setValue, clearErrors]);
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.
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
setDragging(false);
if (submitting) return;
const dropped = e.dataTransfer.files[0];
if (dropped) {
setValue("url", "");
selectFile(dropped);
clearErrors("root");
}
};
const onSubmit = form.submit(async (
{ url, file, title, comment, isPublic },
) => {
const isPrivate = !isPublic;
let res: Response;
if (!file) {
const normalizedUrl = normalizeUrl(url);
if (!normalizedUrl) throw new Error(t`URL is required.`);
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.
title: titleFromUser && title.trim() ? title.trim() : undefined,
comment: comment.trim() || undefined,
isPrivate,
categoryIds: [...selectedCategoryIds],
};
res = await authFetch(`${API_URL}/api/dumps`, {
method: "POST",
body: JSON.stringify(body),
});
} else {
if (!title.trim()) throw new Error(t`Title is required.`);
if (file.size > MAX_FILE_SIZE) {
throw new Error(t`File too large (max 50 MB).`);
}
const formData = new FormData();
formData.append("file", file);
if (comment.trim()) formData.append("comment", comment.trim());
if (title.trim()) formData.append("title", title.trim());
formData.append("isPrivate", String(isPrivate));
formData.append("categoryIds", JSON.stringify([...selectedCategoryIds]));
setUploadProgress(0);
try {
res = await authUpload(
`${API_URL}/api/dumps`,
formData,
setUploadProgress,
);
} finally {
setUploadProgress(undefined);
}
}
const dump = deserializeDump(await expectOk<RawDump>(res));
clearDumpDraft();
injectDump(dump);
setCreatedDump(dump);
goToStep("playlist", "forward");
setPlaylistsLoading(true);
authFetch(`${API_URL}/api/playlists/by-dump/${dump.id}/memberships`)
.then((r) => r.json())
.then((body) => {
if (body.success) {
setMemberships(
(body.data as RawPlaylistMembership[]).map(
deserializePlaylistMembership,
),
);
}
})
.catch(() => {})
.finally(() => setPlaylistsLoading(false));
});
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.
try {
const res = await authFetch(
`${API_URL}/api/playlists/${playlist.id}/dumps/${createdDump.id}`,
{ method: hasDump ? "DELETE" : "POST" },
);
if (!res.ok) return;
} catch {
return;
}
setMemberships((prev) =>
prev.map((m) =>
m.playlist.id === playlist.id ? { ...m, hasDump: !hasDump } : m
)
);
};
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.
const onFormSubmit = (e: React.FormEvent<HTMLFormElement>) => {
if (step === "what") {
e.preventDefault();
if (canProceed) goToStep("details", "forward");
return;
}
void onSubmit(e);
};
return (
<Modal
title={step === "playlist" ? t`Add to playlist` : t`New dump`}
onClose={onClose}
onBeforeClose={handleBeforeClose}
wide
>
<div
className={`dump-create${dragging ? " dump-create--drag" : ""}`}
onDragOver={(e) => {
e.preventDefault();
if (!submitting && step !== "playlist") setDragging(true);
}}
onDragLeave={(e) => {
if (!e.currentTarget.contains(e.relatedTarget as Node | null)) {
setDragging(false);
}
}}
onDrop={handleDrop}
>
{confirmClose && (
<div className="dump-create-confirm" role="alert">
<p>
<Trans>
Close without posting? The attached file won't be kept —
everything you typed will.
</Trans>
</p>
<div className="dump-create-confirm-actions">
<button type="button" onClick={() => setConfirmClose(false)}>
<Trans>Keep editing</Trans>
</button>
<button type="button" className="btn-danger" onClick={onClose}>
<Trans>Close anyway</Trans>
</button>
</div>
</div>
)}
{draftNoticeVisible && step === "what" && (
<div className="dump-create-draft" role="status">
<span>
<Trans>Picked up where you left off.</Trans>
</span>
<button
type="button"
className="dump-create-draft-discard"
onClick={discardDraft}
>
<Trans>Start fresh</Trans>
</button>
</div>
)}
<StepIndicator current={step} />
{step === "playlist"
? (
<div className="dump-panel dump-panel--forward">
{createdDump && (
<p className="dump-create-success">
<Trans>Dumped!</Trans>{" "}
<Link to={dumpUrl(createdDump)} onClick={onClose}>
<Trans>View dump →</Trans>
</Link>
</p>
)}
<PlaylistMembershipPanel
dumpId={createdDump?.id ?? ""}
memberships={memberships}
loading={playlistsLoading}
onToggle={toggleMembership}
onPlaylistCreated={(membership) =>
setMemberships((prev) => [membership, ...prev])}
/>
<div className="form-actions">
<div className="form-actions-right">
<button
type="button"
className="btn-primary"
onClick={onClose}
>
<Trans>Done</Trans>
</button>
</div>
</div>
</div>
)
: (
<FormProvider {...form}>
<form onSubmit={onFormSubmit} className="form">
<FormError title={t`Failed to post`} />
{/* Keyed so each panel re-enters from the side it came from. */}
<div
key={step}
className={`dump-panel dump-panel--${direction}`}
>
{step === "what"
? (
<>
{mode === "url" && (
<>
<div className="form-field">
<label className="form-label" htmlFor="dc-url">
<Trans>Link</Trans>
</label>
<input
id="dc-url"
type="url"
{...register("url")}
onBlur={(e) =>
setValue("url", normalizeUrl(e.target.value))}
onPaste={(e) => {
const pastedFile = e.clipboardData.files[0];
if (pastedFile) {
e.preventDefault();
setValue("url", "");
selectFile(pastedFile);
clearErrors("root");
}
}}
disabled={submitting}
placeholder={t`Paste a link…`}
autoFocus={!prefersNoAutoFocus()}
/>
</div>
{urlPreview.status === "loading" && (
<p className="preview-loading">
<Trans>Fetching preview…</Trans>
</p>
)}
{urlPreview.status === "done" && (
<>
{urlPreview.richContent && (
<RichContentCard
richContent={urlPreview.richContent}
/>
)}
{!urlPreview.reached && (
<p className="preview-note">
<Trans>
Couldn't load a preview for this link —
check it, or post it as-is and refresh the
preview later.
</Trans>
</p>
)}
</>
)}
{duplicates.length > 0 && (
<DuplicateNotice
matches={duplicates}
onNavigate={onClose}
/>
)}
{urlPreview.status === "done" && (
<TitleField
registration={register("title")}
editing={titleEditing}
onEdit={() => {
setTitleEditing(true);
setTitleFromUser(true);
}}
onReset={titleFromUser && suggestedTitle
? () => {
setTitleFromUser(false);
setTitleEditing(false);
setValue("title", suggestedTitle);
}
: undefined}
disabled={submitting}
/>
)}
</>
)}
{
/* Hidden only while a link is being written: an empty
panel offers both ways in at once, no mode to pick. */
}
{!(mode === "url" && url.trim()) && (
<FileField<CreateValues>
name="file"
onValueChange={selectFile}
disabled={submitting}
hint={t`Drop a file here, or paste one`}
/>
)}
{file && (
<>
<LocalFilePreview file={file} />
<TitleField
registration={register("title")}
editing={titleEditing}
onEdit={() => {
setTitleEditing(true);
setTitleFromUser(true);
}}
disabled={submitting}
/>
</>
)}
</>
)
: (
<>
{
/* The link itself is a panel back, so name the thing
these choices are about. */
}
<p className="dump-panel-recap" title={recap}>
<span aria-hidden="true">
{mode === "url" ? "🔗" : "📎"}
</span>{" "}
{recap}
</p>
{uploadProgress !== undefined && (
<div
className={`dump-upload-progress${
typeof uploadProgress === "number"
? ""
: " dump-upload-progress--indeterminate"
}`}
role="progressbar"
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={typeof uploadProgress === "number"
? Math.round(uploadProgress * 100)
: undefined}
>
<div
className="dump-upload-progress-fill"
style={typeof uploadProgress === "number"
? { width: `${uploadProgress * 100}%` }
: undefined}
/>
</div>
)}
<RichTextField<CreateValues>
name="comment"
label={t`Why?`}
placeholder={t`What makes it worth it?`}
rows={3}
maxLength={VALIDATION.DUMP_COMMENT_MAX}
disabled={submitting}
editorRef={commentEditorRef}
/>
<CategorySelect
selected={selectedCategoryIds}
onToggle={toggleCategory}
disabled={submitting}
/>
<VisibilityToggle<CreateValues>
name="isPublic"
label={t`Who can see it`}
disabled={submitting}
/>
</>
)}
</div>
<div className="form-actions">
{step === "details" && (
<button
type="button"
className="form-cancel"
onClick={() => goToStep("what", "back")}
disabled={submitting}
>
<Trans>← Back</Trans>
</button>
)}
<div className="form-actions-right">
{step === "what" && (
<button
type="button"
className="form-cancel"
onClick={requestClose}
>
<Trans>Cancel</Trans>
</button>
)}
<SubmitButton
disabled={step === "what" && !canProceed}
pendingLabel={mode === "url"
? <Trans>Posting…</Trans>
: typeof uploadProgress === "number"
? uploadPercentLabel
: <Trans>Uploading…</Trans>}
>
{step === "what"
? <Trans>Next →</Trans>
: <Trans>Dump it</Trans>}
</SubmitButton>
</div>
</div>
</form>
</FormProvider>
)}
</div>
</Modal>
);
}