From cde5a98fd2839f3ad4ac06252ce8aa771119281a Mon Sep 17 00:00:00 2001 From: npeter83 Date: Fri, 23 Oct 2026 04:00:58 +0200 Subject: [PATCH] refactor(frontend): each module defines itself in its folder; rails load lazily and keep their own state MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A page used to be listed three times (lib/modules for its label, icon and gate; shell/moduleRegistry for its page and rail; lib/pageMeta for its title), and App held every rail's layout and collapse flag. Now each module's `module.ts` says it all — label, icon, gate, its lazy page and rail, its width — handed out through its index.ts, and features/navigation/moduleRegistry lists them in nav order for the rail, the palette, the tab title, the reachable set and the shell's render. A contract test holds the registry to the page ids and each definition to its own folder, with no static import of a page. Every rail is lazy now, loaded together with its page so a pinned rail never arrives after it and shoves the content aside; the feed's and the playlists' rails took @dnd-kit off the boot path (boot 428.5 → 393.9 KiB gzip, the App chunk 122 → 32 KiB). A rail reads its own layout and collapse flag (lib/useRail) and writes a layout change to the `me` cache too, since it now mounts anew on each visit. App's session effects, its one-shot entry-URL handlers and the admin nudge move into shell hooks; App is layout. The registry's three layer suppressions are gone: none is left. --- CLAUDE.md | 10 +- .../features/diagnostics/IssueComposer.tsx | 2 +- .../navigation/NavigationProvider.tsx | 3 +- .../moduleRegistry.contract.test.ts | 36 +++ .../src/features/navigation/moduleRegistry.ts | 99 ++++++ .../navigation/pageTitleKey.test.ts} | 9 +- frontend/src/lib/lazyWithPreload.ts | 11 + .../src/lib/lazyWithPreloadAlongside.test.ts | 29 ++ frontend/src/lib/moduleDef.ts | 40 +++ frontend/src/lib/modules.ts | 193 ----------- frontend/src/lib/pageMeta.ts | 12 - frontend/src/lib/urlState.ts | 2 +- frontend/src/lib/useRail.dom.test.tsx | 46 +++ frontend/src/lib/useRail.ts | 77 +++++ frontend/src/modules/activity/index.ts | 1 + frontend/src/modules/activity/module.ts | 15 + frontend/src/modules/channels/index.ts | 1 + frontend/src/modules/channels/module.ts | 14 + frontend/src/modules/config/index.ts | 2 + frontend/src/modules/config/module.ts | 15 + .../feed/{modulePages.tsx => FeedPage.tsx} | 9 +- frontend/src/modules/feed/Sidebar.tsx | 17 +- frontend/src/modules/feed/index.ts | 1 + frontend/src/modules/feed/module.ts | 20 ++ .../src/modules/files/FilesRail.dom.test.tsx | 24 +- frontend/src/modules/files/FilesRail.tsx | 16 +- frontend/src/modules/files/index.ts | 1 + frontend/src/modules/files/module.ts | 17 + frontend/src/modules/home/index.ts | 2 + frontend/src/modules/home/module.ts | 14 + frontend/src/modules/notifications/index.ts | 2 + frontend/src/modules/notifications/module.ts | 14 + .../PlaylistsRail.rename.dom.test.tsx | 23 +- .../src/modules/playlists/PlaylistsRail.tsx | 16 +- frontend/src/modules/playlists/index.ts | 1 + frontend/src/modules/playlists/module.ts | 17 + frontend/src/modules/plex/PlexSidebar.tsx | 15 +- frontend/src/modules/plex/index.ts | 1 + frontend/src/modules/plex/module.ts | 18 ++ frontend/src/modules/scheduler/index.ts | 2 + frontend/src/modules/scheduler/module.ts | 15 + .../SettingsPanel.downloadAuth.dom.test.tsx | 6 +- frontend/src/modules/settings/index.ts | 2 + frontend/src/modules/settings/module.ts | 13 + frontend/src/modules/stats/index.ts | 2 + frontend/src/modules/stats/module.ts | 13 + frontend/src/modules/users/index.ts | 2 + frontend/src/modules/users/module.ts | 16 + frontend/src/shell/App.tsx | 300 ++---------------- .../src/shell/AppCommandPalette.dom.test.tsx | 4 +- frontend/src/shell/AppCommandPalette.tsx | 2 +- frontend/src/shell/NavSidebar.tsx | 2 +- frontend/src/shell/moduleRegistry.tsx | 107 ------- frontend/src/shell/useAccessRequestsNudge.ts | 49 +++ frontend/src/shell/useEntryParams.ts | 92 ++++++ frontend/src/shell/useShellSession.ts | 103 ++++++ 56 files changed, 890 insertions(+), 685 deletions(-) create mode 100644 frontend/src/features/navigation/moduleRegistry.contract.test.ts create mode 100644 frontend/src/features/navigation/moduleRegistry.ts rename frontend/src/{lib/pageMeta.test.ts => features/navigation/pageTitleKey.test.ts} (73%) create mode 100644 frontend/src/lib/lazyWithPreloadAlongside.test.ts create mode 100644 frontend/src/lib/moduleDef.ts delete mode 100644 frontend/src/lib/modules.ts delete mode 100644 frontend/src/lib/pageMeta.ts create mode 100644 frontend/src/lib/useRail.dom.test.tsx create mode 100644 frontend/src/lib/useRail.ts create mode 100644 frontend/src/modules/activity/module.ts create mode 100644 frontend/src/modules/channels/module.ts create mode 100644 frontend/src/modules/config/index.ts create mode 100644 frontend/src/modules/config/module.ts rename frontend/src/modules/feed/{modulePages.tsx => FeedPage.tsx} (79%) create mode 100644 frontend/src/modules/feed/module.ts create mode 100644 frontend/src/modules/files/module.ts create mode 100644 frontend/src/modules/home/index.ts create mode 100644 frontend/src/modules/home/module.ts create mode 100644 frontend/src/modules/notifications/index.ts create mode 100644 frontend/src/modules/notifications/module.ts create mode 100644 frontend/src/modules/playlists/module.ts create mode 100644 frontend/src/modules/plex/module.ts create mode 100644 frontend/src/modules/scheduler/index.ts create mode 100644 frontend/src/modules/scheduler/module.ts create mode 100644 frontend/src/modules/settings/index.ts create mode 100644 frontend/src/modules/settings/module.ts create mode 100644 frontend/src/modules/stats/index.ts create mode 100644 frontend/src/modules/stats/module.ts create mode 100644 frontend/src/modules/users/index.ts create mode 100644 frontend/src/modules/users/module.ts delete mode 100644 frontend/src/shell/moduleRegistry.tsx create mode 100644 frontend/src/shell/useAccessRequestsNudge.ts create mode 100644 frontend/src/shell/useEntryParams.ts create mode 100644 frontend/src/shell/useShellSession.ts diff --git a/CLAUDE.md b/CLAUDE.md index 4f6fceec..9a119a62 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -75,10 +75,12 @@ cannot (an ORM write on another unit's loaded row, SQL in `text()`, a commit by [`backend/app/README.md`](backend/app/README.md) with the conventions. Frontend, by folder under `src/`: `main.tsx` provider tree → `shell/` (`App`, header, nav rail, palette, boot; a shared `/watch` link boots from its own entry, `watch.html` → `watchMain.tsx` → `watch/`, held -app-free by a contract test) → `shell/moduleRegistry` route-chunks → **`modules//`** (one folder per page: the page, -its rail, its store, its tests) → **`features//`** (what several pages share: video cards, filters, -downloads, the player, navigation) → the **platform layer `components/ui/`** (domain-free primitives: the -popover engine and its menus, `Modal`/`Overlay`, `Button`/`form`, `DataTable`/`VirtualGrid`, `QueryState`, +app-free by a contract test) → **`modules//`** (one folder per page: the page, its rail, its store, its tests, +and its `module.ts` definition — label, icon, gate, the lazy page and rail — collected in nav order by +`features/navigation/moduleRegistry`; a new page is its folder, its id in `lib/urlState` and one line there) → +**`features//`** (what several pages share: video cards, filters, downloads, the player, navigation) → the +**platform layer `components/ui/`** (domain-free primitives: the popover engine and its menus, `Modal`/`Overlay`, +`Button`/`form`, `DataTable`/`VirtualGrid`, `QueryState`, …) → `lib/`. The direction is one-way and **lint-enforced**, not just described (`local/layers`, with its own test): an import only goes down; another module is reached only through its **`index.ts`** (its public face — the stores, mounted at the root and read app-wide, which `features/` may read too) or diff --git a/frontend/src/features/diagnostics/IssueComposer.tsx b/frontend/src/features/diagnostics/IssueComposer.tsx index d45a1e15..ff7a1024 100644 --- a/frontend/src/features/diagnostics/IssueComposer.tsx +++ b/frontend/src/features/diagnostics/IssueComposer.tsx @@ -16,7 +16,7 @@ import { type DraftStep, type IssueText, } from "../../lib/issueDraft"; -import { moduleDef } from "../../lib/modules"; +import { moduleDef } from "../navigation/moduleRegistry"; import type { Page } from "../../lib/urlState"; import { qk } from "../../lib/queryKeys"; import { focusIsUnclaimed } from "../../lib/useFocusKeeper"; diff --git a/frontend/src/features/navigation/NavigationProvider.tsx b/frontend/src/features/navigation/NavigationProvider.tsx index 439e433c..37909d56 100644 --- a/frontend/src/features/navigation/NavigationProvider.tsx +++ b/frontend/src/features/navigation/NavigationProvider.tsx @@ -4,7 +4,6 @@ import { useTranslation } from "react-i18next"; import { useMe } from "../../lib/useMe"; import { resolvePage, retiredParams, urlWithParams, type Page } from "../../lib/urlState"; import { ACTIVITY_KEYS } from "../../lib/activityUrlState"; -import { pageTitleKey } from "../../lib/pageMeta"; import { useDocumentObjectTitle } from "../../lib/documentTitle"; import { getAccountRaw, setAccountRaw } from "../../lib/storage"; import { @@ -14,7 +13,7 @@ import { notifyNav, rememberNavPosition, } from "../../lib/history"; -import { reachablePage } from "../../lib/modules"; +import { pageTitleKey, reachablePage } from "./moduleRegistry"; import { channelViewFrom, loadInitialPage, diff --git a/frontend/src/features/navigation/moduleRegistry.contract.test.ts b/frontend/src/features/navigation/moduleRegistry.contract.test.ts new file mode 100644 index 00000000..f7c6a4df --- /dev/null +++ b/frontend/src/features/navigation/moduleRegistry.contract.test.ts @@ -0,0 +1,36 @@ +// @vitest-environment jsdom +// +// The registry reaches the modules' faces, whose stores read the browser (i18n, localStorage). +import { readFileSync } from "node:fs"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; +import { describe, expect, it } from "vitest"; +import { PAGES } from "../../lib/urlState"; +import { REGISTERED_PAGES, moduleDef } from "./moduleRegistry"; + +// The registry is the one place a page is listed, so it has to hold every page id the address bar +// accepts and nothing else: a page id with no definition would throw in the nav rail at the first +// render, and a definition with no id could never be navigated to. +const SRC = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); + +describe("the module registry", () => { + it("defines every page id exactly once", () => { + expect([...REGISTERED_PAGES].sort()).toEqual([...PAGES].sort()); + expect(new Set(REGISTERED_PAGES).size).toBe(REGISTERED_PAGES.length); + }); + + it.each([...PAGES])("takes %s's definition from that module's own folder", (page) => { + // The definition's id names its page, and it lives at modules//module.ts — so a page is one + // folder, and a copied definition with a stale id cannot pass as another page's. + expect(moduleDef(page).id).toBe(page); + const source = readFileSync(resolve(SRC, `modules/${page}/module.ts`), "utf8"); + expect(source).toContain(`id: "${page}",`); + }); + + it.each([...PAGES])("loads %s's page (and rail) lazily", (page) => { + // A static import of the page in its definition would put it on the boot path for every + // account; the chunk guard fails the build on that too, this says which module did it. + const source = readFileSync(resolve(SRC, `modules/${page}/module.ts`), "utf8"); + expect(source).not.toMatch(/^import [^;]* from "\.\//m); + }); +}); diff --git a/frontend/src/features/navigation/moduleRegistry.ts b/frontend/src/features/navigation/moduleRegistry.ts new file mode 100644 index 00000000..b94755be --- /dev/null +++ b/frontend/src/features/navigation/moduleRegistry.ts @@ -0,0 +1,99 @@ +import type { Me } from "../../lib/api"; +import type { Page } from "../../lib/urlState"; +import type { ModuleDef } from "../../lib/moduleDef"; +import { homeModule } from "../../modules/home"; +import { feedModule } from "../../modules/feed"; +import { channelsModule } from "../../modules/channels"; +import { playlistsModule } from "../../modules/playlists"; +import { plexModule } from "../../modules/plex"; +import { notificationsModule } from "../../modules/notifications"; +import { filesModule } from "../../modules/files"; +import { statsModule } from "../../modules/stats"; +import { schedulerModule } from "../../modules/scheduler"; +import { configModule } from "../../modules/config"; +import { usersModule } from "../../modules/users"; +import { activityModule } from "../../modules/activity"; +import { settingsModule } from "../../modules/settings"; + +// ── The module registry ─────────────────────────────────────────────────────────────────────── +// Every page, as each module defines itself (its folder's module.ts), in one ordered list: the nav +// rail, the palette, the `g` chords, the tab title, the reachable set and the shell's render all read +// from here, so a new or newly gated module shows up everywhere at once. Each definition's page and +// rail are lazy imports, so listing them here pulls no page into the boot chunk (chunkGuard holds it). +// +// Ordered in nav-rail order — this array IS the sequence the rail and the palette list. Home leads: it +// is the landing (see navigationInit.loadInitialPage). The feed takes the YouTube mark its own toolbar +// already uses, so it and Home sit apart by shape and not just by position. +const MODULES: readonly ModuleDef[] = [ + homeModule, + feedModule, + channelsModule, + playlistsModule, + plexModule, + notificationsModule, + filesModule, + statsModule, + schedulerModule, + configModule, + usersModule, + activityModule, +]; + +// Settings is reachable (its own rail button, a `g` chord) but is not a primary module in the ordered +// nav list, so it lives beside the array rather than in it. +const ALL: readonly ModuleDef[] = [...MODULES, settingsModule]; + +// Every Page has a definition: the registry contract test pins this list against lib/urlState's ids. +export const REGISTERED_PAGES: readonly Page[] = ALL.map((m) => m.id); + +const BY_ID = Object.fromEntries(ALL.map((m) => [m.id, m])) as Record; + +// Look up a module's definition by page id (every Page is covered, incl. settings). +export function moduleDef(id: Page): ModuleDef { + return BY_ID[id]; +} + +// The i18n key for each module's SHORT display name, and its rail label. +export const moduleLabelKey = Object.fromEntries(ALL.map((m) => [m.id, m.labelKey])) as Record< + Page, + string +>; +export const moduleRailKey = Object.fromEntries(ALL.map((m) => [m.id, m.railKey])) as Record< + Page, + string +>; + +// Admin/system modules — rendered in their own section (below a divider) in the nav rail. +export const SYSTEM_PAGES: readonly Page[] = MODULES.filter((m) => m.system).map((m) => m.id); + +// The ordered list of primary module pages available to THIS user, in nav-rail order. Availability +// is derived from the account via each module's `gate`, so the reachable set changes with the +// account state, not the UI. +export function moduleOrder(me: Me): Page[] { + return MODULES.filter((m) => !m.gate || m.gate(me)).map((m) => m.id); +} + +/** The page this account is shown for `page`: itself when its gate lets the account in, else the + * feed (a link to an admin page, a Back onto one, an admin role taken away mid-session). Signed + * out, no page renders, so nothing is moved. */ +export function reachablePage(page: Page, me: Me | null | undefined): Page { + const gate = moduleDef(page).gate; + return !me || !gate || gate(me) ? page : "feed"; +} + +// The i18n key for a page's human title (the browser tab): a module's optional `titleKey` is the +// longer, descriptive one (e.g. "Channel manager"), falling back to its short nav `labelKey`. +export function pageTitleKey(page: Page): string { + const def = moduleDef(page); + return def.titleKey ?? def.labelKey; +} + +/** Fetch the code of these pages and of their side rails now, ahead of their first visit. Errors are + * swallowed: a chunk that fails to preload is simply fetched (and its failure surfaced) on visit. */ +export function preloadPages(pages: Page[]): void { + for (const p of pages) { + const def = moduleDef(p); + def.Page.preload().catch(() => {}); + def.Rail?.preload().catch(() => {}); + } +} diff --git a/frontend/src/lib/pageMeta.test.ts b/frontend/src/features/navigation/pageTitleKey.test.ts similarity index 73% rename from frontend/src/lib/pageMeta.test.ts rename to frontend/src/features/navigation/pageTitleKey.test.ts index cb402f3c..e3318b8f 100644 --- a/frontend/src/lib/pageMeta.test.ts +++ b/frontend/src/features/navigation/pageTitleKey.test.ts @@ -1,8 +1,11 @@ +// @vitest-environment jsdom +// +// The registry reaches the modules' faces, whose stores read the browser (i18n, localStorage). import { describe, expect, it } from "vitest"; -import { pageTitleKey } from "./pageMeta"; -import type { Page } from "./urlState"; +import { pageTitleKey } from "./moduleRegistry"; +import type { Page } from "../../lib/urlState"; -// pageTitleKey now derives from the ModuleDef registry (titleKey ?? labelKey). These are the exact +// pageTitleKey derives from the module registry (titleKey ?? labelKey). These are the exact // browser-tab title keys the old hand-written switch produced — pin every page so a future labelKey // rename (or a dropped titleKey) can't silently change a tab title. const EXPECTED: Record = { diff --git a/frontend/src/lib/lazyWithPreload.ts b/frontend/src/lib/lazyWithPreload.ts index fd7da657..c31906ed 100644 --- a/frontend/src/lib/lazyWithPreload.ts +++ b/frontend/src/lib/lazyWithPreload.ts @@ -41,3 +41,14 @@ export function lazyWithPreload

( } return Object.assign(Preloadable, { preload }); } + +/** A lazy page that resolves only once `alongside` (its side rail) has loaded too. The rail renders in + * its own Suspense beside the page, and a pinned rail sits in the page's flow: arriving after the page, + * it would shove the page's content sideways on its first visit. Loaded together, the rail is already + * there (it renders synchronously once loaded) when the page commits. */ +export function lazyWithPreloadAlongside

( + factory: () => Promise<{ default: ComponentType

}>, + alongside: PreloadableComponent, +): PreloadableComponent

{ + return lazyWithPreload(() => Promise.all([factory(), alongside.preload()]).then(([m]) => m)); +} diff --git a/frontend/src/lib/lazyWithPreloadAlongside.test.ts b/frontend/src/lib/lazyWithPreloadAlongside.test.ts new file mode 100644 index 00000000..4334d6fb --- /dev/null +++ b/frontend/src/lib/lazyWithPreloadAlongside.test.ts @@ -0,0 +1,29 @@ +// A page whose rail sits in its flow must not be ready before the rail: the rail would then appear a +// moment after the page and shove its content sideways. +import { describe, expect, it } from "vitest"; +import type { ComponentType } from "react"; +import { lazyWithPreload, lazyWithPreloadAlongside } from "./lazyWithPreload"; + +const component = (() => null) as ComponentType; + +describe("lazyWithPreloadAlongside", () => { + it("is ready only once its rail has loaded too", async () => { + let releaseRail = () => {}; + const rail = lazyWithPreload( + () => + new Promise<{ default: ComponentType }>((resolve) => { + releaseRail = () => resolve({ default: component }); + }), + ); + const page = lazyWithPreloadAlongside(async () => ({ default: component }), rail); + let pageReady = false; + const loading = page.preload().then(() => { + pageReady = true; + }); + await new Promise((r) => setTimeout(r, 0)); + expect(pageReady).toBe(false); // its own code is in, its rail's is not + releaseRail(); + await loading; + expect(pageReady).toBe(true); + }); +}); diff --git a/frontend/src/lib/moduleDef.ts b/frontend/src/lib/moduleDef.ts new file mode 100644 index 00000000..d8644d79 --- /dev/null +++ b/frontend/src/lib/moduleDef.ts @@ -0,0 +1,40 @@ +import type { LucideIcon } from "lucide-react"; +import type { Me } from "./api"; +import type { Page } from "./urlState"; +import type { PreloadableComponent } from "./lazyWithPreload"; + +// What a module says about itself: one definition per page, in its own folder (`modules//module.ts`, +// handed out through its index.ts), collected in nav order by features/navigation/moduleRegistry. A new +// page is its folder, its id in lib/urlState, and one line in that registry. +export type ModuleDef = { + id: Page; + // i18n key for the SHORT display name — the label the nav rail and the palette show. + labelKey: string; + // The SHORT name under the icon when the user turns the rail labels on (B7): the rail is 3.75rem + // wide with labels or without, so it has to fit there ("Alerts", "Listák"). The full `labelKey` + // stays the tooltip and the accessible name. + railKey: string; + // i18n key for the LONGER, descriptive browser-tab title (e.g. "Channel manager" vs the rail's + // "Channels"). Absent → the tab title falls back to `labelKey`. Read via `pageTitleKey`. + titleKey?: string; + icon: LucideIcon; + // Admin/system modules render in their own section (below a divider) in the nav rail. + system?: boolean; + // Carries a live unread/active count in the rail (the number itself is computed in NavSidebar, + // which owns the polling queries; this only flags which modules have one). + hasBadge?: boolean; + // Availability for THIS account, derived from language-independent account state (plex toggle, + // admin role). Absent = always available. Drives the reachable set: the rail, the palette, the + // `g` chords, and which page a link may land on. + gate?: (me: Me) => boolean; + // The page itself: lazy, its own chunk, fetched on the first visit or by the idle preload. Prop-free: + // it reads `me` and its module's state from hooks. + Page: PreloadableComponent; + // Its side rail, if it has one: lazy like the page, and prop-free (its layout and collapse flag are + // its own, lib/useRail). + Rail?: PreloadableComponent; + // The content column's max width. The header + toolbar centre on this same width (via --content-mw) + // so the floating chrome lines up with the centred content instead of spanning the whole column. + // MUST match the max-w the page's own content wrapper uses. Omit for a full-width page (feed). + maxWidth?: string; +}; diff --git a/frontend/src/lib/modules.ts b/frontend/src/lib/modules.ts deleted file mode 100644 index 19b4b82b..00000000 --- a/frontend/src/lib/modules.ts +++ /dev/null @@ -1,193 +0,0 @@ -import { - Activity, - BarChart3, - Bell, - Clapperboard, - Home, - ListVideo, - ScrollText, - FolderOpen, - Settings, - SlidersHorizontal, - Tv, - Users, - type LucideIcon, -} from "lucide-react"; -import { YouTubeIcon } from "./brandIcons"; -import type { Me } from "./api"; -import type { Page } from "./urlState"; - -// ── The module registry ─────────────────────────────────────────────────────────────────────── -// One ordered source of truth for every navigable module: its nav label, icon, admin/system -// placement, whether it carries a live badge, and its availability gate. The nav rail, the command -// palette's module group, and (as the registry grows) the page render all read from here, so a new or newly -// gated module shows up everywhere at once — nothing hard-codes the sequence or duplicates the icon. -export type ModuleDef = { - id: Page; - // i18n key for the SHORT display name — the label the nav rail and the palette show. - labelKey: string; - // The SHORT name under the icon when the user turns the rail labels on (B7): the rail is 3.75rem - // wide with labels or without, so it has to fit there ("Alerts", "Listák"). The full `labelKey` - // stays the tooltip and the accessible name. - railKey: string; - // i18n key for the LONGER, descriptive browser-tab title (e.g. "Channel manager" vs the rail's - // "Channels"). Absent → the tab title falls back to `labelKey`. Read via `pageTitleKey`. - titleKey?: string; - icon: LucideIcon; - // Admin/system modules render in their own section (below a divider) in the nav rail. - system?: boolean; - // Carries a live unread/active count in the rail (the number itself is computed in NavSidebar, - // which owns the polling queries; this only flags which modules have one). - hasBadge?: boolean; - // Availability for THIS account, derived from language-independent account state (plex toggle, - // admin role). Absent = always available. Drives both the reachable module - // set (nav + stepper) and, later, the render gate. - gate?: (me: Me) => boolean; -}; - -const isAdmin = (me: Me) => me.role === "admin"; - -// Ordered in nav-rail order — this array IS the sequence the rail and the palette list. -const MODULES: readonly ModuleDef[] = [ - // Home leads: it is the landing (see navigationInit.loadInitialPage), and the module order IS the - // nav order and the palette's. The feed takes the YouTube mark its own toolbar - // already uses, so the two are apart by shape and not just by position. - { - id: "home", - labelKey: "home.navLabel", - railKey: "nav.rail.home", - titleKey: "home.title", - icon: Home, - }, - { id: "feed", labelKey: "header.account.feed", railKey: "nav.rail.feed", icon: YouTubeIcon }, - { - id: "channels", - labelKey: "header.account.channels", - railKey: "nav.rail.channels", - titleKey: "header.channelManager", - icon: Tv, - }, - { - id: "playlists", - labelKey: "header.account.playlists", - railKey: "nav.rail.playlists", - icon: ListVideo, - }, - { - id: "plex", - labelKey: "plex.navLabel", - railKey: "nav.rail.plex", - icon: Clapperboard, - gate: (me) => me.plex_enabled, - }, - { - id: "notifications", - labelKey: "inbox.navLabel", - railKey: "nav.rail.notifications", - icon: Bell, - hasBadge: true, - }, - { - id: "files", - labelKey: "files.navLabel", - railKey: "nav.rail.files", - icon: FolderOpen, - }, - { - id: "stats", - labelKey: "header.account.stats", - railKey: "nav.rail.stats", - titleKey: "header.usageStats", - icon: BarChart3, - }, - { - id: "scheduler", - labelKey: "header.account.scheduler", - railKey: "nav.rail.scheduler", - titleKey: "header.scheduler", - icon: Activity, - system: true, - gate: isAdmin, - }, - { - id: "config", - labelKey: "header.account.config", - railKey: "nav.rail.config", - titleKey: "header.configuration", - icon: SlidersHorizontal, - system: true, - gate: isAdmin, - }, - { - id: "users", - labelKey: "header.account.users", - railKey: "nav.rail.users", - titleKey: "header.users", - icon: Users, - system: true, - gate: isAdmin, - }, - { - id: "activity", - labelKey: "header.account.activity", - railKey: "nav.rail.activity", - titleKey: "activity.title", - icon: ScrollText, - system: true, - gate: isAdmin, - }, -]; - -// Settings is reachable (its own rail button + the ◀/▶ wrap target) but is not a primary module in -// the ordered nav list, so it lives beside the array rather than in it. -const SETTINGS_MODULE: ModuleDef = { - id: "settings", - labelKey: "header.account.settings", - railKey: "nav.rail.settings", - titleKey: "settings.title", - icon: Settings, -}; - -// Registering a NEW Page means adding it to MODULES (or SETTINGS_MODULE) above — that's the single -// place. If a Page is left unregistered, moduleDef()/moduleLabelKey[] return undefined and NavSidebar -// throws on `.icon`; the coverage isn't compiler-enforced here because the ordered array can't also -// be an exhaustive Page map, so treat the array as the checklist when the Page union grows. -const BY_ID = Object.fromEntries([...MODULES, SETTINGS_MODULE].map((m) => [m.id, m])) as Record< - Page, - ModuleDef ->; - -// Look up a module's definition by page id (every Page is covered, incl. settings). -export function moduleDef(id: Page): ModuleDef { - return BY_ID[id]; -} - -// The i18n key for each module's SHORT display name. Single source so the nav rail and the header's -// ModuleName pill always read identically (the longer, descriptive `pageTitleKey` names are kept -// only for the browser tab title). -export const moduleLabelKey: Record = Object.fromEntries( - [...MODULES, SETTINGS_MODULE].map((m) => [m.id, m.labelKey]), -) as Record; - -export const moduleRailKey: Record = Object.fromEntries( - [...MODULES, SETTINGS_MODULE].map((m) => [m.id, m.railKey]), -) as Record; - -// Admin/system modules — rendered in their own section (below a divider) in the nav rail. -export const SYSTEM_PAGES: readonly Page[] = MODULES.filter((m) => m.system).map((m) => m.id); - -// The ordered list of primary module pages available to THIS user, in nav-rail order. Single -// source of truth for both the nav rail (NavSidebar) and the header's ◀/▶ module stepper, so a -// new module (or a newly-gated one) shows up in both at once. Availability is derived from the -// account via each module's `gate`, so the reachable set changes with the account state, not the UI. -export function moduleOrder(me: Me): Page[] { - return MODULES.filter((m) => !m.gate || m.gate(me)).map((m) => m.id); -} - -/** The page this account is shown for `page`: itself when its gate lets the account in, else the - * feed (a link to an admin page, a Back onto one, an admin role taken away mid-session). Signed - * out, no page renders, so nothing is moved. */ -export function reachablePage(page: Page, me: Me | null | undefined): Page { - const gate = moduleDef(page).gate; - return !me || !gate || gate(me) ? page : "feed"; -} diff --git a/frontend/src/lib/pageMeta.ts b/frontend/src/lib/pageMeta.ts deleted file mode 100644 index ae531e57..00000000 --- a/frontend/src/lib/pageMeta.ts +++ /dev/null @@ -1,12 +0,0 @@ -import type { Page } from "./urlState"; -import { moduleDef } from "./modules"; - -// The i18n key for a page's human title — shared by the top-bar header and the browser tab title so -// the two never drift. Derived from the single ModuleDef registry (modules.ts): a module's optional -// `titleKey` is the longer, descriptive tab title (e.g. "Channel manager"), falling back to its -// short nav `labelKey` when the two are the same. Registering a new Page is then one touch (add it -// to MODULES) instead of also editing a switch here. -export function pageTitleKey(page: Page): string { - const def = moduleDef(page); - return def.titleKey ?? def.labelKey; -} diff --git a/frontend/src/lib/urlState.ts b/frontend/src/lib/urlState.ts index 1b99624d..486ad5e9 100644 --- a/frontend/src/lib/urlState.ts +++ b/frontend/src/lib/urlState.ts @@ -118,7 +118,7 @@ export function hasFilterParams(params: URLSearchParams): boolean { // The single source of truth for valid page ids; `Page` and the runtime validator both // derive from it, so adding a page is a one-line change (no parallel allowlists to keep in // sync). "feed" is the default/fallback. -const PAGES = [ +export const PAGES = [ "home", "feed", "channels", diff --git a/frontend/src/lib/useRail.dom.test.tsx b/frontend/src/lib/useRail.dom.test.tsx new file mode 100644 index 00000000..998a5ce0 --- /dev/null +++ b/frontend/src/lib/useRail.dom.test.tsx @@ -0,0 +1,46 @@ +// @vitest-environment jsdom +// +// A rail mounts anew on each visit to its page and seeds its layout from the `me` cache. When the shell +// held the layout, a change lived on in its state; now the rail must leave the change where its next +// mount looks, or a visit to another page and back brings the server's older layout back. +import { describe, expect, it, vi } from "vitest"; +import { renderHook, act } from "@testing-library/react"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { ReactNode } from "react"; +import { api } from "./api"; +import { qk } from "./queryKeys"; +import { usePanelLayout } from "./useRail"; + +vi.mock("react-i18next", async (importOriginal) => ({ + ...(await importOriginal()), + useTranslation: () => ({ t: (k: string) => k }), +})); + +const OLD = { + order: ["year", "date", "channel", "tags", "language", "topic"], + collapsed: {}, + hidden: {}, +}; +const NEW = { + order: ["tags", "date", "year", "channel", "language", "topic"], + collapsed: {}, + hidden: {}, +}; + +describe("a rail's layout", () => { + it("is the changed one on the next visit, not the server's older copy", () => { + vi.spyOn(api, "savePrefs").mockResolvedValue(undefined as never); + const qc = new QueryClient(); + qc.setQueryData(qk.me(), { id: 1, preferences: { sidebarLayout: OLD } }); + const wrapper = ({ children }: { children: ReactNode }) => ( + {children} + ); + const first = renderHook(() => usePanelLayout("feed"), { wrapper }); + expect(first.result.current[0].order).toEqual(OLD.order); + act(() => first.result.current[1](NEW)); + first.unmount(); + const next = renderHook(() => usePanelLayout("feed"), { wrapper }); + expect(next.result.current[0].order).toEqual(NEW.order); + expect(api.savePrefs).toHaveBeenCalledWith({ sidebarLayout: NEW }); + }); +}); diff --git a/frontend/src/lib/useRail.ts b/frontend/src/lib/useRail.ts new file mode 100644 index 00000000..ab3ff7bf --- /dev/null +++ b/frontend/src/lib/useRail.ts @@ -0,0 +1,77 @@ +import { useState } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { useTranslation } from "react-i18next"; +import { api, type Me } from "./api"; +import { qk } from "./queryKeys"; +import { notifyActionError } from "./actionErrors"; +import { LS } from "./storage"; +import { usePref } from "./usePref"; +import { updateMeCache } from "./useMe"; +import { + PREF_KEY, + saveLayoutLocal, + seedLayout, + type PanelId, + type PanelLayout, +} from "./panelLayout"; + +// A side rail's own state: its group cards' layout and whether it is collapsed. Each rail reads it +// here rather than being handed it, so the shell only places the rail. +// +// The rail mounts anew on every visit to its page, so the layout seeds from the `me` cache each time: +// a change is written there too (as usePref does), or the next visit would seed the layout from before +// it. The server keeps the copy that follows the account, this browser a local one for an account +// whose server copy has none. +export function usePanelLayout(panel: PanelId): [PanelLayout, (next: PanelLayout) => void] { + const { t } = useTranslation(); + const qc = useQueryClient(); + const [layout, setState] = useState(() => + seedLayout(panel, qc.getQueryData(qk.me())?.preferences), + ); + const setLayout = (next: PanelLayout) => { + setState(next); + saveLayoutLocal(panel, next); + updateMeCache(qc, (m) => ({ + ...m, + preferences: { ...m.preferences, [PREF_KEY[panel]]: next }, + })); + api + .savePrefs({ [PREF_KEY[panel]]: next }) + .catch((e: unknown) => notifyActionError(e, t("errors.writeFailed.prefs"))); + }; + return [layout, setLayout]; +} + +// Which collapse flag a rail uses: the feed and Plex share the filter rail's, the playlists and the +// files rails have their own. Default unpinned (a tab that expands on hover) — except the files rail, +// which is how you move around the library rather than a filter you set once, so it starts pinned. +const COLLAPSE = { + feed: { + key: "filterCollapsed", + initial: true, + opts: { localKey: LS.filterCollapsed, parse: Boolean }, + }, + plex: { + key: "filterCollapsed", + initial: true, + opts: { localKey: LS.filterCollapsed, parse: Boolean }, + }, + playlists: { + key: "playlistsCollapsed", + initial: true, + opts: { localKey: LS.playlistsCollapsed, parse: Boolean }, + }, + files: { + key: "filesCollapsed", + initial: false, + opts: { localKey: LS.filesCollapsed, parse: Boolean }, + }, +} as const; + +/** What a rail hands its FilterRail: the layout and the collapse flag, each with its setter. */ +export function useRail(panel: PanelId) { + const [layout, setLayout] = usePanelLayout(panel); + const c = COLLAPSE[panel]; + const [collapsed, setCollapsed] = usePref(c.key, c.initial, c.opts); + return { layout, setLayout, collapsed, onToggleCollapse: () => setCollapsed(!collapsed) }; +} diff --git a/frontend/src/modules/activity/index.ts b/frontend/src/modules/activity/index.ts index c327c99f..4f3bdcbe 100644 --- a/frontend/src/modules/activity/index.ts +++ b/frontend/src/modules/activity/index.ts @@ -1,2 +1,3 @@ // The activity page's public face: what another unit may use of it. export { StatusDot, type DotKey } from "./RunStatusDot"; +export { activityModule } from "./module"; diff --git a/frontend/src/modules/activity/module.ts b/frontend/src/modules/activity/module.ts new file mode 100644 index 00000000..efe7a1f1 --- /dev/null +++ b/frontend/src/modules/activity/module.ts @@ -0,0 +1,15 @@ +import { ScrollText } from "lucide-react"; +import { lazyWithPreload } from "../../lib/lazyWithPreload"; +import type { ModuleDef } from "../../lib/moduleDef"; + +// This page as the shell and the navigation know it (lib/moduleDef). Its page load lazily. +export const activityModule: ModuleDef = { + id: "activity", + labelKey: "header.account.activity", + railKey: "nav.rail.activity", + titleKey: "activity.title", + icon: ScrollText, + system: true, + gate: (me) => me.role === "admin", + Page: lazyWithPreload(() => import("./Activity")), +}; diff --git a/frontend/src/modules/channels/index.ts b/frontend/src/modules/channels/index.ts index 83aa55ae..2dd9e0be 100644 --- a/frontend/src/modules/channels/index.ts +++ b/frontend/src/modules/channels/index.ts @@ -3,3 +3,4 @@ export { ChannelsProvider } from "./ChannelsProvider"; export { useChannelsActions } from "./channelsContext"; export { default as SyncStatus } from "./SyncStatus"; +export { channelsModule } from "./module"; diff --git a/frontend/src/modules/channels/module.ts b/frontend/src/modules/channels/module.ts new file mode 100644 index 00000000..2e079a6e --- /dev/null +++ b/frontend/src/modules/channels/module.ts @@ -0,0 +1,14 @@ +import { Tv } from "lucide-react"; +import { lazyWithPreload } from "../../lib/lazyWithPreload"; +import type { ModuleDef } from "../../lib/moduleDef"; + +// This page as the shell and the navigation know it (lib/moduleDef). Its page load lazily. +export const channelsModule: ModuleDef = { + id: "channels", + labelKey: "header.account.channels", + railKey: "nav.rail.channels", + titleKey: "header.channelManager", + icon: Tv, + Page: lazyWithPreload(() => import("./Channels")), + maxWidth: "96rem", +}; diff --git a/frontend/src/modules/config/index.ts b/frontend/src/modules/config/index.ts new file mode 100644 index 00000000..e16f7e77 --- /dev/null +++ b/frontend/src/modules/config/index.ts @@ -0,0 +1,2 @@ +// The config page's public face: how the shell and the navigation know it. +export { configModule } from "./module"; diff --git a/frontend/src/modules/config/module.ts b/frontend/src/modules/config/module.ts new file mode 100644 index 00000000..ccd25d4f --- /dev/null +++ b/frontend/src/modules/config/module.ts @@ -0,0 +1,15 @@ +import { SlidersHorizontal } from "lucide-react"; +import { lazyWithPreload } from "../../lib/lazyWithPreload"; +import type { ModuleDef } from "../../lib/moduleDef"; + +// This page as the shell and the navigation know it (lib/moduleDef). Its page load lazily. +export const configModule: ModuleDef = { + id: "config", + labelKey: "header.account.config", + railKey: "nav.rail.config", + titleKey: "header.configuration", + icon: SlidersHorizontal, + system: true, + gate: (me) => me.role === "admin", + Page: lazyWithPreload(() => import("./ConfigPanel")), +}; diff --git a/frontend/src/modules/feed/modulePages.tsx b/frontend/src/modules/feed/FeedPage.tsx similarity index 79% rename from frontend/src/modules/feed/modulePages.tsx rename to frontend/src/modules/feed/FeedPage.tsx index d2b3c311..8ff03af0 100644 --- a/frontend/src/modules/feed/modulePages.tsx +++ b/frontend/src/modules/feed/FeedPage.tsx @@ -1,4 +1,3 @@ -import { lazyWithPreload } from "../../lib/lazyWithPreload"; import { useMe } from "../../lib/useMe"; import { useFeedFilters, useFeedFiltersActions } from "./feedFiltersContext"; import { useFeedView, useFeedViewActions } from "./feedViewContext"; @@ -6,16 +5,12 @@ import { useNavigation, useNavigationActions } from "../../features/navigation/n import PersonalViewsModal from "../../features/filters/PersonalViewsModal"; import { useSavedViews } from "../../features/filters/savedViewsContext"; import FeedHeader from "./FeedHeader"; - -// The two module-registry pages that need per-render wiring (see moduleRegistry), split out of it so -// that file exports only data and this one only components (Fast Refresh). Their lazy chunks are -// exported alongside so the registry can attach each chunk's preload to the wrapper that renders it. -export const Feed = lazyWithPreload(() => import("./Feed")); +import Feed from "./Feed"; // The GLOBAL feed page: wires to the app-wide feed filters / view / live-search, and fills the // header (the channel page's feed has no header). (The channel // page renders itself with its own channel-scoped filters, so those two never share state.) -export function FeedPage() { +export default function FeedPage() { const me = useMe(); const filters = useFeedFilters(); const { setFilters } = useFeedFiltersActions(); diff --git a/frontend/src/modules/feed/Sidebar.tsx b/frontend/src/modules/feed/Sidebar.tsx index 0ac943ff..d9a68176 100644 --- a/frontend/src/modules/feed/Sidebar.tsx +++ b/frontend/src/modules/feed/Sidebar.tsx @@ -4,7 +4,7 @@ import { keepPreviousData, useQuery } from "@tanstack/react-query"; import { qk } from "../../lib/queryKeys"; import { SlidersHorizontal } from "lucide-react"; import { api, NO_TAG_SENTINEL, type FeedFilters, type Tag, type TagCategory } from "../../lib/api"; -import { defaultLayout, type PanelLayout } from "../../lib/panelLayout"; +import { defaultLayout } from "../../lib/panelLayout"; import { useDebounced } from "../../lib/useDebounced"; import { useActiveFilters } from "../../lib/useActiveFilters"; import TagManager from "../../features/channel/TagManager"; @@ -23,6 +23,7 @@ import { useFeedFilters, useFeedFiltersActions } from "./feedFiltersContext"; import { useChannelsActions } from "../channels"; import { useTags } from "../../lib/useTags"; import { compareText } from "../../lib/collate"; +import { useRail } from "../../lib/useRail"; // Filter ids; display labels resolved at render time via t("sidebar.sort.") etc. const DATE_PRESETS: { days: number; key: string }[] = [ @@ -37,17 +38,9 @@ const DATE_PRESETS: { days: number; key: string }[] = [ { days: 1825, key: "5years" }, ]; -export default function Sidebar({ - layout, - setLayout, - collapsed, - onToggleCollapse, -}: { - layout: PanelLayout; - setLayout: (l: PanelLayout) => void; - collapsed: boolean; - onToggleCollapse: () => void; -}) { +export default function Sidebar() { + // Its layout and collapse flag are its own (lib/useRail), so the shell only places it. + const { layout, setLayout, collapsed, onToggleCollapse } = useRail("feed"); const { t, i18n } = useTranslation(); const lang = i18n.language; const { openModal } = useSavedViews(); diff --git a/frontend/src/modules/feed/index.ts b/frontend/src/modules/feed/index.ts index 68ac8954..e3ab4cc0 100644 --- a/frontend/src/modules/feed/index.ts +++ b/frontend/src/modules/feed/index.ts @@ -4,3 +4,4 @@ export { FeedViewProvider } from "./FeedViewProvider"; export { SavedViewsProvider } from "./SavedViewsProvider"; export { useFeedFilters, useFeedFiltersActions } from "./feedFiltersContext"; export { useFeedView, useFeedViewActions } from "./feedViewContext"; +export { ChannelPage, feedModule } from "./module"; diff --git a/frontend/src/modules/feed/module.ts b/frontend/src/modules/feed/module.ts new file mode 100644 index 00000000..e2e83171 --- /dev/null +++ b/frontend/src/modules/feed/module.ts @@ -0,0 +1,20 @@ +import { YouTubeIcon } from "../../lib/brandIcons"; +import { lazyWithPreload, lazyWithPreloadAlongside } from "../../lib/lazyWithPreload"; +import type { ModuleDef } from "../../lib/moduleDef"; + +// The rail loads with the page (lazyWithPreloadAlongside), so it never arrives after it. +const Rail = lazyWithPreload(() => import("./Sidebar")); + +// This page as the shell and the navigation know it (lib/moduleDef). Its page and rail load lazily. +export const feedModule: ModuleDef = { + id: "feed", + labelKey: "header.account.feed", + railKey: "nav.rail.feed", + icon: YouTubeIcon, + Rail, + Page: lazyWithPreloadAlongside(() => import("./FeedPage"), Rail), +}; + +// The channel page: a feed scoped to one channel, opened over any page (App renders it in place of the +// page). Lazy like a page, and preloaded with them. +export const ChannelPage = lazyWithPreload(() => import("./ChannelPage")); diff --git a/frontend/src/modules/files/FilesRail.dom.test.tsx b/frontend/src/modules/files/FilesRail.dom.test.tsx index 553637aa..c6e35c15 100644 --- a/frontend/src/modules/files/FilesRail.dom.test.tsx +++ b/frontend/src/modules/files/FilesRail.dom.test.tsx @@ -9,6 +9,15 @@ import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-libra import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import type { ReactNode } from "react"; +// The rail's own state, pinned open: these tests are about what it lists, not how it folds. +vi.mock("../../lib/useRail", () => ({ + useRail: () => ({ + layout: { order: ["library", "sources", "own"], collapsed: {}, hidden: {} }, + setLayout: () => {}, + collapsed: false, + onToggleCollapse: () => {}, + }), +})); vi.mock("react-i18next", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, useTranslation: () => ({ t: (k: string) => k, i18n: { language: "en" } }) }; @@ -33,7 +42,6 @@ vi.mock("../../components/ui/FilterRail", () => ({ import FilesRail from "./FilesRail"; import { api, type FileFolder } from "../../lib/api"; -import { defaultLayout } from "../../lib/panelLayout"; // The share dialog is a lazy chunk, and the test that opens it would pay for transforming it inside // its own wait for the dialog — over that on a busy machine. Loaded once, up front. @@ -79,12 +87,7 @@ async function mount(folders: FileFolder[] = [YT, OWN]) { vi.spyOn(api, "filesUsage").mockResolvedValue({ unlimited: true, footprint_bytes: 0 } as never); render( - + , ); await screen.findByText("files.folder.new"); @@ -194,12 +197,7 @@ describe("FilesRail · what it says when a part is empty or missing", () => { - + , ); expect(await screen.findByText("files.usage.failed")).toBeTruthy(); diff --git a/frontend/src/modules/files/FilesRail.tsx b/frontend/src/modules/files/FilesRail.tsx index 1c4b45ee..5cbb15e2 100644 --- a/frontend/src/modules/files/FilesRail.tsx +++ b/frontend/src/modules/files/FilesRail.tsx @@ -7,7 +7,6 @@ import { FolderOpen, FolderPlus, HardDrive, Link2, Users } from "lucide-react"; import { api, type FileFolder } from "../../lib/api"; import { compareText } from "../../lib/collate"; import { formatBytes } from "../../lib/format"; -import { type PanelLayout } from "../../lib/panelLayout"; import { qk } from "../../lib/queryKeys"; import { useLiveQuery } from "../../lib/useLiveQuery"; import FilterRail from "../../components/ui/FilterRail"; @@ -19,6 +18,7 @@ import { ContextMenu } from "../../components/ui/ContextMenu"; import { Pressable } from "../../components/ui/Pressable"; import { type PanelItem } from "../../components/ui/PanelGroups"; import { useContextMenu } from "../../components/ui/useContextMenu"; +import { useRail } from "../../lib/useRail"; // The Files module's left rail: the library's places — every file, the ones shared with me, the // source folders the library fills by itself, the user's own folders — and the storage meter. Lifted @@ -111,17 +111,9 @@ function StorageMeter() { ); } -export default function FilesRail({ - layout, - setLayout, - collapsed, - onToggleCollapse, -}: { - layout: PanelLayout; - setLayout: (l: PanelLayout) => void; - collapsed: boolean; - onToggleCollapse: () => void; -}) { +export default function FilesRail() { + // Its layout and collapse flag are its own (lib/useRail), so the shell only places it. + const { layout, setLayout, collapsed, onToggleCollapse } = useRail("files"); const { t, i18n } = useTranslation(); const { view } = useFiles(); const foldersQ = useQuery({ queryKey: qk.fileFolders(), queryFn: api.fileFolders }); diff --git a/frontend/src/modules/files/index.ts b/frontend/src/modules/files/index.ts index 45e89b93..0a5b5c4f 100644 --- a/frontend/src/modules/files/index.ts +++ b/frontend/src/modules/files/index.ts @@ -2,3 +2,4 @@ export { FilesProvider } from "./FilesProvider"; export { useFilesActions } from "./filesContext"; export { folderIcon, folderLabel, sourceLabel, takesUploads } from "./fileFolders"; +export { filesModule } from "./module"; diff --git a/frontend/src/modules/files/module.ts b/frontend/src/modules/files/module.ts new file mode 100644 index 00000000..24e48dd0 --- /dev/null +++ b/frontend/src/modules/files/module.ts @@ -0,0 +1,17 @@ +import { FolderOpen } from "lucide-react"; +import { lazyWithPreload, lazyWithPreloadAlongside } from "../../lib/lazyWithPreload"; +import type { ModuleDef } from "../../lib/moduleDef"; + +// The rail loads with the page (lazyWithPreloadAlongside), so it never arrives after it. +const Rail = lazyWithPreload(() => import("./FilesRail")); + +// This page as the shell and the navigation know it (lib/moduleDef). Its page and rail load lazily. +export const filesModule: ModuleDef = { + id: "files", + labelKey: "files.navLabel", + railKey: "nav.rail.files", + icon: FolderOpen, + Rail, + Page: lazyWithPreloadAlongside(() => import("./Files"), Rail), + maxWidth: "80rem", +}; diff --git a/frontend/src/modules/home/index.ts b/frontend/src/modules/home/index.ts new file mode 100644 index 00000000..e9e067e2 --- /dev/null +++ b/frontend/src/modules/home/index.ts @@ -0,0 +1,2 @@ +// The home page's public face: how the shell and the navigation know it. +export { homeModule } from "./module"; diff --git a/frontend/src/modules/home/module.ts b/frontend/src/modules/home/module.ts new file mode 100644 index 00000000..012a60b7 --- /dev/null +++ b/frontend/src/modules/home/module.ts @@ -0,0 +1,14 @@ +import { Home } from "lucide-react"; +import { lazyWithPreload } from "../../lib/lazyWithPreload"; +import type { ModuleDef } from "../../lib/moduleDef"; + +// This page as the shell and the navigation know it (lib/moduleDef). Its page load lazily. +export const homeModule: ModuleDef = { + id: "home", + labelKey: "home.navLabel", + railKey: "nav.rail.home", + titleKey: "home.title", + icon: Home, + Page: lazyWithPreload(() => import("./Home")), + maxWidth: "80rem", +}; diff --git a/frontend/src/modules/notifications/index.ts b/frontend/src/modules/notifications/index.ts new file mode 100644 index 00000000..23d8f2d2 --- /dev/null +++ b/frontend/src/modules/notifications/index.ts @@ -0,0 +1,2 @@ +// The notifications page's public face: how the shell and the navigation know it. +export { notificationsModule } from "./module"; diff --git a/frontend/src/modules/notifications/module.ts b/frontend/src/modules/notifications/module.ts new file mode 100644 index 00000000..36f5beb9 --- /dev/null +++ b/frontend/src/modules/notifications/module.ts @@ -0,0 +1,14 @@ +import { Bell } from "lucide-react"; +import { lazyWithPreload } from "../../lib/lazyWithPreload"; +import type { ModuleDef } from "../../lib/moduleDef"; + +// This page as the shell and the navigation know it (lib/moduleDef). Its page load lazily. +export const notificationsModule: ModuleDef = { + id: "notifications", + labelKey: "inbox.navLabel", + railKey: "nav.rail.notifications", + icon: Bell, + hasBadge: true, + Page: lazyWithPreload(() => import("./NotificationsPanel")), + maxWidth: "48rem", +}; diff --git a/frontend/src/modules/playlists/PlaylistsRail.rename.dom.test.tsx b/frontend/src/modules/playlists/PlaylistsRail.rename.dom.test.tsx index be6a48a6..ff2e5f38 100644 --- a/frontend/src/modules/playlists/PlaylistsRail.rename.dom.test.tsx +++ b/frontend/src/modules/playlists/PlaylistsRail.rename.dom.test.tsx @@ -10,6 +10,15 @@ import { FloatingTree } from "@floating-ui/react"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import type { ReactNode } from "react"; +// The rail's own state, pinned open: these tests are about what it lists, not how it folds. +vi.mock("../../lib/useRail", () => ({ + useRail: () => ({ + layout: { order: ["manage", "list"], collapsed: {}, hidden: {} }, + setLayout: () => {}, + collapsed: false, + onToggleCollapse: () => {}, + }), +})); vi.mock("react-i18next", async (importOriginal) => { const actual = await importOriginal(); return { ...actual, useTranslation: () => ({ t: (k: string) => k, i18n: { language: "en" } }) }; @@ -82,12 +91,7 @@ async function openRename() { render( - {}} - collapsed={false} - onToggleCollapse={() => {}} - /> + , ); @@ -130,12 +134,7 @@ describe("PlaylistsRail · the row's '…'", () => { render( - {}} - collapsed={false} - onToggleCollapse={() => {}} - /> + , ); diff --git a/frontend/src/modules/playlists/PlaylistsRail.tsx b/frontend/src/modules/playlists/PlaylistsRail.tsx index 2d73216c..38a5ff9f 100644 --- a/frontend/src/modules/playlists/PlaylistsRail.tsx +++ b/frontend/src/modules/playlists/PlaylistsRail.tsx @@ -11,7 +11,6 @@ import { notify } from "../../lib/notifications"; import { notifyYouTubeActionError } from "../../lib/youtubeErrors"; import { LS } from "../../lib/storage"; import { usePref } from "../../lib/usePref"; -import { type PanelLayout } from "../../lib/panelLayout"; import FilterRail from "../../components/ui/FilterRail"; import { Button } from "../../components/ui/Button"; import { type ActionMenuItem } from "../../components/ui/ActionMenu"; @@ -31,6 +30,7 @@ import { Pressable } from "../../components/ui/Pressable"; import { Input } from "../../components/ui/Input"; import { MoreButton } from "../../components/ui/MoreButton"; import RemoteImage from "../../components/ui/RemoteImage"; +import { useRail } from "../../lib/useRail"; type PlSort = { key: "custom" | "name" | "count" | "duration" | "recent"; @@ -47,17 +47,9 @@ const ROW_BASE = "w-full flex items-center gap-2.5 p-2 rounded-lg border transit // shared SidePanel with the same collapse-to-tab behavior. It owns the list query + its own rail // controls (sort / new / sync); the selected playlist is App state so the module's detail pane // (Playlists.tsx) reacts to it. Grouped into two islands: "manage" and the playlist list. -export default function PlaylistsRail({ - layout, - setLayout, - collapsed, - onToggleCollapse, -}: { - layout: PanelLayout; - setLayout: (l: PanelLayout) => void; - collapsed: boolean; - onToggleCollapse: () => void; -}) { +export default function PlaylistsRail() { + // Its layout and collapse flag are its own (lib/useRail), so the shell only places it. + const { layout, setLayout, collapsed, onToggleCollapse } = useRail("playlists"); const canWrite = useMe().can_write; // The name filter + the selected playlist live in PlaylistsProvider (shared with the header search // box and the Playlists detail pane); this rail owns the list query + its own controls. diff --git a/frontend/src/modules/playlists/index.ts b/frontend/src/modules/playlists/index.ts index 911e7e06..0f54f916 100644 --- a/frontend/src/modules/playlists/index.ts +++ b/frontend/src/modules/playlists/index.ts @@ -1,3 +1,4 @@ // The playlists page's public face: its store, mounted at the root and read app-wide. export { PlaylistsProvider } from "./PlaylistsProvider"; export { usePlaylistsActions } from "./playlistsContext"; +export { playlistsModule } from "./module"; diff --git a/frontend/src/modules/playlists/module.ts b/frontend/src/modules/playlists/module.ts new file mode 100644 index 00000000..f8536088 --- /dev/null +++ b/frontend/src/modules/playlists/module.ts @@ -0,0 +1,17 @@ +import { ListVideo } from "lucide-react"; +import { lazyWithPreload, lazyWithPreloadAlongside } from "../../lib/lazyWithPreload"; +import type { ModuleDef } from "../../lib/moduleDef"; + +// The rail loads with the page (lazyWithPreloadAlongside), so it never arrives after it. +const Rail = lazyWithPreload(() => import("./PlaylistsRail")); + +// This page as the shell and the navigation know it (lib/moduleDef). Its page and rail load lazily. +export const playlistsModule: ModuleDef = { + id: "playlists", + labelKey: "header.account.playlists", + railKey: "nav.rail.playlists", + icon: ListVideo, + Rail, + Page: lazyWithPreloadAlongside(() => import("./Playlists"), Rail), + maxWidth: "64rem", +}; diff --git a/frontend/src/modules/plex/PlexSidebar.tsx b/frontend/src/modules/plex/PlexSidebar.tsx index 564bcbdd..dbcb9cf7 100644 --- a/frontend/src/modules/plex/PlexSidebar.tsx +++ b/frontend/src/modules/plex/PlexSidebar.tsx @@ -8,7 +8,6 @@ import { useQuery, useQueryClient } from "@tanstack/react-query"; import { ListMusic, Plus, SlidersHorizontal } from "lucide-react"; import { api, plexFilterCount, type PlexFilters } from "../../lib/api"; import { notifyActionError } from "../../lib/actionErrors"; -import { type PanelLayout } from "../../lib/panelLayout"; import { type MediaSourceKind } from "../../lib/mediaSource"; import { useDebounced } from "../../lib/useDebounced"; import FilterRail from "../../components/ui/FilterRail"; @@ -22,19 +21,13 @@ import { useSavedViews } from "../../features/filters/savedViewsContext"; import { usePlex, usePlexActions } from "./plexContext"; import { DismissibleChip } from "../../components/ui/Chip"; import { Input } from "../../components/ui/Input"; +import { useRail } from "../../lib/useRail"; // The Plex module's left filter column — now the shared floating SidePanel with its filter groups // as reorderable "islands" (same system as the feed rail). Movie libraries get the full metadata -// set; shows keep library + sort. State is owned by App (per-account persisted); facets come from +// set; shows keep library + sort. The filter state lives in PlexProvider; facets come from // the backend so the panel only offers what the library actually contains. -type Props = { - layout: PanelLayout; - setLayout: (l: PanelLayout) => void; - collapsed: boolean; - onToggleCollapse: () => void; -}; - const RATING_STEPS = [5, 6, 7, 8, 9]; const ADDED_OPTS = ["24h", "1w", "1m", "6m", "1y"] as const; const DURATION_BUCKETS: { key: string; min: number | null; max: number | null }[] = [ @@ -43,7 +36,9 @@ const DURATION_BUCKETS: { key: string; min: number | null; max: number | null }[ { key: "long", min: 120 * 60, max: null }, ]; -export default function PlexSidebar({ layout, setLayout, collapsed, onToggleCollapse }: Props) { +export default function PlexSidebar() { + // Its layout and collapse flag are its own (lib/useRail), so the shell only places it. + const { layout, setLayout, collapsed, onToggleCollapse } = useRail("plex"); const { t, i18n } = useTranslation(); // Scope / watch-state / sort / expanded filters live in PlexProvider (shared with PlexBrowse and // the header search box); this rail is the primary editor of them. Opening a playlist is a diff --git a/frontend/src/modules/plex/index.ts b/frontend/src/modules/plex/index.ts index 6afc4e8c..104b4407 100644 --- a/frontend/src/modules/plex/index.ts +++ b/frontend/src/modules/plex/index.ts @@ -2,3 +2,4 @@ export { PlexProvider } from "./PlexProvider"; export { usePlex, usePlexActions } from "./plexContext"; export { buildPlexActiveFilters } from "./usePlexActiveFilters"; +export { plexModule } from "./module"; diff --git a/frontend/src/modules/plex/module.ts b/frontend/src/modules/plex/module.ts new file mode 100644 index 00000000..8f350205 --- /dev/null +++ b/frontend/src/modules/plex/module.ts @@ -0,0 +1,18 @@ +import { Clapperboard } from "lucide-react"; +import { lazyWithPreload, lazyWithPreloadAlongside } from "../../lib/lazyWithPreload"; +import type { ModuleDef } from "../../lib/moduleDef"; + +// The rail loads with the page (lazyWithPreloadAlongside), so it never arrives after it. +const Rail = lazyWithPreload(() => import("./PlexSidebar")); + +// This page as the shell and the navigation know it (lib/moduleDef). Its page and rail load lazily. +export const plexModule: ModuleDef = { + id: "plex", + labelKey: "plex.navLabel", + railKey: "nav.rail.plex", + icon: Clapperboard, + gate: (me) => me.plex_enabled, + Rail, + Page: lazyWithPreloadAlongside(() => import("./PlexBrowse"), Rail), + maxWidth: "100rem", +}; diff --git a/frontend/src/modules/scheduler/index.ts b/frontend/src/modules/scheduler/index.ts new file mode 100644 index 00000000..2cf30995 --- /dev/null +++ b/frontend/src/modules/scheduler/index.ts @@ -0,0 +1,2 @@ +// The scheduler page's public face: how the shell and the navigation know it. +export { schedulerModule } from "./module"; diff --git a/frontend/src/modules/scheduler/module.ts b/frontend/src/modules/scheduler/module.ts new file mode 100644 index 00000000..ff6db8b0 --- /dev/null +++ b/frontend/src/modules/scheduler/module.ts @@ -0,0 +1,15 @@ +import { Activity } from "lucide-react"; +import { lazyWithPreload } from "../../lib/lazyWithPreload"; +import type { ModuleDef } from "../../lib/moduleDef"; + +// This page as the shell and the navigation know it (lib/moduleDef). Its page load lazily. +export const schedulerModule: ModuleDef = { + id: "scheduler", + labelKey: "header.account.scheduler", + railKey: "nav.rail.scheduler", + titleKey: "header.scheduler", + icon: Activity, + system: true, + gate: (me) => me.role === "admin", + Page: lazyWithPreload(() => import("./Scheduler")), +}; diff --git a/frontend/src/modules/settings/SettingsPanel.downloadAuth.dom.test.tsx b/frontend/src/modules/settings/SettingsPanel.downloadAuth.dom.test.tsx index 8d4f549a..01227c2f 100644 --- a/frontend/src/modules/settings/SettingsPanel.downloadAuth.dom.test.tsx +++ b/frontend/src/modules/settings/SettingsPanel.downloadAuth.dom.test.tsx @@ -21,7 +21,11 @@ vi.mock("react-i18next", async (importOriginal) => { useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }), }; }); -vi.mock("../../lib/api", () => ({ api: h })); +// Partial: the module graph reaches the feed's store, which reads its defaults here. +vi.mock("../../lib/api", async (importOriginal) => ({ + ...(await importOriginal()), + api: h, +})); // Partial mock: SettingsPanel's module graph (PrefsProvider) also pulls getNotifSettings from here. vi.mock("../../lib/notifications", async (importOriginal) => { const actual = await importOriginal(); diff --git a/frontend/src/modules/settings/index.ts b/frontend/src/modules/settings/index.ts new file mode 100644 index 00000000..d149109e --- /dev/null +++ b/frontend/src/modules/settings/index.ts @@ -0,0 +1,2 @@ +// The settings page's public face: how the shell and the navigation know it. +export { settingsModule } from "./module"; diff --git a/frontend/src/modules/settings/module.ts b/frontend/src/modules/settings/module.ts new file mode 100644 index 00000000..71db69e4 --- /dev/null +++ b/frontend/src/modules/settings/module.ts @@ -0,0 +1,13 @@ +import { Settings } from "lucide-react"; +import { lazyWithPreload } from "../../lib/lazyWithPreload"; +import type { ModuleDef } from "../../lib/moduleDef"; + +// This page as the shell and the navigation know it (lib/moduleDef). Its page load lazily. +export const settingsModule: ModuleDef = { + id: "settings", + labelKey: "header.account.settings", + railKey: "nav.rail.settings", + titleKey: "settings.title", + icon: Settings, + Page: lazyWithPreload(() => import("./SettingsPanel")), +}; diff --git a/frontend/src/modules/stats/index.ts b/frontend/src/modules/stats/index.ts new file mode 100644 index 00000000..15c8803d --- /dev/null +++ b/frontend/src/modules/stats/index.ts @@ -0,0 +1,2 @@ +// The stats page's public face: how the shell and the navigation know it. +export { statsModule } from "./module"; diff --git a/frontend/src/modules/stats/module.ts b/frontend/src/modules/stats/module.ts new file mode 100644 index 00000000..3bc2106d --- /dev/null +++ b/frontend/src/modules/stats/module.ts @@ -0,0 +1,13 @@ +import { BarChart3 } from "lucide-react"; +import { lazyWithPreload } from "../../lib/lazyWithPreload"; +import type { ModuleDef } from "../../lib/moduleDef"; + +// This page as the shell and the navigation know it (lib/moduleDef). Its page load lazily. +export const statsModule: ModuleDef = { + id: "stats", + labelKey: "header.account.stats", + railKey: "nav.rail.stats", + titleKey: "header.usageStats", + icon: BarChart3, + Page: lazyWithPreload(() => import("./Stats")), +}; diff --git a/frontend/src/modules/users/index.ts b/frontend/src/modules/users/index.ts new file mode 100644 index 00000000..29c87bbb --- /dev/null +++ b/frontend/src/modules/users/index.ts @@ -0,0 +1,2 @@ +// The users page's public face: how the shell and the navigation know it. +export { usersModule } from "./module"; diff --git a/frontend/src/modules/users/module.ts b/frontend/src/modules/users/module.ts new file mode 100644 index 00000000..b1746616 --- /dev/null +++ b/frontend/src/modules/users/module.ts @@ -0,0 +1,16 @@ +import { Users } from "lucide-react"; +import { lazyWithPreload } from "../../lib/lazyWithPreload"; +import type { ModuleDef } from "../../lib/moduleDef"; + +// This page as the shell and the navigation know it (lib/moduleDef). Its page load lazily. +export const usersModule: ModuleDef = { + id: "users", + labelKey: "header.account.users", + railKey: "nav.rail.users", + titleKey: "header.users", + icon: Users, + system: true, + gate: (me) => me.role === "admin", + Page: lazyWithPreload(() => import("./AdminUsers")), + maxWidth: "48rem", +}; diff --git a/frontend/src/shell/App.tsx b/frontend/src/shell/App.tsx index 884cebfb..dcc537fc 100644 --- a/frontend/src/shell/App.tsx +++ b/frontend/src/shell/App.tsx @@ -1,24 +1,14 @@ -import { lazy, Suspense, useEffect, useRef, useState } from "react"; +import { lazy, Suspense, useState } from "react"; import { qk } from "../lib/queryKeys"; import { saveLanguage } from "../lib/saveLanguage"; import { useTranslation } from "react-i18next"; import { useQuery, useQueryClient } from "@tanstack/react-query"; -import { api, setUnauthorizedHandler, type Me } from "../lib/api"; +import { api } from "../lib/api"; import { notifyActionError } from "../lib/actionErrors"; import { setLanguage, type LangCode } from "../i18n"; -import { clearParams } from "../lib/urlState"; -import { reloadAfterRefusal } from "../lib/entryUrl"; -import { useAccountSwitchNotice } from "../lib/useAccountSwitchNotice"; -import { - PREF_KEY, - saveLayoutLocal, - seedLayout, - type PanelId, - type PanelLayout, -} from "../lib/panelLayout"; -import { notify, removeByMetaKind } from "../lib/notifications"; -import { LS } from "../lib/storage"; -import { usePref } from "../lib/usePref"; +import { useShellSession } from "./useShellSession"; +import { useEntryParams } from "./useEntryParams"; +import { useAccessRequestsNudge } from "./useAccessRequestsNudge"; import { useVerifyEntry } from "../lib/useVerifyEntry"; import { useNavigation, useNavigationActions } from "../features/navigation/navigationContext"; import { @@ -26,10 +16,9 @@ import { useFeedFiltersActions, useFeedView, useFeedViewActions, + ChannelPage, } from "../modules/feed"; -import { moduleOrder } from "../lib/modules"; -import { PAGE_CONTENT, PAGE_RAIL, preloadPages } from "./moduleRegistry"; -import { lazyWithPreload } from "../lib/lazyWithPreload"; +import { moduleDef, pageTitleKey } from "../features/navigation/moduleRegistry"; import { LoadingState } from "../components/ui/QueryState"; // Persistent shell — always mounted, so eagerly imported. import Welcome from "./Welcome"; @@ -40,29 +29,23 @@ import BackToTop from "../components/ui/BackToTop"; import PageShell, { PageHeaderDefault } from "../components/ui/PageShell"; import { PaletteLauncher } from "../features/header/PaletteLauncher"; import { SkipLink } from "../components/ui/SkipLink"; -import { pageTitleKey } from "../lib/pageMeta"; import DebugRecorder from "../features/diagnostics/DebugRecorder"; import Toaster from "../components/ui/Toaster"; import KeyboardHelp from "../components/ui/KeyboardHelp"; import AppCommandPalette from "./AppCommandPalette"; import DownloadIntake from "../features/downloads/DownloadIntake"; import DownloadTray from "../features/downloads/DownloadTray"; -import { useGlobalKeymap } from "../lib/keymap"; import ErrorDialog from "./ErrorDialog"; import VersionBanner from "./VersionBanner"; import VerifyBanner from "./VerifyBanner"; -import { focusAccessRequestsTab } from "../lib/adminUsersTab"; -import { focusSettingsTab } from "../lib/settingsTab"; // The built app version, NOT imported from releaseNotes — importing anything from that module // pulls the release-notes loader into the eager App chunk and defeats the lazy ReleaseNotes // split. FRONTEND_VERSION equals the newest release note's version by release convention (both bump together). import { FRONTEND_VERSION } from "../lib/version"; -import { applyDiagnostics } from "../lib/diagnostics"; -// The page modules (and their side rails) are code-split per route via the module registry -// (shell/moduleRegistry). App keeps only the persistent-shell chunks + the channel page and the -// two modals, which sit outside the registry-driven content column. -const ChannelPage = lazyWithPreload(() => import("../modules/feed/ChannelPage")); +// The page modules (and their side rails) are code-split per route: each module's definition imports +// them lazily (features/navigation/moduleRegistry), and so does the feed's for the channel page. App +// keeps only the persistent-shell chunks and the two modals. const About = lazy(() => import("./About")); const ReleaseNotes = lazy(() => import("./ReleaseNotes")); @@ -85,20 +68,6 @@ export default function App() { const view = useFeedView(); const { setView } = useFeedViewActions(); const queryClient = useQueryClient(); - // Per-panel "customize" layouts (order/collapsed/hidden of each side panel's group cards), - // persisted per-account. One record so feed/plex/playlists share the same mechanism. Seeded from - // the account's server preferences — BootGate mounts App only once `me` is cached — so a layout - // changed on another device is right on the first paint; the local copy covers a key the server - // has never stored. - const [panelLayouts, setPanelLayouts] = useState>(() => { - const prefs = queryClient.getQueryData(qk.me())?.preferences; - return { - feed: seedLayout("feed", prefs), - plex: seedLayout("plex", prefs), - playlists: seedLayout("playlists", prefs), - files: seedLayout("files", prefs), - }; - }); const [aboutOpen, setAboutOpen] = useState(false); const [notesOpen, setNotesOpen] = useState(false); const [notesHighlight, setNotesHighlight] = useState(undefined); @@ -108,38 +77,6 @@ export default function App() { setNotesOpen(true); } - function setPanelLayout(panel: PanelId, next: PanelLayout) { - setPanelLayouts((prev) => ({ ...prev, [panel]: next })); - saveLayoutLocal(panel, next); - api - .savePrefs({ [PREF_KEY[panel]]: next }) - .catch((e: unknown) => notifyActionError(e, t("errors.writeFailed.prefs"))); - } - - // Collapse state for the two full-height panels (left nav + filter sidebar). Persisted to the - // user's preferences (via usePref) so it follows the account across devices, seeded from the `me` - // cache at mount; the localStorage mirror covers an account whose server prefs never stored it. - // Default unpinned (tab + hover-expand overlay); accounts that explicitly pin keep their choice. - const [filterCollapsed, setFilterCollapsed] = usePref("filterCollapsed", true, { - localKey: LS.filterCollapsed, - parse: Boolean, - }); - // The Playlists rail has its own collapse flag (it's a list/navigator, not filters). - const [playlistsCollapsed, setPlaylistsCollapsed] = usePref("playlistsCollapsed", true, { - localKey: LS.playlistsCollapsed, - parse: Boolean, - }); - // The Files rail is the library's folder list — how you move around it, not a filter you set once — - // so it starts pinned open (the owner's mockup), and has its own flag. - const [filesCollapsed, setFilesCollapsed] = usePref("filesCollapsed", false, { - localKey: LS.filesCollapsed, - parse: Boolean, - }); - const railCollapse = { - filter: [filterCollapsed, setFilterCollapsed], - playlists: [playlistsCollapsed, setPlaylistsCollapsed], - files: [filesCollapsed, setFilesCollapsed], - } as const; // (The feed filters + page/channel now LIVE in the address bar — FeedFiltersProvider and // NavigationProvider read them on load and keep them mirrored — so there's no query to strip here.) @@ -156,200 +93,11 @@ export default function App() { // effects below depend on it, and a dep list is only honest if it names the value the body reads. const me = meQuery.data; - // The `global` key scope: `h` opens the cheat sheet, Mod+K the jump-to palette, Shift+W jumps to - // Home ("mit nézzek?"). Bound only once signed in, because that is where the overlays are mounted - // (bottom of this tree) and where the modules exist at all — see useGlobalKeymap on why binding it - // above the signed-out branch is worse than not binding it. - // A global key can fire while an overlay is open; `setPage` closes every overlay before it pushes, - // so Shift+W over the player lands on Home with the player closed — also when Home is already the - // page underneath (owner decision 2026-09-09: the key always ends with the answer on screen). - // The `page` scope rides the same binding: the `g` chords jump to a module, but only to one this - // account can reach (the nav rail's own list, plus Settings, which is not in the rail's order). - useGlobalKeymap(!!me, setPage, me ? [...moduleOrder(me), "settings"] : []); - - // The account's consented diagnostics level, applied to this tab's log: raised, the log is - // kept in more detail and streamed to the server until the level lapses. A debug session that is - // recording raises it for its own run and tags what it keeps. A lapse (its own timer, or the - // server saying it is off) refetches `me`, so Settings and the recorder show what is in force. - // Re-running this on every `me` refetch is FREE and therefore the deps are the honest ones: - // applyDiagnostics computes an effective-level key and returns early when it has not moved - // (lib/diagnostics.ts), so an unchanged level is a no-op. It does refresh the `lapsed` callback - // before that early return, which is the half we WANT re-run. The old dep list picked out four - // primitive fields to dodge the fresh object identity a refetch produces; that is the checker's - // job to verify, not a comment's to assert, and it silently missed any other field the module - // may come to read. - const diagnostics = me?.diagnostics; - const debugSession = me?.debug_session; - useEffect(() => { - applyDiagnostics( - diagnostics, - debugSession, - () => void queryClient.invalidateQueries({ queryKey: qk.me() }), - ); - }, [diagnostics, debugSession, queryClient]); - - // Fetch the code of every module this account can open — and the channel page — once the first page - // is on screen, so a module's first visit renders at once instead of flashing the chunk fallback. - // Idle-scheduled so it never competes with the first page's own requests (Safari has no - // requestIdleCallback, hence the timer). - // Depending on the whole `me` rather than its id is both honest and MORE correct: the module set - // is derived from `me` through each module's own gate (lib/modules moduleOrder), so a permission - // change — an admin grant, a Plex server appearing — alters it without the id moving, and the old - // `[me.id]` dep never re-preloaded for those. Re-running costs nothing: `preload()` is idempotent - // (lazyWithPreload memoises the pending promise), so a refetch that changes nothing fetches - // nothing; only the idle callback is re-scheduled. - useEffect(() => { - if (!me) return; - const run = () => { - preloadPages([...moduleOrder(me), "settings"]); - ChannelPage.preload().catch(() => {}); - }; - const idle = window.requestIdleCallback as typeof window.requestIdleCallback | undefined; - if (idle) { - const id = idle(run, { timeout: 3000 }); - return () => window.cancelIdleCallback(id); - } - const id = window.setTimeout(run, 1000); - return () => window.clearTimeout(id); - }, [me]); - - // Measure the scrollbar's gutter width once and publish it as --sbw, so the floating header and the - // toolbar band can pad their right edge to line up with the scrolled cards (the app scroller reserves - // that gutter via scrollbar-gutter: stable). It's a device constant, so a one-shot mount measure is enough. - useEffect(() => { - const probe = document.createElement("div"); - probe.style.cssText = - "position:absolute;top:-9999px;width:100px;height:100px;overflow:scroll;scrollbar-gutter:stable"; - document.body.appendChild(probe); - const w = probe.offsetWidth - probe.clientWidth; - document.body.removeChild(probe); - document.documentElement.style.setProperty("--sbw", `${w}px`); - }, []); - - // Once signed in, drop any leftover ONE-TIME query params from the pre-login flow (?access, ?verify, - // ?reset) — but NOT the nav/filter params, which the logged-in app now reads from and keeps in the - // URL. Gated on auth so the logged-out Welcome page still reads its params first. - // Keyed on "is there a session at all", which is the whole condition the body tests. Re-running - // on an account switch was never the point — by then the params are already gone and a second - // clearParams is a no-op. - const signedIn = !!me; - useEffect(() => { - if (signedIn) clearParams("access", "verify", "reset"); - }, [signedIn]); - - // A sign-in as someone else that the server refused, or a reload as another account: say so, once. - useAccountSwitchNotice(signedIn); - - // Any request that 401s means the session ended server-side. If we were signed in (cached - // `me` is a user object), reload to the login page at once (also covers any click that hits - // the API). Guarded on a cached user so a stray 401 while logged out (cached `me` is null) - // can't loop the public landing. A tab showing another account than the one signed in now - // reloads as that one instead, and says why (`reloadAfterRefusal`). - useEffect(() => { - setUnauthorizedHandler((err) => { - if (!queryClient.getQueryData(qk.me())) return; - queryClient.clear(); - reloadAfterRefusal(err); - }); - return () => setUnauthorizedHandler(null); - }, [queryClient]); - - // Returning from an in-app Google link/upgrade round-trip (auth.py redirects to ?link=…). - // Surface the outcome, refresh `me` (can_read/can_write/has_google may have flipped), land on - // Settings → Account so the new state is visible, then strip the param for a clean URL. - // "Exactly once per mount" is now ENFORCED by the ref rather than asserted by an empty dep list: - // `t` changes identity on a language switch, so the honest dep list would otherwise re-fire the - // toast when the user changes language. (The old `[]` got away with it only because clearParams - // had already removed the param — an invariant held by a side effect two lines further down.) - const linkAnswered = useRef(false); - const { refetch: refetchMe } = meQuery; - useEffect(() => { - if (linkAnswered.current) return; - const result = new URLSearchParams(window.location.search).get("link"); - if (!result) return; - linkAnswered.current = true; - if (result === "ok") { - notify({ level: "success", message: t("settings.account.googleLink.linked") }); - void refetchMe(); - focusSettingsTab("account"); - setPage("settings"); - } else { - const key = result === "conflict" || result === "mismatch" ? result : "error"; - notify({ level: "error", message: t(`settings.account.googleLink.${key}`) }); - } - clearParams("link"); - }, [t, setPage, refetchMe]); - - // A Google OAuth flow started while already signed in (signing in as someone else, or a Google - // connect) was cancelled at Google's consent screen. The backend only emits this for a live session, so a - // neutral toast is the right feedback — the pre-auth login banner would never render here. - // Same one-shot shape as the `?link=` handler above, same reason for the ref. - const oauthAnswered = useRef(false); - useEffect(() => { - if (oauthAnswered.current) return; - if (new URLSearchParams(window.location.search).get("oauth") !== "cancelled") return; - oauthAnswered.current = true; - notify({ level: "info", message: t("settings.account.googleCancelled") }); - clearParams("oauth"); - }, [t]); - - // Deep-link from the "new access request" admin email (?admin=access-requests) → jump straight to - // the admin Users → Access requests view. Snapshot the param up front (read once at mount); act once - // `me` is known, and only for an admin (others just land on their default page). - const [adminDeepLink] = useState(() => new URLSearchParams(window.location.search).get("admin")); - // Ref-guarded like the two above, and here it closes a real hole rather than only satisfying the - // checker: the snapshot lives for the whole session, so with honest deps a later role change — - // or any `me` refetch, under the old id-only key — would yank the user to Users long after the - // deep link was answered. - const adminDeepLinkAnswered = useRef(false); - useEffect(() => { - if (adminDeepLinkAnswered.current) return; - if (adminDeepLink !== "access-requests" || !me) return; - adminDeepLinkAnswered.current = true; - if (me.role === "admin") { - focusAccessRequestsTab(); - setPage("users"); - } - clearParams("admin"); - }, [adminDeepLink, me, setPage]); - - // Nudge admins when access requests are waiting (in lieu of a server-push bell). Keyed on the - // pending COUNT so it re-resolves the moment an approval/denial changes it — not only on a - // fresh load (which is why the notice used to linger until F5 after an approval). - // Honest deps, and `t` among them is a fix rather than a concession: the nudge is a persisted - // toast whose text is translated at issue time, so a language switch must re-issue it. It did not - // before — the notice stayed in the old language until the count changed. - const role = me?.role; - const pendingInvites = me?.pending_invites ?? 0; - const pendingActivations = me?.pending_activations ?? 0; - useEffect(() => { - if (role !== "admin") return; - // Keep a single, current nudge: drop any prior one (persisted copies reload as dismissed - // history, so they'd otherwise pile up one-per-reload) before re-issuing the live notice. - removeByMetaKind("access-requests"); - // Both kinds of "someone is waiting on you": a request needing a decision, and a sign-up whose - // address was approved before they registered. The second used to produce NO signal at all — - // nothing lands in the queue for it — which is how the 2026-09-03 registrant went unnoticed. - const pending = pendingInvites + pendingActivations; - if (pending > 0) { - notify({ - level: "warning", - title: t("common.accessRequestsTitle"), - message: t("common.accessRequestsMessage", { count: pending }), - // Durable inbox link (survives reload, unlike a live action callback); the toast also - // gets a click via the action. - meta: { kind: "access-requests" }, - action: { - label: t("common.accessRequestsReview"), - onClick: () => { - focusAccessRequestsTab(); - // Clicked inside whatever overlay hosts the toast; setPage closes it before pushing. - setPage("users"); - }, - }, - }); - } - }, [role, pendingInvites, pendingActivations, t, setPage]); + // What runs for the session (keys, diagnostics, preloading, the 401 handler), what the entry URL + // asks for once, and the admin's waiting-requests nudge: each its own shell hook, so App is layout. + useShellSession(me, setPage); + useEntryParams(me, meQuery.refetch, setPage); + useAccessRequestsNudge(me, setPage); // Language is edited outside the Settings page (sidebar/login), so it stays instant. function changeLanguage(code: LangCode) { @@ -385,8 +133,8 @@ export default function App() { // The module registry drives the render from `page` alone — no per-page chrome conditionals. A page // the account can't reach never gets here: the navigation state already moved it to the feed // (`reachablePage`). A channel page overlays the content column, so no side rail shows behind it. - const { Component: PageContent, maxWidth } = PAGE_CONTENT[page]; - const railDef = channelView ? undefined : PAGE_RAIL[page]; + const { Page: PageContent, Rail, maxWidth } = moduleDef(page); + const PageRail = channelView ? undefined : Rail; return (
@@ -399,18 +147,10 @@ export default function App() { /> {/* The active page's side rail (feed filters / plex filters / playlists list), if it has one. A floating collapsible column beside the nav rail; hidden while a channel page overlays the - content. feed + plex share the filter-rail collapse flag, playlists and files have their own. */} - {railDef && ( + content. It holds its own layout and collapse flag (lib/useRail). */} + {PageRail && ( - setPanelLayout(railDef.panelId, l)} - collapsed={railCollapse[railDef.collapse][0]} - onToggleCollapse={() => { - const [collapsed, setCollapsed] = railCollapse[railDef.collapse]; - setCollapsed(!collapsed); - }} - /> + )} {/* `@container/appmain`: the floating header + its search box adapt to THIS column's width diff --git a/frontend/src/shell/AppCommandPalette.dom.test.tsx b/frontend/src/shell/AppCommandPalette.dom.test.tsx index 7b1a5fa1..e9dc09f5 100644 --- a/frontend/src/shell/AppCommandPalette.dom.test.tsx +++ b/frontend/src/shell/AppCommandPalette.dom.test.tsx @@ -26,7 +26,9 @@ vi.mock("../components/ui/CommandPalette", () => ({ const channels = vi.fn(); const playlists = vi.fn(); -vi.mock("../lib/api", () => ({ +// Partial: the module registry the palette lists reaches the feed's store, which reads its defaults here. +vi.mock("../lib/api", async (importOriginal) => ({ + ...(await importOriginal()), api: { channels: (...a: unknown[]) => channels(...a), playlists: (...a: unknown[]) => playlists(...a), diff --git a/frontend/src/shell/AppCommandPalette.tsx b/frontend/src/shell/AppCommandPalette.tsx index 48e515b7..837887c6 100644 --- a/frontend/src/shell/AppCommandPalette.tsx +++ b/frontend/src/shell/AppCommandPalette.tsx @@ -16,7 +16,7 @@ import CommandPalette, { type CommandGroup } from "../components/ui/CommandPalet import { api } from "../lib/api"; import { qk } from "../lib/queryKeys"; import { useMe } from "../lib/useMe"; -import { moduleDef, moduleOrder } from "../lib/modules"; +import { moduleDef, moduleOrder } from "../features/navigation/moduleRegistry"; import { SCHEMES, type Scheme } from "../lib/theme"; import { closePalette, toggleKeyboardHelp, usePaletteOpen } from "../lib/keymap"; import { requestSurprise } from "../lib/homeIntent"; diff --git a/frontend/src/shell/NavSidebar.tsx b/frontend/src/shell/NavSidebar.tsx index bafaa35b..3ec1da14 100644 --- a/frontend/src/shell/NavSidebar.tsx +++ b/frontend/src/shell/NavSidebar.tsx @@ -14,7 +14,7 @@ import { moduleOrder, moduleRailKey, SYSTEM_PAGES, -} from "../lib/modules"; +} from "../features/navigation/moduleRegistry"; import { useScrollFade } from "../lib/useScrollFade"; import { type LangCode } from "../i18n"; import AvatarImg from "../components/ui/Avatar"; diff --git a/frontend/src/shell/moduleRegistry.tsx b/frontend/src/shell/moduleRegistry.tsx deleted file mode 100644 index d10ff7fe..00000000 --- a/frontend/src/shell/moduleRegistry.tsx +++ /dev/null @@ -1,107 +0,0 @@ -import type { ComponentType } from "react"; -import { lazyWithPreload, type PreloadableComponent } from "../lib/lazyWithPreload"; -import type { Page } from "../lib/urlState"; -import type { PanelId, PanelLayout } from "../lib/panelLayout"; -// eslint-disable-next-line local/layers -- a rail (or the feed page's wrapper) the shell still loads eagerly; it goes once every rail loads lazily, like the pages. -import Sidebar from "../modules/feed/Sidebar"; -// eslint-disable-next-line local/layers -- a rail (or the feed page's wrapper) the shell still loads eagerly; it goes once every rail loads lazily, like the pages. -import PlaylistsRail from "../modules/playlists/PlaylistsRail"; -// eslint-disable-next-line local/layers -- a rail (or the feed page's wrapper) the shell still loads eagerly; it goes once every rail loads lazily, like the pages. -import { Feed, FeedPage } from "../modules/feed/modulePages"; - -// The RENDER half of the module registry: which component (and side rail) each page renders. The -// metadata half — nav order, label, icon, gate — lives in lib/modules.ts (imported by NavSidebar / -// the palette / App's gate check). Together they let App drive the whole shell from `page` alone, with no -// `page === X` chrome conditionals. -// -// Every content entry is a PROP-FREE component: it reads `me` from useMe() and its module-local state -// from the scoped providers, so App renders `` uniformly. The two that need per-render -// wiring get a thin wrapper in modulePages (FeedPage supplies the GLOBAL feed scope; the channel page -// threads its own isolated scope to directly — see the scope note in FeedFiltersProvider). - -// Route-level code splitting: each page is its own lazy chunk (admin-only pages never reach -// non-admins; the logged-out landing pulls only the shell). Rendered inside App's . Each -// one is preloadable: App fetches the chunks of every page the account can reach once the first page -// is on screen (preloadPages below), so switching modules never shows the chunk fallback. -const Home = lazyWithPreload(() => import("../modules/home/Home")); -const Channels = lazyWithPreload(() => import("../modules/channels/Channels")); -const Playlists = lazyWithPreload(() => import("../modules/playlists/Playlists")); -const PlexBrowse = lazyWithPreload(() => import("../modules/plex/PlexBrowse")); -const PlexSidebar = lazyWithPreload(() => import("../modules/plex/PlexSidebar")); -const NotificationsPanel = lazyWithPreload( - () => import("../modules/notifications/NotificationsPanel"), -); -const Files = lazyWithPreload(() => import("../modules/files/Files")); -const FilesRail = lazyWithPreload(() => import("../modules/files/FilesRail")); -const Stats = lazyWithPreload(() => import("../modules/stats/Stats")); -const Scheduler = lazyWithPreload(() => import("../modules/scheduler/Scheduler")); -const ConfigPanel = lazyWithPreload(() => import("../modules/config/ConfigPanel")); -const AdminUsers = lazyWithPreload(() => import("../modules/users/AdminUsers")); -const Activity = lazyWithPreload(() => import("../modules/activity/Activity")); -const SettingsPanel = lazyWithPreload(() => import("../modules/settings/SettingsPanel")); - -export type PageDef = { - // Prop-free by construction: reads `me` + module state from hooks/providers. Preloadable by type, so - // a new page cannot silently opt out of preloadPages. - Component: PreloadableComponent; - // The content column's max width. The header + toolbar centre on this same width (via --content-mw) - // so the floating chrome lines up with the centred content instead of spanning the whole column. - // MUST match the max-w the page's own content wrapper uses. Omit for a full-width page (feed). - maxWidth?: string; -}; - -// page id → the component App renders in the content column (+ its shell chrome config). -export const PAGE_CONTENT: Record = { - home: { Component: Home, maxWidth: "80rem" }, - // The two wrappers preload the lazy chunk they render. - feed: { Component: Object.assign(FeedPage, { preload: Feed.preload }) }, - channels: { Component: Channels, maxWidth: "96rem" }, - playlists: { Component: Playlists, maxWidth: "64rem" }, - plex: { Component: PlexBrowse, maxWidth: "100rem" }, - notifications: { Component: NotificationsPanel, maxWidth: "48rem" }, - files: { Component: Files, maxWidth: "80rem" }, - stats: { Component: Stats }, - scheduler: { Component: Scheduler }, - config: { Component: ConfigPanel }, - users: { Component: AdminUsers, maxWidth: "48rem" }, - activity: { Component: Activity }, - settings: { Component: SettingsPanel }, -}; - -// The uniform side-rail contract — shell layout/collapse only; the rail reads its module state from -// its provider and `me` from useMe(). -type RailProps = { - layout: PanelLayout; - setLayout: (l: PanelLayout) => void; - collapsed: boolean; - onToggleCollapse: () => void; -}; - -// Which collapse flag a rail uses (feed + plex share the filter rail's flag; playlists and files -// have their own). -type RailCollapseKey = "filter" | "playlists" | "files"; - -export type RailDef = { - Rail: ComponentType; - // The persisted panel-layout record this rail edits (App owns the panelLayouts map). - panelId: PanelId; - collapse: RailCollapseKey; -}; - -// page id → its side rail (only the pages that have one). App renders it in the rail slot. -export const PAGE_RAIL: Partial> = { - feed: { Rail: Sidebar, panelId: "feed", collapse: "filter" }, - plex: { Rail: PlexSidebar, panelId: "plex", collapse: "filter" }, - playlists: { Rail: PlaylistsRail, panelId: "playlists", collapse: "playlists" }, - files: { Rail: FilesRail, panelId: "files", collapse: "files" }, -}; - -/** Fetch the code of these pages and of their side rails now, ahead of their first visit. Errors are - * swallowed: a chunk that fails to preload is simply fetched (and its failure surfaced) on visit. */ -export function preloadPages(pages: Page[]): void { - for (const p of pages) { - PAGE_CONTENT[p].Component.preload().catch(() => {}); - const rail = PAGE_RAIL[p]?.Rail as Partial> | undefined; - rail?.preload?.().catch(() => {}); - } -} diff --git a/frontend/src/shell/useAccessRequestsNudge.ts b/frontend/src/shell/useAccessRequestsNudge.ts new file mode 100644 index 00000000..b9853960 --- /dev/null +++ b/frontend/src/shell/useAccessRequestsNudge.ts @@ -0,0 +1,49 @@ +import { useEffect } from "react"; +import { useTranslation } from "react-i18next"; +import type { Me } from "../lib/api"; +import type { Page } from "../lib/urlState"; +import { notify, removeByMetaKind } from "../lib/notifications"; +import { focusAccessRequestsTab } from "../lib/adminUsersTab"; + +type SetPage = (next: Page, then?: () => void) => void; + +export function useAccessRequestsNudge(me: Me | null | undefined, setPage: SetPage): void { + const { t } = useTranslation(); + // Nudge admins when access requests are waiting (in lieu of a server-push bell). Keyed on the + // pending COUNT so it re-resolves the moment an approval/denial changes it — not only on a + // fresh load (which is why the notice used to linger until F5 after an approval). + // Honest deps, and `t` among them is a fix rather than a concession: the nudge is a persisted + // toast whose text is translated at issue time, so a language switch must re-issue it. It did not + // before — the notice stayed in the old language until the count changed. + const role = me?.role; + const pendingInvites = me?.pending_invites ?? 0; + const pendingActivations = me?.pending_activations ?? 0; + useEffect(() => { + if (role !== "admin") return; + // Keep a single, current nudge: drop any prior one (persisted copies reload as dismissed + // history, so they'd otherwise pile up one-per-reload) before re-issuing the live notice. + removeByMetaKind("access-requests"); + // Both kinds of "someone is waiting on you": a request needing a decision, and a sign-up whose + // address was approved before they registered. The second used to produce NO signal at all — + // nothing lands in the queue for it — which is how the 2026-09-03 registrant went unnoticed. + const pending = pendingInvites + pendingActivations; + if (pending > 0) { + notify({ + level: "warning", + title: t("common.accessRequestsTitle"), + message: t("common.accessRequestsMessage", { count: pending }), + // Durable inbox link (survives reload, unlike a live action callback); the toast also + // gets a click via the action. + meta: { kind: "access-requests" }, + action: { + label: t("common.accessRequestsReview"), + onClick: () => { + focusAccessRequestsTab(); + // Clicked inside whatever overlay hosts the toast; setPage closes it before pushing. + setPage("users"); + }, + }, + }); + } + }, [role, pendingInvites, pendingActivations, t, setPage]); +} diff --git a/frontend/src/shell/useEntryParams.ts b/frontend/src/shell/useEntryParams.ts new file mode 100644 index 00000000..cea9aa53 --- /dev/null +++ b/frontend/src/shell/useEntryParams.ts @@ -0,0 +1,92 @@ +import { useEffect, useRef, useState } from "react"; +import { useTranslation } from "react-i18next"; +import type { Me } from "../lib/api"; +import { clearParams, type Page } from "../lib/urlState"; +import { useAccountSwitchNotice } from "../lib/useAccountSwitchNotice"; +import { notify } from "../lib/notifications"; +import { focusAccessRequestsTab } from "../lib/adminUsersTab"; +import { focusSettingsTab } from "../lib/settingsTab"; + +type SetPage = (next: Page, then?: () => void) => void; + +// What the address bar the app was entered with asks for, answered once: the pre-login params dropped, +// a refused account switch told, the outcome of a Google link or a cancelled Google sign-in, and the +// admin email's deep link to the access requests. +export function useEntryParams( + me: Me | null | undefined, + refetchMe: () => unknown, + setPage: SetPage, +): void { + const { t } = useTranslation(); + // Once signed in, drop any leftover ONE-TIME query params from the pre-login flow (?access, ?verify, + // ?reset) — but NOT the nav/filter params, which the logged-in app now reads from and keeps in the + // URL. Gated on auth so the logged-out Welcome page still reads its params first. + // Keyed on "is there a session at all", which is the whole condition the body tests. Re-running + // on an account switch was never the point — by then the params are already gone and a second + // clearParams is a no-op. + const signedIn = !!me; + useEffect(() => { + if (signedIn) clearParams("access", "verify", "reset"); + }, [signedIn]); + + // A sign-in as someone else that the server refused, or a reload as another account: say so, once. + useAccountSwitchNotice(signedIn); + + // Returning from an in-app Google link/upgrade round-trip (auth.py redirects to ?link=…). + // Surface the outcome, refresh `me` (can_read/can_write/has_google may have flipped), land on + // Settings → Account so the new state is visible, then strip the param for a clean URL. + // "Exactly once per mount" is now ENFORCED by the ref rather than asserted by an empty dep list: + // `t` changes identity on a language switch, so the honest dep list would otherwise re-fire the + // toast when the user changes language. (The old `[]` got away with it only because clearParams + // had already removed the param — an invariant held by a side effect two lines further down.) + const linkAnswered = useRef(false); + useEffect(() => { + if (linkAnswered.current) return; + const result = new URLSearchParams(window.location.search).get("link"); + if (!result) return; + linkAnswered.current = true; + if (result === "ok") { + notify({ level: "success", message: t("settings.account.googleLink.linked") }); + void refetchMe(); + focusSettingsTab("account"); + setPage("settings"); + } else { + const key = result === "conflict" || result === "mismatch" ? result : "error"; + notify({ level: "error", message: t(`settings.account.googleLink.${key}`) }); + } + clearParams("link"); + }, [t, setPage, refetchMe]); + + // A Google OAuth flow started while already signed in (signing in as someone else, or a Google + // connect) was cancelled at Google's consent screen. The backend only emits this for a live session, so a + // neutral toast is the right feedback — the pre-auth login banner would never render here. + // Same one-shot shape as the `?link=` handler above, same reason for the ref. + const oauthAnswered = useRef(false); + useEffect(() => { + if (oauthAnswered.current) return; + if (new URLSearchParams(window.location.search).get("oauth") !== "cancelled") return; + oauthAnswered.current = true; + notify({ level: "info", message: t("settings.account.googleCancelled") }); + clearParams("oauth"); + }, [t]); + + // Deep-link from the "new access request" admin email (?admin=access-requests) → jump straight to + // the admin Users → Access requests view. Snapshot the param up front (read once at mount); act once + // `me` is known, and only for an admin (others just land on their default page). + const [adminDeepLink] = useState(() => new URLSearchParams(window.location.search).get("admin")); + // Ref-guarded like the two above, and here it closes a real hole rather than only satisfying the + // checker: the snapshot lives for the whole session, so with honest deps a later role change — + // or any `me` refetch, under the old id-only key — would yank the user to Users long after the + // deep link was answered. + const adminDeepLinkAnswered = useRef(false); + useEffect(() => { + if (adminDeepLinkAnswered.current) return; + if (adminDeepLink !== "access-requests" || !me) return; + adminDeepLinkAnswered.current = true; + if (me.role === "admin") { + focusAccessRequestsTab(); + setPage("users"); + } + clearParams("admin"); + }, [adminDeepLink, me, setPage]); +} diff --git a/frontend/src/shell/useShellSession.ts b/frontend/src/shell/useShellSession.ts new file mode 100644 index 00000000..41e4fece --- /dev/null +++ b/frontend/src/shell/useShellSession.ts @@ -0,0 +1,103 @@ +import { useEffect } from "react"; +import { useQueryClient } from "@tanstack/react-query"; +import { setUnauthorizedHandler, type Me } from "../lib/api"; +import { qk } from "../lib/queryKeys"; +import { reloadAfterRefusal } from "../lib/entryUrl"; +import { applyDiagnostics } from "../lib/diagnostics"; +import { useGlobalKeymap } from "../lib/keymap"; +import type { Page } from "../lib/urlState"; +import { moduleOrder, preloadPages } from "../features/navigation/moduleRegistry"; +import { ChannelPage } from "../modules/feed"; + +type SetPage = (next: Page, then?: () => void) => void; + +// What the signed-in shell keeps running for the session: the global keys, the diagnostics level, the +// idle preload of every reachable page, the scrollbar gutter's width, and the handler for a session +// that ended server-side. Called by App above its signed-out branch, so `me` may be null here. +export function useShellSession(me: Me | null | undefined, setPage: SetPage): void { + const queryClient = useQueryClient(); + + // The `global` key scope: `h` opens the cheat sheet, Mod+K the jump-to palette, Shift+W jumps to + // Home ("mit nézzek?"). Bound only once signed in, because that is where the overlays are mounted + // (bottom of this tree) and where the modules exist at all — see useGlobalKeymap on why binding it + // above the signed-out branch is worse than not binding it. + // A global key can fire while an overlay is open; `setPage` closes every overlay before it pushes, + // so Shift+W over the player lands on Home with the player closed — also when Home is already the + // page underneath (owner decision 2026-09-09: the key always ends with the answer on screen). + // The `page` scope rides the same binding: the `g` chords jump to a module, but only to one this + // account can reach (the nav rail's own list, plus Settings, which is not in the rail's order). + useGlobalKeymap(!!me, setPage, me ? [...moduleOrder(me), "settings"] : []); + + // The account's consented diagnostics level, applied to this tab's log: raised, the log is + // kept in more detail and streamed to the server until the level lapses. A debug session that is + // recording raises it for its own run and tags what it keeps. A lapse (its own timer, or the + // server saying it is off) refetches `me`, so Settings and the recorder show what is in force. + // Re-running this on every `me` refetch is FREE and therefore the deps are the honest ones: + // applyDiagnostics computes an effective-level key and returns early when it has not moved + // (lib/diagnostics.ts), so an unchanged level is a no-op. It does refresh the `lapsed` callback + // before that early return, which is the half we WANT re-run. The old dep list picked out four + // primitive fields to dodge the fresh object identity a refetch produces; that is the checker's + // job to verify, not a comment's to assert, and it silently missed any other field the module + // may come to read. + const diagnostics = me?.diagnostics; + const debugSession = me?.debug_session; + useEffect(() => { + applyDiagnostics( + diagnostics, + debugSession, + () => void queryClient.invalidateQueries({ queryKey: qk.me() }), + ); + }, [diagnostics, debugSession, queryClient]); + + // Fetch the code of every module this account can open — and the channel page — once the first page + // is on screen, so a module's first visit renders at once instead of flashing the chunk fallback. + // Idle-scheduled so it never competes with the first page's own requests (Safari has no + // requestIdleCallback, hence the timer). + // Depending on the whole `me` rather than its id is both honest and MORE correct: the module set + // is derived from `me` through each module's own gate (moduleOrder), so a permission + // change — an admin grant, a Plex server appearing — alters it without the id moving, and the old + // `[me.id]` dep never re-preloaded for those. Re-running costs nothing: `preload()` is idempotent + // (lazyWithPreload memoises the pending promise), so a refetch that changes nothing fetches + // nothing; only the idle callback is re-scheduled. + useEffect(() => { + if (!me) return; + const run = () => { + preloadPages([...moduleOrder(me), "settings"]); + ChannelPage.preload().catch(() => {}); + }; + const idle = window.requestIdleCallback as typeof window.requestIdleCallback | undefined; + if (idle) { + const id = idle(run, { timeout: 3000 }); + return () => window.cancelIdleCallback(id); + } + const id = window.setTimeout(run, 1000); + return () => window.clearTimeout(id); + }, [me]); + + // Measure the scrollbar's gutter width once and publish it as --sbw, so the floating header and the + // toolbar band can pad their right edge to line up with the scrolled cards (the app scroller reserves + // that gutter via scrollbar-gutter: stable). It's a device constant, so a one-shot mount measure is enough. + useEffect(() => { + const probe = document.createElement("div"); + probe.style.cssText = + "position:absolute;top:-9999px;width:100px;height:100px;overflow:scroll;scrollbar-gutter:stable"; + document.body.appendChild(probe); + const w = probe.offsetWidth - probe.clientWidth; + document.body.removeChild(probe); + document.documentElement.style.setProperty("--sbw", `${w}px`); + }, []); + + // Any request that 401s means the session ended server-side. If we were signed in (cached + // `me` is a user object), reload to the login page at once (also covers any click that hits + // the API). Guarded on a cached user so a stray 401 while logged out (cached `me` is null) + // can't loop the public landing. A tab showing another account than the one signed in now + // reloads as that one instead, and says why (`reloadAfterRefusal`). + useEffect(() => { + setUnauthorizedHandler((err) => { + if (!queryClient.getQueryData(qk.me())) return; + queryClient.clear(); + reloadAfterRefusal(err); + }); + return () => setUnauthorizedHandler(null); + }, [queryClient]); +}