/** * Renders the page that lists all of a user's playlists, the ones they made and * the ones they follow. * * @module */ import { useCallback, useEffect, useState } from "react"; import { Link, useParams } from "react-router"; import { t } from "@lingui/core/macro"; import { Trans } from "@lingui/react/macro"; import { API_URL } from "../config/api.ts"; import { expectOk, friendlyFetchError } from "../utils/shared/api.ts"; import type { Playlist } from "../../shared/playlist.ts"; import type { PublicUser } from "../../shared/user.ts"; import { deserializePlaylist, hydratePlaylist, } from "../utils/shared/deserialize.ts"; import { useAuth } from "../hooks/auth/useAuth.ts"; import { useDocumentTitle } from "../hooks/shared/useDocumentTitle.ts"; import { useWS } from "../hooks/ws/useWS.ts"; import { usePlaylistListSync } from "../hooks/playlist/usePlaylistListSync.ts"; import { usePositionAwareSync } from "../hooks/feed/usePositionAwareSync.ts"; import { useInfiniteScroll } from "../hooks/feed/useInfiniteScroll.ts"; import { useFeedCache } from "../hooks/feed/useFeedCache.ts"; import { useScrollRestore, useScrollSave, } from "../hooks/feed/useScrollSave.ts"; import { canLoadMore, mapItems, type PagedList, pageUrl, readFirstPage, readPage, restoredList, withFirst, } from "../utils/feed/pagedList.ts"; import { useLoadMore } from "../hooks/feed/usePagedList.ts"; import { fetchPublicUser } from "../utils/shared/fetchPublicUser.ts"; import { PlaylistCardList } from "../components/playlist/PlaylistCardList.tsx"; import { FeedFooter } from "../components/feed/FeedFooter.tsx"; import { ProfileSection } from "../components/profile/ProfileSection.tsx"; import { NewPlaylistButton } from "../components/playlist/NewPlaylistButton.tsx"; import { ProfileSubpageHeader } from "../components/profile/ProfileSubpageHeader.tsx"; import { ConfirmModal } from "../components/shared/ConfirmModal.tsx"; import { PageShell } from "../components/layout/PageShell.tsx"; import { PageError } from "../components/layout/PageError.tsx"; import { ErrorCard } from "../components/shared/ErrorCard.tsx"; type State = | { status: "loading" } | { status: "error"; error: string } | { status: "loaded"; profileUser: PublicUser; created: PagedList; followed: PagedList; }; /** * Renders `/users/:username/playlists`, for anyone. It lists the playlists the * user made (the private ones only to them) and the public ones they follow, * from `GET /api/users/:username/playlists` and `…/followed-playlists`, a page * at a time, and the socket keeps both lists live. The user can make and * delete playlists here. Going back to the page restores both lists and the * scroll position from sessionStorage. */ export function UserPlaylists(): React.JSX.Element { const { username } = useParams(); const { user: me, authFetch } = useAuth(); useDocumentTitle(username ?? null); const { lastPlaylistEvent } = 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 [prevUsername, setPrevUsername] = useState(username); if (prevUsername !== username) { setPrevUsername(username); setState({ status: "loading" }); } const [confirmDeleteId, setConfirmDeleteId] = useState(null); const profileUserId = state.status === "loaded" ? state.profileUser.id : null; const isOwnProfile = me?.id === profileUserId; type SetList = ( fn: (list: PagedList) => PagedList, ) => void; const setCreatedList = useCallback( (fn) => setState((s) => s.status !== "loaded" ? s : { ...s, created: fn(s.created) } ), [], ); const setFollowedList = useCallback( (fn) => setState((s) => s.status !== "loaded" ? s : { ...s, followed: fn(s.followed) } ), [], ); const setCreated = useCallback( (fn: (prev: Playlist[]) => Playlist[]) => setCreatedList(mapItems(fn)), [setCreatedList], ); const setFollowed = useCallback( (fn: (prev: Playlist[]) => Playlist[]) => setFollowedList(mapItems(fn)), [setFollowedList], ); const createdItems = state.status === "loaded" ? state.created.items : []; const lastPlaylistItem = lastPlaylistEvent?.type === "updated" ? (lastPlaylistEvent.playlist ?? null) : null; // Before usePlaylistListSync: it saves a playlist's place before the sync // takes it out, and puts it back there when it turns public again. usePositionAwareSync( createdItems, setCreated, lastPlaylistItem, (p) => p.isPublic && p.userId === profileUserId, ); usePlaylistListSync(setCreated, { isOwner: isOwnProfile, ownerId: profileUserId ?? undefined, }); usePlaylistListSync(setFollowed, { noNewEntries: true }); const createdUrl = `${API_URL}/api/users/${username}/playlists`; const followedUrl = `${API_URL}/api/users/${username}/followed-playlists`; const fetchPage = (url: string) => (page: number) => authFetch(pageUrl(url, page)) .then((res) => readPage(res, deserializePlaylist)); useEffect(() => { if (!username) return; const controller = new AbortController(); const { signal } = controller; const firstPageOf = (url: string) => authFetch(pageUrl(url, 1), { signal }) .then((res) => readFirstPage(res, deserializePlaylist)); // Back on the page, the lists come from the cache and only the user is // fetched again. const cached = cachedCreated && cachedFollowed; Promise.all([ fetchPublicUser(username, signal), cached ? restoredList(cachedCreated) : firstPageOf(createdUrl), cached ? restoredList(cachedFollowed) : firstPageOf(followedUrl), ]) .then(([profileUser, created, followed]) => setState({ status: "loaded", profileUser, created, followed }) ) .catch((err) => { if (signal.aborted) return; setState({ status: "error", error: friendlyFetchError(err) }); }); return () => controller.abort(); }, [ username, cachedCreated, cachedFollowed, authFetch, createdUrl, followedUrl, ]); const createdList = state.status === "loaded" ? state.created : null; const followedList = state.status === "loaded" ? state.followed : null; const createdSentinelRef = useInfiniteScroll( useLoadMore(createdList, setCreatedList, fetchPage(createdUrl)), canLoadMore(createdList), ); const followedSentinelRef = useInfiniteScroll( useLoadMore(followedList, setFollowedList, fetchPage(followedUrl)), canLoadMore(followedList), ); useScrollSave( state.status === "loaded", useCallback((y) => { if (state.status !== "loaded") return; saveCreated(state.created, y); saveFollowed(state.followed, y); }, [state, saveCreated, saveFollowed]), ); useScrollRestore(cachedCreated?.scrollY, state.status === "loaded"); const [deleteError, setDeleteError] = useState(null); const handleDelete = async (playlistId: string) => { setDeleteError(null); try { const res = await authFetch(`${API_URL}/api/playlists/${playlistId}`, { method: "DELETE", }); if (!res.ok) await expectOk(res); // throws the server's message } catch (err) { setDeleteError(friendlyFetchError(err)); return; } setCreated((items) => items.filter((p) => p.id !== playlistId)); }; if (state.status === "loading") { return (

Loading…

); } if (state.status === "error") { return ( ← Back to profile } /> ); } const { profileUser, created, followed } = state; return ( setCreated((items) => withFirst(items, p))} /> )} /> {deleteError && ( )} Created ({created.items.length} {created.hasMore ? "+" : ""}) } > {created.items.length === 0 ? (

No playlists yet.

) : ( <> setConfirmDeleteId(p.id) : undefined} /> )}
Followed ({followed.items.length} {followed.hasMore ? "+" : ""}) } > {followed.items.length === 0 ? (

No followed playlists yet.

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