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

@@ -9,6 +9,9 @@ import { relativeTime } from "../utils/relativeTime.ts";
import { DumpCard } from "../components/DumpCard.tsx";
import { PageShell } from "../components/PageShell.tsx";
import { PageError } from "../components/PageError.tsx";
import { ConfirmModal } from "../components/ConfirmModal.tsx";
import { ImagePicker } from "../components/ImagePicker.tsx";
import { Markdown } from "../components/Markdown.tsx";
type LoadState =
| { status: "loading" }
@@ -48,12 +51,13 @@ export function PlaylistDetail() {
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);
const imageInputRef = useRef<HTMLInputElement>(null);
// prevActiveDumpIds: used by the WS effect to diff incoming dumpIds
const prevActiveDumpIdsRef = useRef<Set<string> | null>(null);
const descriptionRef = useRef<HTMLTextAreaElement>(null);
useEffect(() => () => {
cancels.current.forEach((c) => c());
@@ -205,20 +209,22 @@ export function PlaylistDetail() {
}
}
// Reorder active dumps in state to match the new dumpIds order
// Reorder active dumps to match the new server order,
// keeping fading dumps at their current visual positions.
setState((prev) => {
if (prev.status !== "loaded") return prev;
const dumpMap = new Map(prev.playlist.dumps.map((d) => [d.id, d]));
const reordered = ev.dumpIds!
const activeQueue = ev.dumpIds!
.filter((id) => dumpMap.has(id))
.map((id) => dumpMap.get(id)!);
// Keep fading dumps appended at the end so they stay visible
const fadingDumps = prev.playlist.dumps.filter(
(d) => !newIds.has(d.id) && dumpMap.has(d.id),
);
let qi = 0;
const result = prev.playlist.dumps
.filter((d) => dumpMap.has(d.id))
.map((d) => newIds.has(d.id) ? activeQueue[qi++] : d);
while (qi < activeQueue.length) result.push(activeQueue[qi++]);
return {
...prev,
playlist: { ...prev.playlist, dumps: [...reordered, ...fadingDumps] },
playlist: { ...prev.playlist, dumps: result },
};
});
@@ -332,6 +338,13 @@ export function PlaylistDetail() {
}).catch(() => {});
};
useEffect(() => {
const el = descriptionRef.current;
if (!el) return;
el.style.height = "auto";
el.style.height = `${el.scrollHeight}px`;
}, [editDescription, editOpen]);
const openEdit = () => {
if (state.status !== "loaded") return;
setEditTitle(state.playlist.title);
@@ -343,16 +356,8 @@ export function PlaylistDetail() {
setEditOpen(true);
};
const handleImageChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
setImageFile(file);
const url = URL.createObjectURL(file);
setImagePreview(url);
};
const handleEditSave = async (e: React.FormEvent) => {
e.preventDefault();
const handleEditSave = async () => {
if (!playlistId || state.status !== "loaded") return;
setEditSaving(true);
setEditError(null);
@@ -385,6 +390,12 @@ export function PlaylistDetail() {
}
};
const handleDelete = async () => {
if (!playlistId) return;
await authFetch(`${API_URL}/api/playlists/${playlistId}`, { method: "DELETE" });
navigate("/");
};
if (state.status === "loading") {
return (
<PageShell>
@@ -423,136 +434,141 @@ export function PlaylistDetail() {
<PageShell>
<div className="playlist-detail-header">
<div className="playlist-detail-header-top">
{playlist.imageMime && (
<img
src={`${API_URL}/api/playlists/${playlist.id}/image`}
alt=""
className="playlist-detail-img"
/>
)}
<div>
<h1 className="playlist-detail-title">{playlist.title}</h1>
{playlist.description && (
<p className="playlist-detail-description">
{playlist.description}
</p>
{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));
}}
/>
)
: playlist.imageMime && (
<img
src={`${API_URL}/api/playlists/${playlist.id}/image`}
alt=""
className="playlist-detail-img"
/>
)}
<div className="playlist-detail-meta">
<span
className={`playlist-badge${
playlist.isPublic ? "" : " playlist-badge--private"
}`}
>
{playlist.isPublic ? "public" : "private"}
</span>
<time
dateTime={playlist.createdAt.toISOString()}
title={playlist.createdAt.toLocaleString()}
>
{relativeTime(playlist.createdAt)}
</time>
</div>
</div>
{isOwner && !editOpen && (
<button
type="button"
className="playlist-edit-btn"
onClick={openEdit}
>
Edit
</button>
)}
</div>
{isOwner && editOpen && (
<form className="playlist-edit-form" onSubmit={handleEditSave}>
<div className="playlist-edit-fields">
<input
type="text"
className="playlist-edit-input"
value={editTitle}
onChange={(e) =>
setEditTitle(e.target.value)}
placeholder="Title"
required
/>
<textarea
className="playlist-edit-textarea"
value={editDescription}
onChange={(e) =>
setEditDescription(e.target.value)}
placeholder="Description (optional)"
rows={2}
/>
<div className="dump-mode-toggle playlist-edit-toggle">
<button
type="button"
className={editIsPublic ? "active" : ""}
onClick={() => setEditIsPublic(true)}
>
Public
</button>
<button
type="button"
className={!editIsPublic ? "active" : ""}
onClick={() => setEditIsPublic(false)}
>
Private
</button>
</div>
<div className="playlist-edit-image-row">
{imagePreview
? (
<img
src={imagePreview}
alt="Preview"
className="playlist-edit-img-preview"
/>
)
: playlist.imageMime && (
<img
src={`${API_URL}/api/playlists/${playlist.id}/image`}
alt="Current"
className="playlist-edit-img-preview"
/>
)}
<button
type="button"
className="btn-secondary"
onClick={() => imageInputRef.current?.click()}
>
{playlist.imageMime || imageFile
? "Change image"
: "Add image"}
</button>
<div className="playlist-detail-content">
{editOpen
? (
<input
ref={imageInputRef}
type="file"
accept="image/jpeg,image/png,image/gif,image/webp"
style={{ display: "none" }}
onChange={handleImageChange}
type="text"
className="playlist-edit-input"
value={editTitle}
onChange={(e) => setEditTitle(e.target.value)}
autoFocus
/>
</div>
)
: <h1 className="playlist-detail-title">{playlist.title}</h1>}
{editOpen
? (
<textarea
ref={descriptionRef}
className="playlist-edit-textarea"
value={editDescription}
onChange={(e) => setEditDescription(e.target.value)}
placeholder="Description (optional)"
rows={1}
/>
)
: playlist.description && (
<Markdown className="playlist-detail-description">
{playlist.description}
</Markdown>
)}
<div className="playlist-detail-meta">
{editOpen
? (
<div className="dump-mode-toggle playlist-edit-toggle">
<button
type="button"
className={editIsPublic ? "active" : ""}
onClick={() => setEditIsPublic(true)}
>
Public
</button>
<button
type="button"
className={!editIsPublic ? "active" : ""}
onClick={() => setEditIsPublic(false)}
>
Private
</button>
</div>
)
: (
<>
<span
className={`playlist-badge${
playlist.isPublic ? "" : " playlist-badge--private"
}`}
>
{playlist.isPublic ? "public" : "private"}
</span>
<time
dateTime={playlist.createdAt.toISOString()}
title={playlist.createdAt.toLocaleString()}
>
{relativeTime(playlist.createdAt)}
</time>
</>
)}
</div>
{editError && <p className="form-error">{editError}</p>}
<div className="playlist-edit-actions">
<button
type="submit"
className="btn-primary"
disabled={editSaving}
>
{editSaving ? "Saving…" : "Save"}
</button>
<button
type="button"
className="btn-secondary"
onClick={() => setEditOpen(false)}
>
Cancel
</button>
</div>
{isOwner && (
<div className="playlist-header-actions">
{editOpen
? (
<>
<button
type="button"
className="btn-primary"
disabled={editSaving}
onClick={handleEditSave}
>
{editSaving ? "Saving…" : "Save"}
</button>
<button
type="button"
className="btn-secondary"
onClick={() => setEditOpen(false)}
>
Cancel
</button>
<button
type="button"
className="btn-danger"
onClick={() => setConfirmDelete(true)}
>
Delete
</button>
</>
)
: (
<button
type="button"
className="playlist-edit-btn"
onClick={openEdit}
>
Edit
</button>
)}
</div>
</form>
)}
)}
</div>
</div>
{visibleDumps.length === 0
@@ -598,6 +614,7 @@ export function PlaylistDetail() {
castVote={castVote}
removeVote={removeVote}
className={cardCls}
isOwner={!!user && user.id === dump.userId}
/>
{isOwner && (isActive
? (
@@ -625,6 +642,14 @@ export function PlaylistDetail() {
})}
</div>
)}
{confirmDelete && (
<ConfirmModal
message="Delete this playlist? This cannot be undone."
confirmLabel="Delete playlist"
onConfirm={handleDelete}
onCancel={() => setConfirmDelete(false)}
/>
)}
</PageShell>
);
}