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:
+5
-2
@@ -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];
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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;
|
||||||
@@ -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[];
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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"];
|
||||||
|
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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: [],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>(
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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: [],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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"],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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: [],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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: [],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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: [],
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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: [],
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user