Files
gerbeur/src/pages/Playlists.tsx
2026-10-10 09:14:55 +00:00

198 lines
6.7 KiB
TypeScript

/**
* 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 <NotFound />;
// 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 (
<Navigate to={loginPath(location.pathname + location.search)} replace />
);
}
return (
<PlaylistsList
key={`${categorySlug ?? ""}:${tab}`}
categorySlug={categorySlug}
categoryId={category?.id}
tab={tab}
/>
);
}
/**
* 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<Playlist>(
`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 (
<PageShell>
<PageHeader icon="📋" title={<Trans>Playlists</Trans>}>
<PlaylistSortTabs
tabs={user ? PLAYLISTS_TABS : PLAYLIST_SORTS}
sort={tab}
onChange={(next) =>
navigate(playlistsPath(categorySlug, next), { replace: true })}
leading={
<CategorySwitcher
kind="playlist"
categorySlug={categorySlug}
onSelect={(slug) => navigate(playlistsPath(slug, tab))}
/>
}
/>
</PageHeader>
<ListState
state={state}
isEmpty={({ data }) => data.items.length === 0}
empty={
<p className="empty-state">
<Trans>No playlists yet.</Trans>
</p>
}
>
{({ data }) => (
<>
<PlaylistCardList playlists={data.items} grid privateBadgeOnly />
<FeedFooter
hasMore={data.hasMore}
loadingMore={data.loadingMore}
sentinelRef={sentinelRef}
/>
</>
)}
</ListState>
</PageShell>
);
}