Files
siftlode/frontend/src/features/navigation/moduleRegistry.ts
T
peter 0d5c6273f1 fix(nav): the address follows the view through overlays, reloads and Back (review round 1)
- A URL mirror names the view again on every entry Back or Forward lands on, so a change made
  under a dialog, a drawer or a sub-view is not undone by the next reload.
- A view pushed from the entry a reload under a dialog started from no longer inherits its overlay
  mark; a reload under two overlays closes onto the page in one step.
- The linked player reopens over a live search, gives itself up when its list moves first, and
  plays alone over an emptied playlist; a capped Files list keeps the link it cannot resolve.
- The Feed press over a channel ends the search; leaving a search ends its asked mark; Files and
  Playlists searches end with their page; the feed always names its view and debounces its search.
- Search-box keys are declared per module and kept out of the client log below TRACE.
- Writers outside lib/ go through codecs; the URL contract scans computed keys, overlay writers and
  two-line reads, and holds every page to a bookmark round trip (Config, Activity, Playlists and the
  Files sort and viewer added).
2026-10-27 13:52:42 +01:00

118 lines
5.5 KiB
TypeScript

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>;
// Every module's address-bar keys, and those of every module but `page`: what a page switch clears and
// what a page opens without (NavigationProvider). Derived from the definitions, so a new module's keys
// are cleared the day it declares them.
const ALL_URL_KEYS: readonly string[] = ALL.flatMap((m) => m.urlKeys);
export function moduleUrlKeys(): readonly string[] {
return ALL_URL_KEYS;
}
/** Every module's address-bar keys that carry typed text — what the client log keeps out (main.tsx). */
export function typedUrlKeys(): readonly string[] {
return ALL.flatMap((m) => m.typedUrlKeys ?? []);
}
export function foreignUrlKeys(page: Page): readonly string[] {
return ALL.filter((m) => m.id !== page).flatMap((m) => m.urlKeys);
}
// 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(() => {});
}
}
// The modules' stores (lib/moduleDef `Store`), in registry order: ModuleStores mounts them at the root.
export const MODULE_STORES = ALL.flatMap((m) => (m.Store ? [m.Store] : []));