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
@@ -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(() => {});
}
}