193 lines
6.1 KiB
TypeScript
193 lines
6.1 KiB
TypeScript
import { useCallback, useEffect, useState } from "react";
|
|
import { API_URL } from "../config/api.ts";
|
|
import type { Playlist, RawPlaylist } from "../model.ts";
|
|
import { deserializePlaylist, type PaginatedData } from "../model.ts";
|
|
import { useAuth } from "../hooks/useAuth.ts";
|
|
import { useWS } from "../hooks/useWS.ts";
|
|
import { useInfiniteScroll } from "../hooks/useInfiniteScroll.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";
|
|
|
|
const PAGE_SIZE = 20;
|
|
|
|
type State =
|
|
| { status: "loading" }
|
|
| { status: "error"; error: string }
|
|
| { status: "loaded"; playlists: Playlist[]; hasMore: boolean; page: number; loadingMore: boolean };
|
|
|
|
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?page=1&limit=${PAGE_SIZE}`, {
|
|
headers: { Authorization: `Bearer ${token}` },
|
|
})
|
|
.then((r) => r.json())
|
|
.then((body) => {
|
|
if (!body.success) throw new Error("Failed to load");
|
|
const { items, hasMore } = body.data as PaginatedData<RawPlaylist>;
|
|
setState({
|
|
status: "loaded",
|
|
playlists: items.map(deserializePlaylist),
|
|
hasMore,
|
|
page: 1,
|
|
loadingMore: false,
|
|
});
|
|
})
|
|
.catch((err) =>
|
|
setState({
|
|
status: "error",
|
|
error: err instanceof Error ? err.message : "Failed to load playlists",
|
|
})
|
|
);
|
|
}, [user?.username]);
|
|
|
|
const loadMore = useCallback(() => {
|
|
if (state.status !== "loaded" || !state.hasMore || state.loadingMore || !user) return;
|
|
const nextPage = state.page + 1;
|
|
setState((s) => s.status === "loaded" ? { ...s, loadingMore: true } : s);
|
|
fetch(
|
|
`${API_URL}/api/users/${user.username}/playlists?page=${nextPage}&limit=${PAGE_SIZE}`,
|
|
{ headers: { Authorization: `Bearer ${token}` } },
|
|
)
|
|
.then((r) => r.json())
|
|
.then((body) => {
|
|
const { items, hasMore } = body.data as PaginatedData<RawPlaylist>;
|
|
setState((s) =>
|
|
s.status === "loaded"
|
|
? {
|
|
...s,
|
|
playlists: [...s.playlists, ...items.map(deserializePlaylist)],
|
|
hasMore,
|
|
page: nextPage,
|
|
loadingMore: false,
|
|
}
|
|
: s
|
|
);
|
|
})
|
|
.catch(() =>
|
|
setState((s) => s.status === "loaded" ? { ...s, loadingMore: false } : s)
|
|
);
|
|
}, [state, user, token]);
|
|
|
|
const sentinelRef = useInfiniteScroll(
|
|
loadMore,
|
|
state.status === "loaded" && state.hasMore && !state.loadingMore,
|
|
);
|
|
|
|
// 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>
|
|
)
|
|
)}
|
|
|
|
<div ref={sentinelRef} />
|
|
{state.status === "loaded" && state.loadingMore && (
|
|
<p className="feed-loading-more">Loading more…</p>
|
|
)}
|
|
|
|
{confirmDeleteId && (
|
|
<ConfirmModal
|
|
message="Delete this playlist? This cannot be undone."
|
|
confirmLabel="Delete playlist"
|
|
onConfirm={() => {
|
|
handleDelete(confirmDeleteId);
|
|
setConfirmDeleteId(null);
|
|
}}
|
|
onCancel={() => setConfirmDeleteId(null)}
|
|
/>
|
|
)}
|
|
</PageShell>
|
|
);
|
|
}
|