198 lines
6.7 KiB
TypeScript
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>
|
|
);
|
|
}
|