- The Downloads module, its rail icon and badge are gone. A bookmark, the remembered page and an old
history entry that name it land on Files (urlState.resolvePage); `g d` goes to Files too.
- Users page: a Storage tab — the disk in numbers, every user's use against their own limit with
their file count, every user's downloads; aligned tables, one menu per row (edit / reset quota,
open source, copy, the owner's quota). The admin storage rows carry quota + file count, the admin
job list its owner.
- Settings: a Downloads tab — the download formats in one list (duplicate a built-in, edit or delete
your own) and the site sign-ins, moved from Account.
- "View" on an added download, a clip or a converting upload opens the tray on that row (unfolded,
back if closed; a finished one opens as a file; under a player it opens without taking focus).
- The download dialog opens on the format used last.
- DataTable: `pager={false}` for a short list, `cardRow` to give a lone "…" its own card line.
- Welcome and README speak of Files; a new Files screenshot.
196 lines
6.8 KiB
TypeScript
196 lines
6.8 KiB
TypeScript
import {
|
|
Activity,
|
|
BarChart3,
|
|
Bell,
|
|
Clapperboard,
|
|
Home,
|
|
ListVideo,
|
|
ScrollText,
|
|
FileSearch,
|
|
FolderOpen,
|
|
Settings,
|
|
SlidersHorizontal,
|
|
Tv,
|
|
Users,
|
|
type LucideIcon,
|
|
} from "lucide-react";
|
|
import { YouTubeIcon } from "./brandIcons";
|
|
import type { Me } from "./api";
|
|
import type { Page } from "./urlState";
|
|
|
|
// ── 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;
|
|
// The SHORT name under the icon when the user turns the rail labels on (B7): the rail is 3.75rem
|
|
// wide with labels or without, so it has to fit there ("Alerts", "Listák"). The full `labelKey`
|
|
// stays the tooltip and the accessible name.
|
|
railKey: string;
|
|
// i18n key for the LONGER, descriptive browser-tab title (e.g. "Channel manager" vs the rail's
|
|
// "Channels"). Absent → the tab title falls back to `labelKey`. Read via `pageTitleKey`.
|
|
titleKey?: 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,
|
|
// 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";
|
|
|
|
// Ordered in nav-rail order — this array IS the sequence the rail and the stepper walk.
|
|
const MODULES: readonly ModuleDef[] = [
|
|
// Home leads: it is the landing (see navigationInit.loadInitialPage), and the module order IS the
|
|
// nav order and the header stepper's sequence. The feed takes the YouTube mark its own toolbar
|
|
// already uses, so the two are apart by shape and not just by position.
|
|
{
|
|
id: "home",
|
|
labelKey: "home.navLabel",
|
|
railKey: "nav.rail.home",
|
|
titleKey: "home.title",
|
|
icon: Home,
|
|
},
|
|
{ id: "feed", labelKey: "header.account.feed", railKey: "nav.rail.feed", icon: YouTubeIcon },
|
|
{
|
|
id: "channels",
|
|
labelKey: "header.account.channels",
|
|
railKey: "nav.rail.channels",
|
|
titleKey: "header.channelManager",
|
|
icon: Tv,
|
|
},
|
|
{
|
|
id: "playlists",
|
|
labelKey: "header.account.playlists",
|
|
railKey: "nav.rail.playlists",
|
|
icon: ListVideo,
|
|
},
|
|
{
|
|
id: "plex",
|
|
labelKey: "plex.navLabel",
|
|
railKey: "nav.rail.plex",
|
|
icon: Clapperboard,
|
|
gate: (me) => me.plex_enabled,
|
|
},
|
|
{
|
|
id: "notifications",
|
|
labelKey: "inbox.navLabel",
|
|
railKey: "nav.rail.notifications",
|
|
icon: Bell,
|
|
hasBadge: true,
|
|
},
|
|
{
|
|
id: "files",
|
|
labelKey: "files.navLabel",
|
|
railKey: "nav.rail.files",
|
|
icon: FolderOpen,
|
|
},
|
|
{
|
|
id: "stats",
|
|
labelKey: "header.account.stats",
|
|
railKey: "nav.rail.stats",
|
|
titleKey: "header.usageStats",
|
|
icon: BarChart3,
|
|
},
|
|
{
|
|
id: "scheduler",
|
|
labelKey: "header.account.scheduler",
|
|
railKey: "nav.rail.scheduler",
|
|
titleKey: "header.scheduler",
|
|
icon: Activity,
|
|
system: true,
|
|
gate: isAdmin,
|
|
},
|
|
{
|
|
id: "config",
|
|
labelKey: "header.account.config",
|
|
railKey: "nav.rail.config",
|
|
titleKey: "header.configuration",
|
|
icon: SlidersHorizontal,
|
|
system: true,
|
|
gate: isAdmin,
|
|
},
|
|
{
|
|
id: "users",
|
|
labelKey: "header.account.users",
|
|
railKey: "nav.rail.users",
|
|
titleKey: "header.users",
|
|
icon: Users,
|
|
system: true,
|
|
gate: isAdmin,
|
|
},
|
|
{
|
|
id: "audit",
|
|
labelKey: "header.account.audit",
|
|
railKey: "nav.rail.audit",
|
|
titleKey: "audit.title",
|
|
icon: ScrollText,
|
|
system: true,
|
|
gate: isAdmin,
|
|
},
|
|
{
|
|
id: "logs",
|
|
labelKey: "header.account.logs",
|
|
railKey: "nav.rail.logs",
|
|
titleKey: "adminLogs.title",
|
|
icon: FileSearch,
|
|
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",
|
|
railKey: "nav.rail.settings",
|
|
titleKey: "settings.title",
|
|
icon: Settings,
|
|
};
|
|
|
|
// Registering a NEW Page means adding it to MODULES (or SETTINGS_MODULE) above — that's the single
|
|
// place. If a Page is left unregistered, moduleDef()/moduleLabelKey[] return undefined and NavSidebar
|
|
// throws on `.icon`; the coverage isn't compiler-enforced here because the ordered array can't also
|
|
// be an exhaustive Page map, so treat the array as the checklist when the Page union grows.
|
|
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>;
|
|
|
|
export const moduleRailKey: Record<Page, string> = Object.fromEntries(
|
|
[...MODULES, SETTINGS_MODULE].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. 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. 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);
|
|
}
|