Files
gerbeur/src/pages/DumpEdit.tsx
khannurien 07d0344be7 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>
2026-09-27 19:30:28 +00:00

514 lines
16 KiB
TypeScript

import { useEffect, useState } from "react";
import { Link, useNavigate, useParams } 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 { Dump, RawDump, UpdateDumpRequest } from "../model.ts";
import { deserializeDump, parseAPIResponse } from "../model.ts";
import { useRequiredAuth } from "../hooks/useAuth.ts";
import { useDocumentTitle } from "../hooks/useDocumentTitle.ts";
import { formatBytes } from "../utils/format.ts";
import { dumpFileUrl, dumpThumbnailUrl, dumpUrl } from "../utils/urls.ts";
import { PageShell } from "../components/layout/PageShell.tsx";
import { PageError } from "../components/layout/PageError.tsx";
import { ErrorCard } from "../components/ui/ErrorCard.tsx";
import { friendlyFetchError } from "../utils/apiError.ts";
import { ConfirmModal } from "../components/ui/ConfirmModal.tsx";
import { RichContentCard } from "../components/dump/RichContentCard.tsx";
import { FilePreview } from "../components/dump/FilePreview.tsx";
import { ImagePicker } from "../components/ui/ImagePicker.tsx";
import { CategorySelect } from "../components/category/CategorySelect.tsx";
import {
expectOk,
FileField,
FormError,
FormProvider,
RichTextField,
SubmitButton,
TextField,
useApiForm,
VisibilityToggle,
} from "../components/form/index.ts";
import { useAuth } from "../hooks/useAuth.ts";
type DumpEditState =
| { status: "loading" }
| { status: "error"; error: string }
| { status: "loaded"; dump: Dump };
export function DumpEdit() {
const { selectedDump } = useParams();
const navigate = useNavigate();
const { authFetch, token, mediaToken } = useRequiredAuth();
const [state, setState] = useState<DumpEditState>({ status: "loading" });
useDocumentTitle(
state.status === "loaded"
? t`Editing ${state.dump.title}`
: state.status === "error"
? null
: undefined,
);
const [confirmDelete, setConfirmDelete] = useState(false);
const [refreshing, setRefreshing] = useState(false);
const [refreshError, setRefreshError] = useState<string | null>(null);
const [thumbUploading, setThumbUploading] = useState(false);
const [thumbError, setThumbError] = useState<string | null>(null);
const [deleteError, setDeleteError] = useState<string | null>(null);
useEffect(() => {
if (!selectedDump) return;
(async () => {
setState({ status: "loading" });
try {
const res = await fetch(`${API_URL}/api/dumps/${selectedDump}`, {
cache: "no-store",
headers: token ? { Authorization: `Bearer ${token}` } : {},
});
const apiResponse = parseAPIResponse<RawDump>(await res.json());
if (apiResponse.success) {
setState({
status: "loaded",
dump: deserializeDump(apiResponse.data),
});
} else {
setState({ status: "error", error: apiResponse.error.message });
}
} catch (err) {
setState({ status: "error", error: friendlyFetchError(err) });
}
})();
}, [selectedDump, token]);
const handleRefreshMetadata = async () => {
if (state.status !== "loaded" || state.dump.kind !== "url") return;
setRefreshing(true);
setRefreshError(null);
try {
const res = await authFetch(
`${API_URL}/api/dumps/${state.dump.id}/refresh-metadata`,
{ method: "POST" },
);
const apiResponse = parseAPIResponse<RawDump>(await res.json());
if (apiResponse.success) {
setState({ status: "loaded", dump: deserializeDump(apiResponse.data) });
} else {
setRefreshError(apiResponse.error.message);
}
} catch (err) {
setRefreshError(friendlyFetchError(err));
} finally {
setRefreshing(false);
}
};
const changeThumbnail = async (init: RequestInit) => {
if (state.status !== "loaded") return;
setThumbUploading(true);
setThumbError(null);
try {
const updated = await expectOk<RawDump>(
await authFetch(
`${API_URL}/api/dumps/${state.dump.id}/thumbnail`,
init,
),
);
setState({ status: "loaded", dump: deserializeDump(updated) });
} catch (err) {
setThumbError(friendlyFetchError(err));
} finally {
setThumbUploading(false);
}
};
const handleThumbnailChange = (file: File) => {
const formData = new FormData();
formData.append("file", file);
return changeThumbnail({ method: "PUT", body: formData });
};
const handleThumbnailReset = () => changeThumbnail({ method: "DELETE" });
const handleDelete = async () => {
if (state.status !== "loaded") return;
setConfirmDelete(false);
setDeleteError(null);
try {
const res = await authFetch(`${API_URL}/api/dumps/${state.dump.id}`, {
method: "DELETE",
});
if (!res.ok) {
const body = await res.json().catch(() => null);
throw new Error(
body?.error?.message ?? t`Delete failed (${res.status})`,
);
}
} catch (err) {
setDeleteError(friendlyFetchError(err));
return;
}
navigate("/", { state: { deletedDumpId: state.dump.id } });
};
if (state.status === "loading") {
return (
<PageShell>
<p className="page-loading">
<Trans>Loading dump…</Trans>
</p>
</PageShell>
);
}
if (state.status === "error") {
return (
<PageError
message={state.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 } = state;
const currentThumbnailSrc = dump.thumbnailMime
? dumpThumbnailUrl(dump, mediaToken)
: dump.kind === "file"
? (dump.fileMime?.startsWith("image/")
? dumpFileUrl(dump, mediaToken)
: dump.fileMime?.startsWith("video/")
? dumpThumbnailUrl(dump, mediaToken)
: null)
: dump.richContent?.thumbnailUrl ?? null;
return (
<PageShell>
<div className="form-page form-page--two-col">
<div className="form-page-header">
<p className="form-page-eyebrow">
<Trans>Editing</Trans>
</p>
<h1 className="form-page-title">{dump.title}</h1>
</div>
<DumpEditForm
key={dump.id}
dump={dump}
preview={
<div className="dump-edit-preview">
{dump.kind === "file"
? <FilePreview dump={dump} />
: dump.richContent
? <RichContentCard richContent={dump.richContent} />
: dump.url && (
<a
href={dump.url}
target="_blank"
rel="noopener noreferrer"
className="dump-url-link"
>
{dump.url}
</a>
)}
{dump.kind === "url" && (
<button
type="button"
className="btn-secondary dump-edit-refresh"
onClick={handleRefreshMetadata}
disabled={refreshing}
>
{refreshing
? <Trans>Refreshing…</Trans>
: <Trans>Refresh metadata</Trans>}
</button>
)}
{refreshError && (
<p className="dump-edit-refresh-error">{refreshError}</p>
)}
</div>
}
currentThumbnailSrc={currentThumbnailSrc}
thumbUploading={thumbUploading}
thumbError={thumbError}
deleteError={deleteError}
onThumbnailChange={handleThumbnailChange}
onThumbnailReset={handleThumbnailReset}
onRequestDelete={() => setConfirmDelete(true)}
onSaved={(updated) =>
navigate(dumpUrl(updated), { state: { dump: updated } })}
/>
</div>
{confirmDelete && (
<ConfirmModal
message={t`Delete this dump? This cannot be undone.`}
confirmLabel={t`Delete dump`}
onConfirm={handleDelete}
onCancel={() => setConfirmDelete(false)}
/>
)}
</PageShell>
);
}
interface DumpEditFormProps {
dump: Dump;
preview: React.ReactNode;
currentThumbnailSrc: string | null;
thumbUploading: boolean;
thumbError: string | null;
deleteError: string | null;
onThumbnailChange: (file: File) => void;
onThumbnailReset: () => void;
onRequestDelete: () => void;
onSaved: (dump: Dump) => void;
}
interface EditValues {
title: string;
url: string;
comment: string;
isPublic: boolean;
newFile: File | null;
}
function DumpEditForm({
dump,
preview,
currentThumbnailSrc,
thumbUploading,
thumbError,
deleteError,
onThumbnailChange,
onThumbnailReset,
onRequestDelete,
onSaved,
}: DumpEditFormProps) {
const { authFetch } = useAuth();
const [selectedCategoryIds, setSelectedCategoryIds] = useState<Set<string>>(
() => new Set(dump.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;
});
const form = useApiForm<EditValues>({
defaultValues: {
title: dump.title,
url: dump.url ?? "",
comment: dump.comment ?? "",
isPublic: !dump.isPrivate,
newFile: null,
},
});
// Metadata-extracted title for URL dumps; the source for "Reset to default".
const metaTitle = dump.richContent?.title?.trim() ?? "";
const currentTitle = form.watch("title");
const onSubmit = form.submit(
async ({ title, url, comment, isPublic, newFile }) => {
const trimmedTitle = title.trim();
const isPrivate = !isPublic;
let res: Response;
const categoryIds = [...selectedCategoryIds];
if (dump.kind === "file" && newFile) {
const formData = new FormData();
formData.append("file", newFile);
formData.append("title", trimmedTitle);
if (comment.trim()) formData.append("comment", comment.trim());
formData.append("categoryIds", JSON.stringify(categoryIds));
// In the same request, so the new file never goes out under the old
// visibility, and a failure leaves neither applied.
formData.append("isPrivate", String(isPrivate));
res = await authFetch(`${API_URL}/api/dumps/${dump.id}/file`, {
method: "PUT",
body: formData,
});
} else {
// An emptied note is sent as null: left out, the server keeps the old
// one.
const body: UpdateDumpRequest = dump.kind === "url"
? {
url: url.trim() || undefined,
title: trimmedTitle,
comment: comment.trim() || null,
isPrivate,
categoryIds,
}
: {
title: trimmedTitle,
comment: comment.trim() || null,
isPrivate,
categoryIds,
};
res = await authFetch(`${API_URL}/api/dumps/${dump.id}`, {
method: "PUT",
body: JSON.stringify(body),
});
}
onSaved(deserializeDump(await expectOk<RawDump>(res)));
},
);
return (
<FormProvider {...form}>
<form className="form dump-edit-form" onSubmit={onSubmit}>
{
/* Left: the dumped thing itself — what it looks like and where it
comes from. Right: how it's presented and shared. */
}
<div className="dump-edit-panel dump-edit-panel--source">
{preview}
<div className="form-field">
<span className="form-label">
<Trans>Thumbnail</Trans>
</span>
<div className="dump-edit-thumbnail-row">
<ImagePicker
src={currentThumbnailSrc}
size={64}
onChange={onThumbnailChange}
uploading={thumbUploading}
/>
{dump.thumbnailMime && (
<button
type="button"
className="btn-border"
onClick={onThumbnailReset}
disabled={thumbUploading}
>
<Trans>Reset to default</Trans>
</button>
)}
</div>
{thumbError && (
<span className="form-hint form-hint--error" role="alert">
{thumbError}
</span>
)}
</div>
{dump.kind === "url"
? (
<TextField<EditValues>
name="url"
type="url"
label={t`URL`}
placeholder="https://..."
rules={{ required: true }}
/>
)
: (
<div className="form-field">
<span className="form-label">
<Trans>Replace file</Trans>
</span>
<p className="dump-file-notice">
<strong>{dump.fileName}</strong>
{dump.fileSize != null && ` — ${formatBytes(dump.fileSize)}`}
</p>
<FileField<EditValues>
name="newFile"
hint={t`Drop a replacement here`}
showLimit={false}
/>
</div>
)}
</div>
<div className="dump-edit-panel dump-edit-panel--details">
<FormError title={t`Could not save`} />
<TextField<EditValues>
name="title"
label={t`Title`}
labelAction={metaTitle
? (
<button
type="button"
className="form-label-action"
onClick={() =>
form.setValue("title", metaTitle, { shouldDirty: true })}
disabled={currentTitle.trim() === metaTitle}
>
<Trans>Reset to default</Trans>
</button>
)
: undefined}
maxLength={VALIDATION.DUMP_TITLE_MAX}
rules={{ required: true }}
/>
<RichTextField<EditValues>
name="comment"
label={t`Why?`}
placeholder={t`What makes it worth it?`}
rows={3}
maxLength={VALIDATION.DUMP_COMMENT_MAX}
/>
<CategorySelect
selected={selectedCategoryIds}
onToggle={toggleCategory}
/>
<VisibilityToggle<EditValues> name="isPublic" />
</div>
<div className="dump-edit-footer">
{deleteError && (
<ErrorCard title={t`Could not delete`} message={deleteError} />
)}
<div className="form-actions">
<button
type="button"
onClick={onRequestDelete}
className="btn-danger"
>
<Trans>Delete dump</Trans>
</button>
<div className="form-actions-right">
<Link to={dumpUrl(dump)} className="form-cancel">
<Trans>Cancel</Trans>
</Link>
<SubmitButton>
<Trans>Save</Trans>
</SubmitButton>
</div>
</div>
</div>
</form>
</FormProvider>
);
}