import { useCallback, useEffect, useRef, useState } from "react"; import type { UseFormRegisterReturn } from "react-hook-form"; import { Link } from "react-router"; import { t } from "@lingui/core/macro"; import { Plural, 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 { relativeTime } from "../utils/relativeTime.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 { MediaPlayer } from "./MediaPlayer.tsx"; import type { RichContent } from "../model.ts"; import { Modal } from "./Modal.tsx"; import type { TextEditorHandle } from "./TextEditor.tsx"; import { CategorySelect } from "./CategorySelect.tsx"; import { PlaylistMembershipPanel } from "./PlaylistMembershipPanel.tsx"; import { expectOk, FileField, FormError, FormProvider, RichTextField, SubmitButton, useApiForm, VisibilityToggle, } from "./form/index.ts"; /** * Posting a dump walks three panels. The split is deliberate: categories and * visibility used to sit behind a disclosure, where they read as optional and * went untouched — they are neither, so "why & where" is a stop on the way to * posting rather than something to go looking for. */ type Step = "what" | "details" | "playlist"; /** Stable empty result, so rendering doesn't churn on a fresh array each pass. */ const NO_DUPLICATES: DumpUrlMatch[] = []; const STEPS: { id: Step; label: () => string }[] = [ { id: "what", label: () => t`Link or file` }, { id: "details", label: () => t`Why & where` }, { id: "playlist", label: () => t`Playlists` }, ]; /** Where you are in the three panels — and, past the first, how far you got. */ function StepIndicator({ current }: { current: Step }) { const currentIndex = STEPS.findIndex((s) => s.id === current); return (
    {STEPS.map((step, i) => { const state = i < currentIndex ? "done" : i === currentIndex ? "current" : "todo"; return (
  1. {step.label()}
  2. ); })}
); } 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; } function LocalFilePreview({ file }: { file: File }) { const [src, setSrc] = useState(null); useEffect(() => { const url = URL.createObjectURL(file); // Blob URL lifecycle requires setState in effect — no synchronous alternative. // eslint-disable-next-line react-hooks/set-state-in-effect setSrc(url); return () => { URL.revokeObjectURL(url); }; }, [file]); if (!src) return null; const mime = file.type; if (mime.startsWith("image/")) { return {file.name}; } if (mime.startsWith("video/")) { return ; } if (mime.startsWith("audio/")) { return ; } return null; } /** * Title row shared by both kinds of dump: the suggested title (scraped from the * page, or derived from the filename) sits there read-only until the ✎ is used, * which is enough to stop stray typing while keeping a correction one click * away. Once edited, a reset offers the suggestion back. */ function TitleField({ registration, editing, onEdit, onReset, disabled, }: { registration: UseFormRegisterReturn<"title">; editing: boolean; onEdit: () => void; onReset?: () => void; disabled: boolean; }) { const inputRef = useRef(null); const { ref, ...titleReg } = registration; return (
{ ref(el); inputRef.current = el; }} disabled={disabled || !editing} maxLength={VALIDATION.DUMP_TITLE_MAX} /> {!editing && ( )} {editing && onReset && ( )}
); } /** "Already dumped" hint — never blocking, since reposts are sometimes on purpose. */ function DuplicateNotice( { matches, onNavigate }: { matches: DumpUrlMatch[]; onNavigate: () => void; }, ) { const [first, ...rest] = matches; return (

Already dumped by {first.username} {relativeTime(first.createdAt)}

{first.title} {rest.length > 0 && (

)}

You can post it anyway.

); } interface DumpCreateModalProps { onClose: () => void; initialUrl?: string; } export function DumpCreateModal( { onClose, initialUrl = "" }: DumpCreateModalProps, ) { const { authFetch, authUpload } = useAuth(); const { injectDump } = useWS(); const [step, setStep] = useState("what"); // Which way the next panel should come in from. Purely presentational. const [direction, setDirection] = useState<"forward" | "back">("forward"); const [createdDump, setCreatedDump] = useState(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(() => 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({ 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(); const [confirmClose, setConfirmClose] = useState(false); const debounceRef = useRef | null>(null); const commentEditorRef = useRef(null); const form = useApiForm({ 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>( () => 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([]); 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" })); debounceRef.current = setTimeout(() => { const encoded = encodeURIComponent(canonicalUrl); fetch(`${API_URL}/api/preview?url=${encoded}`) .then((res) => res.json()) .then((body) => { const data = body.success ? body.data as UrlPreviewResponse : undefined; setUrlFetchState({ status: "done", reached: data?.reached ?? false, richContent: data?.richContent ?? null, }); }) .catch(() => { 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}`) .then((res) => res.json()) .then((body) => { setDuplicateState({ url: canonicalUrl, matches: body.success ? (body.data as RawDumpUrlMatch[]).map(deserializeDumpUrlMatch) : [], }); }) .catch(() => setDuplicateState({ url: canonicalUrl, matches: [] }) ); }, 600); return () => { if (debounceRef.current) clearTimeout(debounceRef.current); }; }, [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(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; if (hasDump) { await authFetch( `${API_URL}/api/playlists/${playlist.id}/dumps/${createdDump.id}`, { method: "DELETE" }, ); setMemberships((prev) => prev.map((m) => m.playlist.id === playlist.id ? { ...m, hasDump: false } : m ) ); } else { await authFetch( `${API_URL}/api/playlists/${playlist.id}/dumps/${createdDump.id}`, { method: "POST" }, ); setMemberships((prev) => prev.map((m) => m.playlist.id === playlist.id ? { ...m, hasDump: true } : 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) => { if (step === "what") { e.preventDefault(); if (canProceed) goToStep("details", "forward"); return; } void onSubmit(e); }; return (
{ e.preventDefault(); if (!submitting && step !== "playlist") setDragging(true); }} onDragLeave={(e) => { if (!e.currentTarget.contains(e.relatedTarget as Node | null)) { setDragging(false); } }} onDrop={handleDrop} > {confirmClose && (

Close without posting? The attached file won't be kept — everything you typed will.

)} {draftNoticeVisible && step === "what" && (
Picked up where you left off.
)} {step === "playlist" ? (
{createdDump && (

Dumped!{" "} View dump →

)} setMemberships((prev) => [membership, ...prev])} />
) : (
{/* Keyed so each panel re-enters from the side it came from. */}
{step === "what" ? ( <> {mode === "url" && ( <>
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()} />
{urlPreview.status === "loading" && (

Fetching preview…

)} {urlPreview.status === "done" && ( <> {urlPreview.richContent && ( )} {!urlPreview.reached && (

Couldn't load a preview for this link — check it, or post it as-is and refresh the preview later.

)} )} {duplicates.length > 0 && ( )} {urlPreview.status === "done" && ( { 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()) && ( name="file" onValueChange={selectFile} disabled={submitting} hint={t`Drop a file here, or paste one`} /> )} {file && ( <> { setTitleEditing(true); setTitleFromUser(true); }} disabled={submitting} /> )} ) : ( <> {/* The link itself is a panel back, so name the thing these choices are about. */}

{" "} {recap}

{uploadProgress !== undefined && (
)} name="comment" label={t`Why?`} placeholder={t`What makes it worth it?`} rows={3} maxLength={VALIDATION.DUMP_COMMENT_MAX} rules={{ maxLength: VALIDATION.DUMP_COMMENT_MAX }} disabled={submitting} editorRef={commentEditorRef} /> name="isPublic" label={t`Who can see it`} disabled={submitting} /> )}
{step === "details" && ( )}
{step === "what" && ( )} Posting… : typeof uploadProgress === "number" ? uploadPercentLabel : Uploading…} > {step === "what" ? Next → : Dump it}
)}
); }