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

9
API.md
View File

@@ -16,9 +16,10 @@ the page is the category's own, a dump category's feed at the root (`/music`)
and a playlist category's under `/playlists` (`/playlists/mixes`); where it
narrows a list on another page, a profile's or a search's, it is `?category=`,
of the kind that list holds. The order of a list of playlists is `?sort=`, the
default order being the bare path. Switching tabs, a tab's views or a list's
order takes the place of the current history entry; picking a category, or
opening another page or an editor, adds one.
default order being the bare path, and the playlists page's list of the viewer's
own is `?mine=1`. Switching tabs, a tab's views or a list's order takes the
place of the current history entry; picking a category, or opening another page
or an editor, adds one.
**API.** A resource is `/api/<plural>/<key>[/<noun>]`. Its key is its id, except
a user's, which may also be `@name`. The `@` keeps the two apart, so no regex
@@ -41,7 +42,7 @@ has one word, the same as in pages.
| `/@<name>/dumps/<slug>/edit` | | its poster, `dump:moderate` | `DumpEdit` |
| `/@<name>/playlists/<slug>` | | anyone; a private playlist, its owner | `PlaylistDetail` |
| `/@<name>/playlists/<slug>/edit` | | its owner, `playlist:moderate` | `PlaylistDetail` |
| `/playlists` | `/playlists/<category>`, a playlist category's; `?sort=new`, `?sort=dumps` order either | anyone | `Playlists` |
| `/playlists` | `/playlists/<category>`, a playlist category's; `?sort=new`, `?sort=dumps` order either; `?mine=1` lists the viewer's own, signed in only | anyone | `Playlists` |
| `/search?q=` | `/search/users?q=`, `/search/playlists?q=`; `?category=` narrows the dumps or the playlists | anyone | `Search` |
| `/notifications` | | signed in | `Notifications` |
| `/community` | `/community/monthly`; the bare path is the week | signed in | `Community` |

File diff suppressed because one or more lines are too long

View File

@@ -450,7 +450,7 @@ msgstr "Cancel removal"
msgid "Cancel reply"
msgstr "Cancel reply"
#: src/components/layout/AppHeader.tsx:177
#: src/components/layout/AppHeader.tsx:181
msgid "Cancel search"
msgstr "Cancel search"
@@ -579,7 +579,7 @@ msgstr "Could not change password"
msgid "Could not connect to the server. Check your connection and try again."
msgstr "Could not connect to the server. Check your connection and try again."
#: src/components/profile/ProfilePlaylistsTab.tsx:192
#: src/components/profile/ProfilePlaylistsTab.tsx:194
#: src/pages/DumpEdit.tsx:555
msgid "Could not delete"
msgstr "Could not delete"
@@ -681,7 +681,7 @@ msgstr "Delete failed ({0})"
#: src/components/playlist/PlaylistCard.tsx:162
#: src/components/playlist/PlaylistEditForm.tsx:213
#: src/components/profile/ProfilePlaylistsTab.tsx:222
#: src/components/profile/ProfilePlaylistsTab.tsx:224
#: src/pages/PlaylistDetail.tsx:172
msgid "Delete playlist"
msgstr "Delete playlist"
@@ -698,7 +698,7 @@ msgstr "Delete this dump? This cannot be undone."
msgid "Delete this message?"
msgstr "Delete this message?"
#: src/components/profile/ProfilePlaylistsTab.tsx:221
#: src/components/profile/ProfilePlaylistsTab.tsx:223
#: src/pages/PlaylistDetail.tsx:171
msgid "Delete this playlist? This cannot be undone."
msgstr "Delete this playlist? This cannot be undone."
@@ -753,7 +753,7 @@ msgstr "Drop a file here, or paste one"
msgid "Drop a replacement here"
msgstr "Drop a replacement here"
#: src/components/layout/AppHeader.tsx:196
#: src/components/layout/AppHeader.tsx:200
msgid "Dump"
msgstr "Dump"
@@ -1114,7 +1114,7 @@ msgstr "Link or file"
msgid "Live updates are temporarily disconnected. Trying to reconnect…"
msgstr "Live updates are temporarily disconnected. Trying to reconnect…"
#: src/components/layout/AppHeader.tsx:229
#: src/components/layout/AppHeader.tsx:233
msgid "Live updates unavailable."
msgstr "Live updates unavailable."
@@ -1159,7 +1159,7 @@ msgstr "Loading profile…"
msgid "Loading…"
msgstr "Loading…"
#: src/components/layout/AppHeader.tsx:203
#: src/components/layout/AppHeader.tsx:207
#: src/pages/FederationCallback.tsx:32
#: src/pages/UserLogin.tsx:41
#: src/pages/UserLogin.tsx:66
@@ -1180,7 +1180,7 @@ msgstr "Log in to vote"
msgid "Log in with {0}"
msgstr "Log in with {0}"
#: src/components/layout/UserMenu.tsx:128
#: src/components/layout/UserMenu.tsx:132
#: src/pages/UserPublicProfile.tsx:137
msgid "Log out"
msgstr "Log out"
@@ -1198,7 +1198,7 @@ msgstr "Login failed"
msgid "Lowercase letters, numbers and single hyphens only"
msgstr "Lowercase letters, numbers and single hyphens only"
#: src/components/layout/UserMenu.tsx:115
#: src/components/layout/UserMenu.tsx:119
#: src/pages/Manage.tsx:32
#: src/pages/Manage.tsx:40
#: src/pages/UserPublicProfile.tsx:170
@@ -1209,6 +1209,10 @@ msgstr "Manage"
msgid "Max {limit}"
msgstr "Max {limit}"
#: src/components/playlist/PlaylistSortTabs.tsx:34
msgid "Mine"
msgstr "Mine"
#: src/components/profile/RoleSwitcher.tsx:23
msgid "Moderator"
msgstr "Moderator"
@@ -1218,7 +1222,7 @@ msgstr "Moderator"
msgid "digest.monthly"
msgstr "Monthly recap"
#: src/components/playlist/PlaylistSortTabs.tsx:27
#: src/components/playlist/PlaylistSortTabs.tsx:33
msgid "Most dumps"
msgstr "Most dumps"
@@ -1226,10 +1230,6 @@ msgstr "Most dumps"
msgid "Mute"
msgstr "Mute"
#: src/components/layout/UserMenu.tsx:89
msgid "My playlists"
msgstr "My playlists"
#: src/components/category/CategoryManager.tsx:208
#: src/components/category/CategoryManager.tsx:209
msgid "Name"
@@ -1237,7 +1237,7 @@ msgstr "Name"
#: src/components/chat/ChatModal.tsx:692
#: src/components/feed/FeedTabBar.tsx:58
#: src/components/playlist/PlaylistSortTabs.tsx:26
#: src/components/playlist/PlaylistSortTabs.tsx:32
#: src/pages/Settings.tsx:55
msgid "New"
msgstr "New"
@@ -1315,8 +1315,8 @@ msgid "No playlists match \"{q}\"."
msgstr "No playlists match \"{q}\"."
#: src/components/playlist/PlaylistMembershipPanel.tsx:51
#: src/components/profile/ProfilePlaylistsTab.tsx:199
#: src/pages/Playlists.tsx:151
#: src/components/profile/ProfilePlaylistsTab.tsx:201
#: src/pages/Playlists.tsx:180
msgid "No playlists yet."
msgstr "No playlists yet."
@@ -1477,11 +1477,12 @@ msgid "Playlist categories"
msgstr "Playlist categories"
#: src/components/dump/create/StepIndicator.tsx:21
#: src/components/layout/AppHeader.tsx:169
#: src/components/layout/AppHeader.tsx:173
#: src/components/layout/UserMenu.tsx:93
#: src/components/profile/ProfileFollowTab.tsx:147
#: src/components/profile/ProfilePlaylistsTab.tsx:161
#: src/pages/Playlists.tsx:60
#: src/pages/Playlists.tsx:132
#: src/components/profile/ProfilePlaylistsTab.tsx:162
#: src/pages/Playlists.tsx:74
#: src/pages/Playlists.tsx:160
#: src/pages/Search.tsx:214
#: src/pages/UserPublicProfile.tsx:166
msgid "Playlists"
@@ -1670,7 +1671,7 @@ msgstr "Save"
msgid "Saving…"
msgstr "Saving…"
#: src/components/layout/AppHeader.tsx:177
#: src/components/layout/AppHeader.tsx:181
#: src/components/layout/SearchBar.tsx:83
#: src/pages/Search.tsx:106
msgid "Search"
@@ -1718,7 +1719,7 @@ msgstr "Send reset link"
msgid "Sending…"
msgstr "Sending…"
#: src/components/layout/AppHeader.tsx:193
#: src/components/layout/AppHeader.tsx:197
msgid "Server unreachable"
msgstr "Server unreachable"
@@ -1732,7 +1733,7 @@ msgstr "Set new password"
msgid "email.settings"
msgstr "Settings"
#: src/components/layout/UserMenu.tsx:105
#: src/components/layout/UserMenu.tsx:109
#: src/pages/Settings.tsx:75
#: src/pages/Settings.tsx:88
msgid "Settings"
@@ -1980,7 +1981,7 @@ msgstr "Unsubscribe"
msgid "Unsubscribed"
msgstr "Unsubscribed"
#: src/components/playlist/PlaylistSortTabs.tsx:25
#: src/components/playlist/PlaylistSortTabs.tsx:31
msgid "Updated"
msgstr "Updated"
@@ -2044,7 +2045,7 @@ msgstr "User"
msgid "User management"
msgstr "User management"
#: src/components/layout/UserMenu.tsx:64
#: src/components/layout/UserMenu.tsx:68
msgid "User menu"
msgstr "User menu"

File diff suppressed because one or more lines are too long

View File

@@ -450,7 +450,7 @@ msgstr "Annuler la suppression"
msgid "Cancel reply"
msgstr "Annuler la réponse"
#: src/components/layout/AppHeader.tsx:177
#: src/components/layout/AppHeader.tsx:181
msgid "Cancel search"
msgstr "Annuler la recherche"
@@ -579,7 +579,7 @@ msgstr "Impossible de changer le mot de passe"
msgid "Could not connect to the server. Check your connection and try again."
msgstr "Impossible de joindre le serveur. Vérifiez votre connexion et réessayez."
#: src/components/profile/ProfilePlaylistsTab.tsx:192
#: src/components/profile/ProfilePlaylistsTab.tsx:194
#: src/pages/DumpEdit.tsx:555
msgid "Could not delete"
msgstr "Suppression impossible"
@@ -681,7 +681,7 @@ msgstr "Échec de la suppression ({0})"
#: src/components/playlist/PlaylistCard.tsx:162
#: src/components/playlist/PlaylistEditForm.tsx:213
#: src/components/profile/ProfilePlaylistsTab.tsx:222
#: src/components/profile/ProfilePlaylistsTab.tsx:224
#: src/pages/PlaylistDetail.tsx:172
msgid "Delete playlist"
msgstr "Supprimer la collection"
@@ -698,7 +698,7 @@ msgstr "Supprimer cette reco ? Cette action est irréversible."
msgid "Delete this message?"
msgstr "Supprimer ce message ?"
#: src/components/profile/ProfilePlaylistsTab.tsx:221
#: src/components/profile/ProfilePlaylistsTab.tsx:223
#: src/pages/PlaylistDetail.tsx:171
msgid "Delete this playlist? This cannot be undone."
msgstr "Supprimer cette collection ? Cette action est irréversible."
@@ -753,7 +753,7 @@ msgstr "Déposez un fichier ici, ou collez-en un"
msgid "Drop a replacement here"
msgstr "Déposez un fichier de remplacement ici"
#: src/components/layout/AppHeader.tsx:196
#: src/components/layout/AppHeader.tsx:200
msgid "Dump"
msgstr "Reco"
@@ -1114,7 +1114,7 @@ msgstr "Lien ou fichier"
msgid "Live updates are temporarily disconnected. Trying to reconnect…"
msgstr "Les mises à jour en direct sont temporairement interrompues. Tentative de reconnexion…"
#: src/components/layout/AppHeader.tsx:229
#: src/components/layout/AppHeader.tsx:233
msgid "Live updates unavailable."
msgstr "Mises à jour en direct indisponibles."
@@ -1159,7 +1159,7 @@ msgstr "Chargement du profil…"
msgid "Loading…"
msgstr "Chargement…"
#: src/components/layout/AppHeader.tsx:203
#: src/components/layout/AppHeader.tsx:207
#: src/pages/FederationCallback.tsx:32
#: src/pages/UserLogin.tsx:41
#: src/pages/UserLogin.tsx:66
@@ -1180,7 +1180,7 @@ msgstr "Se connecter pour valider"
msgid "Log in with {0}"
msgstr "Se connecter avec {0}"
#: src/components/layout/UserMenu.tsx:128
#: src/components/layout/UserMenu.tsx:132
#: src/pages/UserPublicProfile.tsx:137
msgid "Log out"
msgstr "Se déconnecter"
@@ -1198,7 +1198,7 @@ msgstr "Connexion échouée"
msgid "Lowercase letters, numbers and single hyphens only"
msgstr "Minuscules, chiffres et tirets simples seulement"
#: src/components/layout/UserMenu.tsx:115
#: src/components/layout/UserMenu.tsx:119
#: src/pages/Manage.tsx:32
#: src/pages/Manage.tsx:40
#: src/pages/UserPublicProfile.tsx:170
@@ -1209,6 +1209,10 @@ msgstr "Administration"
msgid "Max {limit}"
msgstr "Max {limit}"
#: src/components/playlist/PlaylistSortTabs.tsx:34
msgid "Mine"
msgstr "Les miennes"
#: src/components/profile/RoleSwitcher.tsx:23
msgid "Moderator"
msgstr "Modérateur"
@@ -1218,7 +1222,7 @@ msgstr "Modérateur"
msgid "digest.monthly"
msgstr "Récap mensuel"
#: src/components/playlist/PlaylistSortTabs.tsx:27
#: src/components/playlist/PlaylistSortTabs.tsx:33
msgid "Most dumps"
msgstr "Les plus fournies"
@@ -1226,10 +1230,6 @@ msgstr "Les plus fournies"
msgid "Mute"
msgstr "Couper le son"
#: src/components/layout/UserMenu.tsx:89
msgid "My playlists"
msgstr "Mes collections"
#: src/components/category/CategoryManager.tsx:208
#: src/components/category/CategoryManager.tsx:209
msgid "Name"
@@ -1237,7 +1237,7 @@ msgstr "Nom"
#: src/components/chat/ChatModal.tsx:692
#: src/components/feed/FeedTabBar.tsx:58
#: src/components/playlist/PlaylistSortTabs.tsx:26
#: src/components/playlist/PlaylistSortTabs.tsx:32
#: src/pages/Settings.tsx:55
msgid "New"
msgstr "Nouveau"
@@ -1315,8 +1315,8 @@ msgid "No playlists match \"{q}\"."
msgstr "Aucune collection ne correspond à « {q} »."
#: src/components/playlist/PlaylistMembershipPanel.tsx:51
#: src/components/profile/ProfilePlaylistsTab.tsx:199
#: src/pages/Playlists.tsx:151
#: src/components/profile/ProfilePlaylistsTab.tsx:201
#: src/pages/Playlists.tsx:180
msgid "No playlists yet."
msgstr "Pas encore de collections."
@@ -1477,11 +1477,12 @@ msgid "Playlist categories"
msgstr "Catégories de collections"
#: src/components/dump/create/StepIndicator.tsx:21
#: src/components/layout/AppHeader.tsx:169
#: src/components/layout/AppHeader.tsx:173
#: src/components/layout/UserMenu.tsx:93
#: src/components/profile/ProfileFollowTab.tsx:147
#: src/components/profile/ProfilePlaylistsTab.tsx:161
#: src/pages/Playlists.tsx:60
#: src/pages/Playlists.tsx:132
#: src/components/profile/ProfilePlaylistsTab.tsx:162
#: src/pages/Playlists.tsx:74
#: src/pages/Playlists.tsx:160
#: src/pages/Search.tsx:214
#: src/pages/UserPublicProfile.tsx:166
msgid "Playlists"
@@ -1670,7 +1671,7 @@ msgstr "Enregistrer"
msgid "Saving…"
msgstr "Enregistrement…"
#: src/components/layout/AppHeader.tsx:177
#: src/components/layout/AppHeader.tsx:181
#: src/components/layout/SearchBar.tsx:83
#: src/pages/Search.tsx:106
msgid "Search"
@@ -1718,7 +1719,7 @@ msgstr "Envoyer le lien de réinitialisation"
msgid "Sending…"
msgstr "Envoi…"
#: src/components/layout/AppHeader.tsx:193
#: src/components/layout/AppHeader.tsx:197
msgid "Server unreachable"
msgstr "Serveur inaccessible"
@@ -1732,7 +1733,7 @@ msgstr "Définir un nouveau mot de passe"
msgid "email.settings"
msgstr "Paramètres"
#: src/components/layout/UserMenu.tsx:105
#: src/components/layout/UserMenu.tsx:109
#: src/pages/Settings.tsx:75
#: src/pages/Settings.tsx:88
msgid "Settings"
@@ -1980,7 +1981,7 @@ msgstr "Se désabonner"
msgid "Unsubscribed"
msgstr "Désabonné"
#: src/components/playlist/PlaylistSortTabs.tsx:25
#: src/components/playlist/PlaylistSortTabs.tsx:31
msgid "Updated"
msgstr "Mises à jour"
@@ -2044,7 +2045,7 @@ msgstr "Utilisateur"
msgid "User management"
msgstr "Gestion utilisateur"
#: src/components/layout/UserMenu.tsx:64
#: src/components/layout/UserMenu.tsx:68
msgid "User menu"
msgstr "Menu utilisateur"

View File

@@ -8,7 +8,7 @@
* view is the bare path. A category narrowing a list on another page, a
* profile's or a search's, is a query, `?category=`, not a segment, and so is
* the order of a list of playlists, `?sort=`, the default order being the
* bare path.
* bare path, and the playlists page's list of the viewer's own, `?mine=1`.
*
* The client links every page through the builders (`src/utils/shared/urls.ts`
* wraps the dump and playlist ones), the emails prefix them with the
@@ -21,7 +21,11 @@
*/
import type { CategoryKind } from "./category.ts";
import { PLAYLIST_SORTS, type PlaylistSort } from "./playlist.ts";
import {
parsePlaylistSort,
PLAYLIST_SORTS,
type PlaylistSort,
} from "./playlist.ts";
import { DIGEST_CADENCES, type DigestCadence } from "./settings.ts";
import { HANDLE_RE, splitHandle } from "./validation.ts";
@@ -67,6 +71,15 @@ export const SEARCH_TABS = ["dumps", "users", "playlists"] as const;
/** One of the {@link SEARCH_TABS}. */
export type SearchTab = (typeof SEARCH_TABS)[number];
/**
* The tabs of the playlists page, in the tab bar's order: each order of every
* public playlist, `?sort=`, then the viewer's own playlists, `?mine=1`,
* which only a signed-in user is offered.
*/
export const PLAYLISTS_TABS = [...PLAYLIST_SORTS, "mine"] as const;
/** One of the {@link PLAYLISTS_TABS}. */
export type PlaylistsTab = (typeof PLAYLISTS_TABS)[number];
/**
* The views of the community page, one per digest cadence: the week at
* `/community`, the month at `/community/monthly`.
@@ -145,15 +158,24 @@ export function feedPath(
/**
* Returns the path of the page of every public playlist, `/playlists`, or of a
* playlist category's at `/playlists/<slug>`, in the order `sort` names, the
* default one without it.
* playlist category's at `/playlists/<slug>`, on the tab `tab` names: an
* order, the default one without it, or the viewer's own playlists.
*/
export function playlistsPath(
categorySlug?: string,
sort?: PlaylistSort,
tab?: PlaylistsTab,
): string {
const path = categorySlug ? `/playlists/${seg(categorySlug)}` : "/playlists";
return `${path}${playlistSortQuery(sort)}`;
return `${path}${tab === "mine" ? "?mine=1" : playlistSortQuery(tab)}`;
}
/**
* Returns the tab of the playlists page its query names: the viewer's own
* playlists with a `mine`, else the order `sort` names, as
* `parsePlaylistSort` reads it.
*/
export function parsePlaylistsTab(params: URLSearchParams): PlaylistsTab {
return params.has("mine") ? "mine" : parsePlaylistSort(params.get("sort"));
}
/**

View File

@@ -3,6 +3,7 @@ import {
dumpPath,
feedPath,
parsePagePath,
parsePlaylistsTab,
playlistPath,
playlistSortQuery,
playlistsPath,
@@ -43,6 +44,14 @@ Deno.test("builders: one hierarchy, default views bare", () => {
assertEquals(playlistsPath("mixes"), "/playlists/mixes");
assertEquals(playlistsPath(undefined, "updated"), "/playlists");
assertEquals(playlistsPath("mixes", "new"), "/playlists/mixes?sort=new");
assertEquals(playlistsPath("mixes", "mine"), "/playlists/mixes?mine=1");
// The page's tab is one or the other: its own playlists come in one order.
const tab = (query: string) => parsePlaylistsTab(new URLSearchParams(query));
assertEquals(tab(""), "updated");
assertEquals(tab("sort=dumps"), "dumps");
assertEquals(tab("sort=nope"), "updated");
assertEquals(tab("mine=1"), "mine");
assertEquals(tab("mine=1&sort=dumps"), "mine");
// The query of a list that has other parameters: the default order takes
// an old `sort` out, and leaves the rest.
const category = new URLSearchParams({ category: "mixes", sort: "new" });

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");