v3: code quality and visual consistency pass (refactored all forms across the app), fixed filename-related upload bug
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 47s

This commit is contained in:
khannurien
2026-06-26 22:20:07 +00:00
parent 26f5abfa4e
commit 73e0114bf7
30 changed files with 2369 additions and 2120 deletions

View File

@@ -15,9 +15,20 @@ 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";
import { ImagePicker } from "../components/ImagePicker.tsx";
import {
expectOk,
FormError,
FormProvider,
RichTextField,
SubmitButton,
TextField,
useApiForm,
VisibilityToggle,
} from "../components/form/index.ts";
import { useAuth } from "../hooks/useAuth.ts";
import { Controller } from "react-hook-form";
type DumpEditState =
| { status: "loading" }
@@ -30,11 +41,6 @@ export function DumpEdit() {
const { authFetch, token } = useRequiredAuth();
const [state, setState] = useState<DumpEditState>({ status: "loading" });
const [url, setUrl] = useState("");
const [title, setTitle] = 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);
const [thumbUploading, setThumbUploading] = useState(false);
@@ -52,12 +58,7 @@ export function DumpEdit() {
const apiResponse = parseAPIResponse<RawDump>(await res.json());
if (apiResponse.success) {
const dump: Dump = deserializeDump(apiResponse.data);
setUrl(dump.url ?? "");
setTitle(dump.title);
setComment(dump.comment ?? "");
setIsPrivate(dump.isPrivate);
setState({ status: "loaded", dump });
setState({ status: "loaded", dump: deserializeDump(apiResponse.data) });
} else {
setState({ status: "error", error: apiResponse.error.message });
}
@@ -67,56 +68,6 @@ export function DumpEdit() {
})();
}, [selectedDump, token]);
const handleSave = async () => {
const trimmedTitle = title.trim();
if (
state.status !== "loaded" ||
comment.length > VALIDATION.DUMP_COMMENT_MAX ||
!trimmedTitle || trimmedTitle.length > VALIDATION.DUMP_TITLE_MAX
) return;
let res: Response;
if (state.dump.kind === "file" && newFile) {
const formData = new FormData();
formData.append("file", newFile);
formData.append("title", trimmedTitle);
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,
title: trimmedTitle,
comment: comment.trim() || undefined,
isPrivate,
}
: {
title: trimmedTitle,
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;
@@ -128,8 +79,7 @@ export function DumpEdit() {
);
const apiResponse = await res.json();
if (apiResponse.success) {
const updatedDump: Dump = deserializeDump(apiResponse.data);
setState({ status: "loaded", dump: updatedDump });
setState({ status: "loaded", dump: deserializeDump(apiResponse.data) });
}
} finally {
setRefreshing(false);
@@ -274,138 +224,17 @@ export function DumpEdit() {
)}
</div>
<form
className="dump-form"
onSubmit={(e) => {
e.preventDefault();
handleSave();
}}
>
<div className="form-group">
<label>
<Trans>Thumbnail</Trans>
</label>
<div className="dump-edit-thumbnail-row">
<ImagePicker
src={currentThumbnailSrc}
size={64}
onChange={handleThumbnailChange}
uploading={thumbUploading}
/>
{dump.thumbnailMime && (
<button
type="button"
className="btn-border"
onClick={handleThumbnailReset}
disabled={thumbUploading}
>
<Trans>Reset to default</Trans>
</button>
)}
</div>
</div>
<div className="form-group">
<label htmlFor="title">
<Trans>Title</Trans>
</label>
<input
id="title"
type="text"
value={title}
onChange={(e) => setTitle(e.currentTarget.value)}
maxLength={VALIDATION.DUMP_TITLE_MAX}
required
/>
</div>
{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 ||
!title.trim() ||
title.trim().length > VALIDATION.DUMP_TITLE_MAX}
>
<Trans>Save</Trans>
</button>
</div>
</div>
</form>
<DumpEditForm
key={dump.id}
dump={dump}
currentThumbnailSrc={currentThumbnailSrc}
thumbUploading={thumbUploading}
onThumbnailChange={handleThumbnailChange}
onThumbnailReset={handleThumbnailReset}
onRequestDelete={() => setConfirmDelete(true)}
onSaved={(updated) =>
navigate(dumpUrl(updated), { state: { dump: updated } })}
/>
</div>
{confirmDelete && (
<ConfirmModal
@@ -418,3 +247,180 @@ export function DumpEdit() {
</PageShell>
);
}
interface DumpEditFormProps {
dump: Dump;
currentThumbnailSrc: string | null;
thumbUploading: boolean;
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,
currentThumbnailSrc,
thumbUploading,
onThumbnailChange,
onThumbnailReset,
onRequestDelete,
onSaved,
}: DumpEditFormProps) {
const { authFetch } = useAuth();
const form = useApiForm<EditValues>({
defaultValues: {
title: dump.title,
url: dump.url ?? "",
comment: dump.comment ?? "",
isPublic: !dump.isPrivate,
newFile: null,
},
});
const onSubmit = form.submit(
async ({ title, url, comment, isPublic, newFile }) => {
const trimmedTitle = title.trim();
const isPrivate = !isPublic;
let res: Response;
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());
res = await authFetch(`${API_URL}/api/dumps/${dump.id}/file`, {
method: "PUT",
body: formData,
});
} else {
const body: UpdateDumpRequest = dump.kind === "url"
? {
url: url.trim() || undefined,
title: trimmedTitle,
comment: comment.trim() || undefined,
isPrivate,
}
: {
title: trimmedTitle,
comment: comment.trim() || undefined,
isPrivate,
};
res = await authFetch(`${API_URL}/api/dumps/${dump.id}`, {
method: "PUT",
body: JSON.stringify(body),
});
}
onSaved(deserializeDump(await expectOk<RawDump>(res)));
},
);
return (
<FormProvider {...form}>
<form className="form" onSubmit={onSubmit}>
<FormError title={t`Could not save`} />
<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>
</div>
<TextField<EditValues>
name="title"
label={t`Title`}
maxLength={VALIDATION.DUMP_TITLE_MAX}
rules={{ required: true }}
/>
{dump.kind === "url"
? (
<TextField<EditValues>
name="url"
type="url"
label={t`URL`}
placeholder="https://..."
rules={{ required: true }}
/>
)
: (
<div className="form-field">
<p className="dump-file-notice">
<strong>{dump.fileName}</strong>
{dump.fileSize != null && ` — ${formatBytes(dump.fileSize)}`}
</p>
<Controller
name="newFile"
control={form.control}
render={({ field }) => (
<FileDropZone
file={field.value}
onChange={field.onChange}
label={t`Replace file`}
hint={t`Drop a replacement here`}
showLimit={false}
/>
)}
/>
</div>
)}
<RichTextField<EditValues>
name="comment"
label={t`Why?`}
placeholder={t`What makes it worth it?`}
rows={3}
maxLength={VALIDATION.DUMP_COMMENT_MAX}
rules={{ maxLength: VALIDATION.DUMP_COMMENT_MAX }}
/>
<VisibilityToggle<EditValues> name="isPublic" />
<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>
</form>
</FormProvider>
);
}