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

@@ -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>
: (