From f4a68575c5cfe6abb2c72b395aa3e8e804a2412d Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sat, 18 Jul 2026 23:25:39 +0200 Subject: [PATCH] refactor(modules): consolidate nav into a module registry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- frontend/src/components/NavSidebar.tsx | 43 ++-------- frontend/src/lib/modules.ts | 114 ++++++++++++++++++------- 2 files changed, 92 insertions(+), 65 deletions(-) diff --git a/frontend/src/components/NavSidebar.tsx b/frontend/src/components/NavSidebar.tsx index 7028eac..3fc139e 100644 --- a/frontend/src/components/NavSidebar.tsx +++ b/frontend/src/components/NavSidebar.tsx @@ -4,32 +4,21 @@ import { useTranslation } from "react-i18next"; import { useQuery } from "@tanstack/react-query"; import { useNavigation, useNavigationActions } from "./NavigationProvider"; import { - Activity, - BarChart3, - Bell, - Clapperboard, - Download, - Home, Info, - ListVideo, LogOut, - MessageSquare, Pin, PinOff, - ScrollText, Settings, Shield, - SlidersHorizontal, - Users, - Tv, UserPlus, + type LucideIcon, } from "lucide-react"; import { api, clearActiveAccount, setActiveAccount, type Me } from "../lib/api"; import * as e2ee from "../lib/e2ee"; import { useLiveQuery } from "../lib/useLiveQuery"; import { getUnreadCount, subscribe } from "../lib/notifications"; import type { Page } from "../lib/urlState"; -import { moduleLabelKey, moduleOrder, SYSTEM_PAGES } from "../lib/modules"; +import { moduleDef, moduleLabelKey, moduleOrder, SYSTEM_PAGES } from "../lib/modules"; import { useScrollFade } from "../lib/useScrollFade"; import { useHoverFocusWithin } from "../lib/useHoverFocusWithin"; import { type LangCode } from "../i18n"; @@ -177,34 +166,18 @@ export default function NavSidebar({ // also used by the side panels) so a high-zoom overflow scrolls without a scrollbar. const listFade = useScrollFade(); - type NavItem = { page: Page; icon: typeof Home; label: string; badge?: number }; - // Per-page presentation (icon/label/badge). WHICH pages appear and in what order comes from the - // shared moduleOrder(me) — the same source the header's ◀/▶ stepper uses — so the two never drift - // and a new/gated module updates both at once. Badges live here (nav-only concern). - // Icon + badge per page; the label comes from the shared moduleLabelKey so the rail and the - // header's ModuleName pill always read the same name. - const ICON: Record = { - feed: Home, - channels: Tv, - playlists: ListVideo, - plex: Clapperboard, - notifications: Bell, - messages: MessageSquare, - downloads: Download, - stats: BarChart3, - scheduler: Activity, - config: SlidersHorizontal, - users: Users, - audit: ScrollText, - settings: Settings, - }; + type NavItem = { page: Page; icon: LucideIcon; label: string; badge?: number }; + // Per-page presentation (icon/label/badge). WHICH pages appear and in what order — and each + // module's icon + label — come from the shared module registry (the same source the header's + // ◀/▶ stepper uses), so the two never drift and a new/gated module updates both at once. The + // badge NUMBERS are the only nav-only concern (they need the live polling queries above). const BADGE: Partial> = { notifications: unread, messages: msgUnread, downloads: dlActive, }; const META = (p: Page): Omit => ({ - icon: ICON[p], + icon: moduleDef(p).icon, label: t(moduleLabelKey[p]), badge: BADGE[p], }); diff --git a/frontend/src/lib/modules.ts b/frontend/src/lib/modules.ts index 05ef101..7097735 100644 --- a/frontend/src/lib/modules.ts +++ b/frontend/src/lib/modules.ts @@ -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 = { - 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; + +// Look up a module's definition by page id (every Page is covered, incl. settings). +export function moduleDef(id: Page): ModuleDef { + return BY_ID[id]; +} + +// The i18n key for each module's SHORT display name. Single source so the nav rail and the header's +// ModuleName pill always read identically (the longer, descriptive `pageTitleKey` names are kept +// only for the browser tab title). +export const moduleLabelKey: Record = Object.fromEntries( + [...MODULES, SETTINGS_MODULE].map((m) => [m.id, m.labelKey]), +) as Record; + +// 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.