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

@@ -11,6 +11,7 @@ import { Trans } from "@lingui/react/macro";
import { API_URL, VALIDATION } from "../config/api.ts";
import { CountedInput } from "../components/CountedInput.tsx";
import type {
Playlist,
PlaylistWithDumps,
RawDump,
RawPlaylist,
@@ -22,7 +23,6 @@ import {
deserializeDump,
deserializePlaylist,
deserializePlaylistWithDumps,
parseAPIResponse,
} from "../model.ts";
import { playlistUrl } from "../utils/urls.ts";
import { useAuth } from "../hooks/useAuth.ts";
@@ -36,9 +36,16 @@ import { ImagePicker } from "../components/ImagePicker.tsx";
import { Markdown } from "../components/Markdown.tsx";
import { TextEditor } from "../components/TextEditor.tsx";
import { FollowPlaylistButton } from "../components/FollowButton.tsx";
import { ErrorCard } from "../components/ErrorCard.tsx";
import { Tooltip } from "../components/Tooltip.tsx";
import { friendlyFetchError } from "../utils/apiError.ts";
import { Controller } from "react-hook-form";
import {
expectOk,
FormError,
FormProvider,
SubmitButton,
useApiForm,
} from "../components/form/index.ts";
type LoadState =
| { status: "loading" }
@@ -88,14 +95,7 @@ export function PlaylistDetail() {
const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
const [editOpen, setEditOpen] = useState(false);
const [editTitle, setEditTitle] = useState("");
const [editDescription, setEditDescription] = useState("");
const [editIsPublic, setEditIsPublic] = useState(true);
const [editSaving, setEditSaving] = useState(false);
const [editError, setEditError] = useState<string | null>(null);
const [confirmDelete, setConfirmDelete] = useState(false);
const [imageFile, setImageFile] = useState<File | null>(null);
const [imagePreview, setImagePreview] = useState<string | null>(null);
// Mirrors activeDumpIds for use in effects without adding it as a dep.
// Updated on every render via useLayoutEffect so it's always current.
@@ -522,68 +522,11 @@ export function PlaylistDetail() {
.catch(() => {});
};
const openEdit = () => {
if (state.status !== "loaded") return;
setEditTitle(state.playlist.title);
setEditDescription(state.playlist.description ?? "");
setEditIsPublic(state.playlist.isPublic);
setImageFile(null);
setImagePreview(null);
setEditError(null);
setEditOpen(true);
};
const openEdit = () => setEditOpen(true);
const handleEditSave = async () => {
if (
!playlistId || state.status !== "loaded" ||
editDescription.length > VALIDATION.PLAYLIST_DESCRIPTION_MAX
) return;
setEditSaving(true);
setEditError(null);
try {
const updateRes = await authFetch(
`${API_URL}/api/playlists/${playlistId}`,
{
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(
{
...(editTitle !== state.playlist.title
? { title: editTitle }
: {}),
...(editDescription !== (state.playlist.description ?? "")
? { description: editDescription || null }
: {}),
isPublic: editIsPublic,
} satisfies UpdatePlaylistRequest,
),
},
);
const updateJson = parseAPIResponse<RawPlaylist>(await updateRes.json());
const updatedPlaylist = updateJson.success
? deserializePlaylist(updateJson.data)
: null;
if (imageFile) {
const fd = new FormData();
fd.append("file", imageFile);
await authFetch(`${API_URL}/api/playlists/${playlistId}/image`, {
method: "POST",
body: fd,
});
}
setEditOpen(false);
if (updatedPlaylist) {
navigate(playlistUrl(updatedPlaylist), { replace: true });
} else {
fetchPlaylist();
}
} catch (err) {
setEditError(friendlyFetchError(err));
} finally {
setEditSaving(false);
}
const handleEditSaved = (updated: Playlist) => {
setEditOpen(false);
navigate(playlistUrl(updated), { replace: true });
};
const handleDelete = async () => {
@@ -636,125 +579,49 @@ export function PlaylistDetail() {
<div className="playlist-detail-header-top">
{editOpen
? (
<ImagePicker
src={imagePreview ??
(playlist.imageMime
? `${API_URL}/api/playlists/${playlist.id}/image`
: null)}
alt="Cover"
size={72}
onChange={(file) => {
setImageFile(file);
setImagePreview(URL.createObjectURL(file));
}}
<PlaylistEditForm
playlist={playlist}
onCancel={() => setEditOpen(false)}
onRequestDelete={() => setConfirmDelete(true)}
onSaved={handleEditSaved}
/>
)
: playlist.imageMime && (
<img
src={`${API_URL}/api/playlists/${playlist.id}/image`}
alt=""
className="playlist-detail-img"
/>
)}
<div className="playlist-detail-content">
{editOpen
? (
<div className="playlist-detail-title-row">
<CountedInput
className="playlist-edit-input"
value={editTitle}
onChange={(e) => setEditTitle(e.target.value)}
autoFocus
maxLength={VALIDATION.PLAYLIST_TITLE_MAX}
: (
<>
{playlist.imageMime && (
<img
src={`${API_URL}/api/playlists/${playlist.id}/image`}
alt=""
className="playlist-detail-img"
/>
<button
type="button"
className="btn-primary"
disabled={editSaving ||
editDescription.length >
VALIDATION.PLAYLIST_DESCRIPTION_MAX}
onClick={handleEditSave}
>
{editSaving ? <Trans>Saving</Trans> : <Trans>Save</Trans>}
</button>
<button
type="button"
className="form-cancel"
onClick={() => setEditOpen(false)}
>
<Trans>Cancel</Trans>
</button>
<button
type="button"
className="btn-danger"
onClick={() => setConfirmDelete(true)}
>
<Trans>Delete</Trans>
</button>
</div>
)
: (
<div className="playlist-detail-title-row">
<h1 className="playlist-detail-title">{playlist.title}</h1>
{!isOwner && (
<FollowPlaylistButton
targetPlaylistId={playlist.id}
isPublic={playlist.isPublic}
/>
)}
{isOwner && (
<button
type="button"
className="playlist-edit-btn"
onClick={openEdit}
>
<Trans>Edit</Trans>
</button>
)}
</div>
)}
{editOpen
? (
<TextEditor
className="playlist-edit-textarea"
value={editDescription}
onChange={setEditDescription}
placeholder={t`Description (optional)`}
autoResize
rows={1}
maxLength={VALIDATION.PLAYLIST_DESCRIPTION_MAX}
/>
)
: playlist.description && (
<Markdown className="playlist-detail-description">
{playlist.description}
</Markdown>
)}
<div className="playlist-detail-meta">
{editOpen
? (
<div className="visibility-toggle playlist-edit-toggle">
<button
type="button"
className={editIsPublic ? "active" : ""}
onClick={() => setEditIsPublic(true)}
>
<Trans>Public</Trans>
</button>
<button
type="button"
className={!editIsPublic ? "active" : ""}
onClick={() => setEditIsPublic(false)}
>
<Trans>Private</Trans>
</button>
)}
<div className="playlist-detail-content">
<div className="playlist-detail-title-row">
<h1 className="playlist-detail-title">{playlist.title}</h1>
{!isOwner && (
<FollowPlaylistButton
targetPlaylistId={playlist.id}
isPublic={playlist.isPublic}
/>
)}
{isOwner && (
<button
type="button"
className="playlist-edit-btn"
onClick={openEdit}
>
<Trans>Edit</Trans>
</button>
)}
</div>
)
: (
<>
{playlist.description && (
<Markdown className="playlist-detail-description">
{playlist.description}
</Markdown>
)}
<div className="playlist-detail-meta">
<span
className={`playlist-badge${
playlist.isPublic ? "" : " playlist-badge--private"
@@ -788,13 +655,10 @@ export function PlaylistDetail() {
</span>
</Tooltip>
)}
</>
)}
</div>
{editError && (
<ErrorCard title={t`Failed to save`} message={editError} />
</div>
</div>
</>
)}
</div>
</div>
</div>
@@ -887,3 +751,173 @@ export function PlaylistDetail() {
</PageShell>
);
}
interface PlaylistEditFormProps {
playlist: PlaylistWithDumps;
onCancel: () => void;
onRequestDelete: () => void;
onSaved: (playlist: Playlist) => void;
}
interface EditValues {
title: string;
description: string;
isPublic: boolean;
image: File | null;
}
function PlaylistEditForm(
{ playlist, onCancel, onRequestDelete, onSaved }: PlaylistEditFormProps,
) {
const { authFetch } = useAuth();
const [imagePreview, setImagePreview] = useState<string | null>(null);
const form = useApiForm<EditValues>({
defaultValues: {
title: playlist.title,
description: playlist.description ?? "",
isPublic: playlist.isPublic,
image: null,
},
});
const description = form.watch("description");
const onSubmit = form.submit(
async ({ title, description, isPublic, image }) => {
const updated = deserializePlaylist(
await expectOk<RawPlaylist>(
await authFetch(`${API_URL}/api/playlists/${playlist.id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(
{
...(title !== playlist.title ? { title } : {}),
...(description !== (playlist.description ?? "")
? { description: description || null }
: {}),
isPublic,
} satisfies UpdatePlaylistRequest,
),
}),
),
);
if (image) {
const fd = new FormData();
fd.append("file", image);
await authFetch(`${API_URL}/api/playlists/${playlist.id}/image`, {
method: "POST",
body: fd,
});
}
onSaved(updated);
},
);
return (
<FormProvider {...form}>
{/* `display: contents` keeps the image-beside-content flex layout while
still giving us a real <form> for submit + Enter handling. */}
<form style={{ display: "contents" }} onSubmit={onSubmit}>
<Controller
name="image"
control={form.control}
render={({ field }) => (
<ImagePicker
src={imagePreview ??
(playlist.imageMime
? `${API_URL}/api/playlists/${playlist.id}/image`
: null)}
alt="Cover"
size={72}
onChange={(file) => {
field.onChange(file);
setImagePreview(URL.createObjectURL(file));
}}
/>
)}
/>
<div className="playlist-detail-content playlist-edit-content">
<Controller
name="title"
control={form.control}
render={({ field }) => (
<CountedInput
className="playlist-edit-input"
value={field.value}
onChange={field.onChange}
autoFocus
placeholder={t`Playlist title`}
maxLength={VALIDATION.PLAYLIST_TITLE_MAX}
/>
)}
/>
<Controller
name="description"
control={form.control}
render={({ field }) => (
<TextEditor
className="playlist-edit-textarea"
value={field.value}
onChange={field.onChange}
placeholder={t`Description (optional)`}
autoResize
rows={2}
maxLength={VALIDATION.PLAYLIST_DESCRIPTION_MAX}
/>
)}
/>
<Controller
name="isPublic"
control={form.control}
render={({ field }) => (
<div className="visibility-toggle playlist-edit-toggle">
<button
type="button"
className={field.value ? "active" : ""}
onClick={() => field.onChange(true)}
>
<Trans>Public</Trans>
</button>
<button
type="button"
className={!field.value ? "active" : ""}
onClick={() => field.onChange(false)}
>
<Trans>Private</Trans>
</button>
</div>
)}
/>
<FormError title={t`Failed to save`} />
<div className="form-actions">
<button
type="button"
className="btn-danger"
onClick={onRequestDelete}
>
<Trans>Delete</Trans>
</button>
<div className="form-actions-right">
<button type="button" className="form-cancel" onClick={onCancel}>
<Trans>Cancel</Trans>
</button>
<SubmitButton
pendingLabel={<Trans>Saving</Trans>}
disabled={description.length >
VALIDATION.PLAYLIST_DESCRIPTION_MAX}
>
<Trans>Save</Trans>
</SubmitButton>
</div>
</div>
</div>
</form>
</FormProvider>
);
}