From b30c60d1a54f65ff5638422f1c74e56ac655ec87 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sun, 25 Oct 2026 20:48:14 +0100 Subject: [PATCH] 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. --- docs/development.md | 7 +- .../NavigationProvider.dom.test.tsx | 32 +++++ .../navigation/NavigationProvider.tsx | 30 ++--- .../src/features/navigation/moduleRegistry.ts | 11 ++ frontend/src/lib/channelsUrlState.ts | 3 +- frontend/src/lib/filesUrlState.ts | 6 + frontend/src/lib/moduleDef.ts | 6 + frontend/src/lib/plexUrlState.ts | 5 +- frontend/src/lib/urlState.ts | 19 ++- frontend/src/moduleUrl.contract.test.ts | 125 ++++++++++++++++++ frontend/src/modules/activity/module.ts | 2 + frontend/src/modules/channels/module.ts | 2 + frontend/src/modules/config/module.ts | 1 + frontend/src/modules/feed/module.ts | 2 + frontend/src/modules/files/FilesProvider.tsx | 9 +- frontend/src/modules/files/module.ts | 2 + frontend/src/modules/home/module.ts | 3 + frontend/src/modules/notifications/module.ts | 1 + frontend/src/modules/playlists/module.ts | 2 + frontend/src/modules/plex/module.ts | 2 + frontend/src/modules/scheduler/module.ts | 1 + frontend/src/modules/settings/module.ts | 1 + frontend/src/modules/stats/module.ts | 1 + frontend/src/modules/users/module.ts | 1 + 24 files changed, 246 insertions(+), 28 deletions(-) create mode 100644 frontend/src/lib/filesUrlState.ts create mode 100644 frontend/src/moduleUrl.contract.test.ts diff --git a/docs/development.md b/docs/development.md index 0bb8d445..a81a6349 100644 --- a/docs/development.md +++ b/docs/development.md @@ -45,7 +45,9 @@ A frontend page is one folder, `frontend/src/modules//` (the layering is in 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 `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 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 @@ -58,7 +60,8 @@ A frontend page is one folder, `frontend/src/modules//` (the layering is in 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), -`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 i18n parity test. diff --git a/frontend/src/features/navigation/NavigationProvider.dom.test.tsx b/frontend/src/features/navigation/NavigationProvider.dom.test.tsx index 55684824..d21893eb 100644 --- a/frontend/src/features/navigation/NavigationProvider.dom.test.tsx +++ b/frontend/src/features/navigation/NavigationProvider.dom.test.tsx @@ -167,6 +167,19 @@ describe("NavigationProvider · setPage's same-page guard", () => { 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", () => { const { result } = render(); 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", () => { // 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. diff --git a/frontend/src/features/navigation/NavigationProvider.tsx b/frontend/src/features/navigation/NavigationProvider.tsx index 37909d56..a09de092 100644 --- a/frontend/src/features/navigation/NavigationProvider.tsx +++ b/frontend/src/features/navigation/NavigationProvider.tsx @@ -3,7 +3,6 @@ import { useLatest } from "../../lib/useLatest"; 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 { useDocumentObjectTitle } from "../../lib/documentTitle"; import { getAccountRaw, setAccountRaw } from "../../lib/storage"; import { @@ -13,7 +12,7 @@ import { notifyNav, rememberNavPosition, } from "../../lib/history"; -import { pageTitleKey, reachablePage } from "./moduleRegistry"; +import { foreignUrlKeys, moduleUrlKeys, pageTitleKey, reachablePage } from "./moduleRegistry"; import { channelViewFrom, loadInitialPage, @@ -37,21 +36,13 @@ import { // // 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 { return { page: page === "feed" ? null : page, chan: null, - v: 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])), + ...Object.fromEntries(moduleUrlKeys().map((k) => [k, null])), }; } @@ -116,12 +107,10 @@ export function NavigationProvider({ children }: { children: ReactNode }) { setAccountRaw(PAGE_KEY, next); // 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 - // root, and the address bar reflects the page (feed is implicit) while clearing the - // view-specific params (chan/v/pl and the plex content keys pscope/psort/pshow/pf) — a page - // switch resets to that module's root. Feed filters (owned by FeedFiltersProvider) are left - // intact; the plex/channels keys re-mirror from PlexProvider / ChannelsProvider on return to - // their page. The item it was opened from is still focused: the state setters above have not - // re-rendered anything yet. + // root, and the address bar reflects the page (feed is implicit) while clearing `chan` and + // every module's own keys (pageRootParams) — a page switch resets to that module's root, and + // each module's store mirrors its state back in on its page. The item it was opened from is + // still focused: the state setters above have not re-rendered anything yet. rememberNavPosition(); window.history.pushState( { sfPage: next, sfScroll: nextNavId() }, @@ -244,7 +233,10 @@ export function NavigationProvider({ children }: { children: ReactNode }) { // part of the page that replaced it (an `at` tab). const linked = new URLSearchParams(window.location.search).get("page"); 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({ + ...Object.fromEntries(foreignUrlKeys(mountPage).map((k) => [k, null])), ...retiredParams(linked ?? (resolvePage(remembered) === mountPage ? remembered : null)), page: mountPage === "feed" ? null : mountPage, }); diff --git a/frontend/src/features/navigation/moduleRegistry.ts b/frontend/src/features/navigation/moduleRegistry.ts index 2f557273..82e55d74 100644 --- a/frontend/src/features/navigation/moduleRegistry.ts +++ b/frontend/src/features/navigation/moduleRegistry.ts @@ -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; +// 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). export function moduleDef(id: Page): ModuleDef { return BY_ID[id]; diff --git a/frontend/src/lib/channelsUrlState.ts b/frontend/src/lib/channelsUrlState.ts index 3a0fdd0e..e9e64c3a 100644 --- a/frontend/src/lib/channelsUrlState.ts +++ b/frontend/src/lib/channelsUrlState.ts @@ -17,7 +17,8 @@ export interface ChannelsUrlState { 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"]; // Sort ⇄ `key:dir` (e.g. `subscribers:desc`) via the shared codec in columnSort. There is no default diff --git a/frontend/src/lib/filesUrlState.ts b/frontend/src/lib/filesUrlState.ts new file mode 100644 index 00000000..4e964e01 --- /dev/null +++ b/frontend/src/lib/filesUrlState.ts @@ -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; diff --git a/frontend/src/lib/moduleDef.ts b/frontend/src/lib/moduleDef.ts index 207924d7..5bddcdec 100644 --- a/frontend/src/lib/moduleDef.ts +++ b/frontend/src/lib/moduleDef.ts @@ -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 // the order the stores nest in does not matter (moduleStores.contract.test). 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[]; }; diff --git a/frontend/src/lib/plexUrlState.ts b/frontend/src/lib/plexUrlState.ts index 2b1c5990..c40bc375 100644 --- a/frontend/src/lib/plexUrlState.ts +++ b/frontend/src/lib/plexUrlState.ts @@ -18,7 +18,10 @@ export interface PlexUrlState { 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 SHOWS = ["all", "unwatched", "in_progress", "watched"]; diff --git a/frontend/src/lib/urlState.ts b/frontend/src/lib/urlState.ts index 486ad5e9..53c269dd 100644 --- a/frontend/src/lib/urlState.ts +++ b/frontend/src/lib/urlState.ts @@ -6,7 +6,7 @@ // the short module/channel name (NavigationProvider owns document.title) — the query never leaks into it. import { TAG_CATEGORIES, type FeedFilters } from "./api"; -const KEYS = [ +export const FEED_KEYS = [ "q", "show", "sort", @@ -29,6 +29,23 @@ const KEYS = [ "mindur", "maxdur", ] 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 { const p = new URLSearchParams(); diff --git a/frontend/src/moduleUrl.contract.test.ts b/frontend/src/moduleUrl.contract.test.ts new file mode 100644 index 00000000..f587090f --- /dev/null +++ b/frontend/src/moduleUrl.contract.test.ts @@ -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 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(); + 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); + }); +}); diff --git a/frontend/src/modules/activity/module.ts b/frontend/src/modules/activity/module.ts index 9eb2347a..fa95fe6b 100644 --- a/frontend/src/modules/activity/module.ts +++ b/frontend/src/modules/activity/module.ts @@ -1,6 +1,7 @@ import { ScrollText } from "lucide-react"; import { lazyWithPreload } from "../../lib/lazyWithPreload"; 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. export const activityModule: ModuleDef = { @@ -12,4 +13,5 @@ export const activityModule: ModuleDef = { system: true, gate: (me) => me.role === "admin", Page: lazyWithPreload(() => import("./Activity")), + urlKeys: ACTIVITY_KEYS, }; diff --git a/frontend/src/modules/channels/module.ts b/frontend/src/modules/channels/module.ts index 7e798c40..93b63b8e 100644 --- a/frontend/src/modules/channels/module.ts +++ b/frontend/src/modules/channels/module.ts @@ -1,6 +1,7 @@ import { Tv } from "lucide-react"; import { lazyWithPreload } from "../../lib/lazyWithPreload"; import type { ModuleDef } from "../../lib/moduleDef"; +import { CHANNELS_KEYS } from "../../lib/channelsUrlState"; import { ChannelsProvider } from "./ChannelsProvider"; // 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")), maxWidth: "96rem", Store: ChannelsProvider, + urlKeys: CHANNELS_KEYS, }; diff --git a/frontend/src/modules/config/module.ts b/frontend/src/modules/config/module.ts index 705322a4..5d0d4b17 100644 --- a/frontend/src/modules/config/module.ts +++ b/frontend/src/modules/config/module.ts @@ -12,4 +12,5 @@ export const configModule: ModuleDef = { system: true, gate: (me) => me.role === "admin", Page: lazyWithPreload(() => import("./ConfigPanel")), + urlKeys: [], }; diff --git a/frontend/src/modules/feed/module.ts b/frontend/src/modules/feed/module.ts index 51f7a162..3f16fb42 100644 --- a/frontend/src/modules/feed/module.ts +++ b/frontend/src/modules/feed/module.ts @@ -1,6 +1,7 @@ import { YouTubeIcon } from "../../lib/brandIcons"; import { lazyWithPreload, lazyWithPreloadAlongside } from "../../lib/lazyWithPreload"; import type { ModuleDef } from "../../lib/moduleDef"; +import { FEED_KEYS } from "../../lib/urlState"; import { FeedStore } from "./FeedStore"; // The rail loads with the page (lazyWithPreloadAlongside), so it never arrives after it. @@ -15,6 +16,7 @@ export const feedModule: ModuleDef = { Rail, Page: lazyWithPreloadAlongside(() => import("./FeedPage"), Rail), 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 diff --git a/frontend/src/modules/files/FilesProvider.tsx b/frontend/src/modules/files/FilesProvider.tsx index 26251199..d2f15fa1 100644 --- a/frontend/src/modules/files/FilesProvider.tsx +++ b/frontend/src/modules/files/FilesProvider.tsx @@ -1,6 +1,7 @@ import { useCallback, useMemo, useState, type ReactNode } from "react"; import { LS, getAccountRaw, setAccountRaw } from "../../lib/storage"; import { useUrlMirror } from "../../lib/useUrlMirror"; +import { FILES_FOLDER_KEY, FILES_ITEM_KEY } from "../../lib/filesUrlState"; import { useNavigation, useNavigationActions } from "../../features/navigation/navigationContext"; import { ALL_FILES, @@ -33,14 +34,14 @@ import { function initialView(): FilesView { return ( - parseView(new URLSearchParams(window.location.search).get("folder")) ?? + parseView(new URLSearchParams(window.location.search).get(FILES_FOLDER_KEY)) ?? parseView(getAccountRaw(LS.filesView)) ?? ALL_FILES ); } 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 }; } @@ -87,8 +88,8 @@ export function FilesProvider({ children }: { children: ReactNode }) { const arrived = page === "files" ? arrivedRaw : null; useUrlMirror({ - folder: page === "files" ? viewParam(view) : null, - item: focus ? String(focus.id) : null, + [FILES_FOLDER_KEY]: page === "files" ? viewParam(view) : null, + [FILES_ITEM_KEY]: focus ? String(focus.id) : null, }); const state = useMemo( diff --git a/frontend/src/modules/files/module.ts b/frontend/src/modules/files/module.ts index 4c3d798a..5cef1c49 100644 --- a/frontend/src/modules/files/module.ts +++ b/frontend/src/modules/files/module.ts @@ -1,6 +1,7 @@ import { FolderOpen } from "lucide-react"; import { lazyWithPreload, lazyWithPreloadAlongside } from "../../lib/lazyWithPreload"; import type { ModuleDef } from "../../lib/moduleDef"; +import { FILES_KEYS } from "../../lib/filesUrlState"; import { FilesProvider } from "./FilesProvider"; // 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), maxWidth: "80rem", Store: FilesProvider, + urlKeys: FILES_KEYS, }; diff --git a/frontend/src/modules/home/module.ts b/frontend/src/modules/home/module.ts index cc6c0450..e83cee01 100644 --- a/frontend/src/modules/home/module.ts +++ b/frontend/src/modules/home/module.ts @@ -11,4 +11,7 @@ export const homeModule: ModuleDef = { icon: Home, Page: lazyWithPreload(() => import("./Home")), 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: [], }; diff --git a/frontend/src/modules/notifications/module.ts b/frontend/src/modules/notifications/module.ts index bd3072e1..ad05198d 100644 --- a/frontend/src/modules/notifications/module.ts +++ b/frontend/src/modules/notifications/module.ts @@ -11,4 +11,5 @@ export const notificationsModule: ModuleDef = { hasBadge: true, Page: lazyWithPreload(() => import("./NotificationsPanel")), maxWidth: "48rem", + urlKeys: [], }; diff --git a/frontend/src/modules/playlists/module.ts b/frontend/src/modules/playlists/module.ts index 4fba72dd..fc69335c 100644 --- a/frontend/src/modules/playlists/module.ts +++ b/frontend/src/modules/playlists/module.ts @@ -16,4 +16,6 @@ export const playlistsModule: ModuleDef = { Page: lazyWithPreloadAlongside(() => import("./Playlists"), Rail), maxWidth: "64rem", Store: PlaylistsProvider, + // The playlist shown (PlaylistsProvider, playlistsInit). + urlKeys: ["pl"], }; diff --git a/frontend/src/modules/plex/module.ts b/frontend/src/modules/plex/module.ts index 4d2f9b70..70d36358 100644 --- a/frontend/src/modules/plex/module.ts +++ b/frontend/src/modules/plex/module.ts @@ -1,6 +1,7 @@ import { Clapperboard } from "lucide-react"; import { lazyWithPreload, lazyWithPreloadAlongside } from "../../lib/lazyWithPreload"; import type { ModuleDef } from "../../lib/moduleDef"; +import { PLEX_KEYS, PLEX_SUB_KEY } from "../../lib/plexUrlState"; import { PlexStore } from "./PlexStore"; // 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), maxWidth: "100rem", Store: PlexStore, + urlKeys: [...PLEX_KEYS, PLEX_SUB_KEY], }; diff --git a/frontend/src/modules/scheduler/module.ts b/frontend/src/modules/scheduler/module.ts index 0511a3cc..9452e0e7 100644 --- a/frontend/src/modules/scheduler/module.ts +++ b/frontend/src/modules/scheduler/module.ts @@ -12,4 +12,5 @@ export const schedulerModule: ModuleDef = { system: true, gate: (me) => me.role === "admin", Page: lazyWithPreload(() => import("./Scheduler")), + urlKeys: [], }; diff --git a/frontend/src/modules/settings/module.ts b/frontend/src/modules/settings/module.ts index 551920fc..793c1de8 100644 --- a/frontend/src/modules/settings/module.ts +++ b/frontend/src/modules/settings/module.ts @@ -10,4 +10,5 @@ export const settingsModule: ModuleDef = { titleKey: "settings.title", icon: Settings, Page: lazyWithPreload(() => import("./SettingsPanel")), + urlKeys: [], }; diff --git a/frontend/src/modules/stats/module.ts b/frontend/src/modules/stats/module.ts index 08e70a6b..9d731d4e 100644 --- a/frontend/src/modules/stats/module.ts +++ b/frontend/src/modules/stats/module.ts @@ -10,4 +10,5 @@ export const statsModule: ModuleDef = { titleKey: "header.usageStats", icon: BarChart3, Page: lazyWithPreload(() => import("./Stats")), + urlKeys: [], }; diff --git a/frontend/src/modules/users/module.ts b/frontend/src/modules/users/module.ts index 1585f72c..5f34c253 100644 --- a/frontend/src/modules/users/module.ts +++ b/frontend/src/modules/users/module.ts @@ -13,4 +13,5 @@ export const usersModule: ModuleDef = { gate: (me) => me.role === "admin", Page: lazyWithPreload(() => import("./AdminUsers")), maxWidth: "48rem", + urlKeys: [], };