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

@@ -3115,16 +3115,39 @@ body.has-player .chat-fab {
--nav-icon-tint-strength: 100%;
}
/* Text links — visible only at wide viewports */
/* The text link to every playlist. A guest's header holds no other, so
theirs shows at every width. Signed in, the avatar's menu holds one too,
and this one shows where the row has room for it beside the icons: from
560px, where it fits with the "+ Dump" label in every style and language,
up to 860px, where the index page's tabs move into the header, and again
from 1150px, where the header holds both. */
.nav-links {
display: none;
display: flex;
align-items: center;
gap: 0.6rem;
}
@media (min-width: 1150px) {
.nav-links {
display: flex;
@media (max-width: 559px), (min-width: 860px) and (max-width: 1149px) {
.app-header:not(.app-header--guest) .nav-links {
display: none;
}
}
/* A guest's row is the link, the search and "Log in": on the narrowest
phones it gives up its margins rather than the link. */
@media (max-width: 400px) {
.app-header--guest {
gap: 0.5rem;
padding: 0 0.75rem;
}
.app-header--guest .app-header-nav {
gap: 0.4rem;
}
.app-header--guest .app-header-nav button,
.app-header--guest .app-header-user {
padding-inline: 0.6rem;
}
}

View File

@@ -23,7 +23,8 @@
* - `/community` and `/community/monthly`, the site's week and month
* (`Community`), signed in only.
* - `/playlists` is every public playlist, and `/playlists/:categorySlug` a
* playlist category's, in the order `?sort=` names (`Playlists`).
* playlist category's, in the order `?sort=` names, or with `?mine=1` the
* signed-in user's own (`Playlists`).
* - `/@:username/*` is a user's (`UserRoutes`): their profile and its tabs,
* their dumps at `dumps/:slug` (`Dump`) and `dumps/:slug/edit` (`DumpEdit`,
* signed in only), and their playlists at `playlists/:slug`

View File

@@ -63,9 +63,9 @@ export interface AppHeaderProps {
/**
* Renders the site's header. It holds the search, which stays open on the
* results page and slides open under the header on small screens, the link to
* every playlist, the signed-in user's menu and buttons or a guest's "Log in",
* and a banner while live updates are down. It also hosts the dump creation
* dialog that its "+ Dump" button opens.
* the playlists page, the signed-in user's menu and buttons or a guest's
* "Log in", and a banner while live updates are down. It also hosts the dump
* creation dialog that its "+ Dump" button opens.
*/
export function AppHeader(
{ centerSlot, disableNew, initialDumpUrl }: AppHeaderProps,
@@ -143,7 +143,11 @@ export function AppHeader(
return (
<>
<header className="app-header app-header--has-center">
<header
className={`app-header app-header--has-center${
user ? "" : " app-header--guest"
}`}
>
<Link to="/" className="app-header-brand">
{SITE_EMOJI}
<span className="app-header-brand-name">
@@ -160,9 +164,9 @@ export function AppHeader(
<nav className="app-header-nav">
{
/* The links lead the row, so the icons after them run unbroken to
the "+": every playlist on wide screens, for guests too, then
the avatar's menu, which leads to the user's profile and their
own playlists, at every width. */
the "+": the playlists page, at every width for a guest and
where the row has room for a signed-in user, then the avatar's
menu, which leads there too, at every width. */
}
<div className="nav-links">
<Link to={playlistsPath()} className="app-header-user">

View File

@@ -1,8 +1,8 @@
/**
* Renders the avatar menu that leads the header's icon row at every width: it
* holds the profile link, the user's own playlists (the header's text link
* leads to everyone's), the community page, the settings, the site's tools, and
* logging out.
* holds the profile link, the playlists page (the header's text link leads
* there too, at the widths that have room for it), the community page, the
* settings, the site's tools, and logging out.
*
* @module
*/
@@ -16,7 +16,11 @@ import { DIGEST_MESSAGES } from "../../../shared/digest-messages.ts";
import { Avatar } from "../shared/Avatar.tsx";
import type { SelfUser } from "../../../shared/user.ts";
import { can } from "../../utils/auth/permissions.ts";
import { communityPath, userPath } from "../../../shared/paths.ts";
import {
communityPath,
playlistsPath,
userPath,
} from "../../../shared/paths.ts";
import { useAuth } from "../../hooks/auth/useAuth.ts";
/** Passed to {@link UserMenu} by `AppHeader`, for a signed-in user. */
@@ -26,7 +30,7 @@ export interface UserMenuProps {
/**
* Renders the signed-in user's avatar, which opens a menu with links to their
* profile, their playlists, `/community` and `/settings`, to `/manage` for
* profile, `/playlists`, `/community` and `/settings`, to `/manage` for
* those who hold `category:manage` or `federation:manage`, and a Log out
* button. A click outside or Escape closes it.
*/
@@ -81,12 +85,12 @@ export function UserMenu({ user }: UserMenuProps): React.JSX.Element {
@{user.username}
</Link>
<Link
to={userPath(user.username, "playlists")}
to={playlistsPath()}
className="user-menu-item"
role="menuitem"
onClick={() => setOpen(false)}
>
<Trans>My playlists</Trans>
<Trans>Playlists</Trans>
</Link>
<Link
to={communityPath()}

View File

@@ -1,39 +1,46 @@
/**
* Renders the tabs that put a list of playlists in another order, on the
* playlists page and on a profile's playlists tab.
* playlists page and on a profile's playlists tab, and the playlists page's
* tab of the viewer's own.
*
* @module
*/
import type { ReactNode } from "react";
import { Trans } from "@lingui/react/macro";
import { PLAYLIST_SORTS, type PlaylistSort } from "../../../shared/playlist.ts";
import type { PlaylistsTab } from "../../../shared/paths.ts";
import { TabBar } from "../shared/TabBar.tsx";
/**
* Passed to {@link PlaylistSortTabs} by `Playlists` and
* `ProfilePlaylistsTab`, which keep the order in their URL's `?sort=`.
* Passed to {@link PlaylistSortTabs} by `ProfilePlaylistsTab`, whose tabs
* are `PLAYLIST_SORTS`, and by `Playlists`, whose `PLAYLISTS_TABS` add the
* viewer's own playlists for a signed-in user. Both keep the active one in
* their URL's query.
*/
export interface PlaylistSortTabsProps {
sort: PlaylistSort;
onChange: (sort: PlaylistSort) => void;
export interface PlaylistSortTabsProps<T extends PlaylistsTab> {
/** The tabs offered, in order. */
tabs: readonly T[];
/** The active tab. */
sort: T;
onChange: (sort: T) => void;
/** Goes before the tabs, as the playlists page's category menu does. */
leading?: ReactNode;
}
const LABELS: Record<PlaylistSort, ReactNode> = {
const LABELS: Record<PlaylistsTab, ReactNode> = {
updated: <Trans>Updated</Trans>,
new: <Trans>New</Trans>,
dumps: <Trans>Most dumps</Trans>,
mine: <Trans>Mine</Trans>,
};
/** Renders one tab per order of `PLAYLIST_SORTS`, `sort` being the active one. */
export function PlaylistSortTabs(
{ sort, onChange, leading }: PlaylistSortTabsProps,
/** Renders one tab per entry of `tabs`, `sort` being the active one. */
export function PlaylistSortTabs<T extends PlaylistsTab>(
{ tabs, sort, onChange, leading }: PlaylistSortTabsProps<T>,
): React.JSX.Element {
return (
<TabBar
tabs={PLAYLIST_SORTS.map((key) => ({ key, label: LABELS[key] }))}
tabs={tabs.map((key) => ({ key, label: LABELS[key] }))}
activeTab={sort}
onChange={onChange}
leading={leading}

View File

@@ -14,6 +14,7 @@ import { userApiUrl } from "../../utils/shared/urls.ts";
import {
parsePlaylistSort,
type Playlist,
PLAYLIST_SORTS,
type PlaylistSort,
} from "../../../shared/playlist.ts";
import { playlistSortQuery } from "../../../shared/paths.ts";
@@ -162,6 +163,7 @@ function PlaylistList(
actions={
<div className="profile-section-actions">
<PlaylistSortTabs
tabs={PLAYLIST_SORTS}
sort={sort}
// Another order of the same list, as a tab is: it takes the
// current history entry's place.

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))}
/>
}
/>

View File

@@ -22,13 +22,10 @@ describe("UserMenu", () => {
"href",
"/@ann",
);
// One playlists link, the user's own: the header's leads to everyone's.
expect(screen.getAllByRole("menuitem", { name: /playlists/i }))
.toHaveLength(
1,
);
expect(screen.getByRole("menuitem", { name: "My playlists" }))
.toHaveAttribute("href", "/@ann/playlists");
// One playlists link, to the page of everyone's, whose last tab is the
// user's own: the header's text link is hidden where it has no room.
expect(screen.getByRole("menuitem", { name: "Playlists" }))
.toHaveAttribute("href", "/playlists");
expect(screen.getByRole("menuitem", { name: "Community" })).toHaveAttribute(
"href",
"/community",

View File

@@ -1,9 +1,17 @@
import { describe, expect, test } from "vitest";
import { Route } from "react-router";
import { screen } from "@testing-library/react";
import { userEvent } from "@testing-library/user-event";
import { Playlists } from "../../pages/Playlists.tsx";
import { calls, route } from "../fetch.ts";
import { makeCategory, makePlaylist, page, wire } from "../fixtures.ts";
import {
makeCategory,
makePlaylist,
makeSession,
page,
wire,
} from "../fixtures.ts";
import type { Session } from "../../utils/auth/storage.ts";
import { currentPath, renderApp } from "../render.tsx";
import { FakeWebSocket } from "../ws.ts";
@@ -15,12 +23,14 @@ const mixes = makeCategory({
});
const music = makeCategory({ id: "dc1", slug: "music", name: "Music" });
function open(url: string) {
function open(url: string, session?: Session) {
const user = userEvent.setup();
renderApp(<Playlists />, {
route: url,
path: "/playlists/:categorySlug?",
categories: [music, mixes],
session,
extraRoutes: <Route path="/login" element={null} />,
});
return { user, socket: FakeWebSocket.last };
}
@@ -140,6 +150,77 @@ describe("Playlists", () => {
expect(screen.getByText("2 dumps")).toBeInTheDocument();
});
test("a signed-in user's last tab lists their own, private ones too", async () => {
const session = makeSession({ id: "ann-id", username: "ann" });
route(
"GET /api/playlists",
() => page([makePlaylist({ title: "Theirs" })]),
);
route(
"GET /api/users/:u/playlists",
() =>
page([
makePlaylist({ title: "Hidden", userId: "ann-id", isPublic: false }),
]),
);
const { user, socket } = open("/playlists/mixes?sort=dumps", session);
expect(await screen.findByText("Theirs")).toBeInTheDocument();
// Their own come in one order: the tab takes the order's place.
await user.click(screen.getByRole("button", { name: "Mine" }));
expect(currentPath()).toBe("/playlists/mixes?mine=1");
expect(await screen.findByText("Hidden")).toBeInTheDocument();
expect(screen.queryByText("Theirs")).toBeNull();
const asked = calls("GET /api/users/:u/playlists").at(-1)!.url;
expect(asked.pathname).toBe("/api/users/ann-id/playlists");
expect([asked.searchParams.get("category"), asked.searchParams.get("sort")])
.toEqual(["mixes", null]);
// The socket brings their new ones, private or not, and no one else's.
socket.welcome();
for (
const playlist of [
makePlaylist({
title: "Mine too",
userId: "ann-id",
isPublic: false,
categoryIds: ["pc1"],
}),
makePlaylist({
title: "Bob's",
userId: "bob-id",
categoryIds: ["pc1"],
}),
]
) {
socket.receive({ type: "playlist_created", playlist: wire(playlist) });
}
expect(screen.getByText("Mine too")).toBeInTheDocument();
expect(screen.queryByText("Bob's")).toBeNull();
// Picking a category keeps the tab, an order leaves it.
await user.click(screen.getByRole("button", { name: "Mixes" }));
await user.click(screen.getByRole("option", { name: "All" }));
expect(currentPath()).toBe("/playlists?mine=1");
await user.click(screen.getByRole("button", { name: "New" }));
expect(currentPath()).toBe("/playlists?sort=new");
});
test("a guest has no such tab", async () => {
route(
"GET /api/playlists",
() => page([makePlaylist({ title: "Theirs" })]),
);
open("/playlists");
expect(await screen.findByText("Theirs")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Mine" })).toBeNull();
});
test("a guest sent to their own playlists signs in first", () => {
open("/playlists?mine=1");
expect(currentPath()).toBe("/login?next=%2Fplaylists%3Fmine%3D1");
});
test("a slug that names no playlist category is not found", async () => {
route("GET /api/playlists", () => page([]));
open("/playlists/music");