554 lines
17 KiB
TypeScript
554 lines
17 KiB
TypeScript
/**
|
|
* 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>
|
|
);
|
|
}
|