All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 46s
323 lines
9.7 KiB
TypeScript
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>
|
|
);
|
|
}
|