/** * 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 { useCategorySelection } from "../hooks/category/useCategorySelection.ts"; 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"; import { DeleteButton } from "../components/form/DeleteButton.tsx"; import { SlugField } from "../components/form/SlugField.tsx"; import { useCanonicalPath } from "../hooks/shared/useCanonicalPath.ts"; import { userPath } from "../../shared/paths.ts"; type DumpEditState = | { status: "loading" } | { status: "error"; error: string } | { status: "loaded"; dump: Dump }; /** * Renders `/@:username/dumps/:slug/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/users/@:username/dumps/:slug`, which answers a former slug too, * and the URL then takes the current one. 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, at its new path if the slug changed. A * deletion goes to the index. */ export function DumpEdit(): React.JSX.Element { const { handle, slug } = useParams(); const username = handle?.slice(1); const navigate = useNavigate(); const { authFetch, token, mediaToken, user } = useRequiredAuth(); const [state, setState] = useState({ 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(null); const [thumbUploading, setThumbUploading] = useState(false); const [thumbError, setThumbError] = useState(null); const [deleteError, setDeleteError] = useState(null); useCanonicalPath( state.status === "loaded" ? `${dumpUrl(state.dump)}/edit` : null, ); useEffect(() => { if (!username || !slug) return; (async () => { setState({ status: "loading" }); try { const res = await fetch( `${API_URL}/api/users/@${username}/dumps/${slug}`, { cache: "no-store", headers: token ? { Authorization: `Bearer ${token}` } : {}, }, ); const apiResponse = parseAPIResponse>(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) }); } })(); }, [username, slug, 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}/metadata`, { method: "POST" }, ); const apiResponse = parseAPIResponse>(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>( 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 (

Loading dump…

); } if (state.status === "error") { return ( } /> ); } 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 (

Editing

{dump.title}

{dump.kind === "file" ? : dump.richContent ? : dump.url && ( {dump.url} )} {dump.kind === "url" && ( )} {refreshError && (

{refreshError}

)}
} 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 } })} /> {confirmDelete && ( setConfirmDelete(false)} /> )}
); } // 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; slug: 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, toggleCategory] = useCategorySelection( dump.categoryIds ?? [], ); const form = useApiForm({ defaultValues: { title: dump.title, slug: dump.slug, 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, slug, url, comment, isPublic, newFile }) => { const trimmedTitle = title.trim(); const isPrivate = !isPublic; // Sent only when changed, since each change counts against its limit. const newSlug = slug !== dump.slug ? slug : undefined; let res: Response; const categoryIds = [...selectedCategoryIds]; if (dump.kind === "file" && newFile) { const formData = new FormData(); formData.append("file", newFile); formData.append("title", trimmedTitle); if (newSlug) formData.append("slug", newSlug); 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, slug: newSlug, comment: comment.trim() || null, isPrivate, categoryIds, } : { title: trimmedTitle, slug: newSlug, 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>(res))); }, ); return (
{ /* The source panel holds what was dumped, the details panel how it is presented and who sees it. */ }
{preview}
Thumbnail
{dump.thumbnailMime && ( )}
{thumbError && ( {thumbError} )}
{dump.kind === "url" ? ( name="url" type="url" label={t`URL`} placeholder="https://..." rules={{ required: true }} /> ) : (
Replace file

{dump.fileName} {dump.fileSize != null && ` — ${formatBytes(dump.fileSize)}`}

name="newFile" hint={t`Drop a replacement here`} maxBytes={DUMP_MAX_FILE_SIZE_BYTES} showLimit={false} />
)}
name="title" label={t`Title`} labelAction={metaTitle ? ( ) : undefined} maxLength={VALIDATION.DUMP_TITLE_MAX} rules={{ required: true }} /> name="slug" titleName="title" prefix={`${userPath(dump.ownerUsername, "dumps")}/`} saved={dump} /> name="comment" label={t`Why?`} placeholder={t`What makes it worth it?`} rows={3} maxLength={VALIDATION.DUMP_COMMENT_MAX} /> name="isPublic" />
{deleteError && ( )}
Cancel Save
); }