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