import { useCallback, useEffect, useLayoutEffect, useRef, useState, } from "react"; import { Link, useParams } from "react-router"; import { API_URL } from "../config/api.ts"; import { friendlyFetchError } from "../utils/apiError.ts"; import type { PaginatedData, Playlist, PublicUser, RawPlaylist, } from "../model.ts"; import { deserializePlaylist, deserializePublicUser } from "../model.ts"; import { useAuth } from "../hooks/useAuth.ts"; import { useWS } from "../hooks/useWS.ts"; import { useInfiniteScroll } from "../hooks/useInfiniteScroll.ts"; import { useFeedCache } from "../hooks/useFeedCache.ts"; import { Avatar } from "../components/Avatar.tsx"; import { PlaylistCard } from "../components/PlaylistCard.tsx"; import { NewPlaylistForm } from "../components/NewPlaylistForm.tsx"; import { ConfirmModal } from "../components/ConfirmModal.tsx"; import { PageShell } from "../components/PageShell.tsx"; import { PageError } from "../components/PageError.tsx"; const PAGE_SIZE = 20; const hydratePlaylist = (raw: Playlist): Playlist => deserializePlaylist(raw as unknown as RawPlaylist); interface PlaylistFeed { items: Playlist[]; hasMore: boolean; page: number; loadingMore: boolean; } type State = | { status: "loading" } | { status: "error"; error: string } | { status: "loaded"; profileUser: PublicUser; created: PlaylistFeed; followed: PlaylistFeed; }; function initialFeed(items: Playlist[], hasMore: boolean): PlaylistFeed { return { items, hasMore, page: 1, loadingMore: false }; } export function UserPlaylists() { const { username } = useParams(); const { user: me, authFetch, token } = useAuth(); const { lastPlaylistEvent, deletedPlaylistIds } = useWS(); const { cached: cachedCreated, saveState: saveCreated } = useFeedCache< Playlist >( `feed:user-playlists:${username ?? ""}`, hydratePlaylist, ); const { cached: cachedFollowed, saveState: saveFollowed } = useFeedCache< Playlist >( `feed:user-followed-playlists:${username ?? ""}`, hydratePlaylist, ); const [state, setState] = useState({ status: "loading" }); const [confirmDeleteId, setConfirmDeleteId] = useState(null); useEffect(() => { if (!username) return; setState({ status: "loading" }); const authHeaders: HeadersInit = token ? { Authorization: `Bearer ${token}` } : {}; if (cachedCreated && cachedFollowed) { fetch(`${API_URL}/api/users/${username}`) .then((r) => r.json()) .then((body) => { if (!body.success) throw new Error("User not found"); setState({ status: "loaded", profileUser: deserializePublicUser(body.data), created: { items: cachedCreated.items, hasMore: cachedCreated.hasMore, page: cachedCreated.page, loadingMore: false, }, followed: { items: cachedFollowed.items, hasMore: cachedFollowed.hasMore, page: cachedFollowed.page, loadingMore: false, }, }); }) .catch((err) => setState({ status: "error", error: friendlyFetchError(err), }) ); return; } Promise.all([ fetch(`${API_URL}/api/users/${username}`), fetch( `${API_URL}/api/users/${username}/playlists?page=1&limit=${PAGE_SIZE}`, { headers: authHeaders }, ), fetch( `${API_URL}/api/users/${username}/followed-playlists?page=1&limit=${PAGE_SIZE}`, ), ]) .then(([userRes, createdRes, followedRes]) => Promise.all([userRes.json(), createdRes.json(), followedRes.json()]) ) .then(([userBody, createdBody, followedBody]) => { if (!userBody.success) throw new Error("User not found"); const createdData = createdBody.success ? createdBody.data as PaginatedData : { items: [], hasMore: false }; const followedData = followedBody.success ? followedBody.data as PaginatedData : { items: [], hasMore: false }; setState({ status: "loaded", profileUser: deserializePublicUser(userBody.data), created: initialFeed( createdData.items.map(deserializePlaylist), createdData.hasMore, ), followed: initialFeed( followedData.items.map(deserializePlaylist), followedData.hasMore, ), }); }) .catch((err) => setState({ status: "error", error: friendlyFetchError(err), }) ); }, [username]); const loadMoreCreated = useCallback(() => { if ( state.status !== "loaded" || !state.created.hasMore || state.created.loadingMore || !username ) return; const nextPage = state.created.page + 1; setState((s) => s.status === "loaded" ? { ...s, created: { ...s.created, loadingMore: true } } : s ); fetch( `${API_URL}/api/users/${username}/playlists?page=${nextPage}&limit=${PAGE_SIZE}`, { headers: token ? { Authorization: `Bearer ${token}` } : {} }, ) .then((r) => r.json()) .then((body) => { const { items, hasMore } = body.data as PaginatedData; setState((s) => s.status === "loaded" ? { ...s, created: { items: [...s.created.items, ...items.map(deserializePlaylist)], hasMore, page: nextPage, loadingMore: false, }, } : s ); }) .catch(() => setState((s) => s.status === "loaded" ? { ...s, created: { ...s.created, loadingMore: false } } : s ) ); }, [state, username, token]); const loadMoreFollowed = useCallback(() => { if ( state.status !== "loaded" || !state.followed.hasMore || state.followed.loadingMore || !username ) return; const nextPage = state.followed.page + 1; setState((s) => s.status === "loaded" ? { ...s, followed: { ...s.followed, loadingMore: true } } : s ); fetch( `${API_URL}/api/users/${username}/followed-playlists?page=${nextPage}&limit=${PAGE_SIZE}`, ) .then((r) => r.json()) .then((body) => { const { items, hasMore } = body.data as PaginatedData; setState((s) => s.status === "loaded" ? { ...s, followed: { items: [...s.followed.items, ...items.map(deserializePlaylist)], hasMore, page: nextPage, loadingMore: false, }, } : s ); }) .catch(() => setState((s) => s.status === "loaded" ? { ...s, followed: { ...s.followed, loadingMore: false } } : s ) ); }, [state, username]); const createdSentinelRef = useInfiniteScroll( loadMoreCreated, state.status === "loaded" && state.created.hasMore && !state.created.loadingMore, ); const followedSentinelRef = useInfiniteScroll( loadMoreFollowed, state.status === "loaded" && state.followed.hasMore && !state.followed.loadingMore, ); // Real-time WS playlist updates useEffect(() => { if (!lastPlaylistEvent || state.status !== "loaded") return; const ev = lastPlaylistEvent; const isOwnProfile = me?.username === state.profileUser.username; if (ev.type === "created" && ev.playlist?.userId === state.profileUser.id) { if (ev.playlist.isPublic || isOwnProfile) { setState((s) => { if (s.status !== "loaded") return s; if (s.created.items.some((p) => p.id === ev.playlist!.id)) return s; return { ...s, created: { ...s.created, items: [ev.playlist!, ...s.created.items], }, }; }); } } else if (ev.type === "updated") { setState((s) => { if (s.status !== "loaded") return s; const updatedCreated = ev.playlist?.userId === state.profileUser.id ? s.created.items .map((p) => p.id === ev.playlist!.id ? ev.playlist! : p) .filter((p) => p.isPublic || isOwnProfile) : s.created.items; const updatedFollowed = s.followed.items.map((p) => p.id === ev.playlist?.id ? ev.playlist! : p ).filter((p) => p.isPublic); return { ...s, created: { ...s.created, items: updatedCreated }, followed: { ...s.followed, items: updatedFollowed }, }; }); } else if (ev.type === "deleted") { setState((s) => s.status !== "loaded" ? s : { ...s, created: { ...s.created, items: s.created.items.filter((p) => p.id !== ev.playlistId), }, followed: { ...s.followed, items: s.followed.items.filter((p) => p.id !== ev.playlistId), }, } ); } }, [lastPlaylistEvent, me]); useEffect(() => { if (!deletedPlaylistIds.size || state.status !== "loaded") return; setState((s) => s.status !== "loaded" ? s : { ...s, created: { ...s.created, items: s.created.items.filter((p) => !deletedPlaylistIds.has(p.id)), }, followed: { ...s.followed, items: s.followed.items.filter((p) => !deletedPlaylistIds.has(p.id)), }, } ); }, [deletedPlaylistIds]); // Scroll save useEffect(() => { if (state.status !== "loaded") return; let timer: ReturnType; const onScroll = () => { clearTimeout(timer); timer = setTimeout(() => { if (state.status !== "loaded") return; const y = globalThis.scrollY; saveCreated( state.created.items, state.created.page, state.created.hasMore, y, ); saveFollowed( state.followed.items, state.followed.page, state.followed.hasMore, y, ); }, 100); }; globalThis.addEventListener("scroll", onScroll, { passive: true }); return () => { globalThis.removeEventListener("scroll", onScroll); clearTimeout(timer); }; }, [state, saveCreated, saveFollowed]); const scrollRestored = useRef(false); useLayoutEffect(() => { if (cachedCreated?.scrollY == null || scrollRestored.current) return; if (state.status === "loaded") { globalThis.scrollTo(0, cachedCreated.scrollY); scrollRestored.current = true; } }, [state.status, cachedCreated]); const handleDelete = async (playlistId: string) => { await authFetch(`${API_URL}/api/playlists/${playlistId}`, { method: "DELETE", }); setState((s) => s.status === "loaded" ? { ...s, created: { ...s.created, items: s.created.items.filter((p) => p.id !== playlistId), }, } : s ); }; if (state.status === "loading") { return (

Loading…

); } if (state.status === "error") { return ( ← Back to profile } /> ); } const { profileUser, created, followed } = state; const isOwnProfile = me?.username === profileUser.username; return (
← {profileUser.username}

Playlists

{isOwnProfile && ( setState((s) => { if (s.status !== "loaded") return s; if (s.created.items.some((pl) => pl.id === p.id)) return s; return { ...s, created: { ...s.created, items: [p, ...s.created.items] }, }; })} /> )}

Created ({created.items.length} {created.hasMore ? "+" : ""})

{created.items.length === 0 ?

No playlists yet.

: (
    {created.items.map((p) => ( setConfirmDeleteId(p.id) : undefined} /> ))}
)}
{created.loadingMore && (

Loading more…

)}

Followed ({followed.items.length} {followed.hasMore ? "+" : ""})

{followed.items.length === 0 ?

No followed playlists yet.

: (
    {followed.items.map((p) => ( ))}
)}
{followed.loadingMore && (

Loading more…

)}
{confirmDeleteId && ( { handleDelete(confirmDeleteId); setConfirmDeleteId(null); }} onCancel={() => setConfirmDeleteId(null)} /> )}
); }