Files
gerbeur/src/components/DumpCreateModal.tsx
khannurien dde3fe8fe2
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 43s
v3: type checking and lint pass clean — thumbnail placeholder takes file-dump props, no manual memo the compiler can't keep, no setState straight in effect bodies
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-19 20:42:32 +00:00

970 lines
33 KiB
TypeScript

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 (
<ol className="dump-steps">
{STEPS.map((step, i) => {
const state = i < currentIndex
? "done"
: i === currentIndex
? "current"
: "todo";
return (
<li
key={step.id}
className={`dump-step dump-step--${state}`}
aria-current={state === "current" ? "step" : undefined}
>
<span className="dump-step-marker" aria-hidden="true">
{state === "done" ? "✓" : i + 1}
</span>
<span className="dump-step-label">{step.label()}</span>
</li>
);
})}
</ol>
);
}
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<string | null>(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 <img src={src} alt={file.name} className="local-preview-image" />;
}
if (mime.startsWith("video/")) {
return <MediaPlayer key={src} src={src} kind="video" mime={mime} />;
}
if (mime.startsWith("audio/")) {
return <MediaPlayer key={src} src={src} kind="audio" mime={mime} />;
}
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<HTMLInputElement | null>(null);
const { ref, ...titleReg } = registration;
return (
<div className="form-field">
<label className="form-label" htmlFor="dc-title">
<Trans>Title</Trans>
</label>
<div className="dump-create-title-row">
<input
id="dc-title"
type="text"
{...titleReg}
ref={(el) => {
ref(el);
inputRef.current = el;
}}
disabled={disabled || !editing}
maxLength={VALIDATION.DUMP_TITLE_MAX}
/>
{!editing && (
<button
type="button"
className="dump-create-title-edit-btn"
onClick={() => {
onEdit();
setTimeout(() => inputRef.current?.focus(), 0);
}}
disabled={disabled}
aria-label={t`Edit title`}
>
✎
</button>
)}
{editing && onReset && (
<button
type="button"
className="dump-create-title-edit-btn"
onClick={onReset}
disabled={disabled}
aria-label={t`Reset to the suggested title`}
title={t`Reset to the suggested title`}
>
↺
</button>
)}
</div>
</div>
);
}
/** "Already dumped" hint — never blocking, since reposts are sometimes on purpose. */
function DuplicateNotice(
{ matches, onNavigate }: {
matches: DumpUrlMatch[];
onNavigate: () => void;
},
) {
const [first, ...rest] = matches;
return (
<div className="dump-duplicate" role="status">
<span className="dump-duplicate-icon" aria-hidden="true">↩</span>
<div className="dump-duplicate-body">
<p className="dump-duplicate-lead">
<Trans>
Already dumped by {first.username} {relativeTime(first.createdAt)}
</Trans>
</p>
<Link
to={dumpUrl(first)}
className="dump-duplicate-link"
onClick={onNavigate}
>
{first.title}
</Link>
{rest.length > 0 && (
<p className="dump-duplicate-more">
<Plural
value={rest.length}
one="and # earlier dump"
other="and # earlier dumps"
/>
</p>
)}
<p className="dump-duplicate-hint">
<Trans>You can post it anyway.</Trans>
</p>
</div>
</div>
);
}
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" }));
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<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;
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<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}
rules={{ 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>
);
}