refactor(modules): consolidate nav into a module registry

Extend lib/modules.ts into an ordered ModuleDef registry (id/label/icon/
system/badge/gate) as the single source for the nav rail and the header
stepper. Absorb NavSidebar's per-page ICON map; derive moduleOrder /
moduleLabelKey / SYSTEM_PAGES from the one array. Render still on the App
ternary — this is the scaffold S6b.3 will drive from. (Platform S6b.1)
This commit is contained in:
2026-07-18 23:25:39 +02:00
parent 1d84828cd0
commit f4a68575c5
2 changed files with 92 additions and 65 deletions
+84 -30
View File
@@ -1,41 +1,95 @@
import {
Activity,
BarChart3,
Bell,
Clapperboard,
Download,
Home,
ListVideo,
MessageSquare,
ScrollText,
Settings,
SlidersHorizontal,
Tv,
Users,
type LucideIcon,
} from "lucide-react";
import type { Me } from "./api";
import type { Page } from "./urlState";
// Admin/system modules — rendered in their own section (below a divider) in the nav rail.
export const SYSTEM_PAGES: readonly Page[] = ["scheduler", "config", "users", "audit"];
// The i18n key for each module's SHORT display name — the same label the nav rail shows. 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> = {
feed: "header.account.feed",
channels: "header.account.channels",
playlists: "header.account.playlists",
plex: "plex.navLabel",
notifications: "inbox.navLabel",
messages: "messages.navLabel",
downloads: "downloads.navLabel",
stats: "header.account.stats",
scheduler: "header.account.scheduler",
config: "header.account.config",
users: "header.account.users",
audit: "header.account.audit",
settings: "header.account.settings",
// ── 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 header's
// ◀/▶ stepper, 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 same label the nav rail and the header pill show.
labelKey: 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,
// demo restrictions, 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";
const notDemo = (me: Me) => !me.is_demo;
// Ordered in nav-rail order — this array IS the sequence the rail and the stepper walk.
const MODULES: readonly ModuleDef[] = [
{ id: "feed", labelKey: "header.account.feed", icon: Home },
{ id: "channels", labelKey: "header.account.channels", icon: Tv },
{ id: "playlists", labelKey: "header.account.playlists", icon: ListVideo },
{ id: "plex", labelKey: "plex.navLabel", icon: Clapperboard, gate: (me) => me.plex_enabled },
{ id: "notifications", labelKey: "inbox.navLabel", icon: Bell, hasBadge: true },
{ id: "messages", labelKey: "messages.navLabel", icon: MessageSquare, hasBadge: true, gate: notDemo },
{ id: "downloads", labelKey: "downloads.navLabel", icon: Download, hasBadge: true, gate: notDemo },
{ id: "stats", labelKey: "header.account.stats", icon: BarChart3 },
{ id: "scheduler", labelKey: "header.account.scheduler", icon: Activity, system: true, gate: isAdmin },
{ id: "config", labelKey: "header.account.config", icon: SlidersHorizontal, system: true, gate: isAdmin },
{ id: "users", labelKey: "header.account.users", icon: Users, system: true, gate: isAdmin },
{ id: "audit", labelKey: "header.account.audit", 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",
icon: Settings,
};
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>;
// 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 — nothing hard-codes the sequence.
// Availability is derived from the account (plex toggle, demo restrictions, admin role), so the
// reachable set changes with the language-independent account state, not the UI.
// 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[] {
const pages: Page[] = ["feed", "channels", "playlists"];
if (me.plex_enabled) pages.push("plex");
pages.push("notifications");
if (!me.is_demo) pages.push("messages", "downloads");
pages.push("stats");
if (me.role === "admin") pages.push(...SYSTEM_PAGES);
return pages;
return MODULES.filter((m) => !m.gate || m.gate(me)).map((m) => m.id);
}
// Step to the next/previous module cyclically (wraps at both ends). `dir` = +1 forward, -1 back.