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:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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";
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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) };
|
||||
}
|
||||
Reference in New Issue
Block a user