v1 feature: added playlists

This commit is contained in:
khannurien
2026-03-16 16:52:53 +00:00
parent 867e64cb5b
commit be426eb150
25 changed files with 2958 additions and 101 deletions

View File

@@ -0,0 +1,237 @@
import { useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { API_URL } from "../config/api.ts";
import { useAuth } from "../hooks/useAuth.ts";
import type {
CreatePlaylistRequest,
PlaylistMembership,
RawPlaylist,
RawPlaylistMembership,
} from "../model.ts";
import {
deserializePlaylist,
deserializePlaylistMembership,
} from "../model.ts";
interface AddToPlaylistModalProps {
dumpId: string;
onClose: () => void;
}
export function AddToPlaylistModal(
{ dumpId, onClose }: AddToPlaylistModalProps,
) {
const { authFetch } = useAuth();
const [memberships, setMemberships] = useState<PlaylistMembership[]>([]);
const [loading, setLoading] = useState(true);
const [showNewForm, setShowNewForm] = useState(false);
const [newTitle, setNewTitle] = useState("");
const [newDescription, setNewDescription] = useState("");
const [newIsPublic, setNewIsPublic] = useState(true);
const [creating, setCreating] = useState(false);
const backdropRef = useRef<HTMLDivElement>(null);
useEffect(() => {
document.body.style.overflow = "hidden";
return () => {
document.body.style.overflow = "";
};
}, []);
useEffect(() => {
const handler = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
document.addEventListener("keydown", handler);
return () => document.removeEventListener("keydown", handler);
}, [onClose]);
useEffect(() => {
authFetch(`${API_URL}/api/playlists/by-dump/${dumpId}/memberships`)
.then((r) => r.json())
.then((body) => {
if (body.success) {
setMemberships(
(body.data as RawPlaylistMembership[]).map(
deserializePlaylistMembership,
),
);
}
})
.catch(() => {})
.finally(() => setLoading(false));
}, [dumpId]);
const toggleMembership = async (membership: PlaylistMembership) => {
const { playlist, hasDump } = membership;
if (hasDump) {
await authFetch(
`${API_URL}/api/playlists/${playlist.id}/dumps/${dumpId}`,
{ method: "DELETE" },
);
setMemberships((prev) =>
prev.map((m) =>
m.playlist.id === playlist.id ? { ...m, hasDump: false } : m
)
);
} else {
await authFetch(
`${API_URL}/api/playlists/${playlist.id}/dumps/${dumpId}`,
{ method: "POST" },
);
setMemberships((prev) =>
prev.map((m) =>
m.playlist.id === playlist.id ? { ...m, hasDump: true } : m
)
);
}
};
const handleCreate = async (e: React.FormEvent) => {
e.preventDefault();
if (!newTitle.trim()) return;
setCreating(true);
try {
const req: CreatePlaylistRequest = {
title: newTitle.trim(),
description: newDescription.trim() || undefined,
isPublic: newIsPublic,
};
const res = await authFetch(`${API_URL}/api/playlists`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(req),
});
const body = await res.json();
if (!body.success) return;
const playlist = deserializePlaylist(body.data as RawPlaylist);
await authFetch(
`${API_URL}/api/playlists/${playlist.id}/dumps/${dumpId}`,
{
method: "POST",
},
);
setMemberships((prev) => [{ playlist, hasDump: true }, ...prev]);
setNewTitle("");
setNewDescription("");
setShowNewForm(false);
} finally {
setCreating(false);
}
};
return createPortal(
<div
className="modal-backdrop"
ref={backdropRef}
onClick={(e) => {
if (e.target === backdropRef.current) onClose();
}}
>
<div className="modal-card">
<div className="modal-header">
<span className="modal-title">Add to playlist</span>
<button
type="button"
className="modal-close-btn"
onClick={onClose}
aria-label="Close"
>
</button>
</div>
<div className="modal-body">
{loading
? <p className="page-loading">Loading</p>
: memberships.length === 0 && !showNewForm
? <p className="empty-state">No playlists yet.</p>
: (
<ul className="playlist-membership-list">
{memberships.map((m) => (
<li
key={m.playlist.id}
className={`playlist-membership-row${
m.hasDump ? " playlist-membership-row--active" : ""
}`}
onClick={() => toggleMembership(m)}
>
<span className="playlist-membership-check">
{m.hasDump ? "✓" : "○"}
</span>
<span className="playlist-membership-name">
{m.playlist.title}
</span>
{!m.playlist.isPublic && (
<span className="playlist-badge playlist-badge--private">
private
</span>
)}
</li>
))}
</ul>
)}
{showNewForm
? (
<form className="modal-new-playlist-form" onSubmit={handleCreate}>
<input
type="text"
placeholder="Title"
value={newTitle}
onChange={(e) => setNewTitle(e.target.value)}
autoFocus
required
/>
<textarea
placeholder="Description (optional)"
value={newDescription}
onChange={(e) => setNewDescription(e.target.value)}
rows={2}
/>
<div className="dump-mode-toggle">
<button
type="button"
className={newIsPublic ? "active" : ""}
onClick={() => setNewIsPublic(true)}
>
Public
</button>
<button
type="button"
className={!newIsPublic ? "active" : ""}
onClick={() => setNewIsPublic(false)}
>
Private
</button>
</div>
<div style={{ display: "flex", gap: "0.5rem" }}>
<button type="submit" disabled={creating}>
{creating ? "Creating…" : "Create & Add"}
</button>
<button
type="button"
onClick={() => setShowNewForm(false)}
>
Cancel
</button>
</div>
</form>
)
: (
<button
type="button"
className="modal-new-playlist-toggle"
onClick={() => setShowNewForm(true)}
>
+ New playlist
</button>
)}
</div>
</div>
</div>,
document.body,
);
}

View File

@@ -40,6 +40,9 @@ export function AppHeader({ centerSlot }: { centerSlot?: ReactNode }) {
>
{user.username}
</Link>
<Link to="/playlists" className="app-header-user">
Playlists
</Link>
<button
type="button"
className="btn-primary"

View File

@@ -0,0 +1,36 @@
import { useEffect } from "react";
import { createPortal } from "react-dom";
interface ConfirmModalProps {
message: string;
confirmLabel?: string;
onConfirm: () => void;
onCancel: () => void;
}
export function ConfirmModal(
{ message, confirmLabel = "Delete", onConfirm, onCancel }: ConfirmModalProps,
) {
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === "Escape") onCancel();
};
document.addEventListener("keydown", onKey);
return () => document.removeEventListener("keydown", onKey);
}, [onCancel]);
return createPortal(
<div className="modal-backdrop" onClick={onCancel}>
<div className="confirm-modal" onClick={(e) => e.stopPropagation()}>
<p className="confirm-modal-message">{message}</p>
<div className="confirm-modal-actions">
<button type="button" onClick={onCancel}>Cancel</button>
<button type="button" className="btn-danger" onClick={onConfirm}>
{confirmLabel}
</button>
</div>
</div>
</div>,
document.body,
);
}

View File

@@ -49,7 +49,7 @@ export default function FilePreview(
}
if (mime.startsWith("audio/")) {
return <MediaPlayer src={fileUrl} kind="audio" />;
return <MediaPlayer src={fileUrl} kind="audio" mime={mime} />;
}
if (mime === "application/pdf") {

View File

@@ -74,13 +74,11 @@ export function MediaPlayer({ src, kind, mime }: MediaPlayerProps) {
};
}, [dragging]);
// Stop any in-flight load on unmount.
// Stop playback on unmount; the browser aborts network requests when the element leaves the DOM.
useEffect(() => {
const a = mediaRef.current!;
return () => {
a.pause();
a.removeAttribute("src");
a.load();
};
}, []);

View File

@@ -0,0 +1,161 @@
import { useEffect, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { API_URL } from "../config/api.ts";
import { useAuth } from "../hooks/useAuth.ts";
import type { Playlist, RawPlaylist } from "../model.ts";
import { deserializePlaylist } from "../model.ts";
interface NewPlaylistFormProps {
onCreated: (playlist: Playlist) => void;
toggleLabel?: string;
toggleClassName?: string;
}
export function NewPlaylistForm(
{
onCreated,
toggleLabel = "+ New playlist",
toggleClassName = "new-playlist-toggle",
}: NewPlaylistFormProps,
) {
const { authFetch } = useAuth();
const [open, setOpen] = useState(false);
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 backdropRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!open) return;
document.body.style.overflow = "hidden";
return () => {
document.body.style.overflow = "";
};
}, [open]);
useEffect(() => {
if (!open) return;
const handler = (e: KeyboardEvent) => {
if (e.key === "Escape") close();
};
document.addEventListener("keydown", handler);
return () => document.removeEventListener("keydown", handler);
}, [open]);
const close = () => {
setOpen(false);
setTitle("");
setDescription("");
setIsPublic(true);
setError(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;
}
onCreated(deserializePlaylist(body.data as RawPlaylist));
close();
} catch {
setError("Failed to create playlist");
} finally {
setSubmitting(false);
}
};
return (
<>
<button
type="button"
className={toggleClassName}
onClick={() => setOpen(true)}
>
{toggleLabel}
</button>
{open && createPortal(
<div
className="modal-backdrop"
ref={backdropRef}
onClick={(e) => {
if (e.target === backdropRef.current) close();
}}
>
<div className="modal-card">
<div className="modal-header">
<span className="modal-title">New playlist</span>
<button
type="button"
className="modal-close-btn"
onClick={close}
aria-label="Close"
>
</button>
</div>
<div className="modal-body">
<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 style={{ display: "flex", gap: "0.5rem" }}>
<button type="submit" disabled={submitting}>
{submitting ? "Creating…" : "Create"}
</button>
<button type="button" onClick={close}>Cancel</button>
</div>
</form>
</div>
</div>
</div>,
document.body,
)}
</>
);
}

View File

@@ -0,0 +1,79 @@
import { Link, useNavigate } from "react-router";
import { API_URL } from "../config/api.ts";
import type { Playlist } from "../model.ts";
import { relativeTime } from "../utils/relativeTime.ts";
interface PlaylistCardProps {
playlist: Playlist;
onDelete?: () => void;
}
export function PlaylistCard({ playlist, onDelete }: PlaylistCardProps) {
const navigate = useNavigate();
return (
<li className="playlist-card">
<div
className="playlist-card-inner"
onClick={() => navigate(`/playlists/${playlist.id}`)}
>
<div className="playlist-card-preview">
{playlist.imageMime
? (
<img
src={`${API_URL}/api/playlists/${playlist.id}/image`}
alt=""
className="playlist-card-img"
/>
)
: <span className="playlist-card-icon">📋</span>}
</div>
<div className="playlist-card-body">
<Link
to={`/playlists/${playlist.id}`}
className="playlist-card-title"
onClick={(e) => e.stopPropagation()}
>
{playlist.title}
</Link>
{playlist.description && (
<p className="playlist-card-description">{playlist.description}</p>
)}
<div className="playlist-card-meta">
<span
className={`playlist-badge${
playlist.isPublic ? "" : " playlist-badge--private"
}`}
>
{playlist.isPublic ? "public" : "private"}
</span>
{playlist.dumpCount !== undefined && (
<span className="playlist-card-count">
{playlist.dumpCount}{" "}
{playlist.dumpCount === 1 ? "dump" : "dumps"}
</span>
)}
<time
dateTime={playlist.createdAt.toISOString()}
title={playlist.createdAt.toLocaleString()}
>
{relativeTime(playlist.createdAt)}
</time>
</div>
</div>
</div>
{onDelete && (
<button
type="button"
className="playlist-card-delete-btn"
onClick={(e) => {
e.stopPropagation();
onDelete();
}}
aria-label="Delete playlist"
>
</button>
)}
</li>
);
}