refactor(frontend): each module defines itself in its folder; rails load lazily and keep their own state

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.
This commit is contained in:
2026-10-23 04:00:58 +02:00
parent e83c5e2d97
commit cde5a98fd2
56 changed files with 890 additions and 685 deletions
+6 -4
View File
@@ -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. [`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, 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 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/<page>/`** (one folder per page: the page, app-free by a contract test) → **`modules/<page>/`** (one folder per page: the page, its rail, its store, its tests,
its rail, its store, its tests) → **`features/<name>/`** (what several pages share: video cards, filters, and its `module.ts` definition — label, icon, gate, the lazy page and rail — collected in nav order by
downloads, the player, navigation) → the **platform layer `components/ui/`** (domain-free primitives: the `features/navigation/moduleRegistry`; a new page is its folder, its id in `lib/urlState` and one line there) →
popover engine and its menus, `Modal`/`Overlay`, `Button`/`form`, `DataTable`/`VirtualGrid`, `QueryState`, **`features/<name>/`** (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 …) → `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 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 public face — the stores, mounted at the root and read app-wide, which `features/` may read too) or
@@ -16,7 +16,7 @@ import {
type DraftStep, type DraftStep,
type IssueText, type IssueText,
} from "../../lib/issueDraft"; } from "../../lib/issueDraft";
import { moduleDef } from "../../lib/modules"; import { moduleDef } from "../navigation/moduleRegistry";
import type { Page } from "../../lib/urlState"; import type { Page } from "../../lib/urlState";
import { qk } from "../../lib/queryKeys"; import { qk } from "../../lib/queryKeys";
import { focusIsUnclaimed } from "../../lib/useFocusKeeper"; import { focusIsUnclaimed } from "../../lib/useFocusKeeper";
@@ -4,7 +4,6 @@ import { useTranslation } from "react-i18next";
import { useMe } from "../../lib/useMe"; import { useMe } from "../../lib/useMe";
import { resolvePage, retiredParams, urlWithParams, type Page } from "../../lib/urlState"; import { resolvePage, retiredParams, urlWithParams, type Page } from "../../lib/urlState";
import { ACTIVITY_KEYS } from "../../lib/activityUrlState"; import { ACTIVITY_KEYS } from "../../lib/activityUrlState";
import { pageTitleKey } from "../../lib/pageMeta";
import { useDocumentObjectTitle } from "../../lib/documentTitle"; import { useDocumentObjectTitle } from "../../lib/documentTitle";
import { getAccountRaw, setAccountRaw } from "../../lib/storage"; import { getAccountRaw, setAccountRaw } from "../../lib/storage";
import { import {
@@ -14,7 +13,7 @@ import {
notifyNav, notifyNav,
rememberNavPosition, rememberNavPosition,
} from "../../lib/history"; } from "../../lib/history";
import { reachablePage } from "../../lib/modules"; import { pageTitleKey, reachablePage } from "./moduleRegistry";
import { import {
channelViewFrom, channelViewFrom,
loadInitialPage, loadInitialPage,
@@ -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/<page>/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);
});
});
@@ -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<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, 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(() => {});
}
}
@@ -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 { describe, expect, it } from "vitest";
import { pageTitleKey } from "./pageMeta"; import { pageTitleKey } from "./moduleRegistry";
import type { Page } from "./urlState"; 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 // 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. // rename (or a dropped titleKey) can't silently change a tab title.
const EXPECTED: Record<Page, string> = { const EXPECTED: Record<Page, string> = {
+11
View File
@@ -41,3 +41,14 @@ export function lazyWithPreload<P extends object>(
} }
return Object.assign(Preloadable, { preload }); 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<P extends object>(
factory: () => Promise<{ default: ComponentType<P> }>,
alongside: PreloadableComponent<object>,
): PreloadableComponent<P> {
return lazyWithPreload(() => Promise.all([factory(), alongside.preload()]).then(([m]) => m));
}
@@ -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<object>;
describe("lazyWithPreloadAlongside", () => {
it("is ready only once its rail has loaded too", async () => {
let releaseRail = () => {};
const rail = lazyWithPreload(
() =>
new Promise<{ default: ComponentType<object> }>((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);
});
});
+40
View File
@@ -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/<page>/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<object>;
// 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<object>;
// 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;
};
-193
View File
@@ -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<Page, string> = Object.fromEntries(
[...MODULES, SETTINGS_MODULE].map((m) => [m.id, m.labelKey]),
) as Record<Page, string>;
export const moduleRailKey: Record<Page, string> = Object.fromEntries(
[...MODULES, SETTINGS_MODULE].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. 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";
}
-12
View File
@@ -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;
}
+1 -1
View File
@@ -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 // 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 // derive from it, so adding a page is a one-line change (no parallel allowlists to keep in
// sync). "feed" is the default/fallback. // sync). "feed" is the default/fallback.
const PAGES = [ export const PAGES = [
"home", "home",
"feed", "feed",
"channels", "channels",
+46
View File
@@ -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<typeof import("react-i18next")>()),
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 }) => (
<QueryClientProvider client={qc}>{children}</QueryClientProvider>
);
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 });
});
});
+77
View File
@@ -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<Me | null>(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<boolean>(c.key, c.initial, c.opts);
return { layout, setLayout, collapsed, onToggleCollapse: () => setCollapsed(!collapsed) };
}
+1
View File
@@ -1,2 +1,3 @@
// The activity page's public face: what another unit may use of it. // The activity page's public face: what another unit may use of it.
export { StatusDot, type DotKey } from "./RunStatusDot"; export { StatusDot, type DotKey } from "./RunStatusDot";
export { activityModule } from "./module";
+15
View File
@@ -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")),
};
+1
View File
@@ -3,3 +3,4 @@
export { ChannelsProvider } from "./ChannelsProvider"; export { ChannelsProvider } from "./ChannelsProvider";
export { useChannelsActions } from "./channelsContext"; export { useChannelsActions } from "./channelsContext";
export { default as SyncStatus } from "./SyncStatus"; export { default as SyncStatus } from "./SyncStatus";
export { channelsModule } from "./module";
+14
View File
@@ -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",
};
+2
View File
@@ -0,0 +1,2 @@
// The config page's public face: how the shell and the navigation know it.
export { configModule } from "./module";
+15
View File
@@ -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")),
};
@@ -1,4 +1,3 @@
import { lazyWithPreload } from "../../lib/lazyWithPreload";
import { useMe } from "../../lib/useMe"; import { useMe } from "../../lib/useMe";
import { useFeedFilters, useFeedFiltersActions } from "./feedFiltersContext"; import { useFeedFilters, useFeedFiltersActions } from "./feedFiltersContext";
import { useFeedView, useFeedViewActions } from "./feedViewContext"; import { useFeedView, useFeedViewActions } from "./feedViewContext";
@@ -6,16 +5,12 @@ import { useNavigation, useNavigationActions } from "../../features/navigation/n
import PersonalViewsModal from "../../features/filters/PersonalViewsModal"; import PersonalViewsModal from "../../features/filters/PersonalViewsModal";
import { useSavedViews } from "../../features/filters/savedViewsContext"; import { useSavedViews } from "../../features/filters/savedViewsContext";
import FeedHeader from "./FeedHeader"; import FeedHeader from "./FeedHeader";
import Feed from "./Feed";
// 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"));
// The GLOBAL feed page: wires <Feed> to the app-wide feed filters / view / live-search, and fills the // The GLOBAL feed page: wires <Feed> to the app-wide feed filters / view / live-search, and fills the
// header (the channel page's feed has no header). (The channel // header (the channel page's feed has no header). (The channel
// page renders <Feed> itself with its own channel-scoped filters, so those two never share state.) // page renders <Feed> itself with its own channel-scoped filters, so those two never share state.)
export function FeedPage() { export default function FeedPage() {
const me = useMe(); const me = useMe();
const filters = useFeedFilters(); const filters = useFeedFilters();
const { setFilters } = useFeedFiltersActions(); const { setFilters } = useFeedFiltersActions();
+5 -12
View File
@@ -4,7 +4,7 @@ import { keepPreviousData, useQuery } from "@tanstack/react-query";
import { qk } from "../../lib/queryKeys"; import { qk } from "../../lib/queryKeys";
import { SlidersHorizontal } from "lucide-react"; import { SlidersHorizontal } from "lucide-react";
import { api, NO_TAG_SENTINEL, type FeedFilters, type Tag, type TagCategory } from "../../lib/api"; 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 { useDebounced } from "../../lib/useDebounced";
import { useActiveFilters } from "../../lib/useActiveFilters"; import { useActiveFilters } from "../../lib/useActiveFilters";
import TagManager from "../../features/channel/TagManager"; import TagManager from "../../features/channel/TagManager";
@@ -23,6 +23,7 @@ import { useFeedFilters, useFeedFiltersActions } from "./feedFiltersContext";
import { useChannelsActions } from "../channels"; import { useChannelsActions } from "../channels";
import { useTags } from "../../lib/useTags"; import { useTags } from "../../lib/useTags";
import { compareText } from "../../lib/collate"; import { compareText } from "../../lib/collate";
import { useRail } from "../../lib/useRail";
// Filter ids; display labels resolved at render time via t("sidebar.sort.<id>") etc. // Filter ids; display labels resolved at render time via t("sidebar.sort.<id>") etc.
const DATE_PRESETS: { days: number; key: string }[] = [ const DATE_PRESETS: { days: number; key: string }[] = [
@@ -37,17 +38,9 @@ const DATE_PRESETS: { days: number; key: string }[] = [
{ days: 1825, key: "5years" }, { days: 1825, key: "5years" },
]; ];
export default function Sidebar({ export default function Sidebar() {
layout, // Its layout and collapse flag are its own (lib/useRail), so the shell only places it.
setLayout, const { layout, setLayout, collapsed, onToggleCollapse } = useRail("feed");
collapsed,
onToggleCollapse,
}: {
layout: PanelLayout;
setLayout: (l: PanelLayout) => void;
collapsed: boolean;
onToggleCollapse: () => void;
}) {
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
const lang = i18n.language; const lang = i18n.language;
const { openModal } = useSavedViews(); const { openModal } = useSavedViews();
+1
View File
@@ -4,3 +4,4 @@ export { FeedViewProvider } from "./FeedViewProvider";
export { SavedViewsProvider } from "./SavedViewsProvider"; export { SavedViewsProvider } from "./SavedViewsProvider";
export { useFeedFilters, useFeedFiltersActions } from "./feedFiltersContext"; export { useFeedFilters, useFeedFiltersActions } from "./feedFiltersContext";
export { useFeedView, useFeedViewActions } from "./feedViewContext"; export { useFeedView, useFeedViewActions } from "./feedViewContext";
export { ChannelPage, feedModule } from "./module";
+20
View File
@@ -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"));
@@ -9,6 +9,15 @@ import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-libra
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { ReactNode } from "react"; 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) => { vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>(); const actual = await importOriginal<typeof import("react-i18next")>();
return { ...actual, useTranslation: () => ({ t: (k: string) => k, i18n: { language: "en" } }) }; return { ...actual, useTranslation: () => ({ t: (k: string) => k, i18n: { language: "en" } }) };
@@ -33,7 +42,6 @@ vi.mock("../../components/ui/FilterRail", () => ({
import FilesRail from "./FilesRail"; import FilesRail from "./FilesRail";
import { api, type FileFolder } from "../../lib/api"; 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 // 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. // 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); vi.spyOn(api, "filesUsage").mockResolvedValue({ unlimited: true, footprint_bytes: 0 } as never);
render( render(
<QueryClientProvider client={new QueryClient()}> <QueryClientProvider client={new QueryClient()}>
<FilesRail <FilesRail />
layout={defaultLayout("files")}
setLayout={vi.fn()}
collapsed={false}
onToggleCollapse={vi.fn()}
/>
</QueryClientProvider>, </QueryClientProvider>,
); );
await screen.findByText("files.folder.new"); await screen.findByText("files.folder.new");
@@ -194,12 +197,7 @@ describe("FilesRail · what it says when a part is empty or missing", () => {
<QueryClientProvider <QueryClientProvider
client={new QueryClient({ defaultOptions: { queries: { retry: false } } })} client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}
> >
<FilesRail <FilesRail />
layout={defaultLayout("files")}
setLayout={vi.fn()}
collapsed={false}
onToggleCollapse={vi.fn()}
/>
</QueryClientProvider>, </QueryClientProvider>,
); );
expect(await screen.findByText("files.usage.failed")).toBeTruthy(); expect(await screen.findByText("files.usage.failed")).toBeTruthy();
+4 -12
View File
@@ -7,7 +7,6 @@ import { FolderOpen, FolderPlus, HardDrive, Link2, Users } from "lucide-react";
import { api, type FileFolder } from "../../lib/api"; import { api, type FileFolder } from "../../lib/api";
import { compareText } from "../../lib/collate"; import { compareText } from "../../lib/collate";
import { formatBytes } from "../../lib/format"; import { formatBytes } from "../../lib/format";
import { type PanelLayout } from "../../lib/panelLayout";
import { qk } from "../../lib/queryKeys"; import { qk } from "../../lib/queryKeys";
import { useLiveQuery } from "../../lib/useLiveQuery"; import { useLiveQuery } from "../../lib/useLiveQuery";
import FilterRail from "../../components/ui/FilterRail"; import FilterRail from "../../components/ui/FilterRail";
@@ -19,6 +18,7 @@ import { ContextMenu } from "../../components/ui/ContextMenu";
import { Pressable } from "../../components/ui/Pressable"; import { Pressable } from "../../components/ui/Pressable";
import { type PanelItem } from "../../components/ui/PanelGroups"; import { type PanelItem } from "../../components/ui/PanelGroups";
import { useContextMenu } from "../../components/ui/useContextMenu"; 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 // 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 // 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({ export default function FilesRail() {
layout, // Its layout and collapse flag are its own (lib/useRail), so the shell only places it.
setLayout, const { layout, setLayout, collapsed, onToggleCollapse } = useRail("files");
collapsed,
onToggleCollapse,
}: {
layout: PanelLayout;
setLayout: (l: PanelLayout) => void;
collapsed: boolean;
onToggleCollapse: () => void;
}) {
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
const { view } = useFiles(); const { view } = useFiles();
const foldersQ = useQuery({ queryKey: qk.fileFolders(), queryFn: api.fileFolders }); const foldersQ = useQuery({ queryKey: qk.fileFolders(), queryFn: api.fileFolders });
+1
View File
@@ -2,3 +2,4 @@
export { FilesProvider } from "./FilesProvider"; export { FilesProvider } from "./FilesProvider";
export { useFilesActions } from "./filesContext"; export { useFilesActions } from "./filesContext";
export { folderIcon, folderLabel, sourceLabel, takesUploads } from "./fileFolders"; export { folderIcon, folderLabel, sourceLabel, takesUploads } from "./fileFolders";
export { filesModule } from "./module";
+17
View File
@@ -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",
};
+2
View File
@@ -0,0 +1,2 @@
// The home page's public face: how the shell and the navigation know it.
export { homeModule } from "./module";
+14
View File
@@ -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",
};
@@ -0,0 +1,2 @@
// The notifications page's public face: how the shell and the navigation know it.
export { notificationsModule } from "./module";
@@ -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",
};
@@ -10,6 +10,15 @@ import { FloatingTree } from "@floating-ui/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { ReactNode } from "react"; 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) => { vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>(); const actual = await importOriginal<typeof import("react-i18next")>();
return { ...actual, useTranslation: () => ({ t: (k: string) => k, i18n: { language: "en" } }) }; return { ...actual, useTranslation: () => ({ t: (k: string) => k, i18n: { language: "en" } }) };
@@ -82,12 +91,7 @@ async function openRename() {
render( render(
<QueryClientProvider client={qc}> <QueryClientProvider client={qc}>
<FloatingTree> <FloatingTree>
<PlaylistsRail <PlaylistsRail />
layout={{} as never}
setLayout={() => {}}
collapsed={false}
onToggleCollapse={() => {}}
/>
</FloatingTree> </FloatingTree>
</QueryClientProvider>, </QueryClientProvider>,
); );
@@ -130,12 +134,7 @@ describe("PlaylistsRail · the row's '…'", () => {
render( render(
<QueryClientProvider client={qc}> <QueryClientProvider client={qc}>
<FloatingTree> <FloatingTree>
<PlaylistsRail <PlaylistsRail />
layout={{} as never}
setLayout={() => {}}
collapsed={false}
onToggleCollapse={() => {}}
/>
</FloatingTree> </FloatingTree>
</QueryClientProvider>, </QueryClientProvider>,
); );
@@ -11,7 +11,6 @@ import { notify } from "../../lib/notifications";
import { notifyYouTubeActionError } from "../../lib/youtubeErrors"; import { notifyYouTubeActionError } from "../../lib/youtubeErrors";
import { LS } from "../../lib/storage"; import { LS } from "../../lib/storage";
import { usePref } from "../../lib/usePref"; import { usePref } from "../../lib/usePref";
import { type PanelLayout } from "../../lib/panelLayout";
import FilterRail from "../../components/ui/FilterRail"; import FilterRail from "../../components/ui/FilterRail";
import { Button } from "../../components/ui/Button"; import { Button } from "../../components/ui/Button";
import { type ActionMenuItem } from "../../components/ui/ActionMenu"; import { type ActionMenuItem } from "../../components/ui/ActionMenu";
@@ -31,6 +30,7 @@ import { Pressable } from "../../components/ui/Pressable";
import { Input } from "../../components/ui/Input"; import { Input } from "../../components/ui/Input";
import { MoreButton } from "../../components/ui/MoreButton"; import { MoreButton } from "../../components/ui/MoreButton";
import RemoteImage from "../../components/ui/RemoteImage"; import RemoteImage from "../../components/ui/RemoteImage";
import { useRail } from "../../lib/useRail";
type PlSort = { type PlSort = {
key: "custom" | "name" | "count" | "duration" | "recent"; 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 // 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 // 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. // (Playlists.tsx) reacts to it. Grouped into two islands: "manage" and the playlist list.
export default function PlaylistsRail({ export default function PlaylistsRail() {
layout, // Its layout and collapse flag are its own (lib/useRail), so the shell only places it.
setLayout, const { layout, setLayout, collapsed, onToggleCollapse } = useRail("playlists");
collapsed,
onToggleCollapse,
}: {
layout: PanelLayout;
setLayout: (l: PanelLayout) => void;
collapsed: boolean;
onToggleCollapse: () => void;
}) {
const canWrite = useMe().can_write; const canWrite = useMe().can_write;
// The name filter + the selected playlist live in PlaylistsProvider (shared with the header search // 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. // box and the Playlists detail pane); this rail owns the list query + its own controls.
+1
View File
@@ -1,3 +1,4 @@
// The playlists page's public face: its store, mounted at the root and read app-wide. // The playlists page's public face: its store, mounted at the root and read app-wide.
export { PlaylistsProvider } from "./PlaylistsProvider"; export { PlaylistsProvider } from "./PlaylistsProvider";
export { usePlaylistsActions } from "./playlistsContext"; export { usePlaylistsActions } from "./playlistsContext";
export { playlistsModule } from "./module";
+17
View File
@@ -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",
};
+5 -10
View File
@@ -8,7 +8,6 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
import { ListMusic, Plus, SlidersHorizontal } from "lucide-react"; import { ListMusic, Plus, SlidersHorizontal } from "lucide-react";
import { api, plexFilterCount, type PlexFilters } from "../../lib/api"; import { api, plexFilterCount, type PlexFilters } from "../../lib/api";
import { notifyActionError } from "../../lib/actionErrors"; import { notifyActionError } from "../../lib/actionErrors";
import { type PanelLayout } from "../../lib/panelLayout";
import { type MediaSourceKind } from "../../lib/mediaSource"; import { type MediaSourceKind } from "../../lib/mediaSource";
import { useDebounced } from "../../lib/useDebounced"; import { useDebounced } from "../../lib/useDebounced";
import FilterRail from "../../components/ui/FilterRail"; import FilterRail from "../../components/ui/FilterRail";
@@ -22,19 +21,13 @@ import { useSavedViews } from "../../features/filters/savedViewsContext";
import { usePlex, usePlexActions } from "./plexContext"; import { usePlex, usePlexActions } from "./plexContext";
import { DismissibleChip } from "../../components/ui/Chip"; import { DismissibleChip } from "../../components/ui/Chip";
import { Input } from "../../components/ui/Input"; 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 // 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 // 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. // 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 RATING_STEPS = [5, 6, 7, 8, 9];
const ADDED_OPTS = ["24h", "1w", "1m", "6m", "1y"] as const; const ADDED_OPTS = ["24h", "1w", "1m", "6m", "1y"] as const;
const DURATION_BUCKETS: { key: string; min: number | null; max: number | null }[] = [ 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 }, { 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(); const { t, i18n } = useTranslation();
// Scope / watch-state / sort / expanded filters live in PlexProvider (shared with PlexBrowse and // 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 // the header search box); this rail is the primary editor of them. Opening a playlist is a
+1
View File
@@ -2,3 +2,4 @@
export { PlexProvider } from "./PlexProvider"; export { PlexProvider } from "./PlexProvider";
export { usePlex, usePlexActions } from "./plexContext"; export { usePlex, usePlexActions } from "./plexContext";
export { buildPlexActiveFilters } from "./usePlexActiveFilters"; export { buildPlexActiveFilters } from "./usePlexActiveFilters";
export { plexModule } from "./module";
+18
View File
@@ -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",
};
+2
View File
@@ -0,0 +1,2 @@
// The scheduler page's public face: how the shell and the navigation know it.
export { schedulerModule } from "./module";
+15
View File
@@ -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")),
};
@@ -21,7 +21,11 @@ vi.mock("react-i18next", async (importOriginal) => {
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }), 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<typeof import("../../lib/api")>()),
api: h,
}));
// Partial mock: SettingsPanel's module graph (PrefsProvider) also pulls getNotifSettings from here. // Partial mock: SettingsPanel's module graph (PrefsProvider) also pulls getNotifSettings from here.
vi.mock("../../lib/notifications", async (importOriginal) => { vi.mock("../../lib/notifications", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../lib/notifications")>(); const actual = await importOriginal<typeof import("../../lib/notifications")>();
+2
View File
@@ -0,0 +1,2 @@
// The settings page's public face: how the shell and the navigation know it.
export { settingsModule } from "./module";
+13
View File
@@ -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")),
};
+2
View File
@@ -0,0 +1,2 @@
// The stats page's public face: how the shell and the navigation know it.
export { statsModule } from "./module";
+13
View File
@@ -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")),
};
+2
View File
@@ -0,0 +1,2 @@
// The users page's public face: how the shell and the navigation know it.
export { usersModule } from "./module";
+16
View File
@@ -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",
};
+20 -280
View File
@@ -1,24 +1,14 @@
import { lazy, Suspense, useEffect, useRef, useState } from "react"; import { lazy, Suspense, useState } from "react";
import { qk } from "../lib/queryKeys"; import { qk } from "../lib/queryKeys";
import { saveLanguage } from "../lib/saveLanguage"; import { saveLanguage } from "../lib/saveLanguage";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query"; 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 { notifyActionError } from "../lib/actionErrors";
import { setLanguage, type LangCode } from "../i18n"; import { setLanguage, type LangCode } from "../i18n";
import { clearParams } from "../lib/urlState"; import { useShellSession } from "./useShellSession";
import { reloadAfterRefusal } from "../lib/entryUrl"; import { useEntryParams } from "./useEntryParams";
import { useAccountSwitchNotice } from "../lib/useAccountSwitchNotice"; import { useAccessRequestsNudge } from "./useAccessRequestsNudge";
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 { useVerifyEntry } from "../lib/useVerifyEntry"; import { useVerifyEntry } from "../lib/useVerifyEntry";
import { useNavigation, useNavigationActions } from "../features/navigation/navigationContext"; import { useNavigation, useNavigationActions } from "../features/navigation/navigationContext";
import { import {
@@ -26,10 +16,9 @@ import {
useFeedFiltersActions, useFeedFiltersActions,
useFeedView, useFeedView,
useFeedViewActions, useFeedViewActions,
ChannelPage,
} from "../modules/feed"; } from "../modules/feed";
import { moduleOrder } from "../lib/modules"; import { moduleDef, pageTitleKey } from "../features/navigation/moduleRegistry";
import { PAGE_CONTENT, PAGE_RAIL, preloadPages } from "./moduleRegistry";
import { lazyWithPreload } from "../lib/lazyWithPreload";
import { LoadingState } from "../components/ui/QueryState"; import { LoadingState } from "../components/ui/QueryState";
// Persistent shell — always mounted, so eagerly imported. // Persistent shell — always mounted, so eagerly imported.
import Welcome from "./Welcome"; import Welcome from "./Welcome";
@@ -40,29 +29,23 @@ import BackToTop from "../components/ui/BackToTop";
import PageShell, { PageHeaderDefault } from "../components/ui/PageShell"; import PageShell, { PageHeaderDefault } from "../components/ui/PageShell";
import { PaletteLauncher } from "../features/header/PaletteLauncher"; import { PaletteLauncher } from "../features/header/PaletteLauncher";
import { SkipLink } from "../components/ui/SkipLink"; import { SkipLink } from "../components/ui/SkipLink";
import { pageTitleKey } from "../lib/pageMeta";
import DebugRecorder from "../features/diagnostics/DebugRecorder"; import DebugRecorder from "../features/diagnostics/DebugRecorder";
import Toaster from "../components/ui/Toaster"; import Toaster from "../components/ui/Toaster";
import KeyboardHelp from "../components/ui/KeyboardHelp"; import KeyboardHelp from "../components/ui/KeyboardHelp";
import AppCommandPalette from "./AppCommandPalette"; import AppCommandPalette from "./AppCommandPalette";
import DownloadIntake from "../features/downloads/DownloadIntake"; import DownloadIntake from "../features/downloads/DownloadIntake";
import DownloadTray from "../features/downloads/DownloadTray"; import DownloadTray from "../features/downloads/DownloadTray";
import { useGlobalKeymap } from "../lib/keymap";
import ErrorDialog from "./ErrorDialog"; import ErrorDialog from "./ErrorDialog";
import VersionBanner from "./VersionBanner"; import VersionBanner from "./VersionBanner";
import VerifyBanner from "./VerifyBanner"; 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 // 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 // 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). // split. FRONTEND_VERSION equals the newest release note's version by release convention (both bump together).
import { FRONTEND_VERSION } from "../lib/version"; 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 // The page modules (and their side rails) are code-split per route: each module's definition imports
// (shell/moduleRegistry). App keeps only the persistent-shell chunks + the channel page and the // them lazily (features/navigation/moduleRegistry), and so does the feed's for the channel page. App
// two modals, which sit outside the registry-driven content column. // keeps only the persistent-shell chunks and the two modals.
const ChannelPage = lazyWithPreload(() => import("../modules/feed/ChannelPage"));
const About = lazy(() => import("./About")); const About = lazy(() => import("./About"));
const ReleaseNotes = lazy(() => import("./ReleaseNotes")); const ReleaseNotes = lazy(() => import("./ReleaseNotes"));
@@ -85,20 +68,6 @@ export default function App() {
const view = useFeedView(); const view = useFeedView();
const { setView } = useFeedViewActions(); const { setView } = useFeedViewActions();
const queryClient = useQueryClient(); 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<Record<PanelId, PanelLayout>>(() => {
const prefs = queryClient.getQueryData<Me | null>(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 [aboutOpen, setAboutOpen] = useState(false);
const [notesOpen, setNotesOpen] = useState(false); const [notesOpen, setNotesOpen] = useState(false);
const [notesHighlight, setNotesHighlight] = useState<string | undefined>(undefined); const [notesHighlight, setNotesHighlight] = useState<string | undefined>(undefined);
@@ -108,38 +77,6 @@ export default function App() {
setNotesOpen(true); 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<boolean>("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<boolean>("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<boolean>("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 // (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.) // 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. // effects below depend on it, and a dep list is only honest if it names the value the body reads.
const me = meQuery.data; const me = meQuery.data;
// The `global` key scope: `h` opens the cheat sheet, Mod+K the jump-to palette, Shift+W jumps to // What runs for the session (keys, diagnostics, preloading, the 401 handler), what the entry URL
// Home ("mit nézzek?"). Bound only once signed in, because that is where the overlays are mounted // asks for once, and the admin's waiting-requests nudge: each its own shell hook, so App is layout.
// (bottom of this tree) and where the modules exist at all — see useGlobalKeymap on why binding it useShellSession(me, setPage);
// above the signed-out branch is worse than not binding it. useEntryParams(me, meQuery.refetch, setPage);
// A global key can fire while an overlay is open; `setPage` closes every overlay before it pushes, useAccessRequestsNudge(me, setPage);
// 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]);
// Language is edited outside the Settings page (sidebar/login), so it stays instant. // Language is edited outside the Settings page (sidebar/login), so it stays instant.
function changeLanguage(code: LangCode) { 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 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 // 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. // (`reachablePage`). A channel page overlays the content column, so no side rail shows behind it.
const { Component: PageContent, maxWidth } = PAGE_CONTENT[page]; const { Page: PageContent, Rail, maxWidth } = moduleDef(page);
const railDef = channelView ? undefined : PAGE_RAIL[page]; const PageRail = channelView ? undefined : Rail;
return ( return (
<div className="h-dvh flex text-fg"> <div className="h-dvh flex text-fg">
@@ -399,18 +147,10 @@ export default function App() {
/> />
{/* The active page's side rail (feed filters / plex filters / playlists list), if it has one. {/* 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 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. */} content. It holds its own layout and collapse flag (lib/useRail). */}
{railDef && ( {PageRail && (
<Suspense fallback={null}> <Suspense fallback={null}>
<railDef.Rail <PageRail />
layout={panelLayouts[railDef.panelId]}
setLayout={(l) => setPanelLayout(railDef.panelId, l)}
collapsed={railCollapse[railDef.collapse][0]}
onToggleCollapse={() => {
const [collapsed, setCollapsed] = railCollapse[railDef.collapse];
setCollapsed(!collapsed);
}}
/>
</Suspense> </Suspense>
)} )}
{/* `@container/appmain`: the floating header + its search box adapt to THIS column's width {/* `@container/appmain`: the floating header + its search box adapt to THIS column's width
@@ -26,7 +26,9 @@ vi.mock("../components/ui/CommandPalette", () => ({
const channels = vi.fn(); const channels = vi.fn();
const playlists = 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<typeof import("../lib/api")>()),
api: { api: {
channels: (...a: unknown[]) => channels(...a), channels: (...a: unknown[]) => channels(...a),
playlists: (...a: unknown[]) => playlists(...a), playlists: (...a: unknown[]) => playlists(...a),
+1 -1
View File
@@ -16,7 +16,7 @@ import CommandPalette, { type CommandGroup } from "../components/ui/CommandPalet
import { api } from "../lib/api"; import { api } from "../lib/api";
import { qk } from "../lib/queryKeys"; import { qk } from "../lib/queryKeys";
import { useMe } from "../lib/useMe"; 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 { SCHEMES, type Scheme } from "../lib/theme";
import { closePalette, toggleKeyboardHelp, usePaletteOpen } from "../lib/keymap"; import { closePalette, toggleKeyboardHelp, usePaletteOpen } from "../lib/keymap";
import { requestSurprise } from "../lib/homeIntent"; import { requestSurprise } from "../lib/homeIntent";
+1 -1
View File
@@ -14,7 +14,7 @@ import {
moduleOrder, moduleOrder,
moduleRailKey, moduleRailKey,
SYSTEM_PAGES, SYSTEM_PAGES,
} from "../lib/modules"; } from "../features/navigation/moduleRegistry";
import { useScrollFade } from "../lib/useScrollFade"; import { useScrollFade } from "../lib/useScrollFade";
import { type LangCode } from "../i18n"; import { type LangCode } from "../i18n";
import AvatarImg from "../components/ui/Avatar"; import AvatarImg from "../components/ui/Avatar";
-107
View File
@@ -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 `<Content />` 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 <Feed> 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 <Suspense>. 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<object>;
// 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<Page, PageDef> = {
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<RailProps>;
// 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<Record<Page, RailDef>> = {
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<PreloadableComponent<RailProps>> | undefined;
rail?.preload?.().catch(() => {});
}
}
@@ -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]);
}
+92
View File
@@ -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]);
}
+103
View File
@@ -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]);
}