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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user