v1 feature: added playlists
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { Link, useLocation, useNavigate, useParams } from "react-router";
|
||||
import { AddToPlaylistModal } from "../components/AddToPlaylistModal.tsx";
|
||||
|
||||
import { API_URL } from "../config/api.ts";
|
||||
|
||||
@@ -31,6 +32,7 @@ export function Dump() {
|
||||
preloaded ? { status: "loaded", dump: preloaded } : { status: "loading" },
|
||||
);
|
||||
const [op, setOp] = useState<PublicUser | null>(null);
|
||||
const [playlistModalOpen, setPlaylistModalOpen] = useState(false);
|
||||
|
||||
const { user } = useAuth();
|
||||
const { voteCounts, myVotes, castVote, removeVote } = useWS();
|
||||
@@ -178,8 +180,23 @@ export function Dump() {
|
||||
<div className="dump-actions">
|
||||
{canEdit && <Link to={`/dumps/${dump.id}/edit`}>Edit</Link>}
|
||||
<Link to="/">← Back to all dumps</Link>
|
||||
{user && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn-add-playlist"
|
||||
onClick={() => setPlaylistModalOpen(true)}
|
||||
>
|
||||
+ Playlist
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{playlistModalOpen && (
|
||||
<AddToPlaylistModal
|
||||
dumpId={dump.id}
|
||||
onClose={() => setPlaylistModalOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import { useRequiredAuth } from "../hooks/useAuth.ts";
|
||||
import { formatBytes } from "../utils/format.ts";
|
||||
import { PageShell } from "../components/PageShell.tsx";
|
||||
import { PageError } from "../components/PageError.tsx";
|
||||
import { ConfirmModal } from "../components/ConfirmModal.tsx";
|
||||
import RichContentCard from "../components/RichContentCard.tsx";
|
||||
import FilePreview from "../components/FilePreview.tsx";
|
||||
|
||||
@@ -25,6 +26,7 @@ export function DumpEdit() {
|
||||
const [url, setUrl] = useState("");
|
||||
const [comment, setComment] = useState("");
|
||||
const [newFile, setNewFile] = useState<File | null>(null);
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedDump) return;
|
||||
@@ -229,7 +231,11 @@ export function DumpEdit() {
|
||||
</div>
|
||||
|
||||
<div className="form-actions">
|
||||
<button type="button" onClick={handleDelete} className="btn-danger">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setConfirmDelete(true)}
|
||||
className="btn-danger"
|
||||
>
|
||||
Delete dump
|
||||
</button>
|
||||
<div className="form-actions-right">
|
||||
@@ -241,6 +247,14 @@ export function DumpEdit() {
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{confirmDelete && (
|
||||
<ConfirmModal
|
||||
message="Delete this dump? This cannot be undone."
|
||||
confirmLabel="Delete dump"
|
||||
onConfirm={handleDelete}
|
||||
onCancel={() => setConfirmDelete(false)}
|
||||
/>
|
||||
)}
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
|
||||
149
src/pages/MyPlaylists.tsx
Normal file
149
src/pages/MyPlaylists.tsx
Normal file
@@ -0,0 +1,149 @@
|
||||
import { useEffect, 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";
|
||||
import { useWS } from "../hooks/useWS.ts";
|
||||
import { NewPlaylistForm } from "../components/NewPlaylistForm.tsx";
|
||||
import { ConfirmModal } from "../components/ConfirmModal.tsx";
|
||||
import { PlaylistCard } from "../components/PlaylistCard.tsx";
|
||||
import { PageShell } from "../components/PageShell.tsx";
|
||||
|
||||
type State =
|
||||
| { status: "loading" }
|
||||
| { status: "error"; error: string }
|
||||
| { status: "loaded"; playlists: Playlist[] };
|
||||
|
||||
export function MyPlaylists() {
|
||||
const { user, authFetch, token } = useAuth();
|
||||
const { lastPlaylistEvent, deletedPlaylistIds } = useWS();
|
||||
const [state, setState] = useState<State>({ status: "loading" });
|
||||
const [confirmDeleteId, setConfirmDeleteId] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
fetch(`${API_URL}/api/users/${user.username}/playlists`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
})
|
||||
.then((r) => r.json())
|
||||
.then((body) => {
|
||||
if (!body.success) throw new Error("Failed to load");
|
||||
setState({
|
||||
status: "loaded",
|
||||
playlists: (body.data as RawPlaylist[]).map(deserializePlaylist),
|
||||
});
|
||||
})
|
||||
.catch((err) =>
|
||||
setState({
|
||||
status: "error",
|
||||
error: err instanceof Error
|
||||
? err.message
|
||||
: "Failed to load playlists",
|
||||
})
|
||||
);
|
||||
}, [user?.username]);
|
||||
|
||||
// Real-time WS updates
|
||||
useEffect(() => {
|
||||
if (!lastPlaylistEvent || !user) return;
|
||||
const ev = lastPlaylistEvent;
|
||||
|
||||
if (ev.type === "created" && ev.playlist?.userId === user.id) {
|
||||
setState((s) => {
|
||||
if (s.status !== "loaded") return s;
|
||||
if (s.playlists.some((p) => p.id === ev.playlist!.id)) return s;
|
||||
return { ...s, playlists: [ev.playlist!, ...s.playlists] };
|
||||
});
|
||||
} else if (ev.type === "updated" && ev.playlist?.userId === user.id) {
|
||||
setState((s) =>
|
||||
s.status === "loaded"
|
||||
? {
|
||||
...s,
|
||||
playlists: s.playlists.map((p) =>
|
||||
p.id === ev.playlist!.id ? ev.playlist! : p
|
||||
),
|
||||
}
|
||||
: s
|
||||
);
|
||||
} else if (ev.type === "deleted") {
|
||||
setState((s) =>
|
||||
s.status === "loaded"
|
||||
? {
|
||||
...s,
|
||||
playlists: s.playlists.filter((p) => p.id !== ev.playlistId),
|
||||
}
|
||||
: s
|
||||
);
|
||||
}
|
||||
}, [lastPlaylistEvent, user]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!deletedPlaylistIds.size) return;
|
||||
setState((s) =>
|
||||
s.status === "loaded"
|
||||
? {
|
||||
...s,
|
||||
playlists: s.playlists.filter((p) => !deletedPlaylistIds.has(p.id)),
|
||||
}
|
||||
: s
|
||||
);
|
||||
}, [deletedPlaylistIds]);
|
||||
|
||||
const handleDelete = async (playlistId: string) => {
|
||||
await authFetch(`${API_URL}/api/playlists/${playlistId}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
setState((s) =>
|
||||
s.status === "loaded"
|
||||
? { ...s, playlists: s.playlists.filter((p) => p.id !== playlistId) }
|
||||
: s
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<PageShell>
|
||||
<div className="my-playlists-header">
|
||||
<h1 className="my-playlists-title">My Playlists</h1>
|
||||
<NewPlaylistForm
|
||||
toggleClassName="btn-primary"
|
||||
onCreated={(p) =>
|
||||
setState((s) => {
|
||||
if (s.status !== "loaded") return s;
|
||||
if (s.playlists.some((pl) => pl.id === p.id)) return s;
|
||||
return { ...s, playlists: [p, ...s.playlists] };
|
||||
})}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{state.status === "loading" && <p className="page-loading">Loading…</p>}
|
||||
{state.status === "error" && <p className="form-error">{state.error}</p>}
|
||||
{state.status === "loaded" && (
|
||||
state.playlists.length === 0
|
||||
? <p className="empty-state">No playlists yet. Create one!</p>
|
||||
: (
|
||||
<ul className="dump-feed">
|
||||
{state.playlists.map((p) => (
|
||||
<PlaylistCard
|
||||
key={p.id}
|
||||
playlist={p}
|
||||
onDelete={() => setConfirmDeleteId(p.id)}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
)
|
||||
)}
|
||||
|
||||
{confirmDeleteId && (
|
||||
<ConfirmModal
|
||||
message="Delete this playlist? This cannot be undone."
|
||||
confirmLabel="Delete playlist"
|
||||
onConfirm={() => {
|
||||
handleDelete(confirmDeleteId);
|
||||
setConfirmDeleteId(null);
|
||||
}}
|
||||
onCancel={() => setConfirmDeleteId(null)}
|
||||
/>
|
||||
)}
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
630
src/pages/PlaylistDetail.tsx
Normal file
630
src/pages/PlaylistDetail.tsx
Normal file
@@ -0,0 +1,630 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useNavigate, useParams } from "react-router";
|
||||
import { API_URL } from "../config/api.ts";
|
||||
import type { PlaylistWithDumps, RawPlaylistWithDumps } from "../model.ts";
|
||||
import { deserializePlaylistWithDumps } from "../model.ts";
|
||||
import { useAuth } from "../hooks/useAuth.ts";
|
||||
import { useWS } from "../hooks/useWS.ts";
|
||||
import { relativeTime } from "../utils/relativeTime.ts";
|
||||
import { DumpCard } from "../components/DumpCard.tsx";
|
||||
import { PageShell } from "../components/PageShell.tsx";
|
||||
import { PageError } from "../components/PageError.tsx";
|
||||
|
||||
type LoadState =
|
||||
| { status: "loading" }
|
||||
| { status: "error"; error: string }
|
||||
| { status: "loaded"; playlist: PlaylistWithDumps };
|
||||
|
||||
export function PlaylistDetail() {
|
||||
const { playlistId } = useParams<{ playlistId: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { user, authFetch, token } = useAuth();
|
||||
const {
|
||||
voteCounts,
|
||||
myVotes,
|
||||
castVote,
|
||||
removeVote,
|
||||
deletedDumpIds,
|
||||
lastPlaylistEvent,
|
||||
} = useWS();
|
||||
|
||||
const [state, setState] = useState<LoadState>({ status: "loading" });
|
||||
|
||||
// activeDumpIds: which dumps are currently in the playlist (the canonical set)
|
||||
const [activeDumpIds, setActiveDumpIds] = useState<Set<string>>(new Set());
|
||||
|
||||
// fading: dumps whose removal is being animated — same cooldown→dismissing pattern as upvoted list
|
||||
const [fading, setFading] = useState<
|
||||
Record<string, "cooldown" | "dismissing">
|
||||
>({});
|
||||
const cancels = useRef<Map<string, () => void>>(new Map());
|
||||
|
||||
const [dragSrcIndex, setDragSrcIndex] = useState<number | null>(null);
|
||||
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 [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);
|
||||
|
||||
useEffect(() => () => {
|
||||
cancels.current.forEach((c) => c());
|
||||
}, []);
|
||||
|
||||
const fetchPlaylist = () => {
|
||||
if (!playlistId) return;
|
||||
setState({ status: "loading" });
|
||||
fetch(`${API_URL}/api/playlists/${playlistId}`, {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
})
|
||||
.then((r) => {
|
||||
if (!r.ok) {
|
||||
throw new Error(
|
||||
r.status === 404 ? "Playlist not found" : `HTTP ${r.status}`,
|
||||
);
|
||||
}
|
||||
return r.json();
|
||||
})
|
||||
.then((body) => {
|
||||
if (!body.success) throw new Error("Failed to load playlist");
|
||||
const pl = deserializePlaylistWithDumps(
|
||||
body.data as RawPlaylistWithDumps,
|
||||
);
|
||||
setState({ status: "loaded", playlist: pl });
|
||||
const ids = new Set(pl.dumps.map((d) => d.id));
|
||||
setActiveDumpIds(ids);
|
||||
prevActiveDumpIdsRef.current = ids;
|
||||
setFading({});
|
||||
cancels.current.forEach((c) => c());
|
||||
cancels.current.clear();
|
||||
})
|
||||
.catch((err) => {
|
||||
setState({
|
||||
status: "error",
|
||||
error: err instanceof Error ? err.message : "Failed to load",
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchPlaylist();
|
||||
}, [playlistId]);
|
||||
|
||||
// Start the cooldown→dismissing→gone sequence for a dump being removed.
|
||||
// After the sequence completes, the dump is removed from state.playlist.dumps.
|
||||
function startFade(id: string) {
|
||||
if (cancels.current.has(id)) return; // already fading
|
||||
let dead = false;
|
||||
let kill = () => {};
|
||||
|
||||
kill = () => {
|
||||
dead = true;
|
||||
setFading((f) => {
|
||||
const n = { ...f };
|
||||
delete n[id];
|
||||
return n;
|
||||
});
|
||||
cancels.current.delete(id);
|
||||
};
|
||||
cancels.current.set(id, () => kill());
|
||||
setFading((f) => ({ ...f, [id]: "cooldown" }));
|
||||
|
||||
const t1 = setTimeout(() => {
|
||||
if (dead) return;
|
||||
setFading((f) => ({ ...f, [id]: "dismissing" }));
|
||||
|
||||
const t2 = setTimeout(() => {
|
||||
if (dead) return;
|
||||
// Remove from the playlist.dumps array now that animation is done
|
||||
setState((prev) => {
|
||||
if (prev.status !== "loaded") return prev;
|
||||
return {
|
||||
...prev,
|
||||
playlist: {
|
||||
...prev.playlist,
|
||||
dumps: prev.playlist.dumps.filter((d) => d.id !== id),
|
||||
},
|
||||
};
|
||||
});
|
||||
kill();
|
||||
}, 350);
|
||||
|
||||
kill = () => {
|
||||
dead = true;
|
||||
clearTimeout(t2);
|
||||
setFading((f) => {
|
||||
const n = { ...f };
|
||||
delete n[id];
|
||||
return n;
|
||||
});
|
||||
cancels.current.delete(id);
|
||||
};
|
||||
cancels.current.set(id, () => kill());
|
||||
}, 2000);
|
||||
|
||||
kill = () => {
|
||||
dead = true;
|
||||
clearTimeout(t1);
|
||||
setFading((f) => {
|
||||
const n = { ...f };
|
||||
delete n[id];
|
||||
return n;
|
||||
});
|
||||
cancels.current.delete(id);
|
||||
};
|
||||
cancels.current.set(id, () => kill());
|
||||
}
|
||||
|
||||
// WS: playlist metadata updated or deleted
|
||||
useEffect(() => {
|
||||
if (!lastPlaylistEvent || !playlistId) return;
|
||||
const ev = lastPlaylistEvent;
|
||||
if (ev.playlistId !== playlistId) return;
|
||||
|
||||
if (ev.type === "dumps_updated" && ev.dumpIds) {
|
||||
const newIds = new Set(ev.dumpIds);
|
||||
const prev = prevActiveDumpIdsRef.current ?? new Set<string>();
|
||||
|
||||
// Removed: were active, not in new set → fade out
|
||||
for (const id of prev) {
|
||||
if (!newIds.has(id)) {
|
||||
setActiveDumpIds((s) => {
|
||||
const n = new Set(s);
|
||||
n.delete(id);
|
||||
return n;
|
||||
});
|
||||
startFade(id);
|
||||
}
|
||||
}
|
||||
|
||||
// Re-added while fading → cancel fade, restore to active
|
||||
for (const id of newIds) {
|
||||
if (!prev.has(id)) {
|
||||
if (cancels.current.has(id)) {
|
||||
cancels.current.get(id)!();
|
||||
}
|
||||
// If this is a brand-new dump we haven't seen, re-fetch
|
||||
setState((s) => {
|
||||
if (s.status !== "loaded") return s;
|
||||
const known = s.playlist.dumps.some((d) => d.id === id);
|
||||
if (!known) {
|
||||
// Trigger a re-fetch asynchronously
|
||||
setTimeout(fetchPlaylist, 0);
|
||||
}
|
||||
return s;
|
||||
});
|
||||
setActiveDumpIds((s) => new Set([...s, id]));
|
||||
}
|
||||
}
|
||||
|
||||
// Reorder active dumps in state to match the new dumpIds order
|
||||
setState((prev) => {
|
||||
if (prev.status !== "loaded") return prev;
|
||||
const dumpMap = new Map(prev.playlist.dumps.map((d) => [d.id, d]));
|
||||
const reordered = 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),
|
||||
);
|
||||
return {
|
||||
...prev,
|
||||
playlist: { ...prev.playlist, dumps: [...reordered, ...fadingDumps] },
|
||||
};
|
||||
});
|
||||
|
||||
prevActiveDumpIdsRef.current = newIds;
|
||||
} else if (ev.type === "updated" && ev.playlist) {
|
||||
setState((prev) => {
|
||||
if (prev.status !== "loaded") return prev;
|
||||
return {
|
||||
...prev,
|
||||
playlist: {
|
||||
...prev.playlist,
|
||||
title: ev.playlist!.title,
|
||||
description: ev.playlist!.description,
|
||||
isPublic: ev.playlist!.isPublic,
|
||||
imageMime: ev.playlist!.imageMime,
|
||||
},
|
||||
};
|
||||
});
|
||||
} else if (ev.type === "deleted") {
|
||||
navigate("/");
|
||||
}
|
||||
}, [lastPlaylistEvent, playlistId]);
|
||||
|
||||
// Filter out globally deleted dumps (dump was deleted entirely, not just removed from playlist)
|
||||
useEffect(() => {
|
||||
if (deletedDumpIds.size === 0) return;
|
||||
setState((prev) => {
|
||||
if (prev.status !== "loaded") return prev;
|
||||
const filtered = prev.playlist.dumps.filter((d) =>
|
||||
!deletedDumpIds.has(d.id)
|
||||
);
|
||||
if (filtered.length === prev.playlist.dumps.length) return prev;
|
||||
return { ...prev, playlist: { ...prev.playlist, dumps: filtered } };
|
||||
});
|
||||
setActiveDumpIds((prev) => {
|
||||
const n = new Set(prev);
|
||||
for (const id of deletedDumpIds) n.delete(id);
|
||||
return n;
|
||||
});
|
||||
}, [deletedDumpIds]);
|
||||
|
||||
const handleDragStart = (index: number) => setDragSrcIndex(index);
|
||||
|
||||
const handleDragOver = (e: React.DragEvent, index: number) => {
|
||||
e.preventDefault();
|
||||
if (dragSrcIndex === null || dragSrcIndex === index) return;
|
||||
setState((prev) => {
|
||||
if (prev.status !== "loaded") return prev;
|
||||
// Only reorder among active dumps
|
||||
const activeDumps = prev.playlist.dumps.filter((d) =>
|
||||
activeDumpIds.has(d.id)
|
||||
);
|
||||
const fadingDumps = prev.playlist.dumps.filter((d) =>
|
||||
!activeDumpIds.has(d.id)
|
||||
);
|
||||
const reordered = [...activeDumps];
|
||||
const [moved] = reordered.splice(dragSrcIndex, 1);
|
||||
reordered.splice(index, 0, moved);
|
||||
setDragSrcIndex(index);
|
||||
setDragOverIndex(index);
|
||||
return {
|
||||
...prev,
|
||||
playlist: { ...prev.playlist, dumps: [...reordered, ...fadingDumps] },
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
const handleDragEnd = async () => {
|
||||
if (state.status !== "loaded" || !playlistId) return;
|
||||
setDragSrcIndex(null);
|
||||
setDragOverIndex(null);
|
||||
const activeDumps = state.playlist.dumps.filter((d) =>
|
||||
activeDumpIds.has(d.id)
|
||||
);
|
||||
try {
|
||||
await authFetch(`${API_URL}/api/playlists/${playlistId}/order`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ dumpIds: activeDumps.map((d) => d.id) }),
|
||||
});
|
||||
} catch {
|
||||
fetchPlaylist();
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveDump = (dumpId: string) => {
|
||||
if (!playlistId) return;
|
||||
// Fire-and-forget the API call; animate immediately
|
||||
authFetch(`${API_URL}/api/playlists/${playlistId}/dumps/${dumpId}`, {
|
||||
method: "DELETE",
|
||||
}).catch(() => {
|
||||
// On failure, cancel the fade and restore the item
|
||||
cancels.current.get(dumpId)?.();
|
||||
setActiveDumpIds((prev) => new Set([...prev, dumpId]));
|
||||
});
|
||||
setActiveDumpIds((prev) => {
|
||||
const n = new Set(prev);
|
||||
n.delete(dumpId);
|
||||
return n;
|
||||
});
|
||||
startFade(dumpId);
|
||||
};
|
||||
|
||||
const handleCancelRemove = (dumpId: string) => {
|
||||
if (!playlistId) return;
|
||||
cancels.current.get(dumpId)?.();
|
||||
setActiveDumpIds((prev) => new Set([...prev, dumpId]));
|
||||
// Re-add server-side since DELETE already fired
|
||||
authFetch(`${API_URL}/api/playlists/${playlistId}/dumps/${dumpId}`, {
|
||||
method: "POST",
|
||||
}).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 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();
|
||||
if (!playlistId || state.status !== "loaded") return;
|
||||
setEditSaving(true);
|
||||
setEditError(null);
|
||||
try {
|
||||
await authFetch(`${API_URL}/api/playlists/${playlistId}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
title: editTitle,
|
||||
description: editDescription || undefined,
|
||||
isPublic: editIsPublic,
|
||||
}),
|
||||
});
|
||||
|
||||
if (imageFile) {
|
||||
const fd = new FormData();
|
||||
fd.append("file", imageFile);
|
||||
await authFetch(`${API_URL}/api/playlists/${playlistId}/image`, {
|
||||
method: "POST",
|
||||
body: fd,
|
||||
});
|
||||
}
|
||||
|
||||
setEditOpen(false);
|
||||
fetchPlaylist();
|
||||
} catch (err) {
|
||||
setEditError(err instanceof Error ? err.message : "Save failed");
|
||||
} finally {
|
||||
setEditSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (state.status === "loading") {
|
||||
return (
|
||||
<PageShell>
|
||||
<p className="page-loading">Loading playlist…</p>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
|
||||
if (state.status === "error") {
|
||||
return (
|
||||
<PageError
|
||||
message={state.error}
|
||||
actions={
|
||||
<button
|
||||
className="logout-btn"
|
||||
type="button"
|
||||
onClick={() => navigate("/")}
|
||||
>
|
||||
← Back
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const { playlist } = state;
|
||||
const isOwner = !!user && user.id === playlist.userId;
|
||||
|
||||
// Active dumps in playlist order; fading dumps appended so they stay visible
|
||||
const activeDumps = playlist.dumps.filter((d) => activeDumpIds.has(d.id));
|
||||
const visibleDumps = playlist.dumps.filter((d) =>
|
||||
activeDumpIds.has(d.id) || d.id in fading
|
||||
);
|
||||
|
||||
return (
|
||||
<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>
|
||||
)}
|
||||
<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>
|
||||
<input
|
||||
ref={imageInputRef}
|
||||
type="file"
|
||||
accept="image/jpeg,image/png,image/gif,image/webp"
|
||||
style={{ display: "none" }}
|
||||
onChange={handleImageChange}
|
||||
/>
|
||||
</div>
|
||||
</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>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{visibleDumps.length === 0
|
||||
? <p className="empty-state">No dumps in this playlist yet.</p>
|
||||
: (
|
||||
<div className="playlist-dump-list">
|
||||
{visibleDumps.map((dump) => {
|
||||
const isActive = activeDumpIds.has(dump.id);
|
||||
const phase = fading[dump.id];
|
||||
// drag index is within the active-only list
|
||||
const activeIndex = isActive ? activeDumps.indexOf(dump) : -1;
|
||||
const cardCls = phase === "cooldown"
|
||||
? "dump-card--fading"
|
||||
: phase === "dismissing"
|
||||
? "dump-card--dismissing"
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={dump.id}
|
||||
className={`playlist-dump-item${
|
||||
activeIndex === dragOverIndex && isActive
|
||||
? " playlist-dump-item--drag-over"
|
||||
: ""
|
||||
}`}
|
||||
draggable={isOwner && isActive}
|
||||
onDragStart={isOwner && isActive
|
||||
? () => handleDragStart(activeIndex)
|
||||
: undefined}
|
||||
onDragOver={isOwner && isActive
|
||||
? (e) => handleDragOver(e, activeIndex)
|
||||
: undefined}
|
||||
onDragEnd={isOwner && isActive ? handleDragEnd : undefined}
|
||||
>
|
||||
{isOwner && isActive && (
|
||||
<span className="drag-handle" aria-hidden>⠿</span>
|
||||
)}
|
||||
<DumpCard
|
||||
dump={dump}
|
||||
voteCount={voteCounts[dump.id] ?? dump.voteCount}
|
||||
voted={myVotes.has(dump.id)}
|
||||
canVote={!!user}
|
||||
castVote={castVote}
|
||||
removeVote={removeVote}
|
||||
className={cardCls}
|
||||
/>
|
||||
{isOwner && (isActive
|
||||
? (
|
||||
<button
|
||||
type="button"
|
||||
className="playlist-remove-btn"
|
||||
onClick={() => handleRemoveDump(dump.id)}
|
||||
aria-label="Remove from playlist"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
)
|
||||
: phase === "cooldown" && (
|
||||
<button
|
||||
type="button"
|
||||
className="playlist-cancel-btn"
|
||||
onClick={() => handleCancelRemove(dump.id)}
|
||||
aria-label="Cancel removal"
|
||||
>
|
||||
Undo
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
@@ -12,21 +12,39 @@ import {
|
||||
} from "../model.ts";
|
||||
import { Avatar } from "../components/Avatar.tsx";
|
||||
import { DumpCard } from "../components/DumpCard.tsx";
|
||||
import { PlaylistCard } from "../components/PlaylistCard.tsx";
|
||||
import { NewPlaylistForm } from "../components/NewPlaylistForm.tsx";
|
||||
import { PageShell } from "../components/PageShell.tsx";
|
||||
import { PageError } from "../components/PageError.tsx";
|
||||
import { useAuth } from "../hooks/useAuth.ts";
|
||||
import { useWS } from "../hooks/useWS.ts";
|
||||
import type { Playlist, RawPlaylist } from "../model.ts";
|
||||
import { deserializePlaylist } from "../model.ts";
|
||||
|
||||
type ProfileState =
|
||||
| { status: "loading" }
|
||||
| { status: "error"; error: string }
|
||||
| { status: "loaded"; user: PublicUser; dumps: Dump[]; votes: Dump[] };
|
||||
| {
|
||||
status: "loaded";
|
||||
user: PublicUser;
|
||||
dumps: Dump[];
|
||||
votes: Dump[];
|
||||
playlists: Playlist[];
|
||||
};
|
||||
|
||||
export function UserPublicProfile() {
|
||||
const { username } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const { user: me, authFetch, login, logout } = useAuth();
|
||||
const { voteCounts, myVotes, lastVoteEvent, castVote, removeVote } = useWS();
|
||||
const { user: me, authFetch, login, logout, token } = useAuth();
|
||||
const {
|
||||
voteCounts,
|
||||
myVotes,
|
||||
lastVoteEvent,
|
||||
castVote,
|
||||
removeVote,
|
||||
lastPlaylistEvent,
|
||||
deletedPlaylistIds,
|
||||
} = useWS();
|
||||
|
||||
const [state, setState] = useState<ProfileState>({ status: "loading" });
|
||||
const [uploading, setUploading] = useState(false);
|
||||
@@ -45,10 +63,13 @@ export function UserPublicProfile() {
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
const [userRes, dumpsRes, votesRes] = await Promise.all([
|
||||
const [userRes, dumpsRes, votesRes, playlistsRes] = await Promise.all([
|
||||
fetch(`${API_URL}/api/users/${username}`),
|
||||
fetch(`${API_URL}/api/users/${username}/dumps`),
|
||||
fetch(`${API_URL}/api/users/${username}/votes`),
|
||||
fetch(`${API_URL}/api/users/${username}/playlists`, {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
}),
|
||||
]);
|
||||
|
||||
if (!userRes.ok) {
|
||||
@@ -59,20 +80,26 @@ export function UserPublicProfile() {
|
||||
);
|
||||
}
|
||||
|
||||
const [userBody, dumpsBody, votesBody] = await Promise.all([
|
||||
userRes.json(),
|
||||
dumpsRes.json(),
|
||||
votesRes.json(),
|
||||
]);
|
||||
const [userBody, dumpsBody, votesBody, playlistsBody] = await Promise
|
||||
.all([
|
||||
userRes.json(),
|
||||
dumpsRes.json(),
|
||||
votesRes.json(),
|
||||
playlistsRes.json(),
|
||||
]);
|
||||
|
||||
const votes: Dump[] = votesBody.success
|
||||
? votesBody.data.map(deserializeDump)
|
||||
: [];
|
||||
const playlists: Playlist[] = playlistsBody.success
|
||||
? (playlistsBody.data as RawPlaylist[]).map(deserializePlaylist)
|
||||
: [];
|
||||
setState({
|
||||
status: "loaded",
|
||||
user: deserializePublicUser(userBody.data),
|
||||
dumps: dumpsBody.success ? dumpsBody.data.map(deserializeDump) : [],
|
||||
votes,
|
||||
playlists,
|
||||
});
|
||||
setProfileVotedIds(new Set(votes.map((d: Dump) => d.id)));
|
||||
} catch (err) {
|
||||
@@ -148,6 +175,56 @@ export function UserPublicProfile() {
|
||||
}
|
||||
}, [lastVoteEvent, me, profileUserId]);
|
||||
|
||||
// Real-time playlist updates
|
||||
useEffect(() => {
|
||||
if (!lastPlaylistEvent || state.status !== "loaded") return;
|
||||
const profileUserId = state.user.id;
|
||||
const isOwnProfile = me?.id === profileUserId;
|
||||
const ev = lastPlaylistEvent;
|
||||
|
||||
if (
|
||||
ev.type === "created" && ev.playlist &&
|
||||
ev.playlist.userId === profileUserId
|
||||
) {
|
||||
if (ev.playlist.isPublic || isOwnProfile) {
|
||||
setState((s) => {
|
||||
if (s.status !== "loaded") return s;
|
||||
if (s.playlists.some((p) => p.id === ev.playlist!.id)) return s;
|
||||
return { ...s, playlists: [ev.playlist!, ...s.playlists] };
|
||||
});
|
||||
}
|
||||
} else if (
|
||||
ev.type === "updated" && ev.playlist &&
|
||||
ev.playlist.userId === profileUserId
|
||||
) {
|
||||
setState((s) => {
|
||||
if (s.status !== "loaded") return s;
|
||||
const updated = s.playlists.map((p) =>
|
||||
p.id === ev.playlist!.id ? ev.playlist! : p
|
||||
).filter((p) => p.isPublic || isOwnProfile);
|
||||
return { ...s, playlists: updated };
|
||||
});
|
||||
} else if (ev.type === "deleted") {
|
||||
setState((s) => {
|
||||
if (s.status !== "loaded") return s;
|
||||
return {
|
||||
...s,
|
||||
playlists: s.playlists.filter((p) => p.id !== ev.playlistId),
|
||||
};
|
||||
});
|
||||
}
|
||||
}, [lastPlaylistEvent, me]);
|
||||
|
||||
useEffect(() => {
|
||||
if (deletedPlaylistIds.size === 0 || state.status !== "loaded") return;
|
||||
setState((s) => {
|
||||
if (s.status !== "loaded") return s;
|
||||
const filtered = s.playlists.filter((p) => !deletedPlaylistIds.has(p.id));
|
||||
if (filtered.length === s.playlists.length) return s;
|
||||
return { ...s, playlists: filtered };
|
||||
});
|
||||
}, [deletedPlaylistIds]);
|
||||
|
||||
const handleAvatarUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file || state.status !== "loaded") return;
|
||||
@@ -230,7 +307,7 @@ export function UserPublicProfile() {
|
||||
);
|
||||
}
|
||||
|
||||
const { user: profileUser, dumps, votes } = state;
|
||||
const { user: profileUser, dumps, votes, playlists } = state;
|
||||
const isOwnProfile = me?.username === profileUser.username;
|
||||
|
||||
return (
|
||||
@@ -277,6 +354,7 @@ export function UserPublicProfile() {
|
||||
canVote={!!me}
|
||||
castVote={castVote}
|
||||
removeVote={removeVote}
|
||||
isOwnProfile={isOwnProfile}
|
||||
/>
|
||||
|
||||
<UpvotedDumpList
|
||||
@@ -290,6 +368,31 @@ export function UserPublicProfile() {
|
||||
removeVote={removeVote}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<section className="profile-section" id="playlists">
|
||||
<div className="profile-section-header">
|
||||
<h2 className="profile-section-title">
|
||||
Playlists ({playlists.length})
|
||||
</h2>
|
||||
{isOwnProfile && (
|
||||
<NewPlaylistForm
|
||||
onCreated={(p) =>
|
||||
setState((s) => {
|
||||
if (s.status !== "loaded") return s;
|
||||
if (s.playlists.some((pl) => pl.id === p.id)) return s;
|
||||
return { ...s, playlists: [p, ...s.playlists] };
|
||||
})}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{playlists.length === 0
|
||||
? <p className="empty-state">No playlists yet.</p>
|
||||
: (
|
||||
<ul className="dump-feed">
|
||||
{playlists.map((p) => <PlaylistCard key={p.id} playlist={p} />)}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
</PageShell>
|
||||
);
|
||||
}
|
||||
@@ -297,7 +400,16 @@ export function UserPublicProfile() {
|
||||
// ── Plain dump list (no dismiss behaviour) ──────────────────────────────────
|
||||
|
||||
function DumpList(
|
||||
{ title, dumps, voteCounts, myVotes, canVote, castVote, removeVote }: {
|
||||
{
|
||||
title,
|
||||
dumps,
|
||||
voteCounts,
|
||||
myVotes,
|
||||
canVote,
|
||||
castVote,
|
||||
removeVote,
|
||||
isOwnProfile,
|
||||
}: {
|
||||
title: string;
|
||||
dumps: Dump[];
|
||||
voteCounts: Record<string, number>;
|
||||
@@ -305,11 +417,24 @@ function DumpList(
|
||||
canVote: boolean;
|
||||
castVote: (id: string) => void;
|
||||
removeVote: (id: string) => void;
|
||||
isOwnProfile?: boolean;
|
||||
},
|
||||
) {
|
||||
const navigate = useNavigate();
|
||||
return (
|
||||
<section className="profile-section">
|
||||
<h2>{title}</h2>
|
||||
<div className="profile-section-header">
|
||||
<h2 className="profile-section-title">{title}</h2>
|
||||
{isOwnProfile && (
|
||||
<button
|
||||
type="button"
|
||||
className="new-playlist-toggle"
|
||||
onClick={() => navigate("/dumps/new")}
|
||||
>
|
||||
+ New dump
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{dumps.length === 0
|
||||
? <p className="empty-state">Nothing here yet.</p>
|
||||
: (
|
||||
@@ -446,7 +571,9 @@ function UpvotedDumpList(
|
||||
|
||||
return (
|
||||
<section className="profile-section">
|
||||
<h2>{title}</h2>
|
||||
<div className="profile-section-header">
|
||||
<h2 className="profile-section-title">{title}</h2>
|
||||
</div>
|
||||
{visibleDumps.length === 0
|
||||
? <p className="empty-state">Nothing here yet.</p>
|
||||
: (
|
||||
|
||||
Reference in New Issue
Block a user