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

@@ -1,5 +1,6 @@
import { useEffect, useRef, useState } from "react";
import { Link } from "react-router";
import { Controller } from "react-hook-form";
import { t } from "@lingui/core/macro";
import { Trans } from "@lingui/react/macro";
@@ -14,7 +15,6 @@ import type {
import {
deserializeDump,
deserializePlaylistMembership,
parseAPIResponse,
} from "../model.ts";
import { useAuth } from "../hooks/useAuth.ts";
import { useWS } from "../hooks/useWS.ts";
@@ -23,26 +23,36 @@ import { MAX_FILE_SIZE } from "../config/upload.ts";
import RichContentCard from "./RichContentCard.tsx";
import { MediaPlayer } from "./MediaPlayer.tsx";
import type { RichContent } from "../model.ts";
import { ErrorCard } from "./ErrorCard.tsx";
import { FileDropZone } from "./FileDropZone.tsx";
import { TextEditor } from "./TextEditor.tsx";
import { Modal } from "./Modal.tsx";
import { PlaylistMembershipPanel } from "./PlaylistMembershipPanel.tsx";
import { friendlyFetchError } from "../utils/apiError.ts";
import {
expectOk,
FormActions,
FormError,
FormProvider,
RichTextField,
SubmitButton,
useApiForm,
VisibilityToggle,
} from "./form/index.ts";
type Mode = "url" | "file";
type Phase = "create" | "playlist";
type SubmitState =
| { status: "idle" }
| { status: "submitting" }
| { status: "error"; error: string };
type UrlPreview =
| { status: "idle" }
| { status: "loading" }
| { status: "done"; richContent: RichContent | null };
interface CreateValues {
url: string;
file: File | null;
title: string;
comment: string;
isPublic: boolean;
}
function LocalFilePreview({ file }: { file: File }) {
const [src, setSrc] = useState<string | null>(null);
@@ -86,28 +96,34 @@ export function DumpCreateModal(
// Create phase state
const [mode, setMode] = useState<Mode>("url");
const [url, setUrl] = useState(initialUrl);
const [file, setFile] = useState<File | null>(null);
const [title, setTitle] = useState("");
const [titleEditing, setTitleEditing] = useState(false);
const titleInputRef = useRef<HTMLInputElement | null>(null);
const [comment, setComment] = useState("");
const [isPrivate, setIsPrivate] = useState(false);
const [submitState, setSubmitState] = useState<SubmitState>({
status: "idle",
});
const [urlFetchState, setUrlFetchState] = useState<UrlPreview>({
status: "idle",
});
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const form = useApiForm<CreateValues>({
defaultValues: {
url: initialUrl,
file: null,
title: "",
comment: "",
isPublic: true,
},
});
const { register, setValue, watch, clearErrors, control } = form;
const submitting = form.formState.isSubmitting;
const url = watch("url");
const file = watch("file");
// Playlist phase state
const [memberships, setMemberships] = useState<PlaylistMembership[]>([]);
const [playlistsLoading, setPlaylistsLoading] = useState(false);
const selectFile = (f: File | null) => {
setFile(f);
setTitle(f?.name ?? "");
setValue("file", f);
setValue("title", f?.name ?? "");
setTitleEditing(false);
};
@@ -166,9 +182,9 @@ export function DumpCreateModal(
const pastedFile = e.clipboardData?.files[0];
if (pastedFile) {
setMode("file");
setUrl("");
setValue("url", "");
selectFile(pastedFile);
setSubmitState({ status: "idle" });
clearErrors("root");
return;
}
const tag = (e.target as HTMLElement).tagName;
@@ -179,95 +195,71 @@ export function DumpCreateModal(
if (u.protocol === "http:" || u.protocol === "https:") {
setMode("url");
selectFile(null);
setUrl(u.toString());
setSubmitState({ status: "idle" });
setValue("url", u.toString());
clearErrors("root");
}
} catch { /* not a URL */ }
};
globalThis.addEventListener("paste", handler);
return () => globalThis.removeEventListener("paste", handler);
// setValue/clearErrors are stable RHF refs; selectFile closes over them.
}, []);
const handleSubmit = async (e: React.SubmitEvent) => {
e.preventDefault();
if (comment.length > VALIDATION.DUMP_COMMENT_MAX) return;
setSubmitState({ status: "submitting" });
const onSubmit = form.submit(async (
{ url, file, title, comment, isPublic },
) => {
const isPrivate = !isPublic;
let res: Response;
try {
let res: Response;
if (mode === "url") {
const normalizedUrl = normalizeUrl(url);
if (!normalizedUrl) {
setSubmitState({ status: "error", error: t`URL is required.` });
return;
}
setUrl(normalizedUrl);
const body: CreateUrlDumpRequest = {
url: normalizedUrl,
comment: comment.trim() || undefined,
isPrivate,
};
res = await authFetch(`${API_URL}/api/dumps`, {
method: "POST",
body: JSON.stringify(body),
});
} else {
if (!file) {
setSubmitState({
status: "error",
error: t`Please select a file.`,
});
return;
}
if (file.size > MAX_FILE_SIZE) {
setSubmitState({
status: "error",
error: t`File too large (max 50 MB).`,
});
return;
}
const formData = new FormData();
formData.append("file", file);
if (comment.trim()) formData.append("comment", comment.trim());
if (title.trim()) formData.append("title", title.trim());
formData.append("isPrivate", String(isPrivate));
res = await authFetch(`${API_URL}/api/dumps`, {
method: "POST",
body: formData,
});
if (mode === "url") {
const normalizedUrl = normalizeUrl(url);
if (!normalizedUrl) throw new Error(t`URL is required.`);
setValue("url", normalizedUrl);
const body: CreateUrlDumpRequest = {
url: normalizedUrl,
comment: comment.trim() || undefined,
isPrivate,
};
res = await authFetch(`${API_URL}/api/dumps`, {
method: "POST",
body: JSON.stringify(body),
});
} else {
if (!file) throw new Error(t`Please select a file.`);
if (!title.trim()) throw new Error(t`Title is required.`);
if (file.size > MAX_FILE_SIZE) {
throw new Error(t`File too large (max 50 MB).`);
}
const apiResponse = parseAPIResponse<RawDump>(await res.json());
if (apiResponse.success) {
const dump = deserializeDump(apiResponse.data);
injectDump(dump);
setCreatedDump(dump);
setPhase("playlist");
setPlaylistsLoading(true);
authFetch(`${API_URL}/api/playlists/by-dump/${dump.id}/memberships`)
.then((r) => r.json())
.then((body) => {
if (body.success) {
setMemberships(
(body.data as RawPlaylistMembership[]).map(
deserializePlaylistMembership,
),
);
}
})
.catch(() => {})
.finally(() => setPlaylistsLoading(false));
} else {
setSubmitState({
status: "error",
error: apiResponse.error.message,
});
}
} catch (err) {
setSubmitState({ status: "error", error: friendlyFetchError(err) });
const formData = new FormData();
formData.append("file", file);
if (comment.trim()) formData.append("comment", comment.trim());
if (title.trim()) formData.append("title", title.trim());
formData.append("isPrivate", String(isPrivate));
res = await authFetch(`${API_URL}/api/dumps`, {
method: "POST",
body: formData,
});
}
};
const dump = deserializeDump(await expectOk<RawDump>(res));
injectDump(dump);
setCreatedDump(dump);
setPhase("playlist");
setPlaylistsLoading(true);
authFetch(`${API_URL}/api/playlists/by-dump/${dump.id}/memberships`)
.then((r) => r.json())
.then((body) => {
if (body.success) {
setMemberships(
(body.data as RawPlaylistMembership[]).map(
deserializePlaylistMembership,
),
);
}
})
.catch(() => {})
.finally(() => setPlaylistsLoading(false));
});
const toggleMembership = async (membership: PlaylistMembership) => {
if (!createdDump) return;
@@ -295,8 +287,6 @@ export function DumpCreateModal(
}
};
const submitting = submitState.status === "submitting";
return (
<Modal
title={phase === "create" ? t`New dump` : t`Add to playlist`}
@@ -313,7 +303,7 @@ export function DumpCreateModal(
onClick={() => {
setMode("url");
selectFile(null);
setSubmitState({ status: "idle" });
clearErrors("root");
}}
disabled={submitting}
>
@@ -324,8 +314,8 @@ export function DumpCreateModal(
className={mode === "file" ? "active" : ""}
onClick={() => {
setMode("file");
setUrl("");
setSubmitState({ status: "idle" });
setValue("url", "");
clearErrors("root");
}}
disabled={submitting}
>
@@ -333,161 +323,134 @@ export function DumpCreateModal(
</button>
</div>
<form onSubmit={handleSubmit} className="dump-form">
{submitState.status === "error" && (
<ErrorCard
title={t`Failed to post`}
message={submitState.error}
/>
)}
<FormProvider {...form}>
<form onSubmit={onSubmit} className="form">
<FormError title={t`Failed to post`} />
{mode === "url"
? (
<>
<div className="form-group">
<label htmlFor="dc-url">
<Trans>URL</Trans>
</label>
<input
id="dc-url"
type="url"
value={url}
onChange={(e) => setUrl(e.target.value)}
onBlur={(e) => setUrl(normalizeUrl(e.target.value))}
onPaste={(e) => {
const pastedFile = e.clipboardData.files[0];
if (pastedFile) {
e.preventDefault();
setMode("file");
setUrl("");
selectFile(pastedFile);
setSubmitState({ status: "idle" });
}
}}
disabled={submitting}
placeholder="https://..."
required
autoFocus
/>
</div>
{urlPreview.status === "loading" && (
<p className="preview-loading">
<Trans>Fetching preview…</Trans>
</p>
)}
{urlPreview.status === "done" &&
urlPreview.richContent && (
<RichContentCard
richContent={urlPreview.richContent}
/>
)}
</>
)
: (
<>
<FileDropZone
file={file}
onChange={selectFile}
disabled={submitting}
/>
{file && <LocalFilePreview file={file} />}
{file && (
<div className="form-group">
<label htmlFor="dc-title">
<Trans>Title</Trans>
{mode === "url"
? (
<>
<div className="form-field">
<label className="form-label" htmlFor="dc-url">
<Trans>URL</Trans>
</label>
<div className="dump-create-title-row">
<input
id="dc-title"
ref={titleInputRef}
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
disabled={submitting || !titleEditing}
maxLength={VALIDATION.DUMP_TITLE_MAX}
required
/>
{!titleEditing && (
<button
type="button"
className="dump-create-title-edit-btn"
onClick={() => {
setTitleEditing(true);
setTimeout(
() => titleInputRef.current?.focus(),
0,
);
}}
disabled={submitting}
aria-label={t`Edit title`}
>
✎
</button>
)}
</div>
<input
id="dc-url"
type="url"
{...register("url")}
onBlur={(e) =>
setValue("url", normalizeUrl(e.target.value))}
onPaste={(e) => {
const pastedFile = e.clipboardData.files[0];
if (pastedFile) {
e.preventDefault();
setMode("file");
setValue("url", "");
selectFile(pastedFile);
clearErrors("root");
}
}}
disabled={submitting}
placeholder="https://..."
autoFocus
/>
</div>
)}
</>
)}
{urlPreview.status === "loading" && (
<p className="preview-loading">
<Trans>Fetching preview…</Trans>
</p>
)}
{urlPreview.status === "done" &&
urlPreview.richContent && (
<RichContentCard richContent={urlPreview.richContent} />
)}
</>
)
: (
<>
<Controller
name="file"
control={control}
render={({ field }) => (
<FileDropZone
file={field.value}
onChange={selectFile}
disabled={submitting}
/>
)}
/>
{file && <LocalFilePreview file={file} />}
{file && (
<div className="form-field">
<label className="form-label" htmlFor="dc-title">
<Trans>Title</Trans>
</label>
<div className="dump-create-title-row">
{(() => {
const { ref, ...titleReg } = register("title");
return (
<input
id="dc-title"
type="text"
{...titleReg}
ref={(el) => {
ref(el);
titleInputRef.current = el;
}}
disabled={submitting || !titleEditing}
maxLength={VALIDATION.DUMP_TITLE_MAX}
/>
);
})()}
{!titleEditing && (
<button
type="button"
className="dump-create-title-edit-btn"
onClick={() => {
setTitleEditing(true);
setTimeout(
() => titleInputRef.current?.focus(),
0,
);
}}
disabled={submitting}
aria-label={t`Edit title`}
>
✎
</button>
)}
</div>
</div>
)}
</>
)}
<div className="form-group">
<label htmlFor="dc-comment">
<Trans>Why?</Trans>
</label>
<TextEditor
id="dc-comment"
value={comment}
onChange={setComment}
disabled={submitting}
<RichTextField<CreateValues>
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 }}
disabled={submitting}
/>
</div>
<div className="visibility-toggle">
<button
type="button"
className={!isPrivate ? "active" : ""}
<VisibilityToggle<CreateValues>
name="isPublic"
disabled={submitting}
onClick={() => setIsPrivate(false)}
>
<Trans>Public</Trans>
</button>
<button
type="button"
className={isPrivate ? "active" : ""}
disabled={submitting}
onClick={() => setIsPrivate(true)}
>
<Trans>Private</Trans>
</button>
</div>
/>
<div className="form-actions">
<div className="form-actions-right">
<button
type="button"
className="form-cancel"
onClick={onClose}
<FormActions onCancel={onClose}>
<SubmitButton
pendingLabel={mode === "url"
? <Trans>Fetching…</Trans>
: <Trans>Uploading…</Trans>}
>
<Trans>Cancel</Trans>
</button>
<button
type="submit"
className="btn-primary"
disabled={submitting ||
comment.length > VALIDATION.DUMP_COMMENT_MAX}
>
{submitting
? (mode === "url"
? <Trans>Fetching…</Trans>
: <Trans>Uploading…</Trans>)
: <Trans>Dump it</Trans>}
</button>
</div>
</div>
</form>
<Trans>Dump it</Trans>
</SubmitButton>
</FormActions>
</form>
</FormProvider>
</>
)
: (
@@ -512,11 +475,7 @@ export function DumpCreateModal(
<div className="form-actions">
<div className="form-actions-right">
<button
type="button"
className="btn-primary"
onClick={onClose}
>
<button type="button" className="btn-primary" onClick={onClose}>
<Trans>Done</Trans>
</button>
</div>