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/PageShell.tsx"; import { PageError } from "../components/PageError.tsx"; import { ErrorCard } from "../components/ErrorCard.tsx"; import { friendlyFetchError } from "../utils/apiError.ts"; import { ConfirmModal } from "../components/ConfirmModal.tsx"; import RichContentCard from "../components/RichContentCard.tsx"; import FilePreview from "../components/FilePreview.tsx"; import { ImagePicker } from "../components/ImagePicker.tsx"; import { CategorySelect } from "../components/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({ 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); 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(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(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; } 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)} onSaved={(updated) => navigate(dumpUrl(updated), { state: { dump: updated } })} /> {confirmDelete && ( setConfirmDelete(false)} /> )}
); } 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>( () => 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({ 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(res))); }, ); return (
{ /* Left: the dumped thing itself — what it looks like and where it comes from. Right: how it's presented and shared. */ }
{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`} showLimit={false} />
)}
name="title" label={t`Title`} labelAction={metaTitle ? ( ) : undefined} maxLength={VALIDATION.DUMP_TITLE_MAX} rules={{ required: true }} /> name="comment" label={t`Why?`} placeholder={t`What makes it worth it?`} rows={3} maxLength={VALIDATION.DUMP_COMMENT_MAX} /> name="isPublic" />
{deleteError && ( )}
Cancel Save
); }