Files
gerbeur/src/pages/DumpEdit.tsx
khannurien f3f07ed1e3
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 49s
Build and Deploy Static Documentation Site / build-and-push (push) Successful in 22s
v3: every doc opens with the role, then the mechanism, then the details — the docs were written bottom-up: what a thing does first ("Declares the three contexts WSProvider fills…"), details that only make sense once its purpose is known next, and the reason it exists last ("…so that a feed doesn't re-render"), so a reader had nothing to hang the first sentences on. The README's rule caused it, since a module doc "opened with what the module does"; it now opens with the module's role in the app, and the value-side phrasing of a type ("Returned to Y by X") comes once the role is said, with WSContext as the before and after. WSContext.ts is rewritten that way throughout, and so are the docs elsewhere that buried their purpose in a trailing "so that" (the migrations, og, image-proxy, public-address, useFading, useFeedCache, the drafts), opened on a generic verb that hid it ("Holds", "Declares", "Loads", "Defines", "Types": the contexts and providers, the shared types, the models, the hooks), or were label fragments ("The page of a dump…", "A row of tabs."). A function whose verb is its action ("Stores session"), a props or result type, and a protocol message ("Sent by X when Y") already say their role and are left as they are. Only comments changed, and the catalogs only move their references; deno fmt, deno lint and doc:lint are clean.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 21:42:08 +00:00

554 lines
17 KiB
TypeScript

/**
* Renders the page where a dump's poster, or a moderator, edits or deletes it.
*
* @module
*/
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 } from "../config/api.ts";
import {
DUMP_MAX_FILE_SIZE_BYTES,
VALIDATION,
} from "../../shared/validation.ts";
import type { Dump, UpdateDumpRequest } from "../../shared/dump.ts";
import type { Wire } from "../../shared/api.ts";
import { deserializeDump } from "../utils/shared/deserialize.ts";
import {
expectOk,
friendlyFetchError,
parseAPIResponse,
} from "../utils/shared/api.ts";
import { useAuth, useRequiredAuth } from "../hooks/auth/useAuth.ts";
import { can } from "../utils/auth/permissions.ts";
import { useDocumentTitle } from "../hooks/shared/useDocumentTitle.ts";
import { formatBytes } from "../utils/shared/format.ts";
import {
dumpFileUrl,
dumpThumbnailUrl,
dumpUrl,
} from "../utils/shared/urls.ts";
import { PageShell } from "../components/layout/PageShell.tsx";
import { PageError } from "../components/layout/PageError.tsx";
import { ErrorCard } from "../components/shared/ErrorCard.tsx";
import { ConfirmModal } from "../components/shared/ConfirmModal.tsx";
import { RichContentCard } from "../components/dump/RichContentCard.tsx";
import { FilePreview } from "../components/dump/FilePreview.tsx";
import { ImagePicker } from "../components/shared/ImagePicker.tsx";
import { CategorySelect } from "../components/category/CategorySelect.tsx";
import { FileField } from "../components/form/FileField.tsx";
import { FormError } from "../components/form/FormError.tsx";
import { FormProvider } from "react-hook-form";
import { RichTextField } from "../components/form/RichTextField.tsx";
import { SubmitButton } from "../components/form/SubmitButton.tsx";
import { TextField } from "../components/form/TextField.tsx";
import { useApiForm } from "../hooks/form/useApiForm.ts";
import { VisibilityToggle } from "../components/form/SegmentedField.tsx";
type DumpEditState =
| { status: "loading" }
| { status: "error"; error: string }
| { status: "loaded"; dump: Dump };
/**
* Renders `/dumps/:selectedDump/edit`, for the dump's poster and moderators;
* anyone else signed in gets an error rather than a form the API would refuse
* to save. The dump comes from `GET /api/dumps/:dumpId`. The thumbnail, the
* preview refresh and the deletion apply at once, and the form's other fields
* on "Save", which goes back to the dump's page. A deletion goes to the index.
*/
export function DumpEdit(): React.JSX.Element {
const { selectedDump } = useParams();
const navigate = useNavigate();
const { authFetch, token, mediaToken, user } = 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<Wire<Dump>>(await res.json());
if (apiResponse.success) {
const dump = deserializeDump(apiResponse.data);
// The same rule as the API's.
if (dump.userId !== user.id && !can(user, "dump:moderate")) {
setState({
status: "error",
error: t`Only its poster or a moderator can edit this dump.`,
});
return;
}
setState({ status: "loaded", dump });
} else {
setState({ status: "error", error: apiResponse.error.message });
}
} catch (err) {
setState({ status: "error", error: friendlyFetchError(err) });
}
})();
}, [selectedDump, token, user]);
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<Wire<Dump>>(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<Wire<Dump>>(
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;
}
// For Index, to hide the dump before `dump_deleted` arrives.
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)}
// Passed along so the dump's page shows the saved dump without
// refetching it.
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>
);
}
// The thumbnail and deletion are handled by DumpEdit, which applies them at
// once; the form only reports them.
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;
}
/**
* Renders the edit form of `dump`, which saves through
* `PUT /api/dumps/:dumpId`, or `PUT /api/dumps/:dumpId/file` when a new file
* is attached. `DumpEdit` keys it by dump, so the fields reset for another.
*/
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,
},
});
// The title scraped from the link, which "Reset to default" puts back.
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<Wire<Dump>>(res)));
},
);
return (
<FormProvider {...form}>
<form className="form dump-edit-form" onSubmit={onSubmit}>
{
/* The source panel holds what was dumped, the details panel how it
is presented and who sees it. */
}
<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`}
maxBytes={DUMP_MAX_FILE_SIZE_BYTES}
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>
);
}