v1 feature: added playlists
This commit is contained in:
@@ -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