refactor(frontend): each module defines itself in its folder; rails load lazily and keep their own state

A page used to be listed three times (lib/modules for its label, icon and gate; shell/moduleRegistry for
its page and rail; lib/pageMeta for its title), and App held every rail's layout and collapse flag.
Now each module's `module.ts` says it all — label, icon, gate, its lazy page and rail, its width —
handed out through its index.ts, and features/navigation/moduleRegistry lists them in nav order for
the rail, the palette, the tab title, the reachable set and the shell's render. A contract test holds
the registry to the page ids and each definition to its own folder, with no static import of a page.

Every rail is lazy now, loaded together with its page so a pinned rail never arrives after it and
shoves the content aside; the feed's and the playlists' rails took @dnd-kit off the boot path (boot
428.5 → 393.9 KiB gzip, the App chunk 122 → 32 KiB). A rail reads its own layout and collapse flag
(lib/useRail) and writes a layout change to the `me` cache too, since it now mounts anew on each
visit. App's session effects, its one-shot entry-URL handlers and the admin nudge move into shell
hooks; App is layout. The registry's three layer suppressions are gone: none is left.
This commit is contained in:
2026-10-23 04:00:58 +02:00
parent e83c5e2d97
commit cde5a98fd2
56 changed files with 890 additions and 685 deletions
+11
View File
@@ -41,3 +41,14 @@ export function lazyWithPreload<P extends object>(
}
return Object.assign(Preloadable, { preload });
}
/** A lazy page that resolves only once `alongside` (its side rail) has loaded too. The rail renders in
* its own Suspense beside the page, and a pinned rail sits in the page's flow: arriving after the page,
* it would shove the page's content sideways on its first visit. Loaded together, the rail is already
* there (it renders synchronously once loaded) when the page commits. */
export function lazyWithPreloadAlongside<P extends object>(
factory: () => Promise<{ default: ComponentType<P> }>,
alongside: PreloadableComponent<object>,
): PreloadableComponent<P> {
return lazyWithPreload(() => Promise.all([factory(), alongside.preload()]).then(([m]) => m));
}
@@ -0,0 +1,29 @@
// A page whose rail sits in its flow must not be ready before the rail: the rail would then appear a
// moment after the page and shove its content sideways.
import { describe, expect, it } from "vitest";
import type { ComponentType } from "react";
import { lazyWithPreload, lazyWithPreloadAlongside } from "./lazyWithPreload";
const component = (() => null) as ComponentType<object>;
describe("lazyWithPreloadAlongside", () => {
it("is ready only once its rail has loaded too", async () => {
let releaseRail = () => {};
const rail = lazyWithPreload(
() =>
new Promise<{ default: ComponentType<object> }>((resolve) => {
releaseRail = () => resolve({ default: component });
}),
);
const page = lazyWithPreloadAlongside(async () => ({ default: component }), rail);
let pageReady = false;
const loading = page.preload().then(() => {
pageReady = true;
});
await new Promise((r) => setTimeout(r, 0));
expect(pageReady).toBe(false); // its own code is in, its rail's is not
releaseRail();
await loading;
expect(pageReady).toBe(true);
});
});
+40
View File
@@ -0,0 +1,40 @@
import type { LucideIcon } from "lucide-react";
import type { Me } from "./api";
import type { Page } from "./urlState";
import type { PreloadableComponent } from "./lazyWithPreload";
// What a module says about itself: one definition per page, in its own folder (`modules/<page>/module.ts`,
// handed out through its index.ts), collected in nav order by features/navigation/moduleRegistry. A new
// page is its folder, its id in lib/urlState, and one line in that registry.
export type ModuleDef = {
id: Page;
// i18n key for the SHORT display name — the label the nav rail and the palette 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 the reachable set: the rail, the palette, the
// `g` chords, and which page a link may land on.
gate?: (me: Me) => boolean;
// The page itself: lazy, its own chunk, fetched on the first visit or by the idle preload. Prop-free:
// it reads `me` and its module's state from hooks.
Page: PreloadableComponent<object>;
// Its side rail, if it has one: lazy like the page, and prop-free (its layout and collapse flag are
// its own, lib/useRail).
Rail?: PreloadableComponent<object>;
// The content column's max width. The header + toolbar centre on this same width (via --content-mw)
// so the floating chrome lines up with the centred content instead of spanning the whole column.
// MUST match the max-w the page's own content wrapper uses. Omit for a full-width page (feed).
maxWidth?: string;
};
-193
View File
@@ -1,193 +0,0 @@
import {
Activity,
BarChart3,
Bell,
Clapperboard,
Home,
ListVideo,
ScrollText,
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 command
// palette's module group, 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 label the nav rail and the palette 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 palette list.
const MODULES: readonly ModuleDef[] = [
// Home leads: it is the landing (see navigationInit.loadInitialPage), and the module order IS the
// nav order and the palette's. 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: "activity",
labelKey: "header.account.activity",
railKey: "nav.rail.activity",
titleKey: "activity.title",
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",
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);
}
/** 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";
}
-30
View File
@@ -1,30 +0,0 @@
import { describe, expect, it } from "vitest";
import { pageTitleKey } from "./pageMeta";
import type { Page } from "./urlState";
// pageTitleKey now derives from the ModuleDef registry (titleKey ?? labelKey). These are the exact
// browser-tab title keys the old hand-written switch produced — pin every page so a future labelKey
// rename (or a dropped titleKey) can't silently change a tab title.
const EXPECTED: Record<Page, string> = {
home: "home.title",
feed: "header.account.feed",
channels: "header.channelManager",
playlists: "header.account.playlists",
notifications: "inbox.navLabel",
files: "files.navLabel",
stats: "header.usageStats",
plex: "plex.navLabel",
scheduler: "header.scheduler",
config: "header.configuration",
users: "header.users",
activity: "activity.title",
settings: "settings.title",
};
describe("pageTitleKey", () => {
it("returns the exact title key for every page (registry-derived)", () => {
for (const [page, key] of Object.entries(EXPECTED)) {
expect(pageTitleKey(page as Page)).toBe(key);
}
});
});
-12
View File
@@ -1,12 +0,0 @@
import type { Page } from "./urlState";
import { moduleDef } from "./modules";
// The i18n key for a page's human title — shared by the top-bar header and the browser tab title so
// the two never drift. Derived from the single ModuleDef registry (modules.ts): a module's optional
// `titleKey` is the longer, descriptive tab title (e.g. "Channel manager"), falling back to its
// short nav `labelKey` when the two are the same. Registering a new Page is then one touch (add it
// to MODULES) instead of also editing a switch here.
export function pageTitleKey(page: Page): string {
const def = moduleDef(page);
return def.titleKey ?? def.labelKey;
}
+1 -1
View File
@@ -118,7 +118,7 @@ export function hasFilterParams(params: URLSearchParams): boolean {
// The single source of truth for valid page ids; `Page` and the runtime validator both
// derive from it, so adding a page is a one-line change (no parallel allowlists to keep in
// sync). "feed" is the default/fallback.
const PAGES = [
export const PAGES = [
"home",
"feed",
"channels",
+46
View File
@@ -0,0 +1,46 @@
// @vitest-environment jsdom
//
// A rail mounts anew on each visit to its page and seeds its layout from the `me` cache. When the shell
// held the layout, a change lived on in its state; now the rail must leave the change where its next
// mount looks, or a visit to another page and back brings the server's older layout back.
import { describe, expect, it, vi } from "vitest";
import { renderHook, act } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { ReactNode } from "react";
import { api } from "./api";
import { qk } from "./queryKeys";
import { usePanelLayout } from "./useRail";
vi.mock("react-i18next", async (importOriginal) => ({
...(await importOriginal<typeof import("react-i18next")>()),
useTranslation: () => ({ t: (k: string) => k }),
}));
const OLD = {
order: ["year", "date", "channel", "tags", "language", "topic"],
collapsed: {},
hidden: {},
};
const NEW = {
order: ["tags", "date", "year", "channel", "language", "topic"],
collapsed: {},
hidden: {},
};
describe("a rail's layout", () => {
it("is the changed one on the next visit, not the server's older copy", () => {
vi.spyOn(api, "savePrefs").mockResolvedValue(undefined as never);
const qc = new QueryClient();
qc.setQueryData(qk.me(), { id: 1, preferences: { sidebarLayout: OLD } });
const wrapper = ({ children }: { children: ReactNode }) => (
<QueryClientProvider client={qc}>{children}</QueryClientProvider>
);
const first = renderHook(() => usePanelLayout("feed"), { wrapper });
expect(first.result.current[0].order).toEqual(OLD.order);
act(() => first.result.current[1](NEW));
first.unmount();
const next = renderHook(() => usePanelLayout("feed"), { wrapper });
expect(next.result.current[0].order).toEqual(NEW.order);
expect(api.savePrefs).toHaveBeenCalledWith({ sidebarLayout: NEW });
});
});
+77
View File
@@ -0,0 +1,77 @@
import { useState } from "react";
import { useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { api, type Me } from "./api";
import { qk } from "./queryKeys";
import { notifyActionError } from "./actionErrors";
import { LS } from "./storage";
import { usePref } from "./usePref";
import { updateMeCache } from "./useMe";
import {
PREF_KEY,
saveLayoutLocal,
seedLayout,
type PanelId,
type PanelLayout,
} from "./panelLayout";
// A side rail's own state: its group cards' layout and whether it is collapsed. Each rail reads it
// here rather than being handed it, so the shell only places the rail.
//
// The rail mounts anew on every visit to its page, so the layout seeds from the `me` cache each time:
// a change is written there too (as usePref does), or the next visit would seed the layout from before
// it. The server keeps the copy that follows the account, this browser a local one for an account
// whose server copy has none.
export function usePanelLayout(panel: PanelId): [PanelLayout, (next: PanelLayout) => void] {
const { t } = useTranslation();
const qc = useQueryClient();
const [layout, setState] = useState(() =>
seedLayout(panel, qc.getQueryData<Me | null>(qk.me())?.preferences),
);
const setLayout = (next: PanelLayout) => {
setState(next);
saveLayoutLocal(panel, next);
updateMeCache(qc, (m) => ({
...m,
preferences: { ...m.preferences, [PREF_KEY[panel]]: next },
}));
api
.savePrefs({ [PREF_KEY[panel]]: next })
.catch((e: unknown) => notifyActionError(e, t("errors.writeFailed.prefs")));
};
return [layout, setLayout];
}
// Which collapse flag a rail uses: the feed and Plex share the filter rail's, the playlists and the
// files rails have their own. Default unpinned (a tab that expands on hover) — except the files rail,
// which is how you move around the library rather than a filter you set once, so it starts pinned.
const COLLAPSE = {
feed: {
key: "filterCollapsed",
initial: true,
opts: { localKey: LS.filterCollapsed, parse: Boolean },
},
plex: {
key: "filterCollapsed",
initial: true,
opts: { localKey: LS.filterCollapsed, parse: Boolean },
},
playlists: {
key: "playlistsCollapsed",
initial: true,
opts: { localKey: LS.playlistsCollapsed, parse: Boolean },
},
files: {
key: "filesCollapsed",
initial: false,
opts: { localKey: LS.filesCollapsed, parse: Boolean },
},
} as const;
/** What a rail hands its FilterRail: the layout and the collapse flag, each with its setter. */
export function useRail(panel: PanelId) {
const [layout, setLayout] = usePanelLayout(panel);
const c = COLLAPSE[panel];
const [collapsed, setCollapsed] = usePref<boolean>(c.key, c.initial, c.opts);
return { layout, setLayout, collapsed, onToggleCollapse: () => setCollapsed(!collapsed) };
}