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
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 47s
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user