v4: the playlists page is reachable at every width, and lists the user's own — below 1150px the header's text link was hidden and nothing replaced it: the avatar menu's "My playlists" led to the user's own, and a guest has no menu. The menu's entry is now "Playlists", to /playlists, at every width, and the page gains a tab after its orders, "Mine", for a signed-in user: their own playlists, private ones too, from GET /api/users/:id/playlists in the default order, narrowed by the category menu and kept live as the others are. It is /playlists?mine=1 (PLAYLISTS_TABS, parsePlaylistsTab, shared/paths.ts), a tab taking the order's place; a guest isn't offered it, and its link sends one to sign in. PlaylistSortTabs takes the tabs it shows, a profile's being the orders alone. The text link shows to a guest at every width, their header giving up its margins under 400px rather than the link, and to a signed-in user where the row has room beside the icons, as measured in every style and both languages: from 560px up to 860px, where the index page's tabs move into the header, and from 1150px as before. A guest in French in the kanagawa style still overflows the header under 340px, by up to 19px.
All checks were successful
Build and Publish Docker Image / build-and-push (push) Successful in 51s
Build and Deploy Static Documentation Site / build-and-push (push) Successful in 26s

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
khannurien
2026-10-10 09:14:55 +00:00
parent 561cdf98b0
commit de223baaa4
16 changed files with 312 additions and 130 deletions

View File

@@ -1,21 +1,33 @@
/**
* Renders every public playlist, `/playlists`, and those of one playlist
* category, `/playlists/:categorySlug`, in the order `?sort=` names.
* 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 { useNavigate, useParams, useSearchParams } from "react-router";
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 {
parsePlaylistSort,
type Playlist,
type PlaylistSort,
} from "../../shared/playlist.ts";
import { playlistSortQuery, playlistsPath } from "../../shared/paths.ts";
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,
@@ -45,14 +57,16 @@ import { NotFound } from "./NotFound.tsx";
/**
* Renders the list for the route's category, or every public playlist
* without one, in the order of the URL's `?sort=`. Each category's list in
* each order is another, which the page mounts afresh. A slug that names no
* 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 sort = parsePlaylistSort(params.get("sort"));
const location = useLocation();
const { user } = useAuth();
const tab = parsePlaylistsTab(params);
const { categories, isLoaded } = useCategories("playlist");
const category = categorySlug
? categories.find((c) => c.slug === categorySlug)
@@ -60,12 +74,19 @@ export function Playlists(): React.JSX.Element {
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 ?? ""}:${sort}`}
key={`${categorySlug ?? ""}:${tab}`}
categorySlug={categorySlug}
categoryId={category?.id}
sort={sort}
tab={tab}
/>
);
}
@@ -74,25 +95,32 @@ export function Playlists(): React.JSX.Element {
* 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 order, while a tab changes the order in place of the current
* history entry, as a feed's tabs do. The socket keeps them live, a
* reconnection fetches the pages loaded again, and going back restores the
* list and the scroll.
* 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, sort }: {
{ categorySlug, categoryId, tab }: {
categorySlug?: string;
categoryId?: string;
sort: PlaylistSort;
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"}:${sort}`,
`feed:playlists:${categorySlug ?? "all"}:${tab}`,
hydratePlaylist,
);
const url = `${API_URL}/api/playlists${
const url = `${
owner ? userApiUrl(owner, "playlists") : `${API_URL}/api/playlists`
}${
playlistSortQuery(
sort,
new URLSearchParams(categorySlug ? { category: categorySlug } : {}),
@@ -114,7 +142,7 @@ function PlaylistsList(
usePlaylistListSync(
loaded?.items ?? [],
useCategoryFilter(setAllItems, categoryId),
{ sort },
{ sort, isOwner: !!owner, ownerId: owner?.id },
);
useOnReconnect(resync);
@@ -131,14 +159,15 @@ function PlaylistsList(
<PageShell>
<PageHeader icon="📋" title={<Trans>Playlists</Trans>}>
<PlaylistSortTabs
sort={sort}
onChange={(s) =>
navigate(playlistsPath(categorySlug, s), { replace: true })}
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, sort))}
onSelect={(slug) => navigate(playlistsPath(slug, tab))}
/>
}
/>