Files
gerbeur/src/pages/DumpEdit.tsx
khannurien 9c889a9531
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 46s
v3: fixed search in prod, lots of UI fixes across the app
2026-04-09 21:54:07 +00:00

323 lines
9.7 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 { formatBytes } from "../utils/format.ts";
import { dumpUrl } from "../utils/urls.ts";
import { PageShell } from "../components/PageShell.tsx";
import { PageError } from "../components/PageError.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 { TextEditor } from "../components/TextEditor.tsx";
import { FileDropZone } from "../components/FileDropZone.tsx";
type DumpEditState =
| { status: "loading" }
| { status: "error"; error: string }
| { status: "loaded"; dump: Dump };
export function DumpEdit() {
const { selectedDump } = useParams();
const navigate = useNavigate();
const { authFetch, token } = useRequiredAuth();
const [state, setState] = useState<DumpEditState>({ status: "loading" });
const [url, setUrl] = useState("");
const [comment, setComment] = useState("");
const [isPrivate, setIsPrivate] = useState(false);
const [newFile, setNewFile] = useState<File | null>(null);
const [confirmDelete, setConfirmDelete] = useState(false);
const [refreshing, setRefreshing] = useState(false);
useEffect(() => {
if (!selectedDump) return;
setState({ status: "loading" });
(async () => {
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) {
const dump: Dump = deserializeDump(apiResponse.data);
setUrl(dump.url ?? "");
setComment(dump.comment ?? "");
setIsPrivate(dump.isPrivate);
setState({ status: "loaded", dump });
} else {
setState({ status: "error", error: apiResponse.error.message });
}
} catch (err) {
setState({ status: "error", error: friendlyFetchError(err) });
}
})();
}, [selectedDump, token]);
const handleSave = async () => {
if (
state.status !== "loaded" || comment.length > VALIDATION.DUMP_COMMENT_MAX
) return;
let res: Response;
if (state.dump.kind === "file" && newFile) {
const formData = new FormData();
formData.append("file", newFile);
if (comment.trim()) formData.append("comment", comment.trim());
res = await authFetch(`${API_URL}/api/dumps/${state.dump.id}/file`, {
method: "PUT",
body: formData,
});
} else {
const body: UpdateDumpRequest = state.dump.kind === "url"
? {
url: url.trim() || undefined,
comment: comment.trim() || undefined,
isPrivate,
}
: { comment: comment.trim() || undefined, isPrivate };
res = await authFetch(`${API_URL}/api/dumps/${state.dump.id}`, {
method: "PUT",
body: JSON.stringify(body),
});
}
const apiResponse = parseAPIResponse<RawDump>(await res.json());
if (!apiResponse.success) {
setState({ status: "error", error: apiResponse.error.message });
return;
}
const updatedDump: Dump = deserializeDump(apiResponse.data);
setState({ status: "loaded", dump: updatedDump });
setNewFile(null);
navigate(dumpUrl(updatedDump), { state: { dump: updatedDump } });
};
const handleRefreshMetadata = async () => {
if (state.status !== "loaded" || state.dump.kind !== "url") return;
setRefreshing(true);
try {
const res = await authFetch(
`${API_URL}/api/dumps/${state.dump.id}/refresh-metadata`,
{ method: "POST" },
);
const apiResponse = await res.json();
if (apiResponse.success) {
const updatedDump: Dump = deserializeDump(apiResponse.data);
setState({ status: "loaded", dump: updatedDump });
}
} finally {
setRefreshing(false);
}
};
const handleDelete = async () => {
if (state.status !== "loaded") return;
const res = await authFetch(`${API_URL}/api/dumps/${state.dump.id}`, {
method: "DELETE",
});
if (!res.ok) {
setState({ status: "error", error: `Delete failed (${res.status})` });
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;
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>
<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>
)}
</div>
<form
className="dump-form"
onSubmit={(e) => {
e.preventDefault();
handleSave();
}}
>
{dump.kind === "url"
? (
<div className="form-group">
<label htmlFor="url">
<Trans>URL</Trans>
</label>
<input
id="url"
type="url"
value={url}
onChange={(e) => setUrl(e.currentTarget.value)}
placeholder="https://..."
required
/>
</div>
)
: (
<div className="form-group">
<p className="dump-file-notice">
<strong>{dump.fileName}</strong>
{dump.fileSize != null && `${formatBytes(dump.fileSize)}`}
</p>
<FileDropZone
file={newFile}
onChange={setNewFile}
label={t`Replace file`}
hint={t`Drop a replacement here`}
showLimit={false}
/>
</div>
)}
<div className="form-group">
<label htmlFor="comment">
<Trans>Why?</Trans>
</label>
<TextEditor
id="comment"
value={comment}
onChange={setComment}
placeholder={t`What makes it worth it?`}
rows={3}
maxLength={VALIDATION.DUMP_COMMENT_MAX}
/>
</div>
<div className="visibility-toggle">
<button
type="button"
className={!isPrivate ? "active" : ""}
onClick={() => setIsPrivate(false)}
>
<Trans>Public</Trans>
</button>
<button
type="button"
className={isPrivate ? "active" : ""}
onClick={() => setIsPrivate(true)}
>
<Trans>Private</Trans>
</button>
</div>
<div className="form-actions">
<button
type="button"
onClick={() => setConfirmDelete(true)}
className="btn-danger"
>
<Trans>Delete dump</Trans>
</button>
<div className="form-actions-right">
<Link to={dumpUrl(dump)} className="form-cancel">
<Trans>Cancel</Trans>
</Link>
<button
type="submit"
className="btn-primary"
disabled={comment.length > VALIDATION.DUMP_COMMENT_MAX}
>
<Trans>Save</Trans>
</button>
</div>
</div>
</form>
</div>
{confirmDelete && (
<ConfirmModal
message={t`Delete this dump? This cannot be undone.`}
confirmLabel={t`Delete dump`}
onConfirm={handleDelete}
onCancel={() => setConfirmDelete(false)}
/>
)}
</PageShell>
);
}