feat(nav): each module declares its URL keys; a page switch clears them and a page opens without another's

The shell cleared a hand-kept key list on a page switch (it missed the Files and feed keys) and cleared
nothing when a page opened from a link carrying another page's keys. ModuleDef.urlKeys now names every
key a page's view writes, the shell derives both from the registry, and moduleUrl.contract.test holds the
declarations true: one owner per key, no shell key, exactly what each codec writes, and no undeclared
key literal in the app.
This commit is contained in:
2026-10-25 20:48:14 +01:00
parent 4aff19a9a4
commit b30c60d1a5
24 changed files with 246 additions and 28 deletions
+5 -2
View File
@@ -45,7 +45,9 @@ A frontend page is one folder, `frontend/src/modules/<id>/` (the layering is in
page renders into the floating header itself through `PageHeader`; the shell knows no page. page renders into the floating header itself through `PageHeader`; the shell knows no page.
2. `module.ts`: its `ModuleDef` (`lib/moduleDef.ts`): id, label keys, icon, an optional `gate`, the page as a 2. `module.ts`: its `ModuleDef` (`lib/moduleDef.ts`): id, label keys, icon, an optional `gate`, the page as a
`lazyWithPreload(() => import(…))`, and optionally a rail and a `Store`. Of its own folder it imports statically `lazyWithPreload(() => import(…))`, and optionally a rail and a `Store`. Of its own folder it imports statically
only its store (a `…Provider` / `…Store` file); the page and the rail load lazily. only its store (a `…Provider` / `…Store` file); the page and the rail load lazily. `urlKeys` names every
address-bar key its view writes (the URL is the view: a bookmark reopens it), its list kept in `lib/` beside
its codec; the shell clears them on a page switch and opens a page without another page's keys.
3. `index.ts`, the module's public face: the definition, and only what other folders must read (its store's 3. `index.ts`, the module's public face: the definition, and only what other folders must read (its store's
hooks). Another folder reaches the module through this file or not at all. hooks). Another folder reaches the module through this file or not at all.
4. The id in `lib/urlState`'s `PAGES`, and the definition in `features/navigation/moduleRegistry.ts`, in 4. The id in `lib/urlState`'s `PAGES`, and the definition in `features/navigation/moduleRegistry.ts`, in
@@ -58,7 +60,8 @@ A frontend page is one folder, `frontend/src/modules/<id>/` (the layering is in
returns to `ui/FilterRail`; tsc names each table still missing the new id. returns to `ui/FilterRail`; tsc names each table still missing the new id.
What holds it: `moduleRegistry.contract.test.ts` (every id defined once, from its own folder, lazily), What holds it: `moduleRegistry.contract.test.ts` (every id defined once, from its own folder, lazily),
`moduleStores.contract.test.tsx` (no store reads another module), the `local/layers` lint rule (imports only go `moduleStores.contract.test.tsx` (no store reads another module), `moduleUrl.contract.test.ts` (each URL key
belongs to one module and matches what its codec writes; no undeclared key literal), the `local/layers` lint rule (imports only go
down, a module only through its face) and `chunkGuard` in the build (no lazy page on the boot path), plus the down, a module only through its face) and `chunkGuard` in the build (no lazy page on the boot path), plus the
i18n parity test. i18n parity test.
@@ -167,6 +167,19 @@ describe("NavigationProvider · setPage's same-page guard", () => {
expect(window.location.search).toBe("?page=plex&keep=1"); expect(window.location.search).toBe("?page=plex&keep=1");
}); });
it("leaves the keys of the modules it does not list by hand behind too: Files and the feed's", () => {
// Each module declares its keys (`urlKeys`), and the switch clears the union of them: a key
// missing from a hand-kept list rode along onto the next page's entry.
window.history.replaceState(
{ sfPage: "files" },
"",
"/?page=files&folder=all&item=5&q=music&sort=oldest&keep=1",
);
const { result } = render();
act(() => result.current.setPage("plex"));
expect(window.location.search).toBe("?page=plex&keep=1");
});
it("pushes nothing when it is already that page", () => { it("pushes nothing when it is already that page", () => {
const { result } = render(); const { result } = render();
act(() => result.current.setPage("plex")); act(() => result.current.setPage("plex"));
@@ -195,6 +208,25 @@ describe("NavigationProvider · setPage's same-page guard", () => {
}); });
}); });
describe("NavigationProvider · a link carrying another page's keys", () => {
it("opens with only its own page's keys: what the page does not show, its address does not say", () => {
window.history.replaceState(
{},
"",
"/?page=activity&at=logs&folder=all&ctab=hidden&q=music&pscope=movie&pl=3&keep=1",
);
render();
expect(window.location.search).toBe("?page=activity&at=logs&keep=1");
});
it("keeps the channel page open over any page", () => {
window.history.replaceState({}, "", "/?page=stats&chan=UC1&folder=all");
const { result } = render();
expect(window.location.search).toBe("?page=stats&chan=UC1");
expect(result.current.channelView?.id).toBe("UC1");
});
});
describe("NavigationProvider · Back", () => { describe("NavigationProvider · Back", () => {
// A tab that had a since-removed module open, reloaded onto the new build: its older history // A tab that had a since-removed module open, reloaded onto the new build: its older history
// entries still name that page, and Back lands on one of them. // entries still name that page, and Back lands on one of them.
@@ -3,7 +3,6 @@ import { useLatest } from "../../lib/useLatest";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { useMe } from "../../lib/useMe"; import { useMe } from "../../lib/useMe";
import { resolvePage, retiredParams, urlWithParams, type Page } from "../../lib/urlState"; import { resolvePage, retiredParams, urlWithParams, type Page } from "../../lib/urlState";
import { ACTIVITY_KEYS } from "../../lib/activityUrlState";
import { useDocumentObjectTitle } from "../../lib/documentTitle"; import { useDocumentObjectTitle } from "../../lib/documentTitle";
import { getAccountRaw, setAccountRaw } from "../../lib/storage"; import { getAccountRaw, setAccountRaw } from "../../lib/storage";
import { import {
@@ -13,7 +12,7 @@ import {
notifyNav, notifyNav,
rememberNavPosition, rememberNavPosition,
} from "../../lib/history"; } from "../../lib/history";
import { pageTitleKey, reachablePage } from "./moduleRegistry"; import { foreignUrlKeys, moduleUrlKeys, pageTitleKey, reachablePage } from "./moduleRegistry";
import { import {
channelViewFrom, channelViewFrom,
loadInitialPage, loadInitialPage,
@@ -37,21 +36,13 @@ import {
// //
// The contexts, their value types and the hooks live in navigationContext.ts. // The contexts, their value types and the hooks live in navigationContext.ts.
/** The address bar of `page`'s root: the page (feed implicit) with every view-specific key cleared. */ /** The address bar of `page`'s root: the page (feed implicit) with every module's own keys cleared — a
* page switch resets to that module's root, and its store mirrors what it holds back in. */
function pageRootParams(page: Page): Record<string, string | null> { function pageRootParams(page: Page): Record<string, string | null> {
return { return {
page: page === "feed" ? null : page, page: page === "feed" ? null : page,
chan: null, chan: null,
v: null, ...Object.fromEntries(moduleUrlKeys().map((k) => [k, null])),
pl: null,
pscope: null,
psort: null,
pshow: null,
pf: null,
ctab: null,
csort: null,
cf: null,
...Object.fromEntries(ACTIVITY_KEYS.map((k) => [k, null])),
}; };
} }
@@ -116,12 +107,10 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
setAccountRaw(PAGE_KEY, next); setAccountRaw(PAGE_KEY, next);
// Push an in-app history entry so browser/mouse Back steps through pages instead of leaving // Push an in-app history entry so browser/mouse Back steps through pages instead of leaving
// the app. A fresh { sfPage } (no carried-over _yt/_chan markers) so each page starts at its // the app. A fresh { sfPage } (no carried-over _yt/_chan markers) so each page starts at its
// root, and the address bar reflects the page (feed is implicit) while clearing the // root, and the address bar reflects the page (feed is implicit) while clearing `chan` and
// view-specific params (chan/v/pl and the plex content keys pscope/psort/pshow/pf) — a page // every module's own keys (pageRootParams) — a page switch resets to that module's root, and
// switch resets to that module's root. Feed filters (owned by FeedFiltersProvider) are left // each module's store mirrors its state back in on its page. The item it was opened from is
// intact; the plex/channels keys re-mirror from PlexProvider / ChannelsProvider on return to // still focused: the state setters above have not re-rendered anything yet.
// their page. The item it was opened from is still focused: the state setters above have not
// re-rendered anything yet.
rememberNavPosition(); rememberNavPosition();
window.history.pushState( window.history.pushState(
{ sfPage: next, sfScroll: nextNavId() }, { sfPage: next, sfScroll: nextNavId() },
@@ -244,7 +233,10 @@ export function NavigationProvider({ children }: { children: ReactNode }) {
// part of the page that replaced it (an `at` tab). // part of the page that replaced it (an `at` tab).
const linked = new URLSearchParams(window.location.search).get("page"); const linked = new URLSearchParams(window.location.search).get("page");
const remembered = getAccountRaw(PAGE_KEY); const remembered = getAccountRaw(PAGE_KEY);
// A link can carry another page's keys (hand-edited, or written by an older build): the page
// opens without them, so its address names only what it shows.
const url = urlWithParams({ const url = urlWithParams({
...Object.fromEntries(foreignUrlKeys(mountPage).map((k) => [k, null])),
...retiredParams(linked ?? (resolvePage(remembered) === mountPage ? remembered : null)), ...retiredParams(linked ?? (resolvePage(remembered) === mountPage ? remembered : null)),
page: mountPage === "feed" ? null : mountPage, page: mountPage === "feed" ? null : mountPage,
}); });
@@ -48,6 +48,17 @@ 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>; const BY_ID = Object.fromEntries(ALL.map((m) => [m.id, m])) as Record<Page, ModuleDef>;
// Every module's address-bar keys, and those of every module but `page`: what a page switch clears and
// what a page opens without (NavigationProvider). Derived from the definitions, so a new module's keys
// are cleared the day it declares them.
const ALL_URL_KEYS: readonly string[] = ALL.flatMap((m) => m.urlKeys);
export function moduleUrlKeys(): readonly string[] {
return ALL_URL_KEYS;
}
export function foreignUrlKeys(page: Page): readonly string[] {
return ALL.filter((m) => m.id !== page).flatMap((m) => m.urlKeys);
}
// Look up a module's definition by page id (every Page is covered, incl. settings). // Look up a module's definition by page id (every Page is covered, incl. settings).
export function moduleDef(id: Page): ModuleDef { export function moduleDef(id: Page): ModuleDef {
return BY_ID[id]; return BY_ID[id];
+2 -1
View File
@@ -17,7 +17,8 @@ export interface ChannelsUrlState {
filters: FilterMap; filters: FilterMap;
} }
const KEYS = ["ctab", "csort", "cf"] as const; export const CHANNELS_KEYS = ["ctab", "csort", "cf"] as const;
const KEYS = CHANNELS_KEYS;
const VIEWS = ["subscribed", "discovery", "hidden", "blocked"]; const VIEWS = ["subscribed", "discovery", "hidden", "blocked"];
// Sort ⇄ `key:dir` (e.g. `subscribers:desc`) via the shared codec in columnSort. There is no default // Sort ⇄ `key:dir` (e.g. `subscribers:desc`) via the shared codec in columnSort. There is no default
+6
View File
@@ -0,0 +1,6 @@
// The Files page's address-bar keys: the folder shown (`?folder=`, its encoding in
// modules/files/filesContext) and the file it turns to once (`?item=`). Here, beside the other pages'
// key lists, so the page's definition can declare them without importing its own folder.
export const FILES_FOLDER_KEY = "folder";
export const FILES_ITEM_KEY = "item";
export const FILES_KEYS = [FILES_FOLDER_KEY, FILES_ITEM_KEY] as const;
+6
View File
@@ -44,4 +44,10 @@ export type ModuleDef = {
// Static, unlike the page: it is on the boot path by design. It reads no other module's store, so // Static, unlike the page: it is on the boot path by design. It reads no other module's store, so
// the order the stores nest in does not matter (moduleStores.contract.test). // the order the stores nest in does not matter (moduleStores.contract.test).
Store?: ComponentType<{ children: ReactNode }>; Store?: ComponentType<{ children: ReactNode }>;
// Every address-bar key this page's view writes: its filters, sort, tab, the item it shows. The URL
// IS the view, so a bookmark reopens it. The shell clears them all on a page switch, and opens a page
// with no other page's keys in its address (NavigationProvider), so one page's state never rides
// along onto another's URL. Empty for a page with nothing of its own in the URL. Each key belongs to
// one module and none is a shell key (moduleUrl.contract.test).
urlKeys: readonly string[];
}; };
+4 -1
View File
@@ -18,7 +18,10 @@ export interface PlexUrlState {
filters: PlexFilters; filters: PlexFilters;
} }
const KEYS = ["pscope", "psort", "pshow", "pf"] as const; export const PLEX_KEYS = ["pscope", "psort", "pshow", "pf"] as const;
const KEYS = PLEX_KEYS;
// The Plex sub-view's key (show, season, info, person… — modules/plex/plexSubUrl encodes it).
export const PLEX_SUB_KEY = "v";
const SCOPES = ["movie", "show", "both"]; const SCOPES = ["movie", "show", "both"];
const SHOWS = ["all", "unwatched", "in_progress", "watched"]; const SHOWS = ["all", "unwatched", "in_progress", "watched"];
+18 -1
View File
@@ -6,7 +6,7 @@
// the short module/channel name (NavigationProvider owns document.title) — the query never leaks into it. // the short module/channel name (NavigationProvider owns document.title) — the query never leaks into it.
import { TAG_CATEGORIES, type FeedFilters } from "./api"; import { TAG_CATEGORIES, type FeedFilters } from "./api";
const KEYS = [ export const FEED_KEYS = [
"q", "q",
"show", "show",
"sort", "sort",
@@ -29,6 +29,23 @@ const KEYS = [
"mindur", "mindur",
"maxdur", "maxdur",
] as const; ] as const;
const KEYS = FEED_KEYS;
// The keys the shell itself reads, whatever page is on screen: the page, the channel page (it opens
// over any page), the welcome card's mode and the one-time entry links. No module may declare one of
// these (moduleUrl.contract.test), and a page switch never clears them.
export const SHELL_KEYS = [
"page",
"chan",
"m",
"link",
"oauth",
"admin",
"account",
"login",
"deleted",
"token",
] as const;
export function filtersToParams(f: FeedFilters): URLSearchParams { export function filtersToParams(f: FeedFilters): URLSearchParams {
const p = new URLSearchParams(); const p = new URLSearchParams();
+125
View File
@@ -0,0 +1,125 @@
// @vitest-environment jsdom
//
// The URL is the view: each module declares the address-bar keys its view writes (`urlKeys`), and the
// shell clears them on a page switch and opens a page without another page's keys (NavigationProvider,
// derived from these declarations). That only holds while the declarations are true, so this pins
// them: each key belongs to one module and is no shell key, every codec writes only its module's keys,
// and no key literal anywhere in the app reads or writes the address bar undeclared.
import { describe, it, expect } from "vitest";
import { readdirSync, readFileSync, statSync } from "node:fs";
import { join, relative } from "node:path";
import { moduleDef, REGISTERED_PAGES } from "./features/navigation/moduleRegistry";
import { DEFAULT_TAG_MODES, EMPTY_PLEX_FILTERS, type FeedFilters } from "./lib/api";
import { filterParamUpdates, SHELL_KEYS, type Page } from "./lib/urlState";
import { channelsParamUpdates } from "./lib/channelsUrlState";
import { plexParamUpdates } from "./lib/plexUrlState";
import { activityParamUpdates, DEFAULT_ACTIVITY_STATE } from "./lib/activityUrlState";
import { DEFAULT_LOGS_STATE, logsParamUpdates } from "./lib/logsUrlState";
import { FILES_KEYS } from "./lib/filesUrlState";
import { PLEX_SUB_URL } from "./modules/plex/plexSubUrl";
import { MODE_URL } from "./shell/welcomeMode";
const SRC = __dirname;
const FEED: FeedFilters = {
tags: [],
tagModes: DEFAULT_TAG_MODES,
channelIds: [],
q: "",
sort: "newest",
scope: "my",
includeNormal: true,
includeShorts: false,
includeLive: false,
show: "unwatched",
};
// What each module's own encoders write, from the encoders themselves. Every module that declares a
// key has a row, so a new one cannot declare keys its codec does not write, or the other way round.
const WRITES: Partial<Record<Page, () => string[]>> = {
feed: () => Object.keys(filterParamUpdates(FEED)),
channels: () =>
Object.keys(channelsParamUpdates({ view: "subscribed", sort: null, filters: {} })),
plex: () => [
...Object.keys(
plexParamUpdates({ scope: "both", sort: null, show: "all", filters: EMPTY_PLEX_FILTERS }),
),
PLEX_SUB_URL.key,
],
files: () => [...FILES_KEYS],
playlists: () => ["pl"],
activity: () => [
...Object.keys(activityParamUpdates(DEFAULT_ACTIVITY_STATE)),
...Object.keys(logsParamUpdates(DEFAULT_LOGS_STATE)),
],
};
function sources(dir: string): string[] {
return readdirSync(dir).flatMap((name) => {
const path = join(dir, name);
if (statSync(path).isDirectory()) return sources(path);
return /\.tsx?$/.test(name) && !/\.test\.tsx?$/.test(name) ? [path] : [];
});
}
// Key literals that reach the address bar's QUERY: the keys of an object literal handed to
// `urlWithParams` / `useUrlMirror`, and a `.get("…")` on the live query.
function queryKeyLiterals(): { key: string; where: string }[] {
const out: { key: string; where: string }[] = [];
for (const file of sources(SRC)) {
const text = readFileSync(file, "utf8");
const where = relative(SRC, file).split("\\").join("/");
for (const m of text.matchAll(/(?:urlWithParams|useUrlMirror)\(\{([^}]*)\}/g)) {
for (const k of `{${m[1]!}`.matchAll(/[{,]\s*(\w+)\s*:/g)) out.push({ key: k[1]!, where });
}
for (const m of text.matchAll(
/new URLSearchParams\(window\.location\.search\)\.get\("(\w+)"\)/g,
)) {
out.push({ key: m[1]!, where });
}
}
return out;
}
describe("module URL keys", () => {
const owner = new Map<string, Page>();
for (const page of REGISTERED_PAGES) {
for (const key of moduleDef(page).urlKeys) owner.set(key, owner.get(key) ?? page);
}
it("belong to one module each", () => {
const all = REGISTERED_PAGES.flatMap((p) => moduleDef(p).urlKeys);
expect(all.filter((k, i) => all.indexOf(k) !== i)).toEqual([]);
});
it("are never a shell key", () => {
const shell: readonly string[] = SHELL_KEYS;
expect([...owner.keys()].filter((k) => shell.includes(k))).toEqual([]);
// The welcome card's sub-view key is the shell's own.
expect(shell).toContain(MODE_URL.key);
});
it.each([...REGISTERED_PAGES])("are exactly what %s's encoders write", (page) => {
const declared = [...moduleDef(page).urlKeys].sort();
const writes = WRITES[page];
if (!declared.length) {
expect(writes).toBeUndefined();
return;
}
expect(writes, `${page} declares keys but has no WRITES row`).toBeDefined();
expect([...new Set(writes!())].sort()).toEqual(declared);
});
it("cover every key literal the app reads or writes in the address bar", () => {
const shell: readonly string[] = SHELL_KEYS;
const undeclared = queryKeyLiterals().filter(
({ key }) => !owner.has(key) && !shell.includes(key),
);
expect(undeclared).toEqual([]);
});
it("the literal scan sees the call sites it is meant to (a regex that matched nothing would pass)", () => {
const seen = new Set(queryKeyLiterals().map(({ key }) => key));
for (const key of ["chan", "pl", "page", "rjob", "link"]) expect(seen).toContain(key);
});
});
+2
View File
@@ -1,6 +1,7 @@
import { ScrollText } from "lucide-react"; import { ScrollText } from "lucide-react";
import { lazyWithPreload } from "../../lib/lazyWithPreload"; import { lazyWithPreload } from "../../lib/lazyWithPreload";
import type { ModuleDef } from "../../lib/moduleDef"; import type { ModuleDef } from "../../lib/moduleDef";
import { ACTIVITY_KEYS } from "../../lib/activityUrlState";
// This page as the shell and the navigation know it (lib/moduleDef). Its page loads lazily. // This page as the shell and the navigation know it (lib/moduleDef). Its page loads lazily.
export const activityModule: ModuleDef = { export const activityModule: ModuleDef = {
@@ -12,4 +13,5 @@ export const activityModule: ModuleDef = {
system: true, system: true,
gate: (me) => me.role === "admin", gate: (me) => me.role === "admin",
Page: lazyWithPreload(() => import("./Activity")), Page: lazyWithPreload(() => import("./Activity")),
urlKeys: ACTIVITY_KEYS,
}; };
+2
View File
@@ -1,6 +1,7 @@
import { Tv } from "lucide-react"; import { Tv } from "lucide-react";
import { lazyWithPreload } from "../../lib/lazyWithPreload"; import { lazyWithPreload } from "../../lib/lazyWithPreload";
import type { ModuleDef } from "../../lib/moduleDef"; import type { ModuleDef } from "../../lib/moduleDef";
import { CHANNELS_KEYS } from "../../lib/channelsUrlState";
import { ChannelsProvider } from "./ChannelsProvider"; import { ChannelsProvider } from "./ChannelsProvider";
// This page as the shell and the navigation know it (lib/moduleDef). Its page loads lazily. // This page as the shell and the navigation know it (lib/moduleDef). Its page loads lazily.
@@ -13,4 +14,5 @@ export const channelsModule: ModuleDef = {
Page: lazyWithPreload(() => import("./Channels")), Page: lazyWithPreload(() => import("./Channels")),
maxWidth: "96rem", maxWidth: "96rem",
Store: ChannelsProvider, Store: ChannelsProvider,
urlKeys: CHANNELS_KEYS,
}; };
+1
View File
@@ -12,4 +12,5 @@ export const configModule: ModuleDef = {
system: true, system: true,
gate: (me) => me.role === "admin", gate: (me) => me.role === "admin",
Page: lazyWithPreload(() => import("./ConfigPanel")), Page: lazyWithPreload(() => import("./ConfigPanel")),
urlKeys: [],
}; };
+2
View File
@@ -1,6 +1,7 @@
import { YouTubeIcon } from "../../lib/brandIcons"; import { YouTubeIcon } from "../../lib/brandIcons";
import { lazyWithPreload, lazyWithPreloadAlongside } from "../../lib/lazyWithPreload"; import { lazyWithPreload, lazyWithPreloadAlongside } from "../../lib/lazyWithPreload";
import type { ModuleDef } from "../../lib/moduleDef"; import type { ModuleDef } from "../../lib/moduleDef";
import { FEED_KEYS } from "../../lib/urlState";
import { FeedStore } from "./FeedStore"; import { FeedStore } from "./FeedStore";
// The rail loads with the page (lazyWithPreloadAlongside), so it never arrives after it. // The rail loads with the page (lazyWithPreloadAlongside), so it never arrives after it.
@@ -15,6 +16,7 @@ export const feedModule: ModuleDef = {
Rail, Rail,
Page: lazyWithPreloadAlongside(() => import("./FeedPage"), Rail), Page: lazyWithPreloadAlongside(() => import("./FeedPage"), Rail),
Store: FeedStore, Store: FeedStore,
urlKeys: FEED_KEYS,
}; };
// The channel page: a feed scoped to one channel, opened over any page (App renders it in place of the // The channel page: a feed scoped to one channel, opened over any page (App renders it in place of the
+5 -4
View File
@@ -1,6 +1,7 @@
import { useCallback, useMemo, useState, type ReactNode } from "react"; import { useCallback, useMemo, useState, type ReactNode } from "react";
import { LS, getAccountRaw, setAccountRaw } from "../../lib/storage"; import { LS, getAccountRaw, setAccountRaw } from "../../lib/storage";
import { useUrlMirror } from "../../lib/useUrlMirror"; import { useUrlMirror } from "../../lib/useUrlMirror";
import { FILES_FOLDER_KEY, FILES_ITEM_KEY } from "../../lib/filesUrlState";
import { useNavigation, useNavigationActions } from "../../features/navigation/navigationContext"; import { useNavigation, useNavigationActions } from "../../features/navigation/navigationContext";
import { import {
ALL_FILES, ALL_FILES,
@@ -33,14 +34,14 @@ import {
function initialView(): FilesView { function initialView(): FilesView {
return ( return (
parseView(new URLSearchParams(window.location.search).get("folder")) ?? parseView(new URLSearchParams(window.location.search).get(FILES_FOLDER_KEY)) ??
parseView(getAccountRaw(LS.filesView)) ?? parseView(getAccountRaw(LS.filesView)) ??
ALL_FILES ALL_FILES
); );
} }
function initialFocus(): FilesFocus | null { function initialFocus(): FilesFocus | null {
const id = parseItem(new URLSearchParams(window.location.search).get("item")); const id = parseItem(new URLSearchParams(window.location.search).get(FILES_ITEM_KEY));
return id === null ? null : { id, token: 1 }; return id === null ? null : { id, token: 1 };
} }
@@ -87,8 +88,8 @@ export function FilesProvider({ children }: { children: ReactNode }) {
const arrived = page === "files" ? arrivedRaw : null; const arrived = page === "files" ? arrivedRaw : null;
useUrlMirror({ useUrlMirror({
folder: page === "files" ? viewParam(view) : null, [FILES_FOLDER_KEY]: page === "files" ? viewParam(view) : null,
item: focus ? String(focus.id) : null, [FILES_ITEM_KEY]: focus ? String(focus.id) : null,
}); });
const state = useMemo<FilesState>( const state = useMemo<FilesState>(
+2
View File
@@ -1,6 +1,7 @@
import { FolderOpen } from "lucide-react"; import { FolderOpen } from "lucide-react";
import { lazyWithPreload, lazyWithPreloadAlongside } from "../../lib/lazyWithPreload"; import { lazyWithPreload, lazyWithPreloadAlongside } from "../../lib/lazyWithPreload";
import type { ModuleDef } from "../../lib/moduleDef"; import type { ModuleDef } from "../../lib/moduleDef";
import { FILES_KEYS } from "../../lib/filesUrlState";
import { FilesProvider } from "./FilesProvider"; import { FilesProvider } from "./FilesProvider";
// The rail loads with the page (lazyWithPreloadAlongside), so it never arrives after it. // The rail loads with the page (lazyWithPreloadAlongside), so it never arrives after it.
@@ -16,4 +17,5 @@ export const filesModule: ModuleDef = {
Page: lazyWithPreloadAlongside(() => import("./Files"), Rail), Page: lazyWithPreloadAlongside(() => import("./Files"), Rail),
maxWidth: "80rem", maxWidth: "80rem",
Store: FilesProvider, Store: FilesProvider,
urlKeys: FILES_KEYS,
}; };
+3
View File
@@ -11,4 +11,7 @@ export const homeModule: ModuleDef = {
icon: Home, icon: Home,
Page: lazyWithPreload(() => import("./Home")), Page: lazyWithPreload(() => import("./Home")),
maxWidth: "80rem", maxWidth: "80rem",
// Nothing of its own: the question is an answer, not a setting — the same question draws other
// cards once what you have watched moves on.
urlKeys: [],
}; };
@@ -11,4 +11,5 @@ export const notificationsModule: ModuleDef = {
hasBadge: true, hasBadge: true,
Page: lazyWithPreload(() => import("./NotificationsPanel")), Page: lazyWithPreload(() => import("./NotificationsPanel")),
maxWidth: "48rem", maxWidth: "48rem",
urlKeys: [],
}; };
+2
View File
@@ -16,4 +16,6 @@ export const playlistsModule: ModuleDef = {
Page: lazyWithPreloadAlongside(() => import("./Playlists"), Rail), Page: lazyWithPreloadAlongside(() => import("./Playlists"), Rail),
maxWidth: "64rem", maxWidth: "64rem",
Store: PlaylistsProvider, Store: PlaylistsProvider,
// The playlist shown (PlaylistsProvider, playlistsInit).
urlKeys: ["pl"],
}; };
+2
View File
@@ -1,6 +1,7 @@
import { Clapperboard } from "lucide-react"; import { Clapperboard } from "lucide-react";
import { lazyWithPreload, lazyWithPreloadAlongside } from "../../lib/lazyWithPreload"; import { lazyWithPreload, lazyWithPreloadAlongside } from "../../lib/lazyWithPreload";
import type { ModuleDef } from "../../lib/moduleDef"; import type { ModuleDef } from "../../lib/moduleDef";
import { PLEX_KEYS, PLEX_SUB_KEY } from "../../lib/plexUrlState";
import { PlexStore } from "./PlexStore"; import { PlexStore } from "./PlexStore";
// The rail loads with the page (lazyWithPreloadAlongside), so it never arrives after it. // The rail loads with the page (lazyWithPreloadAlongside), so it never arrives after it.
@@ -17,4 +18,5 @@ export const plexModule: ModuleDef = {
Page: lazyWithPreloadAlongside(() => import("./PlexBrowse"), Rail), Page: lazyWithPreloadAlongside(() => import("./PlexBrowse"), Rail),
maxWidth: "100rem", maxWidth: "100rem",
Store: PlexStore, Store: PlexStore,
urlKeys: [...PLEX_KEYS, PLEX_SUB_KEY],
}; };
+1
View File
@@ -12,4 +12,5 @@ export const schedulerModule: ModuleDef = {
system: true, system: true,
gate: (me) => me.role === "admin", gate: (me) => me.role === "admin",
Page: lazyWithPreload(() => import("./Scheduler")), Page: lazyWithPreload(() => import("./Scheduler")),
urlKeys: [],
}; };
+1
View File
@@ -10,4 +10,5 @@ export const settingsModule: ModuleDef = {
titleKey: "settings.title", titleKey: "settings.title",
icon: Settings, icon: Settings,
Page: lazyWithPreload(() => import("./SettingsPanel")), Page: lazyWithPreload(() => import("./SettingsPanel")),
urlKeys: [],
}; };
+1
View File
@@ -10,4 +10,5 @@ export const statsModule: ModuleDef = {
titleKey: "header.usageStats", titleKey: "header.usageStats",
icon: BarChart3, icon: BarChart3,
Page: lazyWithPreload(() => import("./Stats")), Page: lazyWithPreload(() => import("./Stats")),
urlKeys: [],
}; };
+1
View File
@@ -13,4 +13,5 @@ export const usersModule: ModuleDef = {
gate: (me) => me.role === "admin", gate: (me) => me.role === "admin",
Page: lazyWithPreload(() => import("./AdminUsers")), Page: lazyWithPreload(() => import("./AdminUsers")),
maxWidth: "48rem", maxWidth: "48rem",
urlKeys: [],
}; };