v2: global player, infinite scroll, image picker, threaded comments

This commit is contained in:
khannurien
2026-03-21 13:55:22 +00:00
parent be426eb150
commit 7c098e7c4c
48 changed files with 4346 additions and 711 deletions

View File

@@ -0,0 +1,112 @@
import { useState } from "react";
import { API_URL } from "../config/api.ts";
import type { Playlist, RawPlaylist } from "../model.ts";
import { deserializePlaylist } from "../model.ts";
import { useAuth } from "../hooks/useAuth.ts";
interface PlaylistCreateFormProps {
/** If provided, the new playlist will have this dump added to it. */
dumpId?: string;
onCreated: (playlist: Playlist) => void;
onCancel: () => void;
}
export function PlaylistCreateForm(
{ dumpId, onCreated, onCancel }: PlaylistCreateFormProps,
) {
const { authFetch } = useAuth();
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [isPublic, setIsPublic] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!title.trim()) return;
setSubmitting(true);
setError(null);
try {
const res = await authFetch(`${API_URL}/api/playlists`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
title: title.trim(),
description: description.trim() || undefined,
isPublic,
}),
});
const body = await res.json();
if (!body.success) {
setError(body.error?.message ?? "Failed to create playlist");
return;
}
const playlist = deserializePlaylist(body.data as RawPlaylist);
if (dumpId) {
await authFetch(
`${API_URL}/api/playlists/${playlist.id}/dumps/${dumpId}`,
{ method: "POST" },
);
}
onCreated(playlist);
} catch {
setError("Failed to create playlist");
} finally {
setSubmitting(false);
}
};
return (
<form className="modal-new-playlist-form" onSubmit={handleSubmit}>
<input
type="text"
placeholder="Title"
value={title}
onChange={(e) => setTitle(e.target.value)}
autoFocus
required
/>
<textarea
placeholder="Description (optional)"
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={3}
/>
<div className="dump-mode-toggle">
<button
type="button"
className={isPublic ? "active" : ""}
onClick={() => setIsPublic(true)}
>
Public
</button>
<button
type="button"
className={!isPublic ? "active" : ""}
onClick={() => setIsPublic(false)}
>
Private
</button>
</div>
{error && <p className="form-error">{error}</p>}
<div className="form-actions">
<div className="form-actions-right">
<button
type="button"
className="btn-secondary"
onClick={onCancel}
>
Cancel
</button>
<button
type="submit"
className="btn-primary"
disabled={submitting}
>
{submitting ? "Creating…" : dumpId ? "Create & Add" : "Create"}
</button>
</div>
</div>
</form>
);
}