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:
@@ -16,7 +16,7 @@ import {
|
||||
type DraftStep,
|
||||
type IssueText,
|
||||
} from "../../lib/issueDraft";
|
||||
import { moduleDef } from "../../lib/modules";
|
||||
import { moduleDef } from "../navigation/moduleRegistry";
|
||||
import type { Page } from "../../lib/urlState";
|
||||
import { qk } from "../../lib/queryKeys";
|
||||
import { focusIsUnclaimed } from "../../lib/useFocusKeeper";
|
||||
|
||||
@@ -4,7 +4,6 @@ import { useTranslation } from "react-i18next";
|
||||
import { useMe } from "../../lib/useMe";
|
||||
import { resolvePage, retiredParams, urlWithParams, type Page } from "../../lib/urlState";
|
||||
import { ACTIVITY_KEYS } from "../../lib/activityUrlState";
|
||||
import { pageTitleKey } from "../../lib/pageMeta";
|
||||
import { useDocumentObjectTitle } from "../../lib/documentTitle";
|
||||
import { getAccountRaw, setAccountRaw } from "../../lib/storage";
|
||||
import {
|
||||
@@ -14,7 +13,7 @@ import {
|
||||
notifyNav,
|
||||
rememberNavPosition,
|
||||
} from "../../lib/history";
|
||||
import { reachablePage } from "../../lib/modules";
|
||||
import { pageTitleKey, reachablePage } from "./moduleRegistry";
|
||||
import {
|
||||
channelViewFrom,
|
||||
loadInitialPage,
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The registry reaches the modules' faces, whose stores read the browser (i18n, localStorage).
|
||||
import { readFileSync } from "node:fs";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { PAGES } from "../../lib/urlState";
|
||||
import { REGISTERED_PAGES, moduleDef } from "./moduleRegistry";
|
||||
|
||||
// The registry is the one place a page is listed, so it has to hold every page id the address bar
|
||||
// accepts and nothing else: a page id with no definition would throw in the nav rail at the first
|
||||
// render, and a definition with no id could never be navigated to.
|
||||
const SRC = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
|
||||
describe("the module registry", () => {
|
||||
it("defines every page id exactly once", () => {
|
||||
expect([...REGISTERED_PAGES].sort()).toEqual([...PAGES].sort());
|
||||
expect(new Set(REGISTERED_PAGES).size).toBe(REGISTERED_PAGES.length);
|
||||
});
|
||||
|
||||
it.each([...PAGES])("takes %s's definition from that module's own folder", (page) => {
|
||||
// The definition's id names its page, and it lives at modules/<page>/module.ts — so a page is one
|
||||
// folder, and a copied definition with a stale id cannot pass as another page's.
|
||||
expect(moduleDef(page).id).toBe(page);
|
||||
const source = readFileSync(resolve(SRC, `modules/${page}/module.ts`), "utf8");
|
||||
expect(source).toContain(`id: "${page}",`);
|
||||
});
|
||||
|
||||
it.each([...PAGES])("loads %s's page (and rail) lazily", (page) => {
|
||||
// A static import of the page in its definition would put it on the boot path for every
|
||||
// account; the chunk guard fails the build on that too, this says which module did it.
|
||||
const source = readFileSync(resolve(SRC, `modules/${page}/module.ts`), "utf8");
|
||||
expect(source).not.toMatch(/^import [^;]* from "\.\//m);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,99 @@
|
||||
import type { Me } from "../../lib/api";
|
||||
import type { Page } from "../../lib/urlState";
|
||||
import type { ModuleDef } from "../../lib/moduleDef";
|
||||
import { homeModule } from "../../modules/home";
|
||||
import { feedModule } from "../../modules/feed";
|
||||
import { channelsModule } from "../../modules/channels";
|
||||
import { playlistsModule } from "../../modules/playlists";
|
||||
import { plexModule } from "../../modules/plex";
|
||||
import { notificationsModule } from "../../modules/notifications";
|
||||
import { filesModule } from "../../modules/files";
|
||||
import { statsModule } from "../../modules/stats";
|
||||
import { schedulerModule } from "../../modules/scheduler";
|
||||
import { configModule } from "../../modules/config";
|
||||
import { usersModule } from "../../modules/users";
|
||||
import { activityModule } from "../../modules/activity";
|
||||
import { settingsModule } from "../../modules/settings";
|
||||
|
||||
// ── The module registry ───────────────────────────────────────────────────────────────────────
|
||||
// Every page, as each module defines itself (its folder's module.ts), in one ordered list: the nav
|
||||
// rail, the palette, the `g` chords, the tab title, the reachable set and the shell's render all read
|
||||
// from here, so a new or newly gated module shows up everywhere at once. Each definition's page and
|
||||
// rail are lazy imports, so listing them here pulls no page into the boot chunk (chunkGuard holds it).
|
||||
//
|
||||
// Ordered in nav-rail order — this array IS the sequence the rail and the palette list. Home leads: it
|
||||
// is the landing (see navigationInit.loadInitialPage). The feed takes the YouTube mark its own toolbar
|
||||
// already uses, so it and Home sit apart by shape and not just by position.
|
||||
const MODULES: readonly ModuleDef[] = [
|
||||
homeModule,
|
||||
feedModule,
|
||||
channelsModule,
|
||||
playlistsModule,
|
||||
plexModule,
|
||||
notificationsModule,
|
||||
filesModule,
|
||||
statsModule,
|
||||
schedulerModule,
|
||||
configModule,
|
||||
usersModule,
|
||||
activityModule,
|
||||
];
|
||||
|
||||
// Settings is reachable (its own rail button, a `g` chord) but is not a primary module in the ordered
|
||||
// nav list, so it lives beside the array rather than in it.
|
||||
const ALL: readonly ModuleDef[] = [...MODULES, settingsModule];
|
||||
|
||||
// Every Page has a definition: the registry contract test pins this list against lib/urlState's ids.
|
||||
export const REGISTERED_PAGES: readonly Page[] = ALL.map((m) => m.id);
|
||||
|
||||
const BY_ID = Object.fromEntries(ALL.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, and its rail label.
|
||||
export const moduleLabelKey = Object.fromEntries(ALL.map((m) => [m.id, m.labelKey])) as Record<
|
||||
Page,
|
||||
string
|
||||
>;
|
||||
export const moduleRailKey = Object.fromEntries(ALL.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. 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";
|
||||
}
|
||||
|
||||
// The i18n key for a page's human title (the browser tab): a module's optional `titleKey` is the
|
||||
// longer, descriptive one (e.g. "Channel manager"), falling back to its short nav `labelKey`.
|
||||
export function pageTitleKey(page: Page): string {
|
||||
const def = moduleDef(page);
|
||||
return def.titleKey ?? def.labelKey;
|
||||
}
|
||||
|
||||
/** Fetch the code of these pages and of their side rails now, ahead of their first visit. Errors are
|
||||
* swallowed: a chunk that fails to preload is simply fetched (and its failure surfaced) on visit. */
|
||||
export function preloadPages(pages: Page[]): void {
|
||||
for (const p of pages) {
|
||||
const def = moduleDef(p);
|
||||
def.Page.preload().catch(() => {});
|
||||
def.Rail?.preload().catch(() => {});
|
||||
}
|
||||
}
|
||||
+6
-3
@@ -1,8 +1,11 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The registry reaches the modules' faces, whose stores read the browser (i18n, localStorage).
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { pageTitleKey } from "./pageMeta";
|
||||
import type { Page } from "./urlState";
|
||||
import { pageTitleKey } from "./moduleRegistry";
|
||||
import type { Page } from "../../lib/urlState";
|
||||
|
||||
// pageTitleKey now derives from the ModuleDef registry (titleKey ?? labelKey). These are the exact
|
||||
// pageTitleKey derives from the module 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> = {
|
||||
@@ -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,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) };
|
||||
}
|
||||
@@ -1,2 +1,3 @@
|
||||
// The activity page's public face: what another unit may use of it.
|
||||
export { StatusDot, type DotKey } from "./RunStatusDot";
|
||||
export { activityModule } from "./module";
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { ScrollText } from "lucide-react";
|
||||
import { lazyWithPreload } from "../../lib/lazyWithPreload";
|
||||
import type { ModuleDef } from "../../lib/moduleDef";
|
||||
|
||||
// This page as the shell and the navigation know it (lib/moduleDef). Its page load lazily.
|
||||
export const activityModule: ModuleDef = {
|
||||
id: "activity",
|
||||
labelKey: "header.account.activity",
|
||||
railKey: "nav.rail.activity",
|
||||
titleKey: "activity.title",
|
||||
icon: ScrollText,
|
||||
system: true,
|
||||
gate: (me) => me.role === "admin",
|
||||
Page: lazyWithPreload(() => import("./Activity")),
|
||||
};
|
||||
@@ -3,3 +3,4 @@
|
||||
export { ChannelsProvider } from "./ChannelsProvider";
|
||||
export { useChannelsActions } from "./channelsContext";
|
||||
export { default as SyncStatus } from "./SyncStatus";
|
||||
export { channelsModule } from "./module";
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Tv } from "lucide-react";
|
||||
import { lazyWithPreload } from "../../lib/lazyWithPreload";
|
||||
import type { ModuleDef } from "../../lib/moduleDef";
|
||||
|
||||
// This page as the shell and the navigation know it (lib/moduleDef). Its page load lazily.
|
||||
export const channelsModule: ModuleDef = {
|
||||
id: "channels",
|
||||
labelKey: "header.account.channels",
|
||||
railKey: "nav.rail.channels",
|
||||
titleKey: "header.channelManager",
|
||||
icon: Tv,
|
||||
Page: lazyWithPreload(() => import("./Channels")),
|
||||
maxWidth: "96rem",
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
// The config page's public face: how the shell and the navigation know it.
|
||||
export { configModule } from "./module";
|
||||
@@ -0,0 +1,15 @@
|
||||
import { SlidersHorizontal } from "lucide-react";
|
||||
import { lazyWithPreload } from "../../lib/lazyWithPreload";
|
||||
import type { ModuleDef } from "../../lib/moduleDef";
|
||||
|
||||
// This page as the shell and the navigation know it (lib/moduleDef). Its page load lazily.
|
||||
export const configModule: ModuleDef = {
|
||||
id: "config",
|
||||
labelKey: "header.account.config",
|
||||
railKey: "nav.rail.config",
|
||||
titleKey: "header.configuration",
|
||||
icon: SlidersHorizontal,
|
||||
system: true,
|
||||
gate: (me) => me.role === "admin",
|
||||
Page: lazyWithPreload(() => import("./ConfigPanel")),
|
||||
};
|
||||
@@ -1,4 +1,3 @@
|
||||
import { lazyWithPreload } from "../../lib/lazyWithPreload";
|
||||
import { useMe } from "../../lib/useMe";
|
||||
import { useFeedFilters, useFeedFiltersActions } from "./feedFiltersContext";
|
||||
import { useFeedView, useFeedViewActions } from "./feedViewContext";
|
||||
@@ -6,16 +5,12 @@ import { useNavigation, useNavigationActions } from "../../features/navigation/n
|
||||
import PersonalViewsModal from "../../features/filters/PersonalViewsModal";
|
||||
import { useSavedViews } from "../../features/filters/savedViewsContext";
|
||||
import FeedHeader from "./FeedHeader";
|
||||
|
||||
// The two module-registry pages that need per-render wiring (see moduleRegistry), split out of it so
|
||||
// that file exports only data and this one only components (Fast Refresh). Their lazy chunks are
|
||||
// exported alongside so the registry can attach each chunk's preload to the wrapper that renders it.
|
||||
export const Feed = lazyWithPreload(() => import("./Feed"));
|
||||
import Feed from "./Feed";
|
||||
|
||||
// The GLOBAL feed page: wires <Feed> to the app-wide feed filters / view / live-search, and fills the
|
||||
// header (the channel page's feed has no header). (The channel
|
||||
// page renders <Feed> itself with its own channel-scoped filters, so those two never share state.)
|
||||
export function FeedPage() {
|
||||
export default function FeedPage() {
|
||||
const me = useMe();
|
||||
const filters = useFeedFilters();
|
||||
const { setFilters } = useFeedFiltersActions();
|
||||
@@ -4,7 +4,7 @@ import { keepPreviousData, useQuery } from "@tanstack/react-query";
|
||||
import { qk } from "../../lib/queryKeys";
|
||||
import { SlidersHorizontal } from "lucide-react";
|
||||
import { api, NO_TAG_SENTINEL, type FeedFilters, type Tag, type TagCategory } from "../../lib/api";
|
||||
import { defaultLayout, type PanelLayout } from "../../lib/panelLayout";
|
||||
import { defaultLayout } from "../../lib/panelLayout";
|
||||
import { useDebounced } from "../../lib/useDebounced";
|
||||
import { useActiveFilters } from "../../lib/useActiveFilters";
|
||||
import TagManager from "../../features/channel/TagManager";
|
||||
@@ -23,6 +23,7 @@ import { useFeedFilters, useFeedFiltersActions } from "./feedFiltersContext";
|
||||
import { useChannelsActions } from "../channels";
|
||||
import { useTags } from "../../lib/useTags";
|
||||
import { compareText } from "../../lib/collate";
|
||||
import { useRail } from "../../lib/useRail";
|
||||
|
||||
// Filter ids; display labels resolved at render time via t("sidebar.sort.<id>") etc.
|
||||
const DATE_PRESETS: { days: number; key: string }[] = [
|
||||
@@ -37,17 +38,9 @@ const DATE_PRESETS: { days: number; key: string }[] = [
|
||||
{ days: 1825, key: "5years" },
|
||||
];
|
||||
|
||||
export default function Sidebar({
|
||||
layout,
|
||||
setLayout,
|
||||
collapsed,
|
||||
onToggleCollapse,
|
||||
}: {
|
||||
layout: PanelLayout;
|
||||
setLayout: (l: PanelLayout) => void;
|
||||
collapsed: boolean;
|
||||
onToggleCollapse: () => void;
|
||||
}) {
|
||||
export default function Sidebar() {
|
||||
// Its layout and collapse flag are its own (lib/useRail), so the shell only places it.
|
||||
const { layout, setLayout, collapsed, onToggleCollapse } = useRail("feed");
|
||||
const { t, i18n } = useTranslation();
|
||||
const lang = i18n.language;
|
||||
const { openModal } = useSavedViews();
|
||||
|
||||
@@ -4,3 +4,4 @@ export { FeedViewProvider } from "./FeedViewProvider";
|
||||
export { SavedViewsProvider } from "./SavedViewsProvider";
|
||||
export { useFeedFilters, useFeedFiltersActions } from "./feedFiltersContext";
|
||||
export { useFeedView, useFeedViewActions } from "./feedViewContext";
|
||||
export { ChannelPage, feedModule } from "./module";
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import { YouTubeIcon } from "../../lib/brandIcons";
|
||||
import { lazyWithPreload, lazyWithPreloadAlongside } from "../../lib/lazyWithPreload";
|
||||
import type { ModuleDef } from "../../lib/moduleDef";
|
||||
|
||||
// The rail loads with the page (lazyWithPreloadAlongside), so it never arrives after it.
|
||||
const Rail = lazyWithPreload(() => import("./Sidebar"));
|
||||
|
||||
// This page as the shell and the navigation know it (lib/moduleDef). Its page and rail load lazily.
|
||||
export const feedModule: ModuleDef = {
|
||||
id: "feed",
|
||||
labelKey: "header.account.feed",
|
||||
railKey: "nav.rail.feed",
|
||||
icon: YouTubeIcon,
|
||||
Rail,
|
||||
Page: lazyWithPreloadAlongside(() => import("./FeedPage"), Rail),
|
||||
};
|
||||
|
||||
// The channel page: a feed scoped to one channel, opened over any page (App renders it in place of the
|
||||
// page). Lazy like a page, and preloaded with them.
|
||||
export const ChannelPage = lazyWithPreload(() => import("./ChannelPage"));
|
||||
@@ -9,6 +9,15 @@ import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-libra
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
// The rail's own state, pinned open: these tests are about what it lists, not how it folds.
|
||||
vi.mock("../../lib/useRail", () => ({
|
||||
useRail: () => ({
|
||||
layout: { order: ["library", "sources", "own"], collapsed: {}, hidden: {} },
|
||||
setLayout: () => {},
|
||||
collapsed: false,
|
||||
onToggleCollapse: () => {},
|
||||
}),
|
||||
}));
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return { ...actual, useTranslation: () => ({ t: (k: string) => k, i18n: { language: "en" } }) };
|
||||
@@ -33,7 +42,6 @@ vi.mock("../../components/ui/FilterRail", () => ({
|
||||
|
||||
import FilesRail from "./FilesRail";
|
||||
import { api, type FileFolder } from "../../lib/api";
|
||||
import { defaultLayout } from "../../lib/panelLayout";
|
||||
|
||||
// The share dialog is a lazy chunk, and the test that opens it would pay for transforming it inside
|
||||
// its own wait for the dialog — over that on a busy machine. Loaded once, up front.
|
||||
@@ -79,12 +87,7 @@ async function mount(folders: FileFolder[] = [YT, OWN]) {
|
||||
vi.spyOn(api, "filesUsage").mockResolvedValue({ unlimited: true, footprint_bytes: 0 } as never);
|
||||
render(
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
<FilesRail
|
||||
layout={defaultLayout("files")}
|
||||
setLayout={vi.fn()}
|
||||
collapsed={false}
|
||||
onToggleCollapse={vi.fn()}
|
||||
/>
|
||||
<FilesRail />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
await screen.findByText("files.folder.new");
|
||||
@@ -194,12 +197,7 @@ describe("FilesRail · what it says when a part is empty or missing", () => {
|
||||
<QueryClientProvider
|
||||
client={new QueryClient({ defaultOptions: { queries: { retry: false } } })}
|
||||
>
|
||||
<FilesRail
|
||||
layout={defaultLayout("files")}
|
||||
setLayout={vi.fn()}
|
||||
collapsed={false}
|
||||
onToggleCollapse={vi.fn()}
|
||||
/>
|
||||
<FilesRail />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
expect(await screen.findByText("files.usage.failed")).toBeTruthy();
|
||||
|
||||
@@ -7,7 +7,6 @@ import { FolderOpen, FolderPlus, HardDrive, Link2, Users } from "lucide-react";
|
||||
import { api, type FileFolder } from "../../lib/api";
|
||||
import { compareText } from "../../lib/collate";
|
||||
import { formatBytes } from "../../lib/format";
|
||||
import { type PanelLayout } from "../../lib/panelLayout";
|
||||
import { qk } from "../../lib/queryKeys";
|
||||
import { useLiveQuery } from "../../lib/useLiveQuery";
|
||||
import FilterRail from "../../components/ui/FilterRail";
|
||||
@@ -19,6 +18,7 @@ import { ContextMenu } from "../../components/ui/ContextMenu";
|
||||
import { Pressable } from "../../components/ui/Pressable";
|
||||
import { type PanelItem } from "../../components/ui/PanelGroups";
|
||||
import { useContextMenu } from "../../components/ui/useContextMenu";
|
||||
import { useRail } from "../../lib/useRail";
|
||||
|
||||
// The Files module's left rail: the library's places — every file, the ones shared with me, the
|
||||
// source folders the library fills by itself, the user's own folders — and the storage meter. Lifted
|
||||
@@ -111,17 +111,9 @@ function StorageMeter() {
|
||||
);
|
||||
}
|
||||
|
||||
export default function FilesRail({
|
||||
layout,
|
||||
setLayout,
|
||||
collapsed,
|
||||
onToggleCollapse,
|
||||
}: {
|
||||
layout: PanelLayout;
|
||||
setLayout: (l: PanelLayout) => void;
|
||||
collapsed: boolean;
|
||||
onToggleCollapse: () => void;
|
||||
}) {
|
||||
export default function FilesRail() {
|
||||
// Its layout and collapse flag are its own (lib/useRail), so the shell only places it.
|
||||
const { layout, setLayout, collapsed, onToggleCollapse } = useRail("files");
|
||||
const { t, i18n } = useTranslation();
|
||||
const { view } = useFiles();
|
||||
const foldersQ = useQuery({ queryKey: qk.fileFolders(), queryFn: api.fileFolders });
|
||||
|
||||
@@ -2,3 +2,4 @@
|
||||
export { FilesProvider } from "./FilesProvider";
|
||||
export { useFilesActions } from "./filesContext";
|
||||
export { folderIcon, folderLabel, sourceLabel, takesUploads } from "./fileFolders";
|
||||
export { filesModule } from "./module";
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { FolderOpen } from "lucide-react";
|
||||
import { lazyWithPreload, lazyWithPreloadAlongside } from "../../lib/lazyWithPreload";
|
||||
import type { ModuleDef } from "../../lib/moduleDef";
|
||||
|
||||
// The rail loads with the page (lazyWithPreloadAlongside), so it never arrives after it.
|
||||
const Rail = lazyWithPreload(() => import("./FilesRail"));
|
||||
|
||||
// This page as the shell and the navigation know it (lib/moduleDef). Its page and rail load lazily.
|
||||
export const filesModule: ModuleDef = {
|
||||
id: "files",
|
||||
labelKey: "files.navLabel",
|
||||
railKey: "nav.rail.files",
|
||||
icon: FolderOpen,
|
||||
Rail,
|
||||
Page: lazyWithPreloadAlongside(() => import("./Files"), Rail),
|
||||
maxWidth: "80rem",
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
// The home page's public face: how the shell and the navigation know it.
|
||||
export { homeModule } from "./module";
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Home } from "lucide-react";
|
||||
import { lazyWithPreload } from "../../lib/lazyWithPreload";
|
||||
import type { ModuleDef } from "../../lib/moduleDef";
|
||||
|
||||
// This page as the shell and the navigation know it (lib/moduleDef). Its page load lazily.
|
||||
export const homeModule: ModuleDef = {
|
||||
id: "home",
|
||||
labelKey: "home.navLabel",
|
||||
railKey: "nav.rail.home",
|
||||
titleKey: "home.title",
|
||||
icon: Home,
|
||||
Page: lazyWithPreload(() => import("./Home")),
|
||||
maxWidth: "80rem",
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
// The notifications page's public face: how the shell and the navigation know it.
|
||||
export { notificationsModule } from "./module";
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Bell } from "lucide-react";
|
||||
import { lazyWithPreload } from "../../lib/lazyWithPreload";
|
||||
import type { ModuleDef } from "../../lib/moduleDef";
|
||||
|
||||
// This page as the shell and the navigation know it (lib/moduleDef). Its page load lazily.
|
||||
export const notificationsModule: ModuleDef = {
|
||||
id: "notifications",
|
||||
labelKey: "inbox.navLabel",
|
||||
railKey: "nav.rail.notifications",
|
||||
icon: Bell,
|
||||
hasBadge: true,
|
||||
Page: lazyWithPreload(() => import("./NotificationsPanel")),
|
||||
maxWidth: "48rem",
|
||||
};
|
||||
@@ -10,6 +10,15 @@ import { FloatingTree } from "@floating-ui/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
// The rail's own state, pinned open: these tests are about what it lists, not how it folds.
|
||||
vi.mock("../../lib/useRail", () => ({
|
||||
useRail: () => ({
|
||||
layout: { order: ["manage", "list"], collapsed: {}, hidden: {} },
|
||||
setLayout: () => {},
|
||||
collapsed: false,
|
||||
onToggleCollapse: () => {},
|
||||
}),
|
||||
}));
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
return { ...actual, useTranslation: () => ({ t: (k: string) => k, i18n: { language: "en" } }) };
|
||||
@@ -82,12 +91,7 @@ async function openRename() {
|
||||
render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<FloatingTree>
|
||||
<PlaylistsRail
|
||||
layout={{} as never}
|
||||
setLayout={() => {}}
|
||||
collapsed={false}
|
||||
onToggleCollapse={() => {}}
|
||||
/>
|
||||
<PlaylistsRail />
|
||||
</FloatingTree>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
@@ -130,12 +134,7 @@ describe("PlaylistsRail · the row's '…'", () => {
|
||||
render(
|
||||
<QueryClientProvider client={qc}>
|
||||
<FloatingTree>
|
||||
<PlaylistsRail
|
||||
layout={{} as never}
|
||||
setLayout={() => {}}
|
||||
collapsed={false}
|
||||
onToggleCollapse={() => {}}
|
||||
/>
|
||||
<PlaylistsRail />
|
||||
</FloatingTree>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
@@ -11,7 +11,6 @@ import { notify } from "../../lib/notifications";
|
||||
import { notifyYouTubeActionError } from "../../lib/youtubeErrors";
|
||||
import { LS } from "../../lib/storage";
|
||||
import { usePref } from "../../lib/usePref";
|
||||
import { type PanelLayout } from "../../lib/panelLayout";
|
||||
import FilterRail from "../../components/ui/FilterRail";
|
||||
import { Button } from "../../components/ui/Button";
|
||||
import { type ActionMenuItem } from "../../components/ui/ActionMenu";
|
||||
@@ -31,6 +30,7 @@ import { Pressable } from "../../components/ui/Pressable";
|
||||
import { Input } from "../../components/ui/Input";
|
||||
import { MoreButton } from "../../components/ui/MoreButton";
|
||||
import RemoteImage from "../../components/ui/RemoteImage";
|
||||
import { useRail } from "../../lib/useRail";
|
||||
|
||||
type PlSort = {
|
||||
key: "custom" | "name" | "count" | "duration" | "recent";
|
||||
@@ -47,17 +47,9 @@ const ROW_BASE = "w-full flex items-center gap-2.5 p-2 rounded-lg border transit
|
||||
// shared SidePanel with the same collapse-to-tab behavior. It owns the list query + its own rail
|
||||
// controls (sort / new / sync); the selected playlist is App state so the module's detail pane
|
||||
// (Playlists.tsx) reacts to it. Grouped into two islands: "manage" and the playlist list.
|
||||
export default function PlaylistsRail({
|
||||
layout,
|
||||
setLayout,
|
||||
collapsed,
|
||||
onToggleCollapse,
|
||||
}: {
|
||||
layout: PanelLayout;
|
||||
setLayout: (l: PanelLayout) => void;
|
||||
collapsed: boolean;
|
||||
onToggleCollapse: () => void;
|
||||
}) {
|
||||
export default function PlaylistsRail() {
|
||||
// Its layout and collapse flag are its own (lib/useRail), so the shell only places it.
|
||||
const { layout, setLayout, collapsed, onToggleCollapse } = useRail("playlists");
|
||||
const canWrite = useMe().can_write;
|
||||
// The name filter + the selected playlist live in PlaylistsProvider (shared with the header search
|
||||
// box and the Playlists detail pane); this rail owns the list query + its own controls.
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
// The playlists page's public face: its store, mounted at the root and read app-wide.
|
||||
export { PlaylistsProvider } from "./PlaylistsProvider";
|
||||
export { usePlaylistsActions } from "./playlistsContext";
|
||||
export { playlistsModule } from "./module";
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import { ListVideo } from "lucide-react";
|
||||
import { lazyWithPreload, lazyWithPreloadAlongside } from "../../lib/lazyWithPreload";
|
||||
import type { ModuleDef } from "../../lib/moduleDef";
|
||||
|
||||
// The rail loads with the page (lazyWithPreloadAlongside), so it never arrives after it.
|
||||
const Rail = lazyWithPreload(() => import("./PlaylistsRail"));
|
||||
|
||||
// This page as the shell and the navigation know it (lib/moduleDef). Its page and rail load lazily.
|
||||
export const playlistsModule: ModuleDef = {
|
||||
id: "playlists",
|
||||
labelKey: "header.account.playlists",
|
||||
railKey: "nav.rail.playlists",
|
||||
icon: ListVideo,
|
||||
Rail,
|
||||
Page: lazyWithPreloadAlongside(() => import("./Playlists"), Rail),
|
||||
maxWidth: "64rem",
|
||||
};
|
||||
@@ -8,7 +8,6 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { ListMusic, Plus, SlidersHorizontal } from "lucide-react";
|
||||
import { api, plexFilterCount, type PlexFilters } from "../../lib/api";
|
||||
import { notifyActionError } from "../../lib/actionErrors";
|
||||
import { type PanelLayout } from "../../lib/panelLayout";
|
||||
import { type MediaSourceKind } from "../../lib/mediaSource";
|
||||
import { useDebounced } from "../../lib/useDebounced";
|
||||
import FilterRail from "../../components/ui/FilterRail";
|
||||
@@ -22,19 +21,13 @@ import { useSavedViews } from "../../features/filters/savedViewsContext";
|
||||
import { usePlex, usePlexActions } from "./plexContext";
|
||||
import { DismissibleChip } from "../../components/ui/Chip";
|
||||
import { Input } from "../../components/ui/Input";
|
||||
import { useRail } from "../../lib/useRail";
|
||||
|
||||
// The Plex module's left filter column — now the shared floating SidePanel with its filter groups
|
||||
// as reorderable "islands" (same system as the feed rail). Movie libraries get the full metadata
|
||||
// set; shows keep library + sort. State is owned by App (per-account persisted); facets come from
|
||||
// set; shows keep library + sort. The filter state lives in PlexProvider; facets come from
|
||||
// the backend so the panel only offers what the library actually contains.
|
||||
|
||||
type Props = {
|
||||
layout: PanelLayout;
|
||||
setLayout: (l: PanelLayout) => void;
|
||||
collapsed: boolean;
|
||||
onToggleCollapse: () => void;
|
||||
};
|
||||
|
||||
const RATING_STEPS = [5, 6, 7, 8, 9];
|
||||
const ADDED_OPTS = ["24h", "1w", "1m", "6m", "1y"] as const;
|
||||
const DURATION_BUCKETS: { key: string; min: number | null; max: number | null }[] = [
|
||||
@@ -43,7 +36,9 @@ const DURATION_BUCKETS: { key: string; min: number | null; max: number | null }[
|
||||
{ key: "long", min: 120 * 60, max: null },
|
||||
];
|
||||
|
||||
export default function PlexSidebar({ layout, setLayout, collapsed, onToggleCollapse }: Props) {
|
||||
export default function PlexSidebar() {
|
||||
// Its layout and collapse flag are its own (lib/useRail), so the shell only places it.
|
||||
const { layout, setLayout, collapsed, onToggleCollapse } = useRail("plex");
|
||||
const { t, i18n } = useTranslation();
|
||||
// Scope / watch-state / sort / expanded filters live in PlexProvider (shared with PlexBrowse and
|
||||
// the header search box); this rail is the primary editor of them. Opening a playlist is a
|
||||
|
||||
@@ -2,3 +2,4 @@
|
||||
export { PlexProvider } from "./PlexProvider";
|
||||
export { usePlex, usePlexActions } from "./plexContext";
|
||||
export { buildPlexActiveFilters } from "./usePlexActiveFilters";
|
||||
export { plexModule } from "./module";
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { Clapperboard } from "lucide-react";
|
||||
import { lazyWithPreload, lazyWithPreloadAlongside } from "../../lib/lazyWithPreload";
|
||||
import type { ModuleDef } from "../../lib/moduleDef";
|
||||
|
||||
// The rail loads with the page (lazyWithPreloadAlongside), so it never arrives after it.
|
||||
const Rail = lazyWithPreload(() => import("./PlexSidebar"));
|
||||
|
||||
// This page as the shell and the navigation know it (lib/moduleDef). Its page and rail load lazily.
|
||||
export const plexModule: ModuleDef = {
|
||||
id: "plex",
|
||||
labelKey: "plex.navLabel",
|
||||
railKey: "nav.rail.plex",
|
||||
icon: Clapperboard,
|
||||
gate: (me) => me.plex_enabled,
|
||||
Rail,
|
||||
Page: lazyWithPreloadAlongside(() => import("./PlexBrowse"), Rail),
|
||||
maxWidth: "100rem",
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
// The scheduler page's public face: how the shell and the navigation know it.
|
||||
export { schedulerModule } from "./module";
|
||||
@@ -0,0 +1,15 @@
|
||||
import { Activity } from "lucide-react";
|
||||
import { lazyWithPreload } from "../../lib/lazyWithPreload";
|
||||
import type { ModuleDef } from "../../lib/moduleDef";
|
||||
|
||||
// This page as the shell and the navigation know it (lib/moduleDef). Its page load lazily.
|
||||
export const schedulerModule: ModuleDef = {
|
||||
id: "scheduler",
|
||||
labelKey: "header.account.scheduler",
|
||||
railKey: "nav.rail.scheduler",
|
||||
titleKey: "header.scheduler",
|
||||
icon: Activity,
|
||||
system: true,
|
||||
gate: (me) => me.role === "admin",
|
||||
Page: lazyWithPreload(() => import("./Scheduler")),
|
||||
};
|
||||
@@ -21,7 +21,11 @@ vi.mock("react-i18next", async (importOriginal) => {
|
||||
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
|
||||
};
|
||||
});
|
||||
vi.mock("../../lib/api", () => ({ api: h }));
|
||||
// Partial: the module graph reaches the feed's store, which reads its defaults here.
|
||||
vi.mock("../../lib/api", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("../../lib/api")>()),
|
||||
api: h,
|
||||
}));
|
||||
// Partial mock: SettingsPanel's module graph (PrefsProvider) also pulls getNotifSettings from here.
|
||||
vi.mock("../../lib/notifications", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("../../lib/notifications")>();
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
// The settings page's public face: how the shell and the navigation know it.
|
||||
export { settingsModule } from "./module";
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Settings } from "lucide-react";
|
||||
import { lazyWithPreload } from "../../lib/lazyWithPreload";
|
||||
import type { ModuleDef } from "../../lib/moduleDef";
|
||||
|
||||
// This page as the shell and the navigation know it (lib/moduleDef). Its page load lazily.
|
||||
export const settingsModule: ModuleDef = {
|
||||
id: "settings",
|
||||
labelKey: "header.account.settings",
|
||||
railKey: "nav.rail.settings",
|
||||
titleKey: "settings.title",
|
||||
icon: Settings,
|
||||
Page: lazyWithPreload(() => import("./SettingsPanel")),
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
// The stats page's public face: how the shell and the navigation know it.
|
||||
export { statsModule } from "./module";
|
||||
@@ -0,0 +1,13 @@
|
||||
import { BarChart3 } from "lucide-react";
|
||||
import { lazyWithPreload } from "../../lib/lazyWithPreload";
|
||||
import type { ModuleDef } from "../../lib/moduleDef";
|
||||
|
||||
// This page as the shell and the navigation know it (lib/moduleDef). Its page load lazily.
|
||||
export const statsModule: ModuleDef = {
|
||||
id: "stats",
|
||||
labelKey: "header.account.stats",
|
||||
railKey: "nav.rail.stats",
|
||||
titleKey: "header.usageStats",
|
||||
icon: BarChart3,
|
||||
Page: lazyWithPreload(() => import("./Stats")),
|
||||
};
|
||||
@@ -0,0 +1,2 @@
|
||||
// The users page's public face: how the shell and the navigation know it.
|
||||
export { usersModule } from "./module";
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Users } from "lucide-react";
|
||||
import { lazyWithPreload } from "../../lib/lazyWithPreload";
|
||||
import type { ModuleDef } from "../../lib/moduleDef";
|
||||
|
||||
// This page as the shell and the navigation know it (lib/moduleDef). Its page load lazily.
|
||||
export const usersModule: ModuleDef = {
|
||||
id: "users",
|
||||
labelKey: "header.account.users",
|
||||
railKey: "nav.rail.users",
|
||||
titleKey: "header.users",
|
||||
icon: Users,
|
||||
system: true,
|
||||
gate: (me) => me.role === "admin",
|
||||
Page: lazyWithPreload(() => import("./AdminUsers")),
|
||||
maxWidth: "48rem",
|
||||
};
|
||||
+20
-280
@@ -1,24 +1,14 @@
|
||||
import { lazy, Suspense, useEffect, useRef, useState } from "react";
|
||||
import { lazy, Suspense, useState } from "react";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { saveLanguage } from "../lib/saveLanguage";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { api, setUnauthorizedHandler, type Me } from "../lib/api";
|
||||
import { api } from "../lib/api";
|
||||
import { notifyActionError } from "../lib/actionErrors";
|
||||
import { setLanguage, type LangCode } from "../i18n";
|
||||
import { clearParams } from "../lib/urlState";
|
||||
import { reloadAfterRefusal } from "../lib/entryUrl";
|
||||
import { useAccountSwitchNotice } from "../lib/useAccountSwitchNotice";
|
||||
import {
|
||||
PREF_KEY,
|
||||
saveLayoutLocal,
|
||||
seedLayout,
|
||||
type PanelId,
|
||||
type PanelLayout,
|
||||
} from "../lib/panelLayout";
|
||||
import { notify, removeByMetaKind } from "../lib/notifications";
|
||||
import { LS } from "../lib/storage";
|
||||
import { usePref } from "../lib/usePref";
|
||||
import { useShellSession } from "./useShellSession";
|
||||
import { useEntryParams } from "./useEntryParams";
|
||||
import { useAccessRequestsNudge } from "./useAccessRequestsNudge";
|
||||
import { useVerifyEntry } from "../lib/useVerifyEntry";
|
||||
import { useNavigation, useNavigationActions } from "../features/navigation/navigationContext";
|
||||
import {
|
||||
@@ -26,10 +16,9 @@ import {
|
||||
useFeedFiltersActions,
|
||||
useFeedView,
|
||||
useFeedViewActions,
|
||||
ChannelPage,
|
||||
} from "../modules/feed";
|
||||
import { moduleOrder } from "../lib/modules";
|
||||
import { PAGE_CONTENT, PAGE_RAIL, preloadPages } from "./moduleRegistry";
|
||||
import { lazyWithPreload } from "../lib/lazyWithPreload";
|
||||
import { moduleDef, pageTitleKey } from "../features/navigation/moduleRegistry";
|
||||
import { LoadingState } from "../components/ui/QueryState";
|
||||
// Persistent shell — always mounted, so eagerly imported.
|
||||
import Welcome from "./Welcome";
|
||||
@@ -40,29 +29,23 @@ import BackToTop from "../components/ui/BackToTop";
|
||||
import PageShell, { PageHeaderDefault } from "../components/ui/PageShell";
|
||||
import { PaletteLauncher } from "../features/header/PaletteLauncher";
|
||||
import { SkipLink } from "../components/ui/SkipLink";
|
||||
import { pageTitleKey } from "../lib/pageMeta";
|
||||
import DebugRecorder from "../features/diagnostics/DebugRecorder";
|
||||
import Toaster from "../components/ui/Toaster";
|
||||
import KeyboardHelp from "../components/ui/KeyboardHelp";
|
||||
import AppCommandPalette from "./AppCommandPalette";
|
||||
import DownloadIntake from "../features/downloads/DownloadIntake";
|
||||
import DownloadTray from "../features/downloads/DownloadTray";
|
||||
import { useGlobalKeymap } from "../lib/keymap";
|
||||
import ErrorDialog from "./ErrorDialog";
|
||||
import VersionBanner from "./VersionBanner";
|
||||
import VerifyBanner from "./VerifyBanner";
|
||||
import { focusAccessRequestsTab } from "../lib/adminUsersTab";
|
||||
import { focusSettingsTab } from "../lib/settingsTab";
|
||||
// The built app version, NOT imported from releaseNotes — importing anything from that module
|
||||
// pulls the release-notes loader into the eager App chunk and defeats the lazy ReleaseNotes
|
||||
// split. FRONTEND_VERSION equals the newest release note's version by release convention (both bump together).
|
||||
import { FRONTEND_VERSION } from "../lib/version";
|
||||
import { applyDiagnostics } from "../lib/diagnostics";
|
||||
|
||||
// The page modules (and their side rails) are code-split per route via the module registry
|
||||
// (shell/moduleRegistry). App keeps only the persistent-shell chunks + the channel page and the
|
||||
// two modals, which sit outside the registry-driven content column.
|
||||
const ChannelPage = lazyWithPreload(() => import("../modules/feed/ChannelPage"));
|
||||
// The page modules (and their side rails) are code-split per route: each module's definition imports
|
||||
// them lazily (features/navigation/moduleRegistry), and so does the feed's for the channel page. App
|
||||
// keeps only the persistent-shell chunks and the two modals.
|
||||
const About = lazy(() => import("./About"));
|
||||
const ReleaseNotes = lazy(() => import("./ReleaseNotes"));
|
||||
|
||||
@@ -85,20 +68,6 @@ export default function App() {
|
||||
const view = useFeedView();
|
||||
const { setView } = useFeedViewActions();
|
||||
const queryClient = useQueryClient();
|
||||
// Per-panel "customize" layouts (order/collapsed/hidden of each side panel's group cards),
|
||||
// persisted per-account. One record so feed/plex/playlists share the same mechanism. Seeded from
|
||||
// the account's server preferences — BootGate mounts App only once `me` is cached — so a layout
|
||||
// changed on another device is right on the first paint; the local copy covers a key the server
|
||||
// has never stored.
|
||||
const [panelLayouts, setPanelLayouts] = useState<Record<PanelId, PanelLayout>>(() => {
|
||||
const prefs = queryClient.getQueryData<Me | null>(qk.me())?.preferences;
|
||||
return {
|
||||
feed: seedLayout("feed", prefs),
|
||||
plex: seedLayout("plex", prefs),
|
||||
playlists: seedLayout("playlists", prefs),
|
||||
files: seedLayout("files", prefs),
|
||||
};
|
||||
});
|
||||
const [aboutOpen, setAboutOpen] = useState(false);
|
||||
const [notesOpen, setNotesOpen] = useState(false);
|
||||
const [notesHighlight, setNotesHighlight] = useState<string | undefined>(undefined);
|
||||
@@ -108,38 +77,6 @@ export default function App() {
|
||||
setNotesOpen(true);
|
||||
}
|
||||
|
||||
function setPanelLayout(panel: PanelId, next: PanelLayout) {
|
||||
setPanelLayouts((prev) => ({ ...prev, [panel]: next }));
|
||||
saveLayoutLocal(panel, next);
|
||||
api
|
||||
.savePrefs({ [PREF_KEY[panel]]: next })
|
||||
.catch((e: unknown) => notifyActionError(e, t("errors.writeFailed.prefs")));
|
||||
}
|
||||
|
||||
// Collapse state for the two full-height panels (left nav + filter sidebar). Persisted to the
|
||||
// user's preferences (via usePref) so it follows the account across devices, seeded from the `me`
|
||||
// cache at mount; the localStorage mirror covers an account whose server prefs never stored it.
|
||||
// Default unpinned (tab + hover-expand overlay); accounts that explicitly pin keep their choice.
|
||||
const [filterCollapsed, setFilterCollapsed] = usePref<boolean>("filterCollapsed", true, {
|
||||
localKey: LS.filterCollapsed,
|
||||
parse: Boolean,
|
||||
});
|
||||
// The Playlists rail has its own collapse flag (it's a list/navigator, not filters).
|
||||
const [playlistsCollapsed, setPlaylistsCollapsed] = usePref<boolean>("playlistsCollapsed", true, {
|
||||
localKey: LS.playlistsCollapsed,
|
||||
parse: Boolean,
|
||||
});
|
||||
// The Files rail is the library's folder list — how you move around it, not a filter you set once —
|
||||
// so it starts pinned open (the owner's mockup), and has its own flag.
|
||||
const [filesCollapsed, setFilesCollapsed] = usePref<boolean>("filesCollapsed", false, {
|
||||
localKey: LS.filesCollapsed,
|
||||
parse: Boolean,
|
||||
});
|
||||
const railCollapse = {
|
||||
filter: [filterCollapsed, setFilterCollapsed],
|
||||
playlists: [playlistsCollapsed, setPlaylistsCollapsed],
|
||||
files: [filesCollapsed, setFilesCollapsed],
|
||||
} as const;
|
||||
// (The feed filters + page/channel now LIVE in the address bar — FeedFiltersProvider and
|
||||
// NavigationProvider read them on load and keep them mirrored — so there's no query to strip here.)
|
||||
|
||||
@@ -156,200 +93,11 @@ export default function App() {
|
||||
// effects below depend on it, and a dep list is only honest if it names the value the body reads.
|
||||
const me = meQuery.data;
|
||||
|
||||
// The `global` key scope: `h` opens the cheat sheet, Mod+K the jump-to palette, Shift+W jumps to
|
||||
// Home ("mit nézzek?"). Bound only once signed in, because that is where the overlays are mounted
|
||||
// (bottom of this tree) and where the modules exist at all — see useGlobalKeymap on why binding it
|
||||
// above the signed-out branch is worse than not binding it.
|
||||
// A global key can fire while an overlay is open; `setPage` closes every overlay before it pushes,
|
||||
// so Shift+W over the player lands on Home with the player closed — also when Home is already the
|
||||
// page underneath (owner decision 2026-09-09: the key always ends with the answer on screen).
|
||||
// The `page` scope rides the same binding: the `g` chords jump to a module, but only to one this
|
||||
// account can reach (the nav rail's own list, plus Settings, which is not in the rail's order).
|
||||
useGlobalKeymap(!!me, setPage, me ? [...moduleOrder(me), "settings"] : []);
|
||||
|
||||
// The account's consented diagnostics level, applied to this tab's log: raised, the log is
|
||||
// kept in more detail and streamed to the server until the level lapses. A debug session that is
|
||||
// recording raises it for its own run and tags what it keeps. A lapse (its own timer, or the
|
||||
// server saying it is off) refetches `me`, so Settings and the recorder show what is in force.
|
||||
// Re-running this on every `me` refetch is FREE and therefore the deps are the honest ones:
|
||||
// applyDiagnostics computes an effective-level key and returns early when it has not moved
|
||||
// (lib/diagnostics.ts), so an unchanged level is a no-op. It does refresh the `lapsed` callback
|
||||
// before that early return, which is the half we WANT re-run. The old dep list picked out four
|
||||
// primitive fields to dodge the fresh object identity a refetch produces; that is the checker's
|
||||
// job to verify, not a comment's to assert, and it silently missed any other field the module
|
||||
// may come to read.
|
||||
const diagnostics = me?.diagnostics;
|
||||
const debugSession = me?.debug_session;
|
||||
useEffect(() => {
|
||||
applyDiagnostics(
|
||||
diagnostics,
|
||||
debugSession,
|
||||
() => void queryClient.invalidateQueries({ queryKey: qk.me() }),
|
||||
);
|
||||
}, [diagnostics, debugSession, queryClient]);
|
||||
|
||||
// Fetch the code of every module this account can open — and the channel page — once the first page
|
||||
// is on screen, so a module's first visit renders at once instead of flashing the chunk fallback.
|
||||
// Idle-scheduled so it never competes with the first page's own requests (Safari has no
|
||||
// requestIdleCallback, hence the timer).
|
||||
// Depending on the whole `me` rather than its id is both honest and MORE correct: the module set
|
||||
// is derived from `me` through each module's own gate (lib/modules moduleOrder), so a permission
|
||||
// change — an admin grant, a Plex server appearing — alters it without the id moving, and the old
|
||||
// `[me.id]` dep never re-preloaded for those. Re-running costs nothing: `preload()` is idempotent
|
||||
// (lazyWithPreload memoises the pending promise), so a refetch that changes nothing fetches
|
||||
// nothing; only the idle callback is re-scheduled.
|
||||
useEffect(() => {
|
||||
if (!me) return;
|
||||
const run = () => {
|
||||
preloadPages([...moduleOrder(me), "settings"]);
|
||||
ChannelPage.preload().catch(() => {});
|
||||
};
|
||||
const idle = window.requestIdleCallback as typeof window.requestIdleCallback | undefined;
|
||||
if (idle) {
|
||||
const id = idle(run, { timeout: 3000 });
|
||||
return () => window.cancelIdleCallback(id);
|
||||
}
|
||||
const id = window.setTimeout(run, 1000);
|
||||
return () => window.clearTimeout(id);
|
||||
}, [me]);
|
||||
|
||||
// Measure the scrollbar's gutter width once and publish it as --sbw, so the floating header and the
|
||||
// toolbar band can pad their right edge to line up with the scrolled cards (the app scroller reserves
|
||||
// that gutter via scrollbar-gutter: stable). It's a device constant, so a one-shot mount measure is enough.
|
||||
useEffect(() => {
|
||||
const probe = document.createElement("div");
|
||||
probe.style.cssText =
|
||||
"position:absolute;top:-9999px;width:100px;height:100px;overflow:scroll;scrollbar-gutter:stable";
|
||||
document.body.appendChild(probe);
|
||||
const w = probe.offsetWidth - probe.clientWidth;
|
||||
document.body.removeChild(probe);
|
||||
document.documentElement.style.setProperty("--sbw", `${w}px`);
|
||||
}, []);
|
||||
|
||||
// Once signed in, drop any leftover ONE-TIME query params from the pre-login flow (?access, ?verify,
|
||||
// ?reset) — but NOT the nav/filter params, which the logged-in app now reads from and keeps in the
|
||||
// URL. Gated on auth so the logged-out Welcome page still reads its params first.
|
||||
// Keyed on "is there a session at all", which is the whole condition the body tests. Re-running
|
||||
// on an account switch was never the point — by then the params are already gone and a second
|
||||
// clearParams is a no-op.
|
||||
const signedIn = !!me;
|
||||
useEffect(() => {
|
||||
if (signedIn) clearParams("access", "verify", "reset");
|
||||
}, [signedIn]);
|
||||
|
||||
// A sign-in as someone else that the server refused, or a reload as another account: say so, once.
|
||||
useAccountSwitchNotice(signedIn);
|
||||
|
||||
// Any request that 401s means the session ended server-side. If we were signed in (cached
|
||||
// `me` is a user object), reload to the login page at once (also covers any click that hits
|
||||
// the API). Guarded on a cached user so a stray 401 while logged out (cached `me` is null)
|
||||
// can't loop the public landing. A tab showing another account than the one signed in now
|
||||
// reloads as that one instead, and says why (`reloadAfterRefusal`).
|
||||
useEffect(() => {
|
||||
setUnauthorizedHandler((err) => {
|
||||
if (!queryClient.getQueryData(qk.me())) return;
|
||||
queryClient.clear();
|
||||
reloadAfterRefusal(err);
|
||||
});
|
||||
return () => setUnauthorizedHandler(null);
|
||||
}, [queryClient]);
|
||||
|
||||
// Returning from an in-app Google link/upgrade round-trip (auth.py redirects to ?link=…).
|
||||
// Surface the outcome, refresh `me` (can_read/can_write/has_google may have flipped), land on
|
||||
// Settings → Account so the new state is visible, then strip the param for a clean URL.
|
||||
// "Exactly once per mount" is now ENFORCED by the ref rather than asserted by an empty dep list:
|
||||
// `t` changes identity on a language switch, so the honest dep list would otherwise re-fire the
|
||||
// toast when the user changes language. (The old `[]` got away with it only because clearParams
|
||||
// had already removed the param — an invariant held by a side effect two lines further down.)
|
||||
const linkAnswered = useRef(false);
|
||||
const { refetch: refetchMe } = meQuery;
|
||||
useEffect(() => {
|
||||
if (linkAnswered.current) return;
|
||||
const result = new URLSearchParams(window.location.search).get("link");
|
||||
if (!result) return;
|
||||
linkAnswered.current = true;
|
||||
if (result === "ok") {
|
||||
notify({ level: "success", message: t("settings.account.googleLink.linked") });
|
||||
void refetchMe();
|
||||
focusSettingsTab("account");
|
||||
setPage("settings");
|
||||
} else {
|
||||
const key = result === "conflict" || result === "mismatch" ? result : "error";
|
||||
notify({ level: "error", message: t(`settings.account.googleLink.${key}`) });
|
||||
}
|
||||
clearParams("link");
|
||||
}, [t, setPage, refetchMe]);
|
||||
|
||||
// A Google OAuth flow started while already signed in (signing in as someone else, or a Google
|
||||
// connect) was cancelled at Google's consent screen. The backend only emits this for a live session, so a
|
||||
// neutral toast is the right feedback — the pre-auth login banner would never render here.
|
||||
// Same one-shot shape as the `?link=` handler above, same reason for the ref.
|
||||
const oauthAnswered = useRef(false);
|
||||
useEffect(() => {
|
||||
if (oauthAnswered.current) return;
|
||||
if (new URLSearchParams(window.location.search).get("oauth") !== "cancelled") return;
|
||||
oauthAnswered.current = true;
|
||||
notify({ level: "info", message: t("settings.account.googleCancelled") });
|
||||
clearParams("oauth");
|
||||
}, [t]);
|
||||
|
||||
// Deep-link from the "new access request" admin email (?admin=access-requests) → jump straight to
|
||||
// the admin Users → Access requests view. Snapshot the param up front (read once at mount); act once
|
||||
// `me` is known, and only for an admin (others just land on their default page).
|
||||
const [adminDeepLink] = useState(() => new URLSearchParams(window.location.search).get("admin"));
|
||||
// Ref-guarded like the two above, and here it closes a real hole rather than only satisfying the
|
||||
// checker: the snapshot lives for the whole session, so with honest deps a later role change —
|
||||
// or any `me` refetch, under the old id-only key — would yank the user to Users long after the
|
||||
// deep link was answered.
|
||||
const adminDeepLinkAnswered = useRef(false);
|
||||
useEffect(() => {
|
||||
if (adminDeepLinkAnswered.current) return;
|
||||
if (adminDeepLink !== "access-requests" || !me) return;
|
||||
adminDeepLinkAnswered.current = true;
|
||||
if (me.role === "admin") {
|
||||
focusAccessRequestsTab();
|
||||
setPage("users");
|
||||
}
|
||||
clearParams("admin");
|
||||
}, [adminDeepLink, me, setPage]);
|
||||
|
||||
// Nudge admins when access requests are waiting (in lieu of a server-push bell). Keyed on the
|
||||
// pending COUNT so it re-resolves the moment an approval/denial changes it — not only on a
|
||||
// fresh load (which is why the notice used to linger until F5 after an approval).
|
||||
// Honest deps, and `t` among them is a fix rather than a concession: the nudge is a persisted
|
||||
// toast whose text is translated at issue time, so a language switch must re-issue it. It did not
|
||||
// before — the notice stayed in the old language until the count changed.
|
||||
const role = me?.role;
|
||||
const pendingInvites = me?.pending_invites ?? 0;
|
||||
const pendingActivations = me?.pending_activations ?? 0;
|
||||
useEffect(() => {
|
||||
if (role !== "admin") return;
|
||||
// Keep a single, current nudge: drop any prior one (persisted copies reload as dismissed
|
||||
// history, so they'd otherwise pile up one-per-reload) before re-issuing the live notice.
|
||||
removeByMetaKind("access-requests");
|
||||
// Both kinds of "someone is waiting on you": a request needing a decision, and a sign-up whose
|
||||
// address was approved before they registered. The second used to produce NO signal at all —
|
||||
// nothing lands in the queue for it — which is how the 2026-09-03 registrant went unnoticed.
|
||||
const pending = pendingInvites + pendingActivations;
|
||||
if (pending > 0) {
|
||||
notify({
|
||||
level: "warning",
|
||||
title: t("common.accessRequestsTitle"),
|
||||
message: t("common.accessRequestsMessage", { count: pending }),
|
||||
// Durable inbox link (survives reload, unlike a live action callback); the toast also
|
||||
// gets a click via the action.
|
||||
meta: { kind: "access-requests" },
|
||||
action: {
|
||||
label: t("common.accessRequestsReview"),
|
||||
onClick: () => {
|
||||
focusAccessRequestsTab();
|
||||
// Clicked inside whatever overlay hosts the toast; setPage closes it before pushing.
|
||||
setPage("users");
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}, [role, pendingInvites, pendingActivations, t, setPage]);
|
||||
// What runs for the session (keys, diagnostics, preloading, the 401 handler), what the entry URL
|
||||
// asks for once, and the admin's waiting-requests nudge: each its own shell hook, so App is layout.
|
||||
useShellSession(me, setPage);
|
||||
useEntryParams(me, meQuery.refetch, setPage);
|
||||
useAccessRequestsNudge(me, setPage);
|
||||
|
||||
// Language is edited outside the Settings page (sidebar/login), so it stays instant.
|
||||
function changeLanguage(code: LangCode) {
|
||||
@@ -385,8 +133,8 @@ export default function App() {
|
||||
// The module registry drives the render from `page` alone — no per-page chrome conditionals. A page
|
||||
// the account can't reach never gets here: the navigation state already moved it to the feed
|
||||
// (`reachablePage`). A channel page overlays the content column, so no side rail shows behind it.
|
||||
const { Component: PageContent, maxWidth } = PAGE_CONTENT[page];
|
||||
const railDef = channelView ? undefined : PAGE_RAIL[page];
|
||||
const { Page: PageContent, Rail, maxWidth } = moduleDef(page);
|
||||
const PageRail = channelView ? undefined : Rail;
|
||||
|
||||
return (
|
||||
<div className="h-dvh flex text-fg">
|
||||
@@ -399,18 +147,10 @@ export default function App() {
|
||||
/>
|
||||
{/* The active page's side rail (feed filters / plex filters / playlists list), if it has one.
|
||||
A floating collapsible column beside the nav rail; hidden while a channel page overlays the
|
||||
content. feed + plex share the filter-rail collapse flag, playlists and files have their own. */}
|
||||
{railDef && (
|
||||
content. It holds its own layout and collapse flag (lib/useRail). */}
|
||||
{PageRail && (
|
||||
<Suspense fallback={null}>
|
||||
<railDef.Rail
|
||||
layout={panelLayouts[railDef.panelId]}
|
||||
setLayout={(l) => setPanelLayout(railDef.panelId, l)}
|
||||
collapsed={railCollapse[railDef.collapse][0]}
|
||||
onToggleCollapse={() => {
|
||||
const [collapsed, setCollapsed] = railCollapse[railDef.collapse];
|
||||
setCollapsed(!collapsed);
|
||||
}}
|
||||
/>
|
||||
<PageRail />
|
||||
</Suspense>
|
||||
)}
|
||||
{/* `@container/appmain`: the floating header + its search box adapt to THIS column's width
|
||||
|
||||
@@ -26,7 +26,9 @@ vi.mock("../components/ui/CommandPalette", () => ({
|
||||
|
||||
const channels = vi.fn();
|
||||
const playlists = vi.fn();
|
||||
vi.mock("../lib/api", () => ({
|
||||
// Partial: the module registry the palette lists reaches the feed's store, which reads its defaults here.
|
||||
vi.mock("../lib/api", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("../lib/api")>()),
|
||||
api: {
|
||||
channels: (...a: unknown[]) => channels(...a),
|
||||
playlists: (...a: unknown[]) => playlists(...a),
|
||||
|
||||
@@ -16,7 +16,7 @@ import CommandPalette, { type CommandGroup } from "../components/ui/CommandPalet
|
||||
import { api } from "../lib/api";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { useMe } from "../lib/useMe";
|
||||
import { moduleDef, moduleOrder } from "../lib/modules";
|
||||
import { moduleDef, moduleOrder } from "../features/navigation/moduleRegistry";
|
||||
import { SCHEMES, type Scheme } from "../lib/theme";
|
||||
import { closePalette, toggleKeyboardHelp, usePaletteOpen } from "../lib/keymap";
|
||||
import { requestSurprise } from "../lib/homeIntent";
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
moduleOrder,
|
||||
moduleRailKey,
|
||||
SYSTEM_PAGES,
|
||||
} from "../lib/modules";
|
||||
} from "../features/navigation/moduleRegistry";
|
||||
import { useScrollFade } from "../lib/useScrollFade";
|
||||
import { type LangCode } from "../i18n";
|
||||
import AvatarImg from "../components/ui/Avatar";
|
||||
|
||||
@@ -1,107 +0,0 @@
|
||||
import type { ComponentType } from "react";
|
||||
import { lazyWithPreload, type PreloadableComponent } from "../lib/lazyWithPreload";
|
||||
import type { Page } from "../lib/urlState";
|
||||
import type { PanelId, PanelLayout } from "../lib/panelLayout";
|
||||
// eslint-disable-next-line local/layers -- a rail (or the feed page's wrapper) the shell still loads eagerly; it goes once every rail loads lazily, like the pages.
|
||||
import Sidebar from "../modules/feed/Sidebar";
|
||||
// eslint-disable-next-line local/layers -- a rail (or the feed page's wrapper) the shell still loads eagerly; it goes once every rail loads lazily, like the pages.
|
||||
import PlaylistsRail from "../modules/playlists/PlaylistsRail";
|
||||
// eslint-disable-next-line local/layers -- a rail (or the feed page's wrapper) the shell still loads eagerly; it goes once every rail loads lazily, like the pages.
|
||||
import { Feed, FeedPage } from "../modules/feed/modulePages";
|
||||
|
||||
// The RENDER half of the module registry: which component (and side rail) each page renders. The
|
||||
// metadata half — nav order, label, icon, gate — lives in lib/modules.ts (imported by NavSidebar /
|
||||
// the palette / App's gate check). Together they let App drive the whole shell from `page` alone, with no
|
||||
// `page === X` chrome conditionals.
|
||||
//
|
||||
// Every content entry is a PROP-FREE component: it reads `me` from useMe() and its module-local state
|
||||
// from the scoped providers, so App renders `<Content />` uniformly. The two that need per-render
|
||||
// wiring get a thin wrapper in modulePages (FeedPage supplies the GLOBAL feed scope; the channel page
|
||||
// threads its own isolated scope to <Feed> directly — see the scope note in FeedFiltersProvider).
|
||||
|
||||
// Route-level code splitting: each page is its own lazy chunk (admin-only pages never reach
|
||||
// non-admins; the logged-out landing pulls only the shell). Rendered inside App's <Suspense>. Each
|
||||
// one is preloadable: App fetches the chunks of every page the account can reach once the first page
|
||||
// is on screen (preloadPages below), so switching modules never shows the chunk fallback.
|
||||
const Home = lazyWithPreload(() => import("../modules/home/Home"));
|
||||
const Channels = lazyWithPreload(() => import("../modules/channels/Channels"));
|
||||
const Playlists = lazyWithPreload(() => import("../modules/playlists/Playlists"));
|
||||
const PlexBrowse = lazyWithPreload(() => import("../modules/plex/PlexBrowse"));
|
||||
const PlexSidebar = lazyWithPreload(() => import("../modules/plex/PlexSidebar"));
|
||||
const NotificationsPanel = lazyWithPreload(
|
||||
() => import("../modules/notifications/NotificationsPanel"),
|
||||
);
|
||||
const Files = lazyWithPreload(() => import("../modules/files/Files"));
|
||||
const FilesRail = lazyWithPreload(() => import("../modules/files/FilesRail"));
|
||||
const Stats = lazyWithPreload(() => import("../modules/stats/Stats"));
|
||||
const Scheduler = lazyWithPreload(() => import("../modules/scheduler/Scheduler"));
|
||||
const ConfigPanel = lazyWithPreload(() => import("../modules/config/ConfigPanel"));
|
||||
const AdminUsers = lazyWithPreload(() => import("../modules/users/AdminUsers"));
|
||||
const Activity = lazyWithPreload(() => import("../modules/activity/Activity"));
|
||||
const SettingsPanel = lazyWithPreload(() => import("../modules/settings/SettingsPanel"));
|
||||
|
||||
export type PageDef = {
|
||||
// Prop-free by construction: reads `me` + module state from hooks/providers. Preloadable by type, so
|
||||
// a new page cannot silently opt out of preloadPages.
|
||||
Component: 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;
|
||||
};
|
||||
|
||||
// page id → the component App renders in the content column (+ its shell chrome config).
|
||||
export const PAGE_CONTENT: Record<Page, PageDef> = {
|
||||
home: { Component: Home, maxWidth: "80rem" },
|
||||
// The two wrappers preload the lazy chunk they render.
|
||||
feed: { Component: Object.assign(FeedPage, { preload: Feed.preload }) },
|
||||
channels: { Component: Channels, maxWidth: "96rem" },
|
||||
playlists: { Component: Playlists, maxWidth: "64rem" },
|
||||
plex: { Component: PlexBrowse, maxWidth: "100rem" },
|
||||
notifications: { Component: NotificationsPanel, maxWidth: "48rem" },
|
||||
files: { Component: Files, maxWidth: "80rem" },
|
||||
stats: { Component: Stats },
|
||||
scheduler: { Component: Scheduler },
|
||||
config: { Component: ConfigPanel },
|
||||
users: { Component: AdminUsers, maxWidth: "48rem" },
|
||||
activity: { Component: Activity },
|
||||
settings: { Component: SettingsPanel },
|
||||
};
|
||||
|
||||
// The uniform side-rail contract — shell layout/collapse only; the rail reads its module state from
|
||||
// its provider and `me` from useMe().
|
||||
type RailProps = {
|
||||
layout: PanelLayout;
|
||||
setLayout: (l: PanelLayout) => void;
|
||||
collapsed: boolean;
|
||||
onToggleCollapse: () => void;
|
||||
};
|
||||
|
||||
// Which collapse flag a rail uses (feed + plex share the filter rail's flag; playlists and files
|
||||
// have their own).
|
||||
type RailCollapseKey = "filter" | "playlists" | "files";
|
||||
|
||||
export type RailDef = {
|
||||
Rail: ComponentType<RailProps>;
|
||||
// The persisted panel-layout record this rail edits (App owns the panelLayouts map).
|
||||
panelId: PanelId;
|
||||
collapse: RailCollapseKey;
|
||||
};
|
||||
|
||||
// page id → its side rail (only the pages that have one). App renders it in the rail slot.
|
||||
export const PAGE_RAIL: Partial<Record<Page, RailDef>> = {
|
||||
feed: { Rail: Sidebar, panelId: "feed", collapse: "filter" },
|
||||
plex: { Rail: PlexSidebar, panelId: "plex", collapse: "filter" },
|
||||
playlists: { Rail: PlaylistsRail, panelId: "playlists", collapse: "playlists" },
|
||||
files: { Rail: FilesRail, panelId: "files", collapse: "files" },
|
||||
};
|
||||
|
||||
/** Fetch the code of these pages and of their side rails now, ahead of their first visit. Errors are
|
||||
* swallowed: a chunk that fails to preload is simply fetched (and its failure surfaced) on visit. */
|
||||
export function preloadPages(pages: Page[]): void {
|
||||
for (const p of pages) {
|
||||
PAGE_CONTENT[p].Component.preload().catch(() => {});
|
||||
const rail = PAGE_RAIL[p]?.Rail as Partial<PreloadableComponent<RailProps>> | undefined;
|
||||
rail?.preload?.().catch(() => {});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { useEffect } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { Me } from "../lib/api";
|
||||
import type { Page } from "../lib/urlState";
|
||||
import { notify, removeByMetaKind } from "../lib/notifications";
|
||||
import { focusAccessRequestsTab } from "../lib/adminUsersTab";
|
||||
|
||||
type SetPage = (next: Page, then?: () => void) => void;
|
||||
|
||||
export function useAccessRequestsNudge(me: Me | null | undefined, setPage: SetPage): void {
|
||||
const { t } = useTranslation();
|
||||
// Nudge admins when access requests are waiting (in lieu of a server-push bell). Keyed on the
|
||||
// pending COUNT so it re-resolves the moment an approval/denial changes it — not only on a
|
||||
// fresh load (which is why the notice used to linger until F5 after an approval).
|
||||
// Honest deps, and `t` among them is a fix rather than a concession: the nudge is a persisted
|
||||
// toast whose text is translated at issue time, so a language switch must re-issue it. It did not
|
||||
// before — the notice stayed in the old language until the count changed.
|
||||
const role = me?.role;
|
||||
const pendingInvites = me?.pending_invites ?? 0;
|
||||
const pendingActivations = me?.pending_activations ?? 0;
|
||||
useEffect(() => {
|
||||
if (role !== "admin") return;
|
||||
// Keep a single, current nudge: drop any prior one (persisted copies reload as dismissed
|
||||
// history, so they'd otherwise pile up one-per-reload) before re-issuing the live notice.
|
||||
removeByMetaKind("access-requests");
|
||||
// Both kinds of "someone is waiting on you": a request needing a decision, and a sign-up whose
|
||||
// address was approved before they registered. The second used to produce NO signal at all —
|
||||
// nothing lands in the queue for it — which is how the 2026-09-03 registrant went unnoticed.
|
||||
const pending = pendingInvites + pendingActivations;
|
||||
if (pending > 0) {
|
||||
notify({
|
||||
level: "warning",
|
||||
title: t("common.accessRequestsTitle"),
|
||||
message: t("common.accessRequestsMessage", { count: pending }),
|
||||
// Durable inbox link (survives reload, unlike a live action callback); the toast also
|
||||
// gets a click via the action.
|
||||
meta: { kind: "access-requests" },
|
||||
action: {
|
||||
label: t("common.accessRequestsReview"),
|
||||
onClick: () => {
|
||||
focusAccessRequestsTab();
|
||||
// Clicked inside whatever overlay hosts the toast; setPage closes it before pushing.
|
||||
setPage("users");
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}, [role, pendingInvites, pendingActivations, t, setPage]);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import type { Me } from "../lib/api";
|
||||
import { clearParams, type Page } from "../lib/urlState";
|
||||
import { useAccountSwitchNotice } from "../lib/useAccountSwitchNotice";
|
||||
import { notify } from "../lib/notifications";
|
||||
import { focusAccessRequestsTab } from "../lib/adminUsersTab";
|
||||
import { focusSettingsTab } from "../lib/settingsTab";
|
||||
|
||||
type SetPage = (next: Page, then?: () => void) => void;
|
||||
|
||||
// What the address bar the app was entered with asks for, answered once: the pre-login params dropped,
|
||||
// a refused account switch told, the outcome of a Google link or a cancelled Google sign-in, and the
|
||||
// admin email's deep link to the access requests.
|
||||
export function useEntryParams(
|
||||
me: Me | null | undefined,
|
||||
refetchMe: () => unknown,
|
||||
setPage: SetPage,
|
||||
): void {
|
||||
const { t } = useTranslation();
|
||||
// Once signed in, drop any leftover ONE-TIME query params from the pre-login flow (?access, ?verify,
|
||||
// ?reset) — but NOT the nav/filter params, which the logged-in app now reads from and keeps in the
|
||||
// URL. Gated on auth so the logged-out Welcome page still reads its params first.
|
||||
// Keyed on "is there a session at all", which is the whole condition the body tests. Re-running
|
||||
// on an account switch was never the point — by then the params are already gone and a second
|
||||
// clearParams is a no-op.
|
||||
const signedIn = !!me;
|
||||
useEffect(() => {
|
||||
if (signedIn) clearParams("access", "verify", "reset");
|
||||
}, [signedIn]);
|
||||
|
||||
// A sign-in as someone else that the server refused, or a reload as another account: say so, once.
|
||||
useAccountSwitchNotice(signedIn);
|
||||
|
||||
// Returning from an in-app Google link/upgrade round-trip (auth.py redirects to ?link=…).
|
||||
// Surface the outcome, refresh `me` (can_read/can_write/has_google may have flipped), land on
|
||||
// Settings → Account so the new state is visible, then strip the param for a clean URL.
|
||||
// "Exactly once per mount" is now ENFORCED by the ref rather than asserted by an empty dep list:
|
||||
// `t` changes identity on a language switch, so the honest dep list would otherwise re-fire the
|
||||
// toast when the user changes language. (The old `[]` got away with it only because clearParams
|
||||
// had already removed the param — an invariant held by a side effect two lines further down.)
|
||||
const linkAnswered = useRef(false);
|
||||
useEffect(() => {
|
||||
if (linkAnswered.current) return;
|
||||
const result = new URLSearchParams(window.location.search).get("link");
|
||||
if (!result) return;
|
||||
linkAnswered.current = true;
|
||||
if (result === "ok") {
|
||||
notify({ level: "success", message: t("settings.account.googleLink.linked") });
|
||||
void refetchMe();
|
||||
focusSettingsTab("account");
|
||||
setPage("settings");
|
||||
} else {
|
||||
const key = result === "conflict" || result === "mismatch" ? result : "error";
|
||||
notify({ level: "error", message: t(`settings.account.googleLink.${key}`) });
|
||||
}
|
||||
clearParams("link");
|
||||
}, [t, setPage, refetchMe]);
|
||||
|
||||
// A Google OAuth flow started while already signed in (signing in as someone else, or a Google
|
||||
// connect) was cancelled at Google's consent screen. The backend only emits this for a live session, so a
|
||||
// neutral toast is the right feedback — the pre-auth login banner would never render here.
|
||||
// Same one-shot shape as the `?link=` handler above, same reason for the ref.
|
||||
const oauthAnswered = useRef(false);
|
||||
useEffect(() => {
|
||||
if (oauthAnswered.current) return;
|
||||
if (new URLSearchParams(window.location.search).get("oauth") !== "cancelled") return;
|
||||
oauthAnswered.current = true;
|
||||
notify({ level: "info", message: t("settings.account.googleCancelled") });
|
||||
clearParams("oauth");
|
||||
}, [t]);
|
||||
|
||||
// Deep-link from the "new access request" admin email (?admin=access-requests) → jump straight to
|
||||
// the admin Users → Access requests view. Snapshot the param up front (read once at mount); act once
|
||||
// `me` is known, and only for an admin (others just land on their default page).
|
||||
const [adminDeepLink] = useState(() => new URLSearchParams(window.location.search).get("admin"));
|
||||
// Ref-guarded like the two above, and here it closes a real hole rather than only satisfying the
|
||||
// checker: the snapshot lives for the whole session, so with honest deps a later role change —
|
||||
// or any `me` refetch, under the old id-only key — would yank the user to Users long after the
|
||||
// deep link was answered.
|
||||
const adminDeepLinkAnswered = useRef(false);
|
||||
useEffect(() => {
|
||||
if (adminDeepLinkAnswered.current) return;
|
||||
if (adminDeepLink !== "access-requests" || !me) return;
|
||||
adminDeepLinkAnswered.current = true;
|
||||
if (me.role === "admin") {
|
||||
focusAccessRequestsTab();
|
||||
setPage("users");
|
||||
}
|
||||
clearParams("admin");
|
||||
}, [adminDeepLink, me, setPage]);
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { useEffect } from "react";
|
||||
import { useQueryClient } from "@tanstack/react-query";
|
||||
import { setUnauthorizedHandler, type Me } from "../lib/api";
|
||||
import { qk } from "../lib/queryKeys";
|
||||
import { reloadAfterRefusal } from "../lib/entryUrl";
|
||||
import { applyDiagnostics } from "../lib/diagnostics";
|
||||
import { useGlobalKeymap } from "../lib/keymap";
|
||||
import type { Page } from "../lib/urlState";
|
||||
import { moduleOrder, preloadPages } from "../features/navigation/moduleRegistry";
|
||||
import { ChannelPage } from "../modules/feed";
|
||||
|
||||
type SetPage = (next: Page, then?: () => void) => void;
|
||||
|
||||
// What the signed-in shell keeps running for the session: the global keys, the diagnostics level, the
|
||||
// idle preload of every reachable page, the scrollbar gutter's width, and the handler for a session
|
||||
// that ended server-side. Called by App above its signed-out branch, so `me` may be null here.
|
||||
export function useShellSession(me: Me | null | undefined, setPage: SetPage): void {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// The `global` key scope: `h` opens the cheat sheet, Mod+K the jump-to palette, Shift+W jumps to
|
||||
// Home ("mit nézzek?"). Bound only once signed in, because that is where the overlays are mounted
|
||||
// (bottom of this tree) and where the modules exist at all — see useGlobalKeymap on why binding it
|
||||
// above the signed-out branch is worse than not binding it.
|
||||
// A global key can fire while an overlay is open; `setPage` closes every overlay before it pushes,
|
||||
// so Shift+W over the player lands on Home with the player closed — also when Home is already the
|
||||
// page underneath (owner decision 2026-09-09: the key always ends with the answer on screen).
|
||||
// The `page` scope rides the same binding: the `g` chords jump to a module, but only to one this
|
||||
// account can reach (the nav rail's own list, plus Settings, which is not in the rail's order).
|
||||
useGlobalKeymap(!!me, setPage, me ? [...moduleOrder(me), "settings"] : []);
|
||||
|
||||
// The account's consented diagnostics level, applied to this tab's log: raised, the log is
|
||||
// kept in more detail and streamed to the server until the level lapses. A debug session that is
|
||||
// recording raises it for its own run and tags what it keeps. A lapse (its own timer, or the
|
||||
// server saying it is off) refetches `me`, so Settings and the recorder show what is in force.
|
||||
// Re-running this on every `me` refetch is FREE and therefore the deps are the honest ones:
|
||||
// applyDiagnostics computes an effective-level key and returns early when it has not moved
|
||||
// (lib/diagnostics.ts), so an unchanged level is a no-op. It does refresh the `lapsed` callback
|
||||
// before that early return, which is the half we WANT re-run. The old dep list picked out four
|
||||
// primitive fields to dodge the fresh object identity a refetch produces; that is the checker's
|
||||
// job to verify, not a comment's to assert, and it silently missed any other field the module
|
||||
// may come to read.
|
||||
const diagnostics = me?.diagnostics;
|
||||
const debugSession = me?.debug_session;
|
||||
useEffect(() => {
|
||||
applyDiagnostics(
|
||||
diagnostics,
|
||||
debugSession,
|
||||
() => void queryClient.invalidateQueries({ queryKey: qk.me() }),
|
||||
);
|
||||
}, [diagnostics, debugSession, queryClient]);
|
||||
|
||||
// Fetch the code of every module this account can open — and the channel page — once the first page
|
||||
// is on screen, so a module's first visit renders at once instead of flashing the chunk fallback.
|
||||
// Idle-scheduled so it never competes with the first page's own requests (Safari has no
|
||||
// requestIdleCallback, hence the timer).
|
||||
// Depending on the whole `me` rather than its id is both honest and MORE correct: the module set
|
||||
// is derived from `me` through each module's own gate (moduleOrder), so a permission
|
||||
// change — an admin grant, a Plex server appearing — alters it without the id moving, and the old
|
||||
// `[me.id]` dep never re-preloaded for those. Re-running costs nothing: `preload()` is idempotent
|
||||
// (lazyWithPreload memoises the pending promise), so a refetch that changes nothing fetches
|
||||
// nothing; only the idle callback is re-scheduled.
|
||||
useEffect(() => {
|
||||
if (!me) return;
|
||||
const run = () => {
|
||||
preloadPages([...moduleOrder(me), "settings"]);
|
||||
ChannelPage.preload().catch(() => {});
|
||||
};
|
||||
const idle = window.requestIdleCallback as typeof window.requestIdleCallback | undefined;
|
||||
if (idle) {
|
||||
const id = idle(run, { timeout: 3000 });
|
||||
return () => window.cancelIdleCallback(id);
|
||||
}
|
||||
const id = window.setTimeout(run, 1000);
|
||||
return () => window.clearTimeout(id);
|
||||
}, [me]);
|
||||
|
||||
// Measure the scrollbar's gutter width once and publish it as --sbw, so the floating header and the
|
||||
// toolbar band can pad their right edge to line up with the scrolled cards (the app scroller reserves
|
||||
// that gutter via scrollbar-gutter: stable). It's a device constant, so a one-shot mount measure is enough.
|
||||
useEffect(() => {
|
||||
const probe = document.createElement("div");
|
||||
probe.style.cssText =
|
||||
"position:absolute;top:-9999px;width:100px;height:100px;overflow:scroll;scrollbar-gutter:stable";
|
||||
document.body.appendChild(probe);
|
||||
const w = probe.offsetWidth - probe.clientWidth;
|
||||
document.body.removeChild(probe);
|
||||
document.documentElement.style.setProperty("--sbw", `${w}px`);
|
||||
}, []);
|
||||
|
||||
// Any request that 401s means the session ended server-side. If we were signed in (cached
|
||||
// `me` is a user object), reload to the login page at once (also covers any click that hits
|
||||
// the API). Guarded on a cached user so a stray 401 while logged out (cached `me` is null)
|
||||
// can't loop the public landing. A tab showing another account than the one signed in now
|
||||
// reloads as that one instead, and says why (`reloadAfterRefusal`).
|
||||
useEffect(() => {
|
||||
setUnauthorizedHandler((err) => {
|
||||
if (!queryClient.getQueryData(qk.me())) return;
|
||||
queryClient.clear();
|
||||
reloadAfterRefusal(err);
|
||||
});
|
||||
return () => setUnauthorizedHandler(null);
|
||||
}, [queryClient]);
|
||||
}
|
||||
Reference in New Issue
Block a user