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:
@@ -4,32 +4,21 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useNavigation, useNavigationActions } from "./NavigationProvider";
|
import { useNavigation, useNavigationActions } from "./NavigationProvider";
|
||||||
import {
|
import {
|
||||||
Activity,
|
|
||||||
BarChart3,
|
|
||||||
Bell,
|
|
||||||
Clapperboard,
|
|
||||||
Download,
|
|
||||||
Home,
|
|
||||||
Info,
|
Info,
|
||||||
ListVideo,
|
|
||||||
LogOut,
|
LogOut,
|
||||||
MessageSquare,
|
|
||||||
Pin,
|
Pin,
|
||||||
PinOff,
|
PinOff,
|
||||||
ScrollText,
|
|
||||||
Settings,
|
Settings,
|
||||||
Shield,
|
Shield,
|
||||||
SlidersHorizontal,
|
|
||||||
Users,
|
|
||||||
Tv,
|
|
||||||
UserPlus,
|
UserPlus,
|
||||||
|
type LucideIcon,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { api, clearActiveAccount, setActiveAccount, type Me } from "../lib/api";
|
import { api, clearActiveAccount, setActiveAccount, type Me } from "../lib/api";
|
||||||
import * as e2ee from "../lib/e2ee";
|
import * as e2ee from "../lib/e2ee";
|
||||||
import { useLiveQuery } from "../lib/useLiveQuery";
|
import { useLiveQuery } from "../lib/useLiveQuery";
|
||||||
import { getUnreadCount, subscribe } from "../lib/notifications";
|
import { getUnreadCount, subscribe } from "../lib/notifications";
|
||||||
import type { Page } from "../lib/urlState";
|
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 { useScrollFade } from "../lib/useScrollFade";
|
||||||
import { useHoverFocusWithin } from "../lib/useHoverFocusWithin";
|
import { useHoverFocusWithin } from "../lib/useHoverFocusWithin";
|
||||||
import { type LangCode } from "../i18n";
|
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.
|
// also used by the side panels) so a high-zoom overflow scrolls without a scrollbar.
|
||||||
const listFade = useScrollFade();
|
const listFade = useScrollFade();
|
||||||
|
|
||||||
type NavItem = { page: Page; icon: typeof Home; label: string; badge?: number };
|
type NavItem = { page: Page; icon: LucideIcon; label: string; badge?: number };
|
||||||
// Per-page presentation (icon/label/badge). WHICH pages appear and in what order comes from the
|
// Per-page presentation (icon/label/badge). WHICH pages appear and in what order — and each
|
||||||
// shared moduleOrder(me) — the same source the header's ◀/▶ stepper uses — so the two never drift
|
// module's icon + label — come from the shared module registry (the same source the header's
|
||||||
// and a new/gated module updates both at once. Badges live here (nav-only concern).
|
// ◀/▶ stepper uses), so the two never drift and a new/gated module updates both at once. The
|
||||||
// Icon + badge per page; the label comes from the shared moduleLabelKey so the rail and the
|
// badge NUMBERS are the only nav-only concern (they need the live polling queries above).
|
||||||
// header's ModuleName pill always read the same name.
|
|
||||||
const ICON: Record<Page, typeof Home> = {
|
|
||||||
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,
|
|
||||||
};
|
|
||||||
const BADGE: Partial<Record<Page, number>> = {
|
const BADGE: Partial<Record<Page, number>> = {
|
||||||
notifications: unread,
|
notifications: unread,
|
||||||
messages: msgUnread,
|
messages: msgUnread,
|
||||||
downloads: dlActive,
|
downloads: dlActive,
|
||||||
};
|
};
|
||||||
const META = (p: Page): Omit<NavItem, "page"> => ({
|
const META = (p: Page): Omit<NavItem, "page"> => ({
|
||||||
icon: ICON[p],
|
icon: moduleDef(p).icon,
|
||||||
label: t(moduleLabelKey[p]),
|
label: t(moduleLabelKey[p]),
|
||||||
badge: BADGE[p],
|
badge: BADGE[p],
|
||||||
});
|
});
|
||||||
|
|||||||
+84
-30
@@ -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 { Me } from "./api";
|
||||||
import type { Page } from "./urlState";
|
import type { Page } from "./urlState";
|
||||||
|
|
||||||
// Admin/system modules — rendered in their own section (below a divider) in the nav rail.
|
// ── The module registry ───────────────────────────────────────────────────────────────────────
|
||||||
export const SYSTEM_PAGES: readonly Page[] = ["scheduler", "config", "users", "audit"];
|
// 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
|
||||||
// The i18n key for each module's SHORT display name — the same label the nav rail shows. Single
|
// ◀/▶ stepper, and (as the registry grows) the page render all read from here, so a new or newly
|
||||||
// source so the nav rail and the header's ModuleName pill always read identically (the longer,
|
// gated module shows up everywhere at once — nothing hard-codes the sequence or duplicates the icon.
|
||||||
// descriptive `pageTitleKey` names are kept only for the browser tab title).
|
export type ModuleDef = {
|
||||||
export const moduleLabelKey: Record<Page, string> = {
|
id: Page;
|
||||||
feed: "header.account.feed",
|
// i18n key for the SHORT display name — the same label the nav rail and the header pill show.
|
||||||
channels: "header.account.channels",
|
labelKey: string;
|
||||||
playlists: "header.account.playlists",
|
icon: LucideIcon;
|
||||||
plex: "plex.navLabel",
|
// Admin/system modules render in their own section (below a divider) in the nav rail.
|
||||||
notifications: "inbox.navLabel",
|
system?: boolean;
|
||||||
messages: "messages.navLabel",
|
// Carries a live unread/active count in the rail (the number itself is computed in NavSidebar,
|
||||||
downloads: "downloads.navLabel",
|
// which owns the polling queries; this only flags which modules have one).
|
||||||
stats: "header.account.stats",
|
hasBadge?: boolean;
|
||||||
scheduler: "header.account.scheduler",
|
// Availability for THIS account, derived from language-independent account state (plex toggle,
|
||||||
config: "header.account.config",
|
// demo restrictions, admin role). Absent = always available. Drives both the reachable module
|
||||||
users: "header.account.users",
|
// set (nav + stepper) and, later, the render gate.
|
||||||
audit: "header.account.audit",
|
gate?: (me: Me) => boolean;
|
||||||
settings: "header.account.settings",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
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
|
// 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
|
// 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.
|
// new module (or a newly-gated one) shows up in both at once. Availability is derived from the
|
||||||
// Availability is derived from the account (plex toggle, demo restrictions, admin role), so the
|
// account via each module's `gate`, so the reachable set changes with the account state, not the UI.
|
||||||
// reachable set changes with the language-independent account state, not the UI.
|
|
||||||
export function moduleOrder(me: Me): Page[] {
|
export function moduleOrder(me: Me): Page[] {
|
||||||
const pages: Page[] = ["feed", "channels", "playlists"];
|
return MODULES.filter((m) => !m.gate || m.gate(me)).map((m) => m.id);
|
||||||
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step to the next/previous module cyclically (wraps at both ends). `dir` = +1 forward, -1 back.
|
// Step to the next/previous module cyclically (wraps at both ends). `dir` = +1 forward, -1 back.
|
||||||
|
|||||||
Reference in New Issue
Block a user