From 20c2f8b3372a9919c1df104c4e16068dcc77af51 Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Mon, 26 Oct 2026 23:30:10 +0100 Subject: [PATCH 1/3] refactor(webapp): move the shell, the rail and the routes out of main.tsx into app/ Signed-off-by: InstaZDLL --- CLAUDE.md | 2 +- webapp/src/app/more-sheet.tsx | 101 +++ webapp/src/app/navigation-entries.ts | 156 ++++ webapp/src/app/navigation.tsx | 330 +++++++ webapp/src/app/route-tree.ts | 104 +++ webapp/src/app/routes/library.tsx | 138 +++ webapp/src/app/routes/listen.tsx | 49 + webapp/src/app/routes/maintenance.tsx | 96 ++ webapp/src/app/routes/root.tsx | 76 ++ webapp/src/app/routes/settings.tsx | 112 +++ webapp/src/app/shell.tsx | 119 +++ webapp/src/main.tsx | 1205 +------------------------ 12 files changed, 1288 insertions(+), 1200 deletions(-) create mode 100644 webapp/src/app/more-sheet.tsx create mode 100644 webapp/src/app/navigation-entries.ts create mode 100644 webapp/src/app/navigation.tsx create mode 100644 webapp/src/app/route-tree.ts create mode 100644 webapp/src/app/routes/library.tsx create mode 100644 webapp/src/app/routes/listen.tsx create mode 100644 webapp/src/app/routes/maintenance.tsx create mode 100644 webapp/src/app/routes/root.tsx create mode 100644 webapp/src/app/routes/settings.tsx create mode 100644 webapp/src/app/shell.tsx diff --git a/CLAUDE.md b/CLAUDE.md index c142d13d..5f28955b 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -31,7 +31,7 @@ Layout: - `remote/` — everything that talks to another host: the outbound client (`outbound`), `enrichment/`, `portrait`, `lrclib`, `podcast` (the feed fetcher) and `scrobblers/`. - `http/` — HTTP outside the two API surfaces: `security_headers`, `public_share`, `reference` (the `/reference` page) and `webui`. - `media/` — streaming, transcoding, artwork, canvases and `replay_gain`; `quality/` — the audio analysis. -- `webapp/` — the embedded React client, built to `webapp/dist` and compiled in by `rust_embed`. Under `webapp/src/`, only `main.tsx` (the router and the providers) sits at the root; everything else is filed by what it is: `api/` (the HTTP client, one file per resource over the session and transport in `client.ts`, re-exported by `index.ts` so the rest of the app imports `../api`; then the query keys and the query client), `pages/` (one screen per file), `components/` (pieces shared between screens), `player/`, `state/` (contexts: i18n, preferences, library scope), `lib/` (logic with no React in it) and `styles/` (the sheet and the design tokens). A test sits beside the file it tests. +- `webapp/` — the embedded React client, built to `webapp/dist` and compiled in by `rust_embed`. Under `webapp/src/`, only `main.tsx` (the bootstrap: the stylesheets in cascade order, the providers, `createRouter` and the render) sits at the root; everything else is filed by what it is: `app/` (the shell, the rail and its More sheet, and the routes — one file per area under `app/routes/`, assembled in matching order by `app/route-tree.ts`), `api/` (the HTTP client, one file per resource over the session and transport in `client.ts`, re-exported by `index.ts` so the rest of the app imports `../api`; then the query keys and the query client), `pages/` (one screen per file), `components/` (pieces shared between screens), `player/`, `state/` (contexts: i18n, preferences, library scope), `lib/` (logic with no React in it) and `styles/` (the sheet and the design tokens). A test sits beside the file it tests. The desktop app at [`InstaZDLL/WaveFlow`](https://github.com/InstaZDLL/WaveFlow) is a separate repository. It consumes this API but is not part of this codebase. diff --git a/webapp/src/app/more-sheet.tsx b/webapp/src/app/more-sheet.tsx new file mode 100644 index 00000000..d03b6e28 --- /dev/null +++ b/webapp/src/app/more-sheet.tsx @@ -0,0 +1,101 @@ +// The sheet behind the phone bar's seventh slot: the entries the bar has no +// room for. +import { Link } from "@tanstack/react-router"; +import type { MouseEvent, RefObject } from "react"; + +import { Icon } from "../components/icons"; +import { useI18n } from "../state/i18n"; +import type { NavigationTarget, navigation } from "./navigation-entries"; + +/** + * What the seventh slot opens. + * + * A native `` opened with `showModal`, because that is where the focus + * trap, the Escape key and the inertness of the page behind come from. Writing + * those by hand is how an accessibility gate starts failing, and this one is + * measured — the suite runs axe over the sheet while it is open. + * + * Rendered only when there is something behind it: an account that may neither + * upload nor administer still has six entries hidden, but a listing with + * nothing in it would be a control that does nothing. + */ +export function MoreSheet({ + sheet, + entries, + pendingTab, + onNavigate, +}: { + sheet: RefObject; + entries: typeof navigation; + pendingTab: NavigationTarget | null; + onNavigate: ( + event: MouseEvent, + to: NavigationTarget, + ) => void; +}) { + const { t } = useI18n(); + + return ( + <> + {/* `onClick` on the dialog itself catches the backdrop: a click landing + on the element rather than on anything inside it is a click outside + the sheet, which is the only thing the backdrop can be. + + The keyboard equivalent of dismissing by the backdrop is Escape, and + `` already answers it — which is the whole reason the element + was chosen over a hand-built overlay. An `onKeyDown` here would + duplicate a behaviour the platform provides, and a second closer is a + second thing to get out of step. */} + {/* biome-ignore lint/a11y/useKeyWithClickEvents: dismissing by keyboard is Escape, which handles natively */} + { + if (event.target === sheet.current) sheet.current?.close(); + }} + > +
+
+

{t("nav.moreTitle")}

+

{t("nav.moreDetail")}

+
+ +
+ +
+ + ); +} diff --git a/webapp/src/app/navigation-entries.ts b/webapp/src/app/navigation-entries.ts new file mode 100644 index 00000000..2d218a09 --- /dev/null +++ b/webapp/src/app/navigation-entries.ts @@ -0,0 +1,156 @@ +// The rail's entries: the groups they are filed under, their labels and icons, +// and the addresses they open. +import type { IconName } from "../components/icons"; +import type { TranslationKey } from "../state/i18n"; + +/** + * The rail's sections, in the order it shows them. + * + * `pinned` is the four places reached for most, drawn as tiles two to a row; + * the others carry a label. + * + * Settings is not among them. It sits in the rail's foot, beside the account + * it belongs to and above Sign out, where it stays in view however long the + * rail above it grows. It used to head a fourth group, "Account", of five + * entries; those are the Settings page's own sections now. + */ +export const NAVIGATION_GROUPS = ["pinned", "library", "listen"] as const; +type NavigationGroup = (typeof NAVIGATION_GROUPS)[number] | "footer"; + +export const GROUP_LABELS: Record< + Exclude, + TranslationKey +> = { + library: "nav.group.library", + listen: "nav.group.listen", +}; + +export const navigation: Array<{ + to: + | "/" + | "/artists" + | "/songs" + | "/folders" + | "/maintenance" + | "/genres" + | "/search" + | "/favourites" + | "/history" + | "/stats" + | "/random" + | "/playing" + | "/playlists" + | "/queue" + | "/offline" + | "/radio" + | "/podcasts" + | "/settings"; + labelKey: TranslationKey; + icon: IconName; + /** + * Shown only when at least one of Maintenance's sections is this account's + * to use in the active library; each keeps the rule its own entry had. + */ + maintenance?: boolean; + primary?: boolean; + /** The rail section it is filed under. */ + group: NavigationGroup; +}> = [ + { + to: "/", + labelKey: "nav.albums", + icon: "albums", + primary: true, + group: "pinned", + }, + { to: "/artists", labelKey: "nav.artists", icon: "artists", group: "pinned" }, + { to: "/songs", labelKey: "nav.songs", icon: "songs", group: "pinned" }, + { to: "/genres", labelKey: "nav.genres", icon: "genres", group: "library" }, + { + to: "/folders", + labelKey: "nav.folders", + icon: "folder", + group: "library", + }, + // One entry where three were: "Needs attention", "Missing files" and + // "Quality" are the sections of Maintenance, each behind its own rule. + { + to: "/maintenance", + labelKey: "nav.maintenance", + icon: "maintenance", + maintenance: true, + group: "library", + }, + { + to: "/search", + labelKey: "nav.search", + icon: "search", + primary: true, + group: "pinned", + }, + { + to: "/playing", + labelKey: "nav.playing", + icon: "lyrics", + primary: true, + group: "listen", + }, + { to: "/random", labelKey: "nav.random", icon: "random", group: "listen" }, + { + to: "/history", + labelKey: "nav.history", + icon: "history", + group: "library", + }, + { + to: "/stats", + labelKey: "nav.stats", + icon: "stats", + group: "library", + }, + { + to: "/favourites", + labelKey: "nav.favourites", + icon: "heart", + primary: true, + group: "library", + }, + { + to: "/playlists", + labelKey: "nav.playlists", + icon: "playlists", + primary: true, + group: "library", + }, + { + to: "/queue", + labelKey: "nav.queue", + icon: "queue", + primary: true, + group: "listen", + }, + { + to: "/offline", + labelKey: "nav.offline", + icon: "download", + group: "listen", + }, + { to: "/radio", labelKey: "nav.radio", icon: "radio", group: "listen" }, + { + to: "/podcasts", + labelKey: "nav.podcasts", + icon: "podcasts", + group: "listen", + }, + // One entry for everything about the account, where five used to be: the + // sections behind it (shares, scrobbling, upload, administration) are the + // Settings page's own menu now, and each keeps its gate there. + { + to: "/settings", + labelKey: "nav.settings", + icon: "admin", + group: "footer", + }, +]; + +export type NavigationTarget = (typeof navigation)[number]["to"]; diff --git a/webapp/src/app/navigation.tsx b/webapp/src/app/navigation.tsx new file mode 100644 index 00000000..c47fd899 --- /dev/null +++ b/webapp/src/app/navigation.tsx @@ -0,0 +1,330 @@ +// The rail and the phone's bar: the brand, which entries an account sees, and +// the tab switch that waits for its destination's first answer. +import { type QueryClient, useQueryClient } from "@tanstack/react-query"; +import { Link, useNavigate } from "@tanstack/react-router"; +import { type MouseEvent, useRef, useState } from "react"; + +import { currentUser, logout } from "../api"; +import { + albumsQuery, + artistsQuery, + favoriteTracksQuery, + genresQuery, + historyTracksQuery, + ownApiTokensQuery, + ownSubsonicCredentialQuery, + playlistsQuery, + podcastsQuery, + radioStationsQuery, + songsQuery, + statsQuery, +} from "../api/queries"; +import { Icon } from "../components/icons"; +import { localDayKey } from "../lib/local-day"; +import { maintenanceSections } from "../pages/maintenance-hub"; +import { useI18n } from "../state/i18n"; +import { useLibraryScope } from "../state/library-scope"; +import { MoreSheet } from "./more-sheet"; +import { + GROUP_LABELS, + NAVIGATION_GROUPS, + type NavigationTarget, + navigation, +} from "./navigation-entries"; + +/** + * How long a tab switch holds the current screen while the next one loads. + * + * Long enough that an ordinary answer arrives first and the switch shows no + * skeleton; short enough that a server slow to answer leaves the visitor on + * the screen they asked for, with its wait, rather than on the old one. + */ +const TAB_WAIT_MS = 1500; + +/** Have the destination's first answer ready before replacing the current tab. */ +async function prepareTab( + to: NavigationTarget, + scope: string | undefined, + client: QueryClient, +): Promise { + switch (to) { + case "/": + await client.ensureQueryData(albumsQuery("alphabeticalByName", scope)); + break; + case "/artists": + await client.ensureQueryData(artistsQuery(scope)); + break; + case "/songs": + await client.ensureInfiniteQueryData( + songsQuery("alphabeticalByName", scope), + ); + break; + case "/genres": + await client.ensureQueryData(genresQuery(scope)); + break; + case "/history": + await client.ensureQueryData(historyTracksQuery(50)); + break; + case "/stats": + await client.ensureQueryData(statsQuery("30d", localDayKey())); + break; + case "/favourites": + await client.ensureQueryData(favoriteTracksQuery()); + break; + case "/playlists": + await client.ensureQueryData(playlistsQuery()); + break; + case "/radio": + await client.ensureQueryData(radioStationsQuery()); + break; + case "/podcasts": + await client.ensureQueryData(podcastsQuery()); + break; + // The section the entry opens on, which is Account. + case "/settings": + await Promise.all([ + client.ensureQueryData(ownApiTokensQuery()), + client.ensureQueryData(ownSubsonicCredentialQuery()), + ]); + break; + // These screens draw their frame immediately or read state already held + // by the player. They have no page-wide answer to wait for. + case "/search": + case "/playing": + case "/queue": + // Read from this browser, not from the server. + case "/offline": + // The folder is in the address, and the rail links to the root: the + // page draws its own frame and waits for its folder there. + case "/folders": + // Its lists are asked for once the page knows the active library. + case "/maintenance": + break; + // A fresh draw on every ask, held for no time at all: a draw made here + // is thrown away before the page mounts, and the page then draws again. + // Waiting for it would double the wait for nothing. + case "/random": + break; + } +} + +/** + * The horizontal logo, the brand guide's primary version, from the logo kit + * unchanged: its wordmark is Inter outlined, which the guide asks for instead + * of the name typed in an interface face. Two files, because the guide gives + * one per background — the colour version on light, the dark one (mint and + * white) on dark — and the theme is the reader's preference, not the system's, + * so no `` media query can choose. Both are in the document and the + * stylesheet shows the one that matches the root's `dark` class, which + * `applyTheme` sets before the first render: nothing swaps after paint. + * + * The name has to contain the words on screen, in order (WCAG 2.5.3, which + * axe checks as `label-content-name-mismatch`): someone who says "click + * WaveFlow server" to voice control must land on this link, and "WaveFlow + * server, home" is what they would read off the logo and the label beside it. + */ +export function Brand() { + const { t } = useI18n(); + return ( + + + + {t("nav.server")} + + ); +} + +/** + * Whether this account may see an entry at all. + * + * The flags are about authority and not about width, so they apply wherever + * the entry is rendered — bar or sheet. `primary` is the separate question of + * what fits on a phone. The upload and administration gates moved with their + * entries, onto the Settings page's section menu. + */ +function permitted( + item: (typeof navigation)[number], + role: string | undefined, + library: Parameters[0], +): boolean { + return !item.maintenance || maintenanceSections(library, role).length > 0; +} + +export function Navigation({ mobile = false }: { mobile?: boolean }) { + const user = currentUser(); + const { active } = useLibraryScope(); + const { t } = useI18n(); + const client = useQueryClient(); + const navigate = useNavigate(); + const [pendingTab, setPendingTab] = useState(null); + const navigationAttempt = useRef(0); + const visible = navigation.filter((item) => + permitted(item, user?.role, active), + ); + // On a phone the bar holds the six things somebody reaches for while + // listening. Everything else used to be reachable by typing its address and + // by nothing else — the sidebar that carries it is `display: none` below + // 820px — so the seventh slot opens the rest rather than promoting one of + // it. Promoting one would have made the others more invisible by contrast. + const overflow = mobile ? visible.filter((item) => !item.primary) : []; + const sheet = useRef(null); + + function openTab(event: MouseEvent, to: NavigationTarget) { + // Leave modified and middle clicks to the link, so opening a new tab keeps + // the browser's normal behaviour. Only an ordinary in-app tab switch waits. + if ( + event.button !== 0 || + event.metaKey || + event.ctrlKey || + event.shiftKey || + event.altKey || + event.defaultPrevented + ) { + return; + } + if (window.location.pathname === to) { + // A second click on the current tab cancels a destination still loading. + // With nothing loading it is an ordinary link: it takes the tab back to + // its plain address, `/playing?view=lyrics` to `/playing`. + if (pendingTab === null) return; + event.preventDefault(); + navigationAttempt.current += 1; + setPendingTab(null); + return; + } + event.preventDefault(); + const attempt = ++navigationAttempt.current; + const origin = window.location.pathname + window.location.search; + setPendingTab(to); + void (async () => { + try { + // Waited for, but not for ever: past the deadline the destination + // shows its own wait, which says more than a busy tab does. + await Promise.race([ + prepareTab(to, active?.id, client), + new Promise((resolve) => setTimeout(resolve, TAB_WAIT_MS)), + ]); + } catch { + // The destination owns its error state. Navigate to it even when its + // first request fails, rather than leaving the old tab stuck as busy. + } + if (attempt !== navigationAttempt.current) return; + setPendingTab(null); + // The visitor moved on while this loaded — a link in the page, Back — + // and that move is the later decision. Arriving now would undo it. + if (window.location.pathname + window.location.search !== origin) return; + await navigate({ to }); + })(); + } + + const entry = (item: (typeof navigation)[number], className?: string) => ( + openTab(event, item.to)} + aria-busy={pendingTab === item.to} + aria-label={mobile ? t(item.labelKey) : undefined} + // Settings is not exact: every section of the page is under it, and + // the entry stays the current one in whichever is open. + activeOptions={{ exact: item.to === "/" }} + > + + {t(item.labelKey)} + + ); + + return ( + <> + + {mobile ? null : ( + // In the same component as the rail rather than in the shell, so a + // click on Settings and a click on a rail entry share one pending tab: + // the later one cancels the earlier, as two rail entries do. +
+
+ +
+ {user?.username} + {user?.role} +
+
+ {visible + .filter((item) => item.group === "footer") + .map((item) => entry(item, "nav-action"))} + +
+ )} + {/* Beside the bar and not inside it. The sheet's own links would + otherwise count as the bar's, and a closed `` is + `display: none` — so a test measuring how many rows the bar occupies + would read a zero-sized child as sitting on the first one, and could + not see a bar that had wrapped. */} + {overflow.length > 0 ? ( + + ) : null} + + ); +} diff --git a/webapp/src/app/route-tree.ts b/webapp/src/app/route-tree.ts new file mode 100644 index 00000000..13529dca --- /dev/null +++ b/webapp/src/app/route-tree.ts @@ -0,0 +1,104 @@ +// The route tree: every route in the order the router matches them. +import { + albumRoute, + albumsRoute, + artistRoute, + artistsRoute, + favoritesRoute, + foldersRoute, + genreRoute, + genresRoute, + historyRoute, + mixRoute, + playlistsRoute, + searchRoute, + songsRoute, + statsRoute, + trackEditRoute, +} from "./routes/library"; +import { + playingRoute, + podcastsRoute, + queueRoute, + radioRoute, + randomRoute, +} from "./routes/listen"; +import { + attentionRoute, + legacyAttentionRoute, + legacyMissingRoute, + legacyQualityRoute, + maintenanceIndexRoute, + maintenanceRoute, + missingRoute, + qualityRoute, +} from "./routes/maintenance"; +import { + authedRoute, + authorizeRoute, + loginRoute, + offlineRoute, + rootRoute, + shareRoute, +} from "./routes/root"; +import { + accountSettingsRoute, + adminRoute, + legacyAdminRoute, + legacySharesRoute, + legacyUploadRoute, + playbackSettingsRoute, + scrobblingRoute, + settingsRoute, + sharesRoute, + uploadRoute, +} from "./routes/settings"; + +export const routeTree = rootRoute.addChildren([ + loginRoute, + shareRoute, + offlineRoute, + authedRoute.addChildren([ + albumsRoute, + albumRoute, + songsRoute, + foldersRoute, + maintenanceRoute.addChildren([ + maintenanceIndexRoute, + attentionRoute, + missingRoute, + qualityRoute, + ]), + legacyAttentionRoute, + legacyMissingRoute, + legacyQualityRoute, + artistsRoute, + artistRoute, + genresRoute, + genreRoute, + historyRoute, + statsRoute, + mixRoute, + randomRoute, + playingRoute, + searchRoute, + favoritesRoute, + playlistsRoute, + queueRoute, + radioRoute, + podcastsRoute, + settingsRoute.addChildren([ + accountSettingsRoute, + playbackSettingsRoute, + scrobblingRoute, + sharesRoute, + uploadRoute, + adminRoute, + ]), + legacySharesRoute, + legacyUploadRoute, + legacyAdminRoute, + authorizeRoute, + trackEditRoute, + ]), +]); diff --git a/webapp/src/app/routes/library.tsx b/webapp/src/app/routes/library.tsx new file mode 100644 index 00000000..a0e1e322 --- /dev/null +++ b/webapp/src/app/routes/library.tsx @@ -0,0 +1,138 @@ +// The catalogue's routes: albums, artists, songs, genres, folders, search, +// the account's own lists and listening, and the track editor. +import { createRoute } from "@tanstack/react-router"; + +import { TrackEditorPage } from "../../components/track-editor"; +import { AlbumPage } from "../../pages/album-page"; +import { AlbumsPage } from "../../pages/albums-page"; +import { ArtistPage } from "../../pages/artist-page"; +import { ArtistsPage } from "../../pages/artists-page"; +import { FavoritesPage } from "../../pages/favorites-page"; +import { FoldersPage } from "../../pages/folders-page"; +import { GenrePage } from "../../pages/genre-page"; +import { GenresPage } from "../../pages/genres-page"; +import { HistoryPage } from "../../pages/history-page"; +import { MixPage } from "../../pages/mix-page"; +import { PlaylistsPage } from "../../pages/playlists-page"; +import { SearchPage } from "../../pages/search-page"; +import { SongsPage } from "../../pages/songs-page"; +import { StatsPage } from "../../pages/stats-page"; +import { authedRoute } from "./root"; + +export const albumsRoute = createRoute({ + getParentRoute: () => authedRoute, + path: "/", + component: AlbumsPage, +}); + +export const albumRoute = createRoute({ + getParentRoute: () => authedRoute, + path: "/albums/$albumId", + component: function AlbumRoute() { + const { albumId } = albumRoute.useParams(); + return ; + }, +}); + +export const songsRoute = createRoute({ + getParentRoute: () => authedRoute, + path: "/songs", + component: SongsPage, +}); + +export const artistsRoute = createRoute({ + getParentRoute: () => authedRoute, + path: "/artists", + component: ArtistsPage, +}); + +export const artistRoute = createRoute({ + getParentRoute: () => authedRoute, + path: "/artists/$artistId", + component: function ArtistRoute() { + const { artistId } = artistRoute.useParams(); + return ; + }, +}); + +export const genresRoute = createRoute({ + getParentRoute: () => authedRoute, + path: "/genres", + component: GenresPage, +}); + +export const genreRoute = createRoute({ + getParentRoute: () => authedRoute, + path: "/genres/$genre", + component: function GenreRoute() { + const { genre } = genreRoute.useParams(); + return ; + }, +}); + +export const historyRoute = createRoute({ + getParentRoute: () => authedRoute, + path: "/history", + component: HistoryPage, +}); + +// RFC-024: this account's listening, and the mixes drawn from it. +export const statsRoute = createRoute({ + getParentRoute: () => authedRoute, + path: "/stats", + component: StatsPage, +}); + +export const mixRoute = createRoute({ + getParentRoute: () => authedRoute, + path: "/mixes/$mixId", + component: function MixRoute() { + const { mixId } = mixRoute.useParams(); + return ; + }, +}); + +export const searchRoute = createRoute({ + getParentRoute: () => authedRoute, + path: "/search", + component: SearchPage, +}); + +export const favoritesRoute = createRoute({ + getParentRoute: () => authedRoute, + path: "/favourites", + component: FavoritesPage, +}); + +export const playlistsRoute = createRoute({ + getParentRoute: () => authedRoute, + path: "/playlists", + component: PlaylistsPage, +}); + +// The folder is a search parameter rather than a path segment: a folder's +// name may hold any character a file system allows, `$` and `?` included, +// and a parameter carries it whole. +export const foldersRoute = createRoute({ + getParentRoute: () => authedRoute, + path: "/folders", + staticData: { layout: "workspace" }, + // The folder: absent or empty for the root, `unfiled` for the tracks filed + // nowhere, otherwise a folder id. Optional, so the rail links to the root. + validateSearch: (search: Record): { folder?: string } => ({ + folder: typeof search.folder === "string" ? search.folder : undefined, + }), + component: function FoldersRoute() { + const { folder } = foldersRoute.useSearch(); + return ; + }, +}); + +export const trackEditRoute = createRoute({ + getParentRoute: () => authedRoute, + path: "/tracks/$trackId/edit", + component: function TrackEditRoute() { + const { trackId } = trackEditRoute.useParams(); + return ; + }, +}); diff --git a/webapp/src/app/routes/listen.tsx b/webapp/src/app/routes/listen.tsx new file mode 100644 index 00000000..3acbd84c --- /dev/null +++ b/webapp/src/app/routes/listen.tsx @@ -0,0 +1,49 @@ +// The listening routes: what plays now, the queue, a random draw, radio and +// podcasts. +import { createRoute } from "@tanstack/react-router"; + +import { PlayingPage } from "../../pages/playing-page"; +import { PodcastsPage } from "../../pages/podcasts-page"; +import { QueuePage } from "../../pages/queue-page"; +import { RadioPage } from "../../pages/radio-page"; +import { RandomPage } from "../../pages/random-page"; +import { authedRoute } from "./root"; + +export const randomRoute = createRoute({ + getParentRoute: () => authedRoute, + path: "/random", + component: RandomPage, +}); + +export const playingRoute = createRoute({ + getParentRoute: () => authedRoute, + path: "/playing", + component: PlayingPage, +}); + +export const queueRoute = createRoute({ + getParentRoute: () => authedRoute, + path: "/queue", + component: QueuePage, +}); + +export const radioRoute = createRoute({ + getParentRoute: () => authedRoute, + path: "/radio", + staticData: { layout: "workspace" }, + component: RadioPage, +}); + +export const podcastsRoute = createRoute({ + getParentRoute: () => authedRoute, + path: "/podcasts", + staticData: { layout: "workspace" }, + // The channel whose episodes are open, so Back and a reload keep it. + validateSearch: (search: Record): { channel?: string } => ({ + channel: typeof search.channel === "string" ? search.channel : undefined, + }), + component: function PodcastsRoute() { + const { channel } = podcastsRoute.useSearch(); + return ; + }, +}); diff --git a/webapp/src/app/routes/maintenance.tsx b/webapp/src/app/routes/maintenance.tsx new file mode 100644 index 00000000..c4c8cdd3 --- /dev/null +++ b/webapp/src/app/routes/maintenance.tsx @@ -0,0 +1,96 @@ +// Maintenance and its sections, and the addresses those sections had before +// they moved under it. +import { createRoute, redirect } from "@tanstack/react-router"; + +import { AttentionPage, isAttentionCategory } from "../../pages/attention-page"; +import { MaintenanceHub, MaintenanceIndex } from "../../pages/maintenance-hub"; +import { MissingPage } from "../../pages/missing-page"; +import { isQualityVerdict, QualityPage } from "../../pages/quality-page"; +import { authedRoute } from "./root"; + +/** + * Maintenance: "Needs attention", "Missing files" and "Quality" behind one + * rail entry, as Settings holds the account's pages. `/maintenance` itself + * opens the first section the account may use. + */ +export const maintenanceRoute = createRoute({ + getParentRoute: () => authedRoute, + path: "/maintenance", + // Every section under the hub inherits it: a child route's match carries + // its own static data, but the hub's match is in the list beside it. + staticData: { layout: "workspace" }, + component: MaintenanceHub, +}); + +export const maintenanceIndexRoute = createRoute({ + getParentRoute: () => maintenanceRoute, + path: "/", + component: MaintenanceIndex, +}); + +const validateAttentionSearch = (search: Record) => ({ + category: isAttentionCategory(search.category) ? search.category : undefined, +}); + +const validateQualitySearch = (search: Record) => ({ + verdict: isQualityVerdict(search.verdict) ? search.verdict : undefined, +}); + +// The open list is a search parameter, so a list can be linked to and the +// back button walks between them. +export const attentionRoute = createRoute({ + getParentRoute: () => maintenanceRoute, + path: "/attention", + validateSearch: validateAttentionSearch, + component: function AttentionRoute() { + const { category } = attentionRoute.useSearch(); + return ; + }, +}); + +export const missingRoute = createRoute({ + getParentRoute: () => maintenanceRoute, + path: "/missing", + component: MissingPage, +}); + +// The verdict filter is a search parameter, so a filtered list can be linked +// to, as the lists of "Needs attention" can. +export const qualityRoute = createRoute({ + getParentRoute: () => maintenanceRoute, + path: "/quality", + validateSearch: validateQualitySearch, + component: function QualityRoute() { + const { verdict } = qualityRoute.useSearch(); + return ; + }, +}); + +// Their addresses before they moved under Maintenance, redirected as the +// Settings pages' are: the open list or verdict filter comes along, and the +// redirect replaces its own history entry. +export const legacyAttentionRoute = createRoute({ + getParentRoute: () => authedRoute, + path: "/attention", + validateSearch: validateAttentionSearch, + beforeLoad: ({ search }) => { + throw redirect({ to: "/maintenance/attention", search, replace: true }); + }, +}); + +export const legacyMissingRoute = createRoute({ + getParentRoute: () => authedRoute, + path: "/missing", + beforeLoad: () => { + throw redirect({ to: "/maintenance/missing", replace: true }); + }, +}); + +export const legacyQualityRoute = createRoute({ + getParentRoute: () => authedRoute, + path: "/quality", + validateSearch: validateQualitySearch, + beforeLoad: ({ search }) => { + throw redirect({ to: "/maintenance/quality", search, replace: true }); + }, +}); diff --git a/webapp/src/app/routes/root.tsx b/webapp/src/app/routes/root.tsx new file mode 100644 index 00000000..210c4b33 --- /dev/null +++ b/webapp/src/app/routes/root.tsx @@ -0,0 +1,76 @@ +// The root route, the addresses open without a session, the session gate +// everything else sits under, and the authorisation a native client asks for. +import { createRootRoute, createRoute, redirect } from "@tanstack/react-router"; + +import { ensureSession } from "../../api"; +import { AuthorizePage } from "../../pages/authorize-page"; +import { LoginPage } from "../../pages/login-page"; +import { NotFoundPage } from "../../pages/not-found-page"; +import { OfflinePage } from "../../pages/offline-page"; +import { SharePage } from "../../pages/share-page"; +import { RootFrame, Shell } from "../shell"; + +export const rootRoute = createRootRoute({ + component: RootFrame, + notFoundComponent: NotFoundPage, +}); + +export const loginRoute = createRoute({ + getParentRoute: () => rootRoute, + path: "/login", + component: LoginPage, +}); + +/** + * What a share link opens (RFC-020 decision 3). Beside the sign-in page and + * not under `authed`: the person a link is sent to has no account, and a + * session check would send them to a sign-in they cannot complete. The server + * hands this shell out only at `/share/{token}`, to a browser asking for HTML. + */ +export const shareRoute = createRoute({ + getParentRoute: () => rootRoute, + path: "/share/$token", + component: function ShareRoute() { + const { token } = shareRoute.useParams(); + return ; + }, +}); + +/** + * The tracks kept for offline listening (RFC-022). Beside the sign-in page and + * not under `authed`: it must open with no network, and a session check with + * no server to answer it would send the listener to a sign-in that cannot + * complete either. + */ +export const offlineRoute = createRoute({ + getParentRoute: () => rootRoute, + path: "/offline", + component: OfflinePage, +}); + +/** Everything below requires a session; unauthenticated visitors land on /login. */ +export const authedRoute = createRoute({ + getParentRoute: () => rootRoute, + id: "authed", + beforeLoad: async () => { + if (!(await ensureSession())) { + // Remember where the user was headed so a desktop authorisation link + // survives the detour through sign-in instead of dropping its PKCE + // parameters and forcing the client to start over. + // Stored and re-read as a path only; safeInternalPath re-checks it on the + // way out, since location.pathname can itself begin with "//". + sessionStorage.setItem( + "waveflow.after-login", + window.location.pathname + window.location.search, + ); + throw redirect({ to: "/login" }); + } + }, + component: Shell, +}); + +export const authorizeRoute = createRoute({ + getParentRoute: () => authedRoute, + path: "/authorize", + component: AuthorizePage, +}); diff --git a/webapp/src/app/routes/settings.tsx b/webapp/src/app/routes/settings.tsx new file mode 100644 index 00000000..945bd8fa --- /dev/null +++ b/webapp/src/app/routes/settings.tsx @@ -0,0 +1,112 @@ +// Settings and its sections, administration among them, and the addresses +// those sections had before they moved under it. +import { createRoute, redirect } from "@tanstack/react-router"; + +import { currentUser } from "../../api"; +import { AdminPage } from "../../pages/admin-page"; +import { ScrobblingPage } from "../../pages/scrobbling-page"; +import { SettingsHub } from "../../pages/settings-hub"; +import { PlaybackSettingsPage, SettingsPage } from "../../pages/settings-page"; +import { SharesPage } from "../../pages/shares-page"; +import { UploadPage } from "../../pages/upload-page"; +import { authedRoute } from "./root"; + +/** + * Settings, and everything about the account behind its one rail entry. + * + * A frame rather than a page: the section menu over whichever section is + * open. Each section is its own route, so it can be linked to and Back walks + * between them; `/settings` itself is the first section, Account. + */ +export const settingsRoute = createRoute({ + getParentRoute: () => authedRoute, + path: "/settings", + component: SettingsHub, +}); + +export const accountSettingsRoute = createRoute({ + getParentRoute: () => settingsRoute, + path: "/", + component: SettingsPage, +}); + +export const playbackSettingsRoute = createRoute({ + getParentRoute: () => settingsRoute, + path: "/playback", + component: PlaybackSettingsPage, +}); + +// The path is the server's, not a preference. `lastfm_callback` redirects +// here when a journey completes, so spelling it any other way puts the last +// step of that journey on the not-found page. +export const scrobblingRoute = createRoute({ + getParentRoute: () => settingsRoute, + path: "/scrobbling", + component: ScrobblingPage, +}); + +export const sharesRoute = createRoute({ + getParentRoute: () => settingsRoute, + path: "/shares", + component: SharesPage, +}); + +export const uploadRoute = createRoute({ + getParentRoute: () => settingsRoute, + path: "/upload", + // A filed folder to put what is received into (RFC-012 decision 7). + validateSearch: (search: Record): { folder?: string } => ({ + folder: typeof search.folder === "string" ? search.folder : undefined, + }), + component: function UploadRoute() { + const { folder } = uploadRoute.useSearch(); + return ; + }, +}); + +export const adminRoute = createRoute({ + getParentRoute: () => settingsRoute, + path: "/admin", + beforeLoad: () => { + if (currentUser()?.role !== "admin") throw redirect({ to: "/" }); + }, + component: AdminPage, +}); + +/** + * The addresses these sections had before they moved under Settings, kept so + * a bookmark or a link written elsewhere still arrives. + * + * Redirects rather than second homes: one address per screen, and the rail's + * Settings entry is current on every one of them because they all sit under + * `/settings`. The router replaces the history entry it redirects from, so + * Back from the section returns to wherever the visitor was before rather + * than to an address that would only send them forward again. + */ +export const legacySharesRoute = createRoute({ + getParentRoute: () => authedRoute, + path: "/shares", + beforeLoad: () => { + throw redirect({ to: "/settings/shares", replace: true }); + }, +}); + +export const legacyUploadRoute = createRoute({ + getParentRoute: () => authedRoute, + path: "/upload", + // Carried across, so `/upload?folder=…` still fills that folder. + validateSearch: (search: Record): { folder?: string } => ({ + folder: typeof search.folder === "string" ? search.folder : undefined, + }), + beforeLoad: ({ search }) => { + throw redirect({ to: "/settings/upload", search, replace: true }); + }, +}); + +export const legacyAdminRoute = createRoute({ + getParentRoute: () => authedRoute, + path: "/admin", + beforeLoad: () => { + throw redirect({ to: "/settings/admin", replace: true }); + }, +}); diff --git a/webapp/src/app/shell.tsx b/webapp/src/app/shell.tsx new file mode 100644 index 00000000..4b8eaae2 --- /dev/null +++ b/webapp/src/app/shell.tsx @@ -0,0 +1,119 @@ +// The frames every screen sits inside: the root, and the signed-in shell +// with its rail, its bars and the player. +import { Outlet, useMatches } from "@tanstack/react-router"; +import { useEffect } from "react"; + +import { CoverLight } from "../components/cover-light"; +import { Waiting } from "../components/page-shell"; +import { dismissSplash } from "../lib/splash"; +import { PlayerBar, PlayerProvider } from "../player/player"; +import { useI18n } from "../state/i18n"; +import { + LibraryPicker, + LibraryScopeProvider, + useLibraryScope, +} from "../state/library-scope"; +import { Brand, Navigation } from "./navigation"; + +/** + * Takes the first-load screen away once the application has committed a + * screen of its own. + * + * Renders nothing. An effect runs after the commit it belongs to, so by the + * time this fires the router has put either the sign-in page or the shell in + * the document — which is what the splash was covering. + * + * **Inside the router's root component**, so the commit this effect belongs + * to is the one that put a screen in the document. As a sibling of + * `RouterProvider` it fired on the first commit of the tree, which the + * router need not have filled yet — the 300ms floor hid that, and a floor + * hiding an ordering is weaker than an ordering that is right. + * + * Not in `loginRoute` and `authedRoute` instead, which was the other + * suggestion: `notFoundComponent` belongs to the root and renders inside + * neither, so an unknown address on a first visit would have kept the mark + * until the ten-second ceiling. + * + * **Not on the scope being ready** either, which would hold the mark over the + * one round trip the shell still waits on. That wait has `Waiting` already, + * with `role="status"`, and is usually finished inside the floor. Covering + * it with the mark would mean the screen outliving a failure it cannot + * describe. + */ +function SplashGate() { + useEffect(() => { + dismissSplash(); + }, []); + return null; +} + +/** + * Holds the catalogue back until the active library is known. One round trip, + * and it buys the guarantee that no screen renders the whole catalogue before + * narrowing to one library — which is what the unscoped first request did. + */ +function ScopedOutlet() { + const { ready } = useLibraryScope(); + // Announced rather than blank: a screen reader on an empty `main` has + // nothing to say, and the wait is a round trip the visitor did not ask for. + return ready ? : ; +} + +/** Compose the authenticated desktop and mobile application chrome. */ +export function Shell() { + const { t } = useI18n(); + // Read from the routes rather than set by each page, so the width holds + // while a page is still loading and the hub's sections share it. + const workspace = useMatches({ + select: (matches) => + matches.some((match) => match.staticData.layout === "workspace"), + }); + return ( + + + + + {t("nav.skip")} + +
+ +
+ + +
+
+ +
+ + +
+
+
+ ); +} + +/** + * Everything every route sits inside: the outlet, and the one effect that + * takes the first-load screen away. + */ +export function RootFrame() { + return ( + <> + + + + ); +} diff --git a/webapp/src/main.tsx b/webapp/src/main.tsx index 17eceff7..cb8a6296 100644 --- a/webapp/src/main.tsx +++ b/webapp/src/main.tsx @@ -1,107 +1,17 @@ -import { - type QueryClient, - QueryClientProvider, - useQueryClient, -} from "@tanstack/react-query"; -import { - createRootRoute, - createRoute, - createRouter, - Link, - Outlet, - RouterProvider, - redirect, - useMatches, - useNavigate, -} from "@tanstack/react-router"; -import { - type MouseEvent, - type RefObject, - StrictMode, - useEffect, - useRef, - useState, -} from "react"; +import { QueryClientProvider } from "@tanstack/react-query"; +import { createRouter, RouterProvider } from "@tanstack/react-router"; +import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; -import { - currentUser, - ensureSession, - forgetOnSessionChange, - logout, - onSessionOwner, -} from "./api"; -import { - albumsQuery, - artistsQuery, - favoriteTracksQuery, - genresQuery, - historyTracksQuery, - ownApiTokensQuery, - ownSubsonicCredentialQuery, - playlistsQuery, - podcastsQuery, - radioStationsQuery, - songsQuery, - statsQuery, -} from "./api/queries"; +import { forgetOnSessionChange, onSessionOwner } from "./api"; import { createQueryClient } from "./api/query-client"; -import { CoverLight } from "./components/cover-light"; -import { Icon, type IconName } from "./components/icons"; -import { Waiting } from "./components/page-shell"; -import { TrackEditorPage } from "./components/track-editor"; -import { localDayKey } from "./lib/local-day"; +import { routeTree } from "./app/route-tree"; import { claimOffline, forgetKnownOffline, registerServiceWorker, } from "./lib/offline"; -import { dismissSplash } from "./lib/splash"; -import { AdminPage } from "./pages/admin-page"; -import { AlbumPage } from "./pages/album-page"; -import { AlbumsPage } from "./pages/albums-page"; -import { ArtistPage } from "./pages/artist-page"; -import { ArtistsPage } from "./pages/artists-page"; -import { AttentionPage, isAttentionCategory } from "./pages/attention-page"; -import { AuthorizePage } from "./pages/authorize-page"; -import { FavoritesPage } from "./pages/favorites-page"; -import { FoldersPage } from "./pages/folders-page"; -import { GenrePage } from "./pages/genre-page"; -import { GenresPage } from "./pages/genres-page"; -import { HistoryPage } from "./pages/history-page"; -import { LoginPage } from "./pages/login-page"; -import { - MaintenanceHub, - MaintenanceIndex, - maintenanceSections, -} from "./pages/maintenance-hub"; -import { MissingPage } from "./pages/missing-page"; -import { MixPage } from "./pages/mix-page"; -import { NotFoundPage } from "./pages/not-found-page"; -import { OfflinePage } from "./pages/offline-page"; -import { PlayingPage } from "./pages/playing-page"; -import { PlaylistsPage } from "./pages/playlists-page"; -import { PodcastsPage } from "./pages/podcasts-page"; -import { isQualityVerdict, QualityPage } from "./pages/quality-page"; -import { QueuePage } from "./pages/queue-page"; -import { RadioPage } from "./pages/radio-page"; -import { RandomPage } from "./pages/random-page"; -import { ScrobblingPage } from "./pages/scrobbling-page"; -import { SearchPage } from "./pages/search-page"; -import { SettingsHub } from "./pages/settings-hub"; -import { PlaybackSettingsPage, SettingsPage } from "./pages/settings-page"; -import { SharePage } from "./pages/share-page"; -import { SharesPage } from "./pages/shares-page"; -import { SongsPage } from "./pages/songs-page"; -import { StatsPage } from "./pages/stats-page"; -import { UploadPage } from "./pages/upload-page"; -import { PlayerBar, PlayerProvider } from "./player/player"; -import { I18nProvider, type TranslationKey, useI18n } from "./state/i18n"; -import { - LibraryPicker, - LibraryScopeProvider, - useLibraryScope, -} from "./state/library-scope"; +import { I18nProvider } from "./state/i18n"; import { PreferencesProvider } from "./state/preferences"; import "./styles/styles.css"; import "./styles/refresh.css"; @@ -111,1109 +21,6 @@ import "@fontsource-variable/dm-sans/opsz.css"; import "@fontsource/playfair-display/400-italic.css"; import "./styles/fluid.css"; -/** - * The rail's sections, in the order it shows them. - * - * `pinned` is the four places reached for most, drawn as tiles two to a row; - * the others carry a label. - * - * Settings is not among them. It sits in the rail's foot, beside the account - * it belongs to and above Sign out, where it stays in view however long the - * rail above it grows. It used to head a fourth group, "Account", of five - * entries; those are the Settings page's own sections now. - */ -const NAVIGATION_GROUPS = ["pinned", "library", "listen"] as const; -type NavigationGroup = (typeof NAVIGATION_GROUPS)[number] | "footer"; - -const GROUP_LABELS: Record< - Exclude, - TranslationKey -> = { - library: "nav.group.library", - listen: "nav.group.listen", -}; - -const navigation: Array<{ - to: - | "/" - | "/artists" - | "/songs" - | "/folders" - | "/maintenance" - | "/genres" - | "/search" - | "/favourites" - | "/history" - | "/stats" - | "/random" - | "/playing" - | "/playlists" - | "/queue" - | "/offline" - | "/radio" - | "/podcasts" - | "/settings"; - labelKey: TranslationKey; - icon: IconName; - /** - * Shown only when at least one of Maintenance's sections is this account's - * to use in the active library; each keeps the rule its own entry had. - */ - maintenance?: boolean; - primary?: boolean; - /** The rail section it is filed under. */ - group: NavigationGroup; -}> = [ - { - to: "/", - labelKey: "nav.albums", - icon: "albums", - primary: true, - group: "pinned", - }, - { to: "/artists", labelKey: "nav.artists", icon: "artists", group: "pinned" }, - { to: "/songs", labelKey: "nav.songs", icon: "songs", group: "pinned" }, - { to: "/genres", labelKey: "nav.genres", icon: "genres", group: "library" }, - { - to: "/folders", - labelKey: "nav.folders", - icon: "folder", - group: "library", - }, - // One entry where three were: "Needs attention", "Missing files" and - // "Quality" are the sections of Maintenance, each behind its own rule. - { - to: "/maintenance", - labelKey: "nav.maintenance", - icon: "maintenance", - maintenance: true, - group: "library", - }, - { - to: "/search", - labelKey: "nav.search", - icon: "search", - primary: true, - group: "pinned", - }, - { - to: "/playing", - labelKey: "nav.playing", - icon: "lyrics", - primary: true, - group: "listen", - }, - { to: "/random", labelKey: "nav.random", icon: "random", group: "listen" }, - { - to: "/history", - labelKey: "nav.history", - icon: "history", - group: "library", - }, - { - to: "/stats", - labelKey: "nav.stats", - icon: "stats", - group: "library", - }, - { - to: "/favourites", - labelKey: "nav.favourites", - icon: "heart", - primary: true, - group: "library", - }, - { - to: "/playlists", - labelKey: "nav.playlists", - icon: "playlists", - primary: true, - group: "library", - }, - { - to: "/queue", - labelKey: "nav.queue", - icon: "queue", - primary: true, - group: "listen", - }, - { - to: "/offline", - labelKey: "nav.offline", - icon: "download", - group: "listen", - }, - { to: "/radio", labelKey: "nav.radio", icon: "radio", group: "listen" }, - { - to: "/podcasts", - labelKey: "nav.podcasts", - icon: "podcasts", - group: "listen", - }, - // One entry for everything about the account, where five used to be: the - // sections behind it (shares, scrobbling, upload, administration) are the - // Settings page's own menu now, and each keeps its gate there. - { - to: "/settings", - labelKey: "nav.settings", - icon: "admin", - group: "footer", - }, -]; - -type NavigationTarget = (typeof navigation)[number]["to"]; - -/** - * How long a tab switch holds the current screen while the next one loads. - * - * Long enough that an ordinary answer arrives first and the switch shows no - * skeleton; short enough that a server slow to answer leaves the visitor on - * the screen they asked for, with its wait, rather than on the old one. - */ -const TAB_WAIT_MS = 1500; - -/** Have the destination's first answer ready before replacing the current tab. */ -async function prepareTab( - to: NavigationTarget, - scope: string | undefined, - client: QueryClient, -): Promise { - switch (to) { - case "/": - await client.ensureQueryData(albumsQuery("alphabeticalByName", scope)); - break; - case "/artists": - await client.ensureQueryData(artistsQuery(scope)); - break; - case "/songs": - await client.ensureInfiniteQueryData( - songsQuery("alphabeticalByName", scope), - ); - break; - case "/genres": - await client.ensureQueryData(genresQuery(scope)); - break; - case "/history": - await client.ensureQueryData(historyTracksQuery(50)); - break; - case "/stats": - await client.ensureQueryData(statsQuery("30d", localDayKey())); - break; - case "/favourites": - await client.ensureQueryData(favoriteTracksQuery()); - break; - case "/playlists": - await client.ensureQueryData(playlistsQuery()); - break; - case "/radio": - await client.ensureQueryData(radioStationsQuery()); - break; - case "/podcasts": - await client.ensureQueryData(podcastsQuery()); - break; - // The section the entry opens on, which is Account. - case "/settings": - await Promise.all([ - client.ensureQueryData(ownApiTokensQuery()), - client.ensureQueryData(ownSubsonicCredentialQuery()), - ]); - break; - // These screens draw their frame immediately or read state already held - // by the player. They have no page-wide answer to wait for. - case "/search": - case "/playing": - case "/queue": - // Read from this browser, not from the server. - case "/offline": - // The folder is in the address, and the rail links to the root: the - // page draws its own frame and waits for its folder there. - case "/folders": - // Its lists are asked for once the page knows the active library. - case "/maintenance": - break; - // A fresh draw on every ask, held for no time at all: a draw made here - // is thrown away before the page mounts, and the page then draws again. - // Waiting for it would double the wait for nothing. - case "/random": - break; - } -} - -/** - * The horizontal logo, the brand guide's primary version, from the logo kit - * unchanged: its wordmark is Inter outlined, which the guide asks for instead - * of the name typed in an interface face. Two files, because the guide gives - * one per background — the colour version on light, the dark one (mint and - * white) on dark — and the theme is the reader's preference, not the system's, - * so no `` media query can choose. Both are in the document and the - * stylesheet shows the one that matches the root's `dark` class, which - * `applyTheme` sets before the first render: nothing swaps after paint. - * - * The name has to contain the words on screen, in order (WCAG 2.5.3, which - * axe checks as `label-content-name-mismatch`): someone who says "click - * WaveFlow server" to voice control must land on this link, and "WaveFlow - * server, home" is what they would read off the logo and the label beside it. - */ -function Brand() { - const { t } = useI18n(); - return ( - - - - {t("nav.server")} - - ); -} - -/** - * Whether this account may see an entry at all. - * - * The flags are about authority and not about width, so they apply wherever - * the entry is rendered — bar or sheet. `primary` is the separate question of - * what fits on a phone. The upload and administration gates moved with their - * entries, onto the Settings page's section menu. - */ -function permitted( - item: (typeof navigation)[number], - role: string | undefined, - library: Parameters[0], -): boolean { - return !item.maintenance || maintenanceSections(library, role).length > 0; -} - -function Navigation({ mobile = false }: { mobile?: boolean }) { - const user = currentUser(); - const { active } = useLibraryScope(); - const { t } = useI18n(); - const client = useQueryClient(); - const navigate = useNavigate(); - const [pendingTab, setPendingTab] = useState(null); - const navigationAttempt = useRef(0); - const visible = navigation.filter((item) => - permitted(item, user?.role, active), - ); - // On a phone the bar holds the six things somebody reaches for while - // listening. Everything else used to be reachable by typing its address and - // by nothing else — the sidebar that carries it is `display: none` below - // 820px — so the seventh slot opens the rest rather than promoting one of - // it. Promoting one would have made the others more invisible by contrast. - const overflow = mobile ? visible.filter((item) => !item.primary) : []; - const sheet = useRef(null); - - function openTab(event: MouseEvent, to: NavigationTarget) { - // Leave modified and middle clicks to the link, so opening a new tab keeps - // the browser's normal behaviour. Only an ordinary in-app tab switch waits. - if ( - event.button !== 0 || - event.metaKey || - event.ctrlKey || - event.shiftKey || - event.altKey || - event.defaultPrevented - ) { - return; - } - if (window.location.pathname === to) { - // A second click on the current tab cancels a destination still loading. - // With nothing loading it is an ordinary link: it takes the tab back to - // its plain address, `/playing?view=lyrics` to `/playing`. - if (pendingTab === null) return; - event.preventDefault(); - navigationAttempt.current += 1; - setPendingTab(null); - return; - } - event.preventDefault(); - const attempt = ++navigationAttempt.current; - const origin = window.location.pathname + window.location.search; - setPendingTab(to); - void (async () => { - try { - // Waited for, but not for ever: past the deadline the destination - // shows its own wait, which says more than a busy tab does. - await Promise.race([ - prepareTab(to, active?.id, client), - new Promise((resolve) => setTimeout(resolve, TAB_WAIT_MS)), - ]); - } catch { - // The destination owns its error state. Navigate to it even when its - // first request fails, rather than leaving the old tab stuck as busy. - } - if (attempt !== navigationAttempt.current) return; - setPendingTab(null); - // The visitor moved on while this loaded — a link in the page, Back — - // and that move is the later decision. Arriving now would undo it. - if (window.location.pathname + window.location.search !== origin) return; - await navigate({ to }); - })(); - } - - const entry = (item: (typeof navigation)[number], className?: string) => ( - openTab(event, item.to)} - aria-busy={pendingTab === item.to} - aria-label={mobile ? t(item.labelKey) : undefined} - // Settings is not exact: every section of the page is under it, and - // the entry stays the current one in whichever is open. - activeOptions={{ exact: item.to === "/" }} - > - - {t(item.labelKey)} - - ); - - return ( - <> - - {mobile ? null : ( - // In the same component as the rail rather than in the shell, so a - // click on Settings and a click on a rail entry share one pending tab: - // the later one cancels the earlier, as two rail entries do. -
-
- -
- {user?.username} - {user?.role} -
-
- {visible - .filter((item) => item.group === "footer") - .map((item) => entry(item, "nav-action"))} - -
- )} - {/* Beside the bar and not inside it. The sheet's own links would - otherwise count as the bar's, and a closed `` is - `display: none` — so a test measuring how many rows the bar occupies - would read a zero-sized child as sitting on the first one, and could - not see a bar that had wrapped. */} - {overflow.length > 0 ? ( - - ) : null} - - ); -} - -/** - * What the seventh slot opens. - * - * A native `` opened with `showModal`, because that is where the focus - * trap, the Escape key and the inertness of the page behind come from. Writing - * those by hand is how an accessibility gate starts failing, and this one is - * measured — the suite runs axe over the sheet while it is open. - * - * Rendered only when there is something behind it: an account that may neither - * upload nor administer still has six entries hidden, but a listing with - * nothing in it would be a control that does nothing. - */ -function MoreSheet({ - sheet, - entries, - pendingTab, - onNavigate, -}: { - sheet: RefObject; - entries: typeof navigation; - pendingTab: NavigationTarget | null; - onNavigate: ( - event: MouseEvent, - to: NavigationTarget, - ) => void; -}) { - const { t } = useI18n(); - - return ( - <> - {/* `onClick` on the dialog itself catches the backdrop: a click landing - on the element rather than on anything inside it is a click outside - the sheet, which is the only thing the backdrop can be. - - The keyboard equivalent of dismissing by the backdrop is Escape, and - `` already answers it — which is the whole reason the element - was chosen over a hand-built overlay. An `onKeyDown` here would - duplicate a behaviour the platform provides, and a second closer is a - second thing to get out of step. */} - {/* biome-ignore lint/a11y/useKeyWithClickEvents: dismissing by keyboard is Escape, which handles natively */} - { - if (event.target === sheet.current) sheet.current?.close(); - }} - > -
-
-

{t("nav.moreTitle")}

-

{t("nav.moreDetail")}

-
- -
- -
- - ); -} - -/** - * Takes the first-load screen away once the application has committed a - * screen of its own. - * - * Renders nothing. An effect runs after the commit it belongs to, so by the - * time this fires the router has put either the sign-in page or the shell in - * the document — which is what the splash was covering. - * - * **Inside the router's root component**, so the commit this effect belongs - * to is the one that put a screen in the document. As a sibling of - * `RouterProvider` it fired on the first commit of the tree, which the - * router need not have filled yet — the 300ms floor hid that, and a floor - * hiding an ordering is weaker than an ordering that is right. - * - * Not in `loginRoute` and `authedRoute` instead, which was the other - * suggestion: `notFoundComponent` belongs to the root and renders inside - * neither, so an unknown address on a first visit would have kept the mark - * until the ten-second ceiling. - * - * **Not on the scope being ready** either, which would hold the mark over the - * one round trip the shell still waits on. That wait has `Waiting` already, - * with `role="status"`, and is usually finished inside the floor. Covering - * it with the mark would mean the screen outliving a failure it cannot - * describe. - */ -function SplashGate() { - useEffect(() => { - dismissSplash(); - }, []); - return null; -} - -/** - * Holds the catalogue back until the active library is known. One round trip, - * and it buys the guarantee that no screen renders the whole catalogue before - * narrowing to one library — which is what the unscoped first request did. - */ -function ScopedOutlet() { - const { ready } = useLibraryScope(); - // Announced rather than blank: a screen reader on an empty `main` has - // nothing to say, and the wait is a round trip the visitor did not ask for. - return ready ? : ; -} - -/** Compose the authenticated desktop and mobile application chrome. */ -function Shell() { - const { t } = useI18n(); - // Read from the routes rather than set by each page, so the width holds - // while a page is still loading and the hub's sections share it. - const workspace = useMatches({ - select: (matches) => - matches.some((match) => match.staticData.layout === "workspace"), - }); - return ( - - - - - {t("nav.skip")} - -
- -
- - -
-
- -
- - -
-
-
- ); -} - -/** - * Everything every route sits inside: the outlet, and the one effect that - * takes the first-load screen away. - */ -function RootFrame() { - return ( - <> - - - - ); -} - -const rootRoute = createRootRoute({ - component: RootFrame, - notFoundComponent: NotFoundPage, -}); - -const loginRoute = createRoute({ - getParentRoute: () => rootRoute, - path: "/login", - component: LoginPage, -}); - -/** - * What a share link opens (RFC-020 decision 3). Beside the sign-in page and - * not under `authed`: the person a link is sent to has no account, and a - * session check would send them to a sign-in they cannot complete. The server - * hands this shell out only at `/share/{token}`, to a browser asking for HTML. - */ -const shareRoute = createRoute({ - getParentRoute: () => rootRoute, - path: "/share/$token", - component: function ShareRoute() { - const { token } = shareRoute.useParams(); - return ; - }, -}); - -/** - * The tracks kept for offline listening (RFC-022). Beside the sign-in page and - * not under `authed`: it must open with no network, and a session check with - * no server to answer it would send the listener to a sign-in that cannot - * complete either. - */ -const offlineRoute = createRoute({ - getParentRoute: () => rootRoute, - path: "/offline", - component: OfflinePage, -}); - -/** Everything below requires a session; unauthenticated visitors land on /login. */ -const authedRoute = createRoute({ - getParentRoute: () => rootRoute, - id: "authed", - beforeLoad: async () => { - if (!(await ensureSession())) { - // Remember where the user was headed so a desktop authorisation link - // survives the detour through sign-in instead of dropping its PKCE - // parameters and forcing the client to start over. - // Stored and re-read as a path only; safeInternalPath re-checks it on the - // way out, since location.pathname can itself begin with "//". - sessionStorage.setItem( - "waveflow.after-login", - window.location.pathname + window.location.search, - ); - throw redirect({ to: "/login" }); - } - }, - component: Shell, -}); - -const albumsRoute = createRoute({ - getParentRoute: () => authedRoute, - path: "/", - component: AlbumsPage, -}); - -const albumRoute = createRoute({ - getParentRoute: () => authedRoute, - path: "/albums/$albumId", - component: function AlbumRoute() { - const { albumId } = albumRoute.useParams(); - return ; - }, -}); - -const songsRoute = createRoute({ - getParentRoute: () => authedRoute, - path: "/songs", - component: SongsPage, -}); - -const artistsRoute = createRoute({ - getParentRoute: () => authedRoute, - path: "/artists", - component: ArtistsPage, -}); - -const artistRoute = createRoute({ - getParentRoute: () => authedRoute, - path: "/artists/$artistId", - component: function ArtistRoute() { - const { artistId } = artistRoute.useParams(); - return ; - }, -}); - -const authorizeRoute = createRoute({ - getParentRoute: () => authedRoute, - path: "/authorize", - component: AuthorizePage, -}); - -const genresRoute = createRoute({ - getParentRoute: () => authedRoute, - path: "/genres", - component: GenresPage, -}); - -const genreRoute = createRoute({ - getParentRoute: () => authedRoute, - path: "/genres/$genre", - component: function GenreRoute() { - const { genre } = genreRoute.useParams(); - return ; - }, -}); - -const historyRoute = createRoute({ - getParentRoute: () => authedRoute, - path: "/history", - component: HistoryPage, -}); - -// RFC-024: this account's listening, and the mixes drawn from it. -const statsRoute = createRoute({ - getParentRoute: () => authedRoute, - path: "/stats", - component: StatsPage, -}); - -const mixRoute = createRoute({ - getParentRoute: () => authedRoute, - path: "/mixes/$mixId", - component: function MixRoute() { - const { mixId } = mixRoute.useParams(); - return ; - }, -}); - -const randomRoute = createRoute({ - getParentRoute: () => authedRoute, - path: "/random", - component: RandomPage, -}); - -const playingRoute = createRoute({ - getParentRoute: () => authedRoute, - path: "/playing", - component: PlayingPage, -}); - -const searchRoute = createRoute({ - getParentRoute: () => authedRoute, - path: "/search", - component: SearchPage, -}); - -const favoritesRoute = createRoute({ - getParentRoute: () => authedRoute, - path: "/favourites", - component: FavoritesPage, -}); - -const playlistsRoute = createRoute({ - getParentRoute: () => authedRoute, - path: "/playlists", - component: PlaylistsPage, -}); - -const queueRoute = createRoute({ - getParentRoute: () => authedRoute, - path: "/queue", - component: QueuePage, -}); - -const radioRoute = createRoute({ - getParentRoute: () => authedRoute, - path: "/radio", - staticData: { layout: "workspace" }, - component: RadioPage, -}); - -const podcastsRoute = createRoute({ - getParentRoute: () => authedRoute, - path: "/podcasts", - staticData: { layout: "workspace" }, - // The channel whose episodes are open, so Back and a reload keep it. - validateSearch: (search: Record): { channel?: string } => ({ - channel: typeof search.channel === "string" ? search.channel : undefined, - }), - component: function PodcastsRoute() { - const { channel } = podcastsRoute.useSearch(); - return ; - }, -}); - -/** - * Settings, and everything about the account behind its one rail entry. - * - * A frame rather than a page: the section menu over whichever section is - * open. Each section is its own route, so it can be linked to and Back walks - * between them; `/settings` itself is the first section, Account. - */ -const settingsRoute = createRoute({ - getParentRoute: () => authedRoute, - path: "/settings", - component: SettingsHub, -}); - -const accountSettingsRoute = createRoute({ - getParentRoute: () => settingsRoute, - path: "/", - component: SettingsPage, -}); - -const playbackSettingsRoute = createRoute({ - getParentRoute: () => settingsRoute, - path: "/playback", - component: PlaybackSettingsPage, -}); - -// The path is the server's, not a preference. `lastfm_callback` redirects -// here when a journey completes, so spelling it any other way puts the last -// step of that journey on the not-found page. -const scrobblingRoute = createRoute({ - getParentRoute: () => settingsRoute, - path: "/scrobbling", - component: ScrobblingPage, -}); - -const sharesRoute = createRoute({ - getParentRoute: () => settingsRoute, - path: "/shares", - component: SharesPage, -}); - -const uploadRoute = createRoute({ - getParentRoute: () => settingsRoute, - path: "/upload", - // A filed folder to put what is received into (RFC-012 decision 7). - validateSearch: (search: Record): { folder?: string } => ({ - folder: typeof search.folder === "string" ? search.folder : undefined, - }), - component: function UploadRoute() { - const { folder } = uploadRoute.useSearch(); - return ; - }, -}); - -const adminRoute = createRoute({ - getParentRoute: () => settingsRoute, - path: "/admin", - beforeLoad: () => { - if (currentUser()?.role !== "admin") throw redirect({ to: "/" }); - }, - component: AdminPage, -}); - -/** - * The addresses these sections had before they moved under Settings, kept so - * a bookmark or a link written elsewhere still arrives. - * - * Redirects rather than second homes: one address per screen, and the rail's - * Settings entry is current on every one of them because they all sit under - * `/settings`. The router replaces the history entry it redirects from, so - * Back from the section returns to wherever the visitor was before rather - * than to an address that would only send them forward again. - */ -const legacySharesRoute = createRoute({ - getParentRoute: () => authedRoute, - path: "/shares", - beforeLoad: () => { - throw redirect({ to: "/settings/shares", replace: true }); - }, -}); - -const legacyUploadRoute = createRoute({ - getParentRoute: () => authedRoute, - path: "/upload", - // Carried across, so `/upload?folder=…` still fills that folder. - validateSearch: (search: Record): { folder?: string } => ({ - folder: typeof search.folder === "string" ? search.folder : undefined, - }), - beforeLoad: ({ search }) => { - throw redirect({ to: "/settings/upload", search, replace: true }); - }, -}); - -const legacyAdminRoute = createRoute({ - getParentRoute: () => authedRoute, - path: "/admin", - beforeLoad: () => { - throw redirect({ to: "/settings/admin", replace: true }); - }, -}); - -// The folder is a search parameter rather than a path segment: a folder's -// name may hold any character a file system allows, `$` and `?` included, -// and a parameter carries it whole. -const foldersRoute = createRoute({ - getParentRoute: () => authedRoute, - path: "/folders", - staticData: { layout: "workspace" }, - // The folder: absent or empty for the root, `unfiled` for the tracks filed - // nowhere, otherwise a folder id. Optional, so the rail links to the root. - validateSearch: (search: Record): { folder?: string } => ({ - folder: typeof search.folder === "string" ? search.folder : undefined, - }), - component: function FoldersRoute() { - const { folder } = foldersRoute.useSearch(); - return ; - }, -}); - -/** - * Maintenance: "Needs attention", "Missing files" and "Quality" behind one - * rail entry, as Settings holds the account's pages. `/maintenance` itself - * opens the first section the account may use. - */ -const maintenanceRoute = createRoute({ - getParentRoute: () => authedRoute, - path: "/maintenance", - // Every section under the hub inherits it: a child route's match carries - // its own static data, but the hub's match is in the list beside it. - staticData: { layout: "workspace" }, - component: MaintenanceHub, -}); - -const maintenanceIndexRoute = createRoute({ - getParentRoute: () => maintenanceRoute, - path: "/", - component: MaintenanceIndex, -}); - -const validateAttentionSearch = (search: Record) => ({ - category: isAttentionCategory(search.category) ? search.category : undefined, -}); - -const validateQualitySearch = (search: Record) => ({ - verdict: isQualityVerdict(search.verdict) ? search.verdict : undefined, -}); - -// The open list is a search parameter, so a list can be linked to and the -// back button walks between them. -const attentionRoute = createRoute({ - getParentRoute: () => maintenanceRoute, - path: "/attention", - validateSearch: validateAttentionSearch, - component: function AttentionRoute() { - const { category } = attentionRoute.useSearch(); - return ; - }, -}); - -const missingRoute = createRoute({ - getParentRoute: () => maintenanceRoute, - path: "/missing", - component: MissingPage, -}); - -// The verdict filter is a search parameter, so a filtered list can be linked -// to, as the lists of "Needs attention" can. -const qualityRoute = createRoute({ - getParentRoute: () => maintenanceRoute, - path: "/quality", - validateSearch: validateQualitySearch, - component: function QualityRoute() { - const { verdict } = qualityRoute.useSearch(); - return ; - }, -}); - -// Their addresses before they moved under Maintenance, redirected as the -// Settings pages' are: the open list or verdict filter comes along, and the -// redirect replaces its own history entry. -const legacyAttentionRoute = createRoute({ - getParentRoute: () => authedRoute, - path: "/attention", - validateSearch: validateAttentionSearch, - beforeLoad: ({ search }) => { - throw redirect({ to: "/maintenance/attention", search, replace: true }); - }, -}); - -const legacyMissingRoute = createRoute({ - getParentRoute: () => authedRoute, - path: "/missing", - beforeLoad: () => { - throw redirect({ to: "/maintenance/missing", replace: true }); - }, -}); - -const legacyQualityRoute = createRoute({ - getParentRoute: () => authedRoute, - path: "/quality", - validateSearch: validateQualitySearch, - beforeLoad: ({ search }) => { - throw redirect({ to: "/maintenance/quality", search, replace: true }); - }, -}); - -const trackEditRoute = createRoute({ - getParentRoute: () => authedRoute, - path: "/tracks/$trackId/edit", - component: function TrackEditRoute() { - const { trackId } = trackEditRoute.useParams(); - return ; - }, -}); - -const routeTree = rootRoute.addChildren([ - loginRoute, - shareRoute, - offlineRoute, - authedRoute.addChildren([ - albumsRoute, - albumRoute, - songsRoute, - foldersRoute, - maintenanceRoute.addChildren([ - maintenanceIndexRoute, - attentionRoute, - missingRoute, - qualityRoute, - ]), - legacyAttentionRoute, - legacyMissingRoute, - legacyQualityRoute, - artistsRoute, - artistRoute, - genresRoute, - genreRoute, - historyRoute, - statsRoute, - mixRoute, - randomRoute, - playingRoute, - searchRoute, - favoritesRoute, - playlistsRoute, - queueRoute, - radioRoute, - podcastsRoute, - settingsRoute.addChildren([ - accountSettingsRoute, - playbackSettingsRoute, - scrobblingRoute, - sharesRoute, - uploadRoute, - adminRoute, - ]), - legacySharesRoute, - legacyUploadRoute, - legacyAdminRoute, - authorizeRoute, - trackEditRoute, - ]), -]); - const router = createRouter({ routeTree, }); From 8eb786ed7daec13ed359c602acc3a7808721fb03 Mon Sep 17 00:00:00 2001 From: InstaZDLL Date: Mon, 26 Oct 2026 23:34:44 +0100 Subject: [PATCH 2/3] refactor(webapp): split player.tsx along the provider's seams, keeping it as the player's face Signed-off-by: InstaZDLL --- webapp/src/player/aside-playback.ts | 349 ++++++ webapp/src/player/bar.tsx | 283 +++++ webapp/src/player/context.ts | 93 ++ webapp/src/player/order.ts | 145 +++ webapp/src/player/player.tsx | 1430 ++---------------------- webapp/src/player/queue-element.ts | 279 +++++ webapp/src/player/replay-gain-graph.ts | 156 +++ webapp/src/player/system-controls.ts | 240 ++++ webapp/src/player/volume.ts | 23 + 9 files changed, 1671 insertions(+), 1327 deletions(-) create mode 100644 webapp/src/player/aside-playback.ts create mode 100644 webapp/src/player/bar.tsx create mode 100644 webapp/src/player/context.ts create mode 100644 webapp/src/player/order.ts create mode 100644 webapp/src/player/queue-element.ts create mode 100644 webapp/src/player/replay-gain-graph.ts create mode 100644 webapp/src/player/system-controls.ts create mode 100644 webapp/src/player/volume.ts diff --git a/webapp/src/player/aside-playback.ts b/webapp/src/player/aside-playback.ts new file mode 100644 index 00000000..1e4d0aad --- /dev/null +++ b/webapp/src/player/aside-playback.ts @@ -0,0 +1,349 @@ +// The aside: a station or an episode, on its own element beside the queue. +import { useQueryClient } from "@tanstack/react-query"; +import { + type RefObject, + useCallback, + useEffect, + useRef, + useState, +} from "react"; + +import { + deleteBookmark, + type PodcastEpisode, + type RadioStation, + setBookmark, + streamUrl, +} from "../api"; +import { podcastChannelQuery } from "../api/queries"; +import { + type Aside, + bookmarkWrite, + dueForSave, + reportsPlaybackFailure, + resumeSeconds, + shownDuration, + sounding, +} from "../lib/aside"; + +/** Part of `PlayerProvider`, called in its place among the provider's hooks. */ +export function useAsidePlayback(audio: RefObject) { + // ── The aside: a station or an episode, beside the queue ────────────── + // + // Its own element, and deliberately so. The queue's element is the one the + // ReplayGain graph claims, and once claimed its sound reaches the speakers + // only through the context: a station on another origin without CORS + // headers would play there as silence. Nothing below ever hands this one to + // `createMediaElementSource`, so a station plays as any `