/** * Renders every public playlist, `/playlists`, and those of one playlist * category, `/playlists/:categorySlug`, in the order `?sort=` names, or with * `?mine=1` the signed-in user's own. The header's link and the avatar * menu's lead here. * * @module */ import { useCallback } from "react"; import { Navigate, useLocation, useNavigate, useParams, useSearchParams, } from "react-router"; import { t } from "@lingui/core/macro"; import { Trans } from "@lingui/react/macro"; import { API_URL } from "../config/api.ts"; import { type Playlist, PLAYLIST_SORTS } from "../../shared/playlist.ts"; import { parsePlaylistsTab, PLAYLISTS_TABS, playlistSortQuery, playlistsPath, type PlaylistsTab, } from "../../shared/paths.ts"; import { loginPath } from "../utils/auth/next.ts"; import { userApiUrl } from "../utils/shared/urls.ts"; import { canLoadMore, pageUrl, readPage } from "../utils/feed/pagedList.ts"; import { deserializePlaylist, hydratePlaylist, } from "../utils/shared/deserialize.ts"; import { useAuth } from "../hooks/auth/useAuth.ts"; import { useCategories } from "../hooks/category/useCategories.ts"; import { useCategoryFilter } from "../hooks/category/useCategoryFilter.ts"; import { useDocumentTitle } from "../hooks/shared/useDocumentTitle.ts"; import { useFeedCache } from "../hooks/feed/useFeedCache.ts"; import { usePagedList } from "../hooks/feed/usePagedList.ts"; import { useInfiniteScroll } from "../hooks/feed/useInfiniteScroll.ts"; import { useScrollRestore, useScrollSave, } from "../hooks/feed/useScrollSave.ts"; import { usePlaylistListSync } from "../hooks/playlist/usePlaylistListSync.ts"; import { useOnReconnect } from "../hooks/ws/useWS.ts"; import { PageShell } from "../components/layout/PageShell.tsx"; import { PageHeader } from "../components/layout/PageHeader.tsx"; import { CategorySwitcher } from "../components/category/CategorySwitcher.tsx"; import { PlaylistCardList } from "../components/playlist/PlaylistCardList.tsx"; import { PlaylistSortTabs } from "../components/playlist/PlaylistSortTabs.tsx"; import { FeedFooter } from "../components/feed/FeedFooter.tsx"; import { ListState } from "../components/shared/ListState.tsx"; import { NotFound } from "./NotFound.tsx"; /** * Renders the list for the route's category, or every public playlist * without one, on the tab the URL's query names. Each category's list on * each tab is another, which the page mounts afresh. A slug that names no * playlist category, once the categories have loaded, is a page not found. */ export function Playlists(): React.JSX.Element { const { categorySlug } = useParams(); const [params] = useSearchParams(); const location = useLocation(); const { user } = useAuth(); const tab = parsePlaylistsTab(params); const { categories, isLoaded } = useCategories("playlist"); const category = categorySlug ? categories.find((c) => c.slug === categorySlug) : undefined; useDocumentTitle(category ? category.name : t`Playlists`); if (categorySlug && isLoaded && !category) return ; // A guest isn't offered the tab of their own playlists, so only a link // leads one there, or signing out on it. if (tab === "mine" && !user) { return ( ); } return ( ); } /** * Renders the public playlists, from `GET /api/playlists`, as a grid of * covers, a page at a time as the list scrolls; with `categorySlug`, only * those in that playlist category, which the menu before the tabs switches, * keeping the tab, while a tab changes the order in place of the current * history entry, as a feed's tabs do. A signed-in user's last tab lists * their own instead, the private ones too, from * `GET /api/users/:userId/playlists` in the default order. The socket keeps * them live, a reconnection fetches the pages loaded again, and going back * restores the list and the scroll. */ function PlaylistsList( { categorySlug, categoryId, tab }: { categorySlug?: string; categoryId?: string; tab: PlaylistsTab; }, ) { const navigate = useNavigate(); const { user, authFetch } = useAuth(); // `Playlists` shows the tab to no guest. const owner = tab === "mine" && user ? user : undefined; const sort = tab === "mine" ? PLAYLIST_SORTS[0] : tab; const { cached, saveState } = useFeedCache( `feed:playlists:${categorySlug ?? "all"}:${tab}`, hydratePlaylist, ); const url = `${ owner ? userApiUrl(owner, "playlists") : `${API_URL}/api/playlists` }${ playlistSortQuery( sort, new URLSearchParams(categorySlug ? { category: categorySlug } : {}), ) }`; const { state, setItems: setAllItems, loadMore, resync } = usePagedList< Playlist >( `${url}:${user?.id ?? "guest"}`, useCallback( (page: number, signal?: AbortSignal) => authFetch(pageUrl(url, page), { signal }) .then((res) => readPage(res, deserializePlaylist)), [authFetch, url], ), cached, ); const loaded = state.status === "loaded" ? state.data : null; usePlaylistListSync( loaded?.items ?? [], useCategoryFilter(setAllItems, categoryId), { sort, isOwner: !!owner, ownerId: owner?.id }, ); useOnReconnect(resync); const sentinelRef = useInfiniteScroll(loadMore, canLoadMore(loaded)); useScrollSave( !!loaded, useCallback((y) => { if (loaded) saveState(loaded, y); }, [loaded, saveState]), ); useScrollRestore(cached?.scrollY, !!loaded); return ( Playlists}> navigate(playlistsPath(categorySlug, next), { replace: true })} leading={ navigate(playlistsPath(slug, tab))} /> } /> data.items.length === 0} empty={

No playlists yet.

} > {({ data }) => ( <> )}
); }