Compare commits
12
Commits
fdf8076013
...
cd5b26efa7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cd5b26efa7 | ||
|
|
0892a1a400 | ||
|
|
b443ac0c84 | ||
|
|
ad05932217 | ||
|
|
6ae7825a14 | ||
|
|
cde5a98fd2 | ||
|
|
e83c5e2d97 | ||
|
|
6a0c1d377f | ||
|
|
4ef963b005 | ||
|
|
0b24f2d8fa | ||
|
|
d5867bacca | ||
|
|
63a0fecc8b |
@@ -7,6 +7,16 @@ edit the release notes and re-run the generator.
|
|||||||
|
|
||||||
Upgrading, pinning a version and rolling back: [docs/self-hosting.md](docs/self-hosting.md#upgrading).
|
Upgrading, pinning a version and rolling back: [docs/self-hosting.md](docs/self-hosting.md#upgrading).
|
||||||
|
|
||||||
|
## 0.109.1 — 2026-10-23
|
||||||
|
|
||||||
|
### New
|
||||||
|
|
||||||
|
- Development: the frontend is one folder per page (its page, rail, store and definition), with the layers held by lint and the shared /watch page held apart by a contract test; the header knows no page, and the feed and Plex each keep their own saved views.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Saved views: a view you applied and then changed is still active after a visit to another page, in the feed and in Plex.
|
||||||
|
|
||||||
## 0.109.0 — 2026-10-21
|
## 0.109.0 — 2026-10-21
|
||||||
|
|
||||||
### New
|
### New
|
||||||
|
|||||||
@@ -73,14 +73,22 @@ only along a declared edge and writes them only through that unit's service; cor
|
|||||||
`queries`. **Fence-held, with no exemptions** (`backend/tests/test_unit_fences.py`) — as far as an AST sees: what it
|
`queries`. **Fence-held, with no exemptions** (`backend/tests/test_unit_fences.py`) — as far as an AST sees: what it
|
||||||
cannot (an ORM write on another unit's loaded row, SQL in `text()`, a commit by `begin()`) is review's to catch, listed in
|
cannot (an ORM write on another unit's loaded row, SQL in `text()`, a commit by `begin()`) is review's to catch, listed in
|
||||||
[`backend/app/README.md`](backend/app/README.md) with the conventions.
|
[`backend/app/README.md`](backend/app/README.md) with the conventions.
|
||||||
Frontend: `main.tsx` provider tree → `App.tsx` shell (a shared `/watch` link boots from its own
|
Frontend, by folder under `src/`: `main.tsx` provider tree → `shell/` (`App`, header, nav rail, palette,
|
||||||
entry, `watch.html` → `watchMain.tsx`, held app-free by a contract test) → `moduleRegistry` route-chunks → module
|
boot; a shared `/watch` link boots from its own entry, `watch.html` → `watchMain.tsx` → `watch/`, held
|
||||||
components → the **platform layer `components/ui/`** (domain-free primitives: the popover engine and
|
app-free by a contract test) → **`modules/<page>/`** (one folder per page: the page, its rail, its store, its tests,
|
||||||
its menus, `Modal`/`Overlay`, `Button`/`form`, `DataTable`/`VirtualGrid`, `QueryState`, …). That
|
and its `module.ts` definition — label, icon, gate, the lazy page and rail, its store — collected in nav order by
|
||||||
boundary is one-way and **lint-enforced**, not just described: a platform component may not import a
|
`features/navigation/moduleRegistry`, which also mounts every store at the root; a new page is its folder, its id in
|
||||||
feature component or a domain API slice, and one layer down `lib/` may not import from `components/` at
|
`lib/urlState` and its entry there, plus its panel in `lib/panelLayout` if it has a rail) →
|
||||||
all, not even a type (`no-restricted-imports` in `eslint.config.js`, with its own test). A new generic
|
**`features/<name>/`** (what several pages share: video cards, filters, downloads, the player, navigation) → the
|
||||||
component goes in `ui/`; see [`frontend/src/components/ui/README.md`](frontend/src/components/ui/README.md).
|
**platform layer `components/ui/`** (domain-free primitives: the popover engine and its menus, `Modal`/`Overlay`,
|
||||||
|
`Button`/`form`, `DataTable`/`VirtualGrid`, `QueryState`,
|
||||||
|
…) → `lib/`. The direction is one-way and **lint-enforced**, not just described (`local/layers`, with
|
||||||
|
its own test): an import only goes down; another module is reached only through its **`index.ts`** (its
|
||||||
|
public face — the stores, mounted at the root and read app-wide, which `features/` may read too) or
|
||||||
|
lazily by `import()`; a platform component may not import a domain API slice, and `lib/` nothing above
|
||||||
|
it, not even a type. A lazily imported page, rail or dialog that a static import drags onto the boot
|
||||||
|
path fails the build (`chunkGuard.ts`). A new generic component goes in `ui/`; see
|
||||||
|
[`frontend/src/components/ui/README.md`](frontend/src/components/ui/README.md).
|
||||||
Data via react-query (`qk` factory), the signed-in account's `localStorage` (`storage.ts`), and server
|
Data via react-query (`qk` factory), the signed-in account's `localStorage` (`storage.ts`), and server
|
||||||
prefs; API through `lib/api/*` slices over `core.req<T>`.
|
prefs; API through `lib/api/*` slices over `core.req<T>`.
|
||||||
|
|
||||||
@@ -107,6 +115,9 @@ CQRS, a repository/DI framework, a router library, a generic CRUD-router factory
|
|||||||
| FE keyboard shortcut | a row in `lib/keymap.ts` + `dispatch()` | a private `keydown` listener with its own key literals |
|
| FE keyboard shortcut | a row in `lib/keymap.ts` + `dispatch()` | a private `keydown` listener with its own key literals |
|
||||||
| FE list/grid keys | `NavGrid`/`keyboardNav` + `navItemAttrs` + `data-nav-open` | a private arrow-key / roving-tabindex handler |
|
| FE list/grid keys | `NavGrid`/`keyboardNav` + `navItemAttrs` + `data-nav-open` | a private arrow-key / roving-tabindex handler |
|
||||||
| FE view push (history) | `rememberNavPosition()` right before `history.pushState` | a push that forgets: Back strands focus on `<body>` |
|
| FE view push (history) | `rememberNavPosition()` right before `history.pushState` | a push that forgets: Back strands focus on `<body>` |
|
||||||
|
| FE a page's header content | the page renders it into the floating header itself (`PageHeader`, `ui/PageShell`) | a `page === …` branch in the shell's header, or a bridge carrying a page's state up to it |
|
||||||
|
| FE module store | its definition's `Store` (`module.ts`), mounted at the root by `ModuleStores`; it reads no other module's store (contract-held) | a provider hand-nested in `main.tsx` |
|
||||||
|
| FE saved views | the module's own store from `features/filters` (`createSavedViewsStore` + `useSavedViewsStore`) | one store switched by the active page — every visit elsewhere reset it |
|
||||||
| FE static label / button-like link | `Badge` (flat, no edge) · `LinkButton` / `IconLink` | a glass pill that reads as a chip; an `<a>` hand-painted like a button |
|
| FE static label / button-like link | `Badge` (flat, no edge) · `LinkButton` / `IconLink` | a glass pill that reads as a chip; an `<a>` hand-painted like a button |
|
||||||
| FE loading/empty/error | `QueryState` / `StateMessage` | ad-hoc conditionals |
|
| FE loading/empty/error | `QueryState` / `StateMessage` | ad-hoc conditionals |
|
||||||
| FE failed write | `meta.failTitle`, else `notifyActionError(err, title)`; a YouTube write action (subscribe, sync, …): `notifyYouTubeActionError` — a 403 offers Connect | a bare `catch {}` or `notify()` with no error attached |
|
| FE failed write | `meta.failTitle`, else `notifyActionError(err, title)`; a YouTube write action (subscribe, sync, …): `notifyYouTubeActionError` — a 403 offers Connect | a bare `catch {}` or `notify()` with no error attached |
|
||||||
|
|||||||
+3
-1
@@ -275,7 +275,9 @@ Also in force:
|
|||||||
- **Page chrome (B4):** a page's tabs and filters sit in ONE sticky band of the page colour, with the fade
|
- **Page chrome (B4):** a page's tabs and filters sit in ONE sticky band of the page colour, with the fade
|
||||||
under it (below). Below ~600px of height the filter row folds into one "Filters · n" button. Every page has
|
under it (below). Below ~600px of height the filter row folds into one "Filters · n" button. Every page has
|
||||||
a visually hidden `h1`; the nav rail and the browser tab title are the visible identity. [WCAG 2.4.6]
|
a visually hidden `h1`; the nav rail and the browser tab title are the visible identity. [WCAG 2.4.6]
|
||||||
Built as: the band is `PageShell`'s toolbar slot (`PageToolbar`), above the one scroller. The band
|
Built as: the band is `PageShell`'s toolbar slot (`PageToolbar`), above the one scroller; the floating
|
||||||
|
header over it is its header slot (`PageHeader`: the page's own pills, the palette's Jump-to pill where a
|
||||||
|
page has none, `PageHeaderDefault`). The band
|
||||||
holds controls only: an intro paragraph scrolls with the content (in the band it ate three lines of a
|
holds controls only: an intro paragraph scrolls with the content (in the band it ate three lines of a
|
||||||
short window). The fold is `useFilterFold` + `FilterFoldButton`: the same row
|
short window). The fold is `useFilterFold` + `FilterFoldButton`: the same row
|
||||||
shown or hidden in place, never copied into a popover. The hidden `h1` is `PageShell`'s `title`; a
|
shown or hidden in place, never copied into a popover. The hidden `h1` is `PageShell`'s `title`; a
|
||||||
|
|||||||
+27
-1
@@ -36,6 +36,32 @@ The dev server also serves the **design lab** at `http://localhost:5173/design-l
|
|||||||
decisions and the chosen patterns, rendered with the app's real CSS. It never reaches a production build
|
decisions and the chosen patterns, rendered with the app's real CSS. It never reaches a production build
|
||||||
(see [`DESIGN.md`](DESIGN.md)).
|
(see [`DESIGN.md`](DESIGN.md)).
|
||||||
|
|
||||||
|
## Adding a page
|
||||||
|
|
||||||
|
A frontend page is one folder, `frontend/src/modules/<id>/` (the layering is in
|
||||||
|
[`CLAUDE.md`](../CLAUDE.md) § Architecture shape):
|
||||||
|
|
||||||
|
1. The page itself, default-exported, and anything only it uses. Header content (a search box, a toggle) the
|
||||||
|
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.
|
||||||
|
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
|
||||||
|
nav-rail order. The rail, the palette, the `g` chords, the tab title and the root's stores all read from there.
|
||||||
|
5. Its strings in both locales (`src/i18n/locales`).
|
||||||
|
6. A page with a side rail also names its panel below the module, in `lib/` (rails share one layout
|
||||||
|
and collapse machinery, which lib may not learn from a module): the `PanelId` and its default group
|
||||||
|
order, storage and pref keys in `lib/panelLayout.ts`, its collapse flag in `lib/useRail.ts`
|
||||||
|
(`COLLAPSE`), and those keys in `lib/storage.ts`. The rail calls `useRail("<id>")` and hands what it
|
||||||
|
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
|
||||||
|
down, a module only through its face) and `chunkGuard` in the build (no lazy page on the boot path), plus the
|
||||||
|
i18n parity test.
|
||||||
|
|
||||||
## The gate
|
## The gate
|
||||||
|
|
||||||
A change is done when this is green, from the repo root:
|
A change is done when this is green, from the repo root:
|
||||||
@@ -105,7 +131,7 @@ npm run lint # eslint (typed rules included; warnings fail)
|
|||||||
npm run format:check # prettier
|
npm run format:check # prettier
|
||||||
npm run knip # unused files/exports/deps
|
npm run knip # unused files/exports/deps
|
||||||
npm run test # vitest (incl. the compiler and gate-register contracts)
|
npm run test # vitest (incl. the compiler and gate-register contracts)
|
||||||
npx vite build # the production bundle (devOnlyGuard fails it on dev-only code)
|
npx vite build # the production bundle (devOnlyGuard fails it on dev-only code, chunkGuard on a lazy page loaded at boot)
|
||||||
npm audit --omit=dev --audit-level=high # what ships to the browser, at high or above
|
npm audit --omit=dev --audit-level=high # what ships to the browser, at high or above
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
+3
-2
@@ -61,7 +61,8 @@ them itself (`no-undef`, `no-redeclare`, `no-dupe-keys`, …), nor the formattin
|
|||||||
| src tests · ui tests · lib tests | `local/literal-a11y-text`, `local/input-primitive`, `local/surface-role`, `local/glyph-link` — off | A test renders throwaway markup (a raw input as a focus target, an untranslated label) that is not product UI. `local/button-primitive` stays on: in a test file (and in a control primitive) it only refuses a raw `<button disabled>`, which drops focus there as anywhere. | — |
|
| src tests · ui tests · lib tests | `local/literal-a11y-text`, `local/input-primitive`, `local/surface-role`, `local/glyph-link` — off | A test renders throwaway markup (a raw input as a focus target, an untranslated label) that is not product UI. `local/button-primitive` stays on: in a test file (and in a control primitive) it only refuses a raw `<button disabled>`, which drops focus there as anywhere. | — |
|
||||||
| design-lab | `local/literal-a11y-text` — off | The dev-only design lab (never in the bundle — devOnlyGuard fails the build if it is) holds the owner's working copy, not product copy. | — |
|
| design-lab | `local/literal-a11y-text` — off | The dev-only design lab (never in the bundle — devOnlyGuard fails the build if it is) holds the owner's working copy, not product copy. | — |
|
||||||
| e2e | `react-hooks/*`, `react-refresh/*`, `local/*`, `no-console`, `no-restricted-globals`, `no-restricted-properties`, `no-restricted-syntax` — not applied | Playwright specs drive a browser from Node: no React, no product UI, and they print to the console on purpose. They get the base + typed rules + the playwright rules. | — |
|
| e2e | `react-hooks/*`, `react-refresh/*`, `local/*`, `no-console`, `no-restricted-globals`, `no-restricted-properties`, `no-restricted-syntax` — not applied | Playwright specs drive a browser from Node: no React, no product UI, and they print to the console on purpose. They get the base + typed rules + the playwright rules. | — |
|
||||||
| ts configs | `react-hooks/*`, `react-refresh/*`, `local/*`, `no-console`, `no-restricted-globals`, `no-restricted-properties`, `no-restricted-syntax` — not applied | The build configs (vite, vitest, playwright, reactPlugin, devOnlyGuard) run in Node at build time. | — |
|
| ts configs · tool tests | `react-hooks/*`, `react-refresh/*`, `local/*`, `no-console`, `no-restricted-globals`, `no-restricted-properties`, `no-restricted-syntax` — not applied | The build configs (vite, vitest, playwright, reactPlugin, devOnlyGuard, chunkGuard) run in Node at build time. Their unit tests beside them (`tool tests`: `chunkGuard.test.ts`) are linted the same way, plus `vitest/no-focused-tests` and `vitest/no-disabled-tests`, since vitest runs them. | — |
|
||||||
|
| src root tests · design-lab | `local/layers` — not applied (inside the rule: its `free` check, which the effective config cannot show) | The app-wide tests at `src/` root exercise a behaviour across modules (a write failure reported the same way in Plex and Files, the playlist editors' failures), so they render module-private components directly; the dev-only design lab shows every component. Neither ships. Every other layer, test or not, is held. | 3 imports of module-private files (`PlaylistEditFailures.dom.test.tsx` → `modules/plex/PlexPlaylistView`; `WriteFailureReporting.dom.test.tsx` → `modules/plex/PlexCollectionEditor`, `modules/files/UploadDialog`); design-lab 0 |
|
||||||
| js configs | `@typescript-eslint/*`, `react-hooks/*`, `react-refresh/*`, `local/*`, `no-console`, `no-restricted-globals`, `no-restricted-properties`, `no-restricted-syntax`, `no-var`, `prefer-const`, `prefer-rest-params`, `prefer-spread`, `no-array-constructor`, `no-unused-expressions` — not applied | `eslint.config.js` and `postcss.config.js`: plain-JS build config, linted with `js.configs.recommended` only. | — |
|
| js configs | `@typescript-eslint/*`, `react-hooks/*`, `react-refresh/*`, `local/*`, `no-console`, `no-restricted-globals`, `no-restricted-properties`, `no-restricted-syntax`, `no-var`, `prefer-const`, `prefer-rest-params`, `prefer-spread`, `no-array-constructor`, `no-unused-expressions` — not applied | `eslint.config.js` and `postcss.config.js`: plain-JS build config, linted with `js.configs.recommended` only. | — |
|
||||||
| repo scripts | `scripts/*.mjs` — not in the eslint or prettier lane; `js.configs.recommended` + Prettier via `src/lib/repoScripts.contract.test.ts` | They sit at the repo root, and an ESLint config cannot reach past its own directory. The contract test lints and format-checks them from the repo root, to the same bar as the `js configs` row; no typed rules, as they are in no tsc project. | 0 lint hits; 2 files reformatted (E32 review) |
|
| repo scripts | `scripts/*.mjs` — not in the eslint or prettier lane; `js.configs.recommended` + Prettier via `src/lib/repoScripts.contract.test.ts` | They sit at the repo root, and an ESLint config cannot reach past its own directory. The contract test lints and format-checks them from the repo root, to the same bar as the `js configs` row; no typed rules, as they are in no tsc project. | 0 lint hits; 2 files reformatted (E32 review) |
|
||||||
| everywhere | ignored paths: `dist`, `node_modules`, `playwright-report`, `test-results`, `e2e/.auth` | Build output, installed packages, test artefacts, a live Playwright session. Generated, not written. | — |
|
| everywhere | ignored paths: `dist`, `node_modules`, `playwright-report`, `test-results`, `e2e/.auth` | Build output, installed packages, test artefacts, a live Playwright session. Generated, not written. | — |
|
||||||
@@ -114,7 +115,7 @@ project checks needs a row.
|
|||||||
| CVE gate | HIGH/CRITICAL with a fix available only | `--ignore-unfixed` + `--severity HIGH,CRITICAL`: a finding with no fix cannot be acted on by an upgrade; it shows in the scan output. A fixable one fails the publish, and the only way past it is `.trivyignore.yaml`. | 0 open (E32 S1) |
|
| CVE gate | HIGH/CRITICAL with a fix available only | `--ignore-unfixed` + `--severity HIGH,CRITICAL`: a finding with no fix cannot be acted on by an upgrade; it shows in the scan output. A fixable one fails the publish, and the only way past it is `.trivyignore.yaml`. | 0 open (E32 S1) |
|
||||||
| npm audit | `--omit=dev --audit-level=high` | What ships to the browser, at high or above. The dev toolchain's advisories were burned down by hand in E32 S1 (18 → 0). | 0 |
|
| npm audit | `--omit=dev --audit-level=high` | What ships to the browser, at high or above. The dev toolchain's advisories were burned down by hand in E32 S1 (18 → 0). | 0 |
|
||||||
| knip | `entry` — one file added | Not a narrowing: knip's Vite plugin reads entries from index.html only, so the shared link's page (`watch.html` → `src/watchMain.tsx`) is named here, or the lane reports that whole entry unused. It adds files to the lane, never takes any out. | 1 entry |
|
| knip | `entry` — one file added | Not a narrowing: knip's Vite plugin reads entries from index.html only, so the shared link's page (`watch.html` → `src/watchMain.tsx`) is named here, or the lane reports that whole entry unused. It adds files to the lane, never takes any out. | 1 entry |
|
||||||
| React Compiler | the functions it skips, pinned | `components/compilerCoverage.contract.test.ts` — the list may only shrink. End state: only VirtualGrid (TanStack Virtual, an IncompatibleLibrary) remains. | 93 (E32 S1) → 29 (E32 S3) → 1, VirtualGrid (E32 S4) |
|
| React Compiler | the functions it skips, pinned | `src/compilerCoverage.contract.test.ts` — the list may only shrink. End state: only VirtualGrid (TanStack Virtual, an IncompatibleLibrary) remains. | 93 (E32 S1) → 29 (E32 S3) → 1, VirtualGrid (E32 S4) |
|
||||||
|
|
||||||
## Accepted findings
|
## Accepted findings
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { bootLeaks, type ChunkLike } from "./chunkGuard";
|
||||||
|
|
||||||
|
const APP = "/r/src/shell/App.tsx";
|
||||||
|
const chunk = (fileName: string, moduleIds: string[], imports: string[] = [], isEntry = false) =>
|
||||||
|
({ fileName, moduleIds, imports, isEntry }) satisfies ChunkLike;
|
||||||
|
|
||||||
|
describe("a lazily imported module", () => {
|
||||||
|
const lazy = new Set([APP, "/r/src/modules/plex/PlexBrowse.tsx"]);
|
||||||
|
|
||||||
|
it("is a leak in the entry chunk, in a chunk the entry imports, or in the App chunk", () => {
|
||||||
|
const P = "/r/src/modules/plex/PlexBrowse.tsx";
|
||||||
|
for (const chunks of [
|
||||||
|
[chunk("index.js", ["/r/src/main.tsx", P], [], true)],
|
||||||
|
[chunk("index.js", ["/r/src/main.tsx"], ["shared.js"], true), chunk("shared.js", [P])],
|
||||||
|
[chunk("index.js", ["/r/src/main.tsx"], [], true), chunk("App.js", [APP, P])],
|
||||||
|
[
|
||||||
|
chunk("index.js", ["/r/src/main.tsx"], [], true),
|
||||||
|
chunk("App.js", [APP], ["s.js"]),
|
||||||
|
chunk("s.js", [P]),
|
||||||
|
],
|
||||||
|
])
|
||||||
|
expect(bootLeaks(chunks, lazy, "src/shell/App.tsx")).toEqual([
|
||||||
|
`${P} (${chunks[chunks.length - 1]!.fileName})`,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is not a leak in its own chunk, or in one only another lazy chunk loads — and App itself is lazy by design", () => {
|
||||||
|
const chunks = [
|
||||||
|
chunk("index.js", ["/r/src/main.tsx"], ["react.js"], true),
|
||||||
|
chunk("react.js", ["/r/node_modules/react/index.js"]),
|
||||||
|
chunk("App.js", [APP], ["react.js"]),
|
||||||
|
chunk("PlexBrowse.js", ["/r/src/modules/plex/PlexBrowse.tsx"], ["react.js"]),
|
||||||
|
];
|
||||||
|
expect(bootLeaks(chunks, lazy, "src/shell/App.tsx")).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is checked on Windows paths too", () => {
|
||||||
|
const win = "D:\\r\\src\\modules\\plex\\PlexBrowse.tsx";
|
||||||
|
const chunks = [chunk("index.js", ["D:\\r\\src\\main.tsx", win], [], true)];
|
||||||
|
expect(bootLeaks(chunks, new Set([win]), "src/shell/App.tsx")).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import type { Plugin } from "vite";
|
||||||
|
|
||||||
|
// A lazily imported page, rail, dialog or player must stay out of the chunks loaded at boot. A page
|
||||||
|
// is imported with import()/lazy() so it is fetched only when it is opened, and Rollup honours that
|
||||||
|
// only while nothing on the boot path imports the same module statically: one static import — a
|
||||||
|
// module's index.ts re-exporting it is the easy way — and the module joins a boot chunk, the import()
|
||||||
|
// becomes a no-op, and nothing says so. That rule used to be a hope; this makes it a build failure,
|
||||||
|
// naming each module and the boot chunk it landed in. The boot path is the entry chunks and what they
|
||||||
|
// import, plus the shell (App, itself loaded lazily but fetched at boot alongside the identity probe)
|
||||||
|
// and what it imports.
|
||||||
|
const SHELL = "src/shell/App.tsx";
|
||||||
|
|
||||||
|
export interface ChunkLike {
|
||||||
|
fileName: string;
|
||||||
|
isEntry: boolean;
|
||||||
|
/** The chunks this one imports statically. */
|
||||||
|
imports: string[];
|
||||||
|
moduleIds: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const posix = (id: string) => id.split("\\").join("/");
|
||||||
|
|
||||||
|
/** Every lazily imported module (`lazy`: module ids) that sits in a boot chunk, as "id (chunk)". The
|
||||||
|
* shell's own module (`shell`, a path under the project) is exempt: it is lazy so that the public
|
||||||
|
* pages never load it, and its chunk is the shell. */
|
||||||
|
export function bootLeaks(chunks: ChunkLike[], lazy: Set<string>, shell: string): string[] {
|
||||||
|
const byName = new Map(chunks.map((c) => [c.fileName, c]));
|
||||||
|
const isShell = (id: string) => posix(id).endsWith(`/${shell}`);
|
||||||
|
const boot = new Set<ChunkLike>();
|
||||||
|
const visit = (c: ChunkLike | undefined) => {
|
||||||
|
if (!c || boot.has(c)) return;
|
||||||
|
boot.add(c);
|
||||||
|
c.imports.forEach((name) => visit(byName.get(name)));
|
||||||
|
};
|
||||||
|
chunks.filter((c) => c.isEntry || c.moduleIds.some(isShell)).forEach(visit);
|
||||||
|
const leaks: string[] = [];
|
||||||
|
for (const c of boot)
|
||||||
|
for (const id of c.moduleIds)
|
||||||
|
if (lazy.has(id) && !isShell(id)) leaks.push(`${posix(id)} (${c.fileName})`);
|
||||||
|
return leaks.sort();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function chunkGuard(): Plugin {
|
||||||
|
return {
|
||||||
|
name: "siftlode-chunk-guard",
|
||||||
|
apply: "build",
|
||||||
|
generateBundle(_options, bundle) {
|
||||||
|
const lazy = new Set(
|
||||||
|
[...this.getModuleIds()].filter(
|
||||||
|
(id) => (this.getModuleInfo(id)?.dynamicImporters.length ?? 0) > 0,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
const chunks: ChunkLike[] = Object.values(bundle).flatMap((o) =>
|
||||||
|
o.type === "chunk"
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
fileName: o.fileName,
|
||||||
|
isEntry: o.isEntry,
|
||||||
|
imports: o.imports,
|
||||||
|
moduleIds: o.moduleIds,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: [],
|
||||||
|
);
|
||||||
|
const leaks = bootLeaks(chunks, lazy, SHELL);
|
||||||
|
if (leaks.length)
|
||||||
|
this.error(
|
||||||
|
`lazily imported, yet loaded at boot: ${leaks.join(", ")}. ` +
|
||||||
|
"Something on the boot path imports it statically (an index.ts re-export?): import it lazily there too, or not at all.",
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
+121
-3
@@ -7,6 +7,8 @@ import globals from "globals";
|
|||||||
import eslintComments from "@eslint-community/eslint-plugin-eslint-comments";
|
import eslintComments from "@eslint-community/eslint-plugin-eslint-comments";
|
||||||
import vitest from "@vitest/eslint-plugin";
|
import vitest from "@vitest/eslint-plugin";
|
||||||
import playwright from "eslint-plugin-playwright";
|
import playwright from "eslint-plugin-playwright";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
// Flat config (ESLint 9). Deliberately NARROW: the high-value rules here are react-hooks' (the
|
// Flat config (ESLint 9). Deliberately NARROW: the high-value rules here are react-hooks' (the
|
||||||
// hook rules and the React Compiler's diagnostics). The 33 inline `exhaustive-deps` suppressions this
|
// hook rules and the React Compiler's diagnostics). The 33 inline `exhaustive-deps` suppressions this
|
||||||
@@ -99,7 +101,8 @@ const localeFormat = {
|
|||||||
// is a clock face with its own 12/24h switch, not a date.
|
// is a clock face with its own 12/24h switch, not a date.
|
||||||
const formats = /\/src\/lib\/(format|plexPlayerPrefs)\.ts$/.test(path);
|
const formats = /\/src\/lib\/(format|plexPlayerPrefs)\.ts$/.test(path);
|
||||||
const collates = /\/src\/lib\/collate\.ts$/.test(path);
|
const collates = /\/src\/lib\/collate\.ts$/.test(path);
|
||||||
const component = /\/src\/components\//.test(path);
|
// Every folder that holds components (CLAUDE.md "Architecture shape"), not lib/ below them.
|
||||||
|
const component = /\/src\/(components|shell|modules|features|watch)\//.test(path);
|
||||||
const importedFromI18n = (node) => {
|
const importedFromI18n = (node) => {
|
||||||
for (let scope = context.sourceCode.getScope(node); scope; scope = scope.upper) {
|
for (let scope = context.sourceCode.getScope(node); scope; scope = scope.upper) {
|
||||||
const v = scope.set.get(node.name);
|
const v = scope.set.get(node.name);
|
||||||
@@ -418,8 +421,105 @@ const glyphLink = {
|
|||||||
};
|
};
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
// The app's layers (CLAUDE.md "Architecture shape"), bottom up: lib/ → components/ui/ → features/ →
|
||||||
|
// modules/ → shell/. An import may go down, never up — with one door: a module's index.ts, its public
|
||||||
|
// face, which features/ may read too (the module stores are mounted at the root and read app-wide).
|
||||||
|
// Anything else of ANOTHER module is reached through that face, or lazily by import(), which is a
|
||||||
|
// chunk boundary and so the way a page is opened. Measured on the import, resolved to its file, so
|
||||||
|
// every spelling of a path is the same path. lib/ and ui/ keep their own `no-restricted-imports`
|
||||||
|
// blocks below; this rule adds the folders above them.
|
||||||
|
const SRC_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), "src");
|
||||||
|
const RANK = { lib: 0, ui: 1, features: 2, modules: 3, shell: 4 };
|
||||||
|
function placeOf(abs) {
|
||||||
|
const rel = path.relative(SRC_DIR, abs).split(path.sep).join("/");
|
||||||
|
if (rel.startsWith("..")) return null;
|
||||||
|
const parts = rel.replace(/\.(tsx?|jsx?)$/, "").split("/");
|
||||||
|
const [top, unit] = parts;
|
||||||
|
if (top === "lib") return { layer: "lib" };
|
||||||
|
if (top === "components") return unit === "ui" ? { layer: "ui" } : { layer: "other" };
|
||||||
|
if (top === "features" || top === "modules") {
|
||||||
|
// `modules/plex` and `modules/plex/index` are the unit's public face.
|
||||||
|
const face = parts.length === 2 || (parts.length === 3 && parts[2] === "index");
|
||||||
|
return { layer: top, unit, face };
|
||||||
|
}
|
||||||
|
if (top === "shell") return { layer: "shell" };
|
||||||
|
// main.tsx, watch/, i18n/, content/, design-lab/, the app-wide tests at the root. The two entries —
|
||||||
|
// the app's main.tsx and the shared /watch page (watch/ + watchMain.tsx) — sit above every layer.
|
||||||
|
const root = parts.length === 1;
|
||||||
|
const entry = top === "watch" || (root && (top === "main" || top === "watchMain"));
|
||||||
|
return { layer: "other", top: root ? "root" : top, entry };
|
||||||
|
}
|
||||||
|
const layers = {
|
||||||
|
meta: {
|
||||||
|
type: "problem",
|
||||||
|
messages: {
|
||||||
|
up: "{{from}} sits below {{to}}: it may not import from it (`{{spec}}`). Move the shared piece down, or take it as a prop.",
|
||||||
|
face: "`{{spec}}` is one of {{unit}}'s own files: reach another unit through its index.ts (its public face), or lazily with import().",
|
||||||
|
entry:
|
||||||
|
"`{{spec}}` is an entry (the app's main.tsx or the shared /watch page): no layer imports one, or the app and the /watch page stop being separable.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
create(context) {
|
||||||
|
const file = context.filename;
|
||||||
|
const from = placeOf(file);
|
||||||
|
// The dev-only lab and the app-wide tests at the root look at everything on purpose.
|
||||||
|
const free =
|
||||||
|
!from ||
|
||||||
|
(from.layer === "other" && from.top === "design-lab") ||
|
||||||
|
(from.layer === "other" && from.top === "root" && /\.test(kit)?\.|\.testkit\./.test(file));
|
||||||
|
const check = (node, spec, lazy) => {
|
||||||
|
if (free || typeof spec !== "string" || !spec.startsWith(".")) return;
|
||||||
|
const to = placeOf(path.resolve(path.dirname(file), spec));
|
||||||
|
if (!to) return;
|
||||||
|
if (to.layer === "other") {
|
||||||
|
if (to.entry && from.layer !== "other")
|
||||||
|
context.report({ node, messageId: "entry", data: { spec } });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const fromRank = from.layer === "other" ? 5 : RANK[from.layer];
|
||||||
|
if (
|
||||||
|
to.layer === "shell" &&
|
||||||
|
from.layer !== "shell" &&
|
||||||
|
!(from.layer === "other" && from.top === "root")
|
||||||
|
)
|
||||||
|
return context.report({
|
||||||
|
node,
|
||||||
|
messageId: "up",
|
||||||
|
data: { from: from.layer, to: "shell", spec },
|
||||||
|
});
|
||||||
|
const door = to.layer === "modules" && (to.face || lazy) && from.layer === "features";
|
||||||
|
if (RANK[to.layer] > fromRank && !door)
|
||||||
|
return context.report({
|
||||||
|
node,
|
||||||
|
messageId: "up",
|
||||||
|
data: { from: from.layer, to: to.layer, spec },
|
||||||
|
});
|
||||||
|
const sameUnit = to.layer === from.layer && to.unit === from.unit;
|
||||||
|
if (to.layer === "modules" && !sameUnit && !to.face && !lazy)
|
||||||
|
context.report({ node, messageId: "face", data: { spec, unit: `modules/${to.unit}` } });
|
||||||
|
};
|
||||||
|
return {
|
||||||
|
ImportDeclaration: (n) => check(n.source, n.source.value, false),
|
||||||
|
ExportNamedDeclaration: (n) => n.source && check(n.source, n.source.value, false),
|
||||||
|
ExportAllDeclaration: (n) => check(n.source, n.source.value, false),
|
||||||
|
ImportExpression: (n) => {
|
||||||
|
// A template literal with nothing substituted is a plain path to TypeScript and Rollup too.
|
||||||
|
const s = n.source;
|
||||||
|
if (s.type === "Literal") check(s, s.value, true);
|
||||||
|
else if (s.type === "TemplateLiteral" && s.expressions.length === 0)
|
||||||
|
check(s, s.quasis[0].value.cooked, true);
|
||||||
|
},
|
||||||
|
TSImportType: (n) => {
|
||||||
|
const lit = n.argument?.literal ?? n.argument;
|
||||||
|
if (lit?.type === "Literal") check(lit, lit.value, false);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
const localRules = {
|
const localRules = {
|
||||||
rules: {
|
rules: {
|
||||||
|
layers,
|
||||||
"glyph-link": glyphLink,
|
"glyph-link": glyphLink,
|
||||||
"sortable-a11y": sortableA11y,
|
"sortable-a11y": sortableA11y,
|
||||||
"jsx-comment-text": jsxCommentText,
|
"jsx-comment-text": jsxCommentText,
|
||||||
@@ -536,7 +636,7 @@ export default tseslint.config(
|
|||||||
// function the compiler skips over a suppression is caught by the coverage contract below.
|
// function the compiler skips over a suppression is caught by the coverage contract below.
|
||||||
// `todo` does NOT see every such shape — a conditional, a loop
|
// `todo` does NOT see every such shape — a conditional, a loop
|
||||||
// or an optional call inside a try skips the function with no lint hit at all — so the full
|
// or an optional call inside a try skips the function with no lint hit at all — so the full
|
||||||
// per-function picture is components/compilerCoverage.contract.test.ts.
|
// per-function picture is src/compilerCoverage.contract.test.ts.
|
||||||
"react-hooks/todo": "error",
|
"react-hooks/todo": "error",
|
||||||
"react-hooks/memo-dependencies": "error",
|
"react-hooks/memo-dependencies": "error",
|
||||||
"react-hooks/invariant": "error",
|
"react-hooks/invariant": "error",
|
||||||
@@ -566,6 +666,7 @@ export default tseslint.config(
|
|||||||
"local/literal-a11y-text": "error",
|
"local/literal-a11y-text": "error",
|
||||||
"local/glyph-link": "error",
|
"local/glyph-link": "error",
|
||||||
"local/sortable-a11y": "error",
|
"local/sortable-a11y": "error",
|
||||||
|
"local/layers": "error",
|
||||||
"react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
|
"react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
|
||||||
// tsc already enforces these under strict + noUnusedLocals/Parameters — let it own them so a
|
// tsc already enforces these under strict + noUnusedLocals/Parameters — let it own them so a
|
||||||
// finding is reported once, by one tool, and this config stays about hooks + refresh safety.
|
// finding is reported once, by one tool, and this config stays about hooks + refresh safety.
|
||||||
@@ -760,7 +861,14 @@ export default tseslint.config(
|
|||||||
// Outside src: the e2e suite and the TypeScript build-tool configs went unlinted — the
|
// Outside src: the e2e suite and the TypeScript build-tool configs went unlinted — the
|
||||||
// flat config named only src/**, so `eslint .` skipped them without a word. The same base and
|
// flat config named only src/**, so `eslint .` skipped them without a word. The same base and
|
||||||
// typed rules as the app; Node globals, since none of it runs in the browser.
|
// typed rules as the app; Node globals, since none of it runs in the browser.
|
||||||
files: ["e2e/**/*.ts", "*.config.ts", "reactPlugin.ts", "devOnlyGuard.ts"],
|
files: [
|
||||||
|
"e2e/**/*.ts",
|
||||||
|
"*.config.ts",
|
||||||
|
"reactPlugin.ts",
|
||||||
|
"devOnlyGuard.ts",
|
||||||
|
"chunkGuard.ts",
|
||||||
|
"chunkGuard.test.ts",
|
||||||
|
],
|
||||||
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||||
languageOptions: {
|
languageOptions: {
|
||||||
ecmaVersion: 2022,
|
ecmaVersion: 2022,
|
||||||
@@ -775,6 +883,16 @@ export default tseslint.config(
|
|||||||
"no-undef": "off",
|
"no-undef": "off",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
// The unit tests of the build tools beside them (chunkGuard's): vitest runs them, so a stray
|
||||||
|
// `.only` / `.skip` there is the same silent skip as in src.
|
||||||
|
files: ["*.test.ts"],
|
||||||
|
plugins: { vitest },
|
||||||
|
rules: {
|
||||||
|
"vitest/no-focused-tests": "error",
|
||||||
|
"vitest/no-disabled-tests": "error",
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
files: ["e2e/**/*.ts"],
|
files: ["e2e/**/*.ts"],
|
||||||
plugins: { playwright },
|
plugins: { playwright },
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import reactCompiler from "babel-plugin-react-compiler";
|
|||||||
// there is no logger here, so a skipped component still works and simply re-renders everything under
|
// there is no logger here, so a skipped component still works and simply re-renders everything under
|
||||||
// it. This comment used to say "its rules are also linted"; it was false while `react-hooks/refs` was
|
// it. This comment used to say "its rules are also linted"; it was false while `react-hooks/refs` was
|
||||||
// off, and 95 functions sat uncompiled behind it. What actually holds the line now is
|
// off, and 95 functions sat uncompiled behind it. What actually holds the line now is
|
||||||
// src/components/compilerCoverage.contract.test.ts, which runs THIS compiler over the shipped source
|
// src/compilerCoverage.contract.test.ts, which runs THIS compiler over the shipped source
|
||||||
// and fails on any skip not on its list; the react-hooks lint rules are the early warning. None of
|
// and fails on any skip not on its list; the react-hooks lint rules are the early warning. None of
|
||||||
// them is off (three run at the preset's warn, which `--max-warnings 0` fails — docs/gates.md), but
|
// them is off (three run at the preset's warn, which `--max-warnings 0` fails — docs/gates.md), but
|
||||||
// three of the compiler's diagnostics cannot report under the installed plugin (eslint.config.js
|
// three of the compiler's diagnostics cannot report under the installed plugin (eslint.config.js
|
||||||
|
|||||||
@@ -1,499 +0,0 @@
|
|||||||
import { lazy, Suspense, useEffect, useRef, useState } from "react";
|
|
||||||
import { qk } from "./lib/queryKeys";
|
|
||||||
import { saveLanguage } from "./lib/saveLanguage";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
||||||
import { api, setUnauthorizedHandler, type Me } from "./lib/api";
|
|
||||||
import { notifyActionError } from "./lib/actionErrors";
|
|
||||||
import { setLanguage, type LangCode } from "./i18n";
|
|
||||||
import { clearParams } from "./lib/urlState";
|
|
||||||
import { reloadAfterRefusal } from "./lib/entryUrl";
|
|
||||||
import { useAccountSwitchNotice } from "./lib/useAccountSwitchNotice";
|
|
||||||
import {
|
|
||||||
PREF_KEY,
|
|
||||||
saveLayoutLocal,
|
|
||||||
seedLayout,
|
|
||||||
type PanelId,
|
|
||||||
type PanelLayout,
|
|
||||||
} from "./lib/panelLayout";
|
|
||||||
import { notify, removeByMetaKind } from "./lib/notifications";
|
|
||||||
import { LS } from "./lib/storage";
|
|
||||||
import { usePref } from "./lib/usePref";
|
|
||||||
import { useVerifyEntry } from "./lib/useVerifyEntry";
|
|
||||||
import { useNavigation, useNavigationActions } from "./components/navigationContext";
|
|
||||||
import { useFeedFilters, useFeedFiltersActions } from "./components/feedFiltersContext";
|
|
||||||
import { useFeedView, useFeedViewActions } from "./components/feedViewContext";
|
|
||||||
import { moduleOrder } from "./lib/modules";
|
|
||||||
import { PAGE_CONTENT, PAGE_RAIL, preloadPages } from "./components/moduleRegistry";
|
|
||||||
import { lazyWithPreload } from "./lib/lazyWithPreload";
|
|
||||||
import { LoadingState } from "./components/ui/QueryState";
|
|
||||||
// Persistent shell — always mounted, so eagerly imported.
|
|
||||||
import Welcome from "./components/Welcome";
|
|
||||||
import SetupWizard from "./components/SetupWizard";
|
|
||||||
import Header from "./components/Header";
|
|
||||||
import NavSidebar from "./components/NavSidebar";
|
|
||||||
import BackToTop from "./components/ui/BackToTop";
|
|
||||||
import PageShell from "./components/ui/PageShell";
|
|
||||||
import { SkipLink } from "./components/ui/SkipLink";
|
|
||||||
import { pageTitleKey } from "./lib/pageMeta";
|
|
||||||
import DebugRecorder from "./components/DebugRecorder";
|
|
||||||
import Toaster from "./components/ui/Toaster";
|
|
||||||
import KeyboardHelp from "./components/ui/KeyboardHelp";
|
|
||||||
import AppCommandPalette from "./components/AppCommandPalette";
|
|
||||||
import DownloadIntake from "./components/DownloadIntake";
|
|
||||||
import DownloadTray from "./components/DownloadTray";
|
|
||||||
import { useGlobalKeymap } from "./lib/keymap";
|
|
||||||
import ErrorDialog from "./components/ErrorDialog";
|
|
||||||
import VersionBanner from "./components/VersionBanner";
|
|
||||||
import VerifyBanner from "./components/VerifyBanner";
|
|
||||||
import { focusAccessRequestsTab } from "./lib/adminUsersTab";
|
|
||||||
import { focusSettingsTab } from "./lib/settingsTab";
|
|
||||||
// The built app version, NOT imported from releaseNotes — importing anything from that module
|
|
||||||
// pulls the release-notes loader into the eager App chunk and defeats the lazy ReleaseNotes
|
|
||||||
// split. FRONTEND_VERSION equals the newest release note's version by release convention (both bump together).
|
|
||||||
import { FRONTEND_VERSION } from "./lib/version";
|
|
||||||
import { applyDiagnostics } from "./lib/diagnostics";
|
|
||||||
|
|
||||||
// The page modules (and their side rails) are code-split per route via the module registry
|
|
||||||
// (components/moduleRegistry). App keeps only the persistent-shell chunks + the channel page and the
|
|
||||||
// two modals, which sit outside the registry-driven content column.
|
|
||||||
const ChannelPage = lazyWithPreload(() => import("./components/ChannelPage"));
|
|
||||||
const About = lazy(() => import("./components/About"));
|
|
||||||
const ReleaseNotes = lazy(() => import("./components/ReleaseNotes"));
|
|
||||||
|
|
||||||
export default function App() {
|
|
||||||
const { t, i18n } = useTranslation();
|
|
||||||
const { page, channelView, ytSearch } = useNavigation();
|
|
||||||
const { setPage, enterYtSearch } = useNavigationActions();
|
|
||||||
// An emailed verification link is confirmed HERE, above the signed-in/signed-out branch below, so
|
|
||||||
// it works in a browser that already holds a session. It used to be handled inside Welcome, which
|
|
||||||
// that branch never renders when signed in — the token was silently dropped. Signed out, the
|
|
||||||
// outcome goes to the auth card's banners; signed in, to the top-of-app bar.
|
|
||||||
const verifyEntry = useVerifyEntry();
|
|
||||||
// The global feed filters live in FeedFiltersProvider (per-account persistence + the account-load
|
|
||||||
// effect + share-link capture all moved there). App reads them to hand to <Feed> and to the
|
|
||||||
// handlers that mutate the feed view (onShowInFeed, tag-click, the feed scrollKey).
|
|
||||||
const filters = useFeedFilters();
|
|
||||||
const { setFilters } = useFeedFiltersActions();
|
|
||||||
// The feed's view mode (card/row/tile density) lives in FeedViewProvider — server-synced, shared
|
|
||||||
// by the feed page and the channel page. App reads it only to hand to <Feed>/<ChannelPage>.
|
|
||||||
const view = useFeedView();
|
|
||||||
const { setView } = useFeedViewActions();
|
|
||||||
const queryClient = useQueryClient();
|
|
||||||
// Per-panel "customize" layouts (order/collapsed/hidden of each side panel's group cards),
|
|
||||||
// persisted per-account. One record so feed/plex/playlists share the same mechanism. Seeded from
|
|
||||||
// the account's server preferences — BootGate mounts App only once `me` is cached — so a layout
|
|
||||||
// changed on another device is right on the first paint; the local copy covers a key the server
|
|
||||||
// has never stored.
|
|
||||||
const [panelLayouts, setPanelLayouts] = useState<Record<PanelId, PanelLayout>>(() => {
|
|
||||||
const prefs = queryClient.getQueryData<Me | null>(qk.me())?.preferences;
|
|
||||||
return {
|
|
||||||
feed: seedLayout("feed", prefs),
|
|
||||||
plex: seedLayout("plex", prefs),
|
|
||||||
playlists: seedLayout("playlists", prefs),
|
|
||||||
files: seedLayout("files", prefs),
|
|
||||||
};
|
|
||||||
});
|
|
||||||
const [aboutOpen, setAboutOpen] = useState(false);
|
|
||||||
const [notesOpen, setNotesOpen] = useState(false);
|
|
||||||
const [notesHighlight, setNotesHighlight] = useState<string | undefined>(undefined);
|
|
||||||
|
|
||||||
function openReleaseNotes(highlight?: string) {
|
|
||||||
setNotesHighlight(highlight);
|
|
||||||
setNotesOpen(true);
|
|
||||||
}
|
|
||||||
|
|
||||||
function setPanelLayout(panel: PanelId, next: PanelLayout) {
|
|
||||||
setPanelLayouts((prev) => ({ ...prev, [panel]: next }));
|
|
||||||
saveLayoutLocal(panel, next);
|
|
||||||
api
|
|
||||||
.savePrefs({ [PREF_KEY[panel]]: next })
|
|
||||||
.catch((e: unknown) => notifyActionError(e, t("errors.writeFailed.prefs")));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Collapse state for the two full-height panels (left nav + filter sidebar). Persisted to the
|
|
||||||
// user's preferences (via usePref) so it follows the account across devices, seeded from the `me`
|
|
||||||
// cache at mount; the localStorage mirror covers an account whose server prefs never stored it.
|
|
||||||
// Default unpinned (tab + hover-expand overlay); accounts that explicitly pin keep their choice.
|
|
||||||
const [filterCollapsed, setFilterCollapsed] = usePref<boolean>("filterCollapsed", true, {
|
|
||||||
localKey: LS.filterCollapsed,
|
|
||||||
parse: Boolean,
|
|
||||||
});
|
|
||||||
// The Playlists rail has its own collapse flag (it's a list/navigator, not filters).
|
|
||||||
const [playlistsCollapsed, setPlaylistsCollapsed] = usePref<boolean>("playlistsCollapsed", true, {
|
|
||||||
localKey: LS.playlistsCollapsed,
|
|
||||||
parse: Boolean,
|
|
||||||
});
|
|
||||||
// The Files rail is the library's folder list — how you move around it, not a filter you set once —
|
|
||||||
// so it starts pinned open (the owner's mockup), and has its own flag.
|
|
||||||
const [filesCollapsed, setFilesCollapsed] = usePref<boolean>("filesCollapsed", false, {
|
|
||||||
localKey: LS.filesCollapsed,
|
|
||||||
parse: Boolean,
|
|
||||||
});
|
|
||||||
const railCollapse = {
|
|
||||||
filter: [filterCollapsed, setFilterCollapsed],
|
|
||||||
playlists: [playlistsCollapsed, setPlaylistsCollapsed],
|
|
||||||
files: [filesCollapsed, setFilesCollapsed],
|
|
||||||
} as const;
|
|
||||||
// (The feed filters + page/channel now LIVE in the address bar — FeedFiltersProvider and
|
|
||||||
// NavigationProvider read them on load and keep them mirrored — so there's no query to strip here.)
|
|
||||||
|
|
||||||
// Passive observers (enabled:false) of the setup-status and `me` queries BootGate fetches, polls
|
|
||||||
// and waits for: App mounts only once both have answered, so their caches are always populated here.
|
|
||||||
const setupQuery = useQuery({
|
|
||||||
queryKey: qk.setupStatus(),
|
|
||||||
queryFn: api.setupStatus,
|
|
||||||
enabled: false,
|
|
||||||
});
|
|
||||||
const needsSetup = setupQuery.data?.configured === false;
|
|
||||||
const meQuery = useQuery({ queryKey: qk.me(), queryFn: api.me, enabled: false });
|
|
||||||
// The signed-in account, or null/undefined while signed out. Hoisted here because half a dozen
|
|
||||||
// effects below depend on it, and a dep list is only honest if it names the value the body reads.
|
|
||||||
const me = meQuery.data;
|
|
||||||
|
|
||||||
// The `global` key scope: `h` opens the cheat sheet, Mod+K the jump-to palette, Shift+W jumps to
|
|
||||||
// Home ("mit nézzek?"). Bound only once signed in, because that is where the overlays are mounted
|
|
||||||
// (bottom of this tree) and where the modules exist at all — see useGlobalKeymap on why binding it
|
|
||||||
// above the signed-out branch is worse than not binding it.
|
|
||||||
// A global key can fire while an overlay is open; `setPage` closes every overlay before it pushes,
|
|
||||||
// so Shift+W over the player lands on Home with the player closed — also when Home is already the
|
|
||||||
// page underneath (owner decision 2026-09-09: the key always ends with the answer on screen).
|
|
||||||
// The `page` scope rides the same binding: the `g` chords jump to a module, but only to one this
|
|
||||||
// account can reach (the nav rail's own list, plus Settings, which is not in the rail's order).
|
|
||||||
useGlobalKeymap(!!me, setPage, me ? [...moduleOrder(me), "settings"] : []);
|
|
||||||
|
|
||||||
// The account's consented diagnostics level, applied to this tab's log: raised, the log is
|
|
||||||
// kept in more detail and streamed to the server until the level lapses. A debug session that is
|
|
||||||
// recording raises it for its own run and tags what it keeps. A lapse (its own timer, or the
|
|
||||||
// server saying it is off) refetches `me`, so Settings and the recorder show what is in force.
|
|
||||||
// Re-running this on every `me` refetch is FREE and therefore the deps are the honest ones:
|
|
||||||
// applyDiagnostics computes an effective-level key and returns early when it has not moved
|
|
||||||
// (lib/diagnostics.ts), so an unchanged level is a no-op. It does refresh the `lapsed` callback
|
|
||||||
// before that early return, which is the half we WANT re-run. The old dep list picked out four
|
|
||||||
// primitive fields to dodge the fresh object identity a refetch produces; that is the checker's
|
|
||||||
// job to verify, not a comment's to assert, and it silently missed any other field the module
|
|
||||||
// may come to read.
|
|
||||||
const diagnostics = me?.diagnostics;
|
|
||||||
const debugSession = me?.debug_session;
|
|
||||||
useEffect(() => {
|
|
||||||
applyDiagnostics(
|
|
||||||
diagnostics,
|
|
||||||
debugSession,
|
|
||||||
() => void queryClient.invalidateQueries({ queryKey: qk.me() }),
|
|
||||||
);
|
|
||||||
}, [diagnostics, debugSession, queryClient]);
|
|
||||||
|
|
||||||
// Fetch the code of every module this account can open — and the channel page — once the first page
|
|
||||||
// is on screen, so a module's first visit renders at once instead of flashing the chunk fallback.
|
|
||||||
// Idle-scheduled so it never competes with the first page's own requests (Safari has no
|
|
||||||
// requestIdleCallback, hence the timer).
|
|
||||||
// Depending on the whole `me` rather than its id is both honest and MORE correct: the module set
|
|
||||||
// is derived from `me` through each module's own gate (lib/modules moduleOrder), so a permission
|
|
||||||
// change — an admin grant, a Plex server appearing — alters it without the id moving, and the old
|
|
||||||
// `[me.id]` dep never re-preloaded for those. Re-running costs nothing: `preload()` is idempotent
|
|
||||||
// (lazyWithPreload memoises the pending promise), so a refetch that changes nothing fetches
|
|
||||||
// nothing; only the idle callback is re-scheduled.
|
|
||||||
useEffect(() => {
|
|
||||||
if (!me) return;
|
|
||||||
const run = () => {
|
|
||||||
preloadPages([...moduleOrder(me), "settings"]);
|
|
||||||
ChannelPage.preload().catch(() => {});
|
|
||||||
};
|
|
||||||
const idle = window.requestIdleCallback as typeof window.requestIdleCallback | undefined;
|
|
||||||
if (idle) {
|
|
||||||
const id = idle(run, { timeout: 3000 });
|
|
||||||
return () => window.cancelIdleCallback(id);
|
|
||||||
}
|
|
||||||
const id = window.setTimeout(run, 1000);
|
|
||||||
return () => window.clearTimeout(id);
|
|
||||||
}, [me]);
|
|
||||||
|
|
||||||
// Measure the scrollbar's gutter width once and publish it as --sbw, so the floating header and the
|
|
||||||
// toolbar band can pad their right edge to line up with the scrolled cards (the app scroller reserves
|
|
||||||
// that gutter via scrollbar-gutter: stable). It's a device constant, so a one-shot mount measure is enough.
|
|
||||||
useEffect(() => {
|
|
||||||
const probe = document.createElement("div");
|
|
||||||
probe.style.cssText =
|
|
||||||
"position:absolute;top:-9999px;width:100px;height:100px;overflow:scroll;scrollbar-gutter:stable";
|
|
||||||
document.body.appendChild(probe);
|
|
||||||
const w = probe.offsetWidth - probe.clientWidth;
|
|
||||||
document.body.removeChild(probe);
|
|
||||||
document.documentElement.style.setProperty("--sbw", `${w}px`);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Once signed in, drop any leftover ONE-TIME query params from the pre-login flow (?access, ?verify,
|
|
||||||
// ?reset) — but NOT the nav/filter params, which the logged-in app now reads from and keeps in the
|
|
||||||
// URL. Gated on auth so the logged-out Welcome page still reads its params first.
|
|
||||||
// Keyed on "is there a session at all", which is the whole condition the body tests. Re-running
|
|
||||||
// on an account switch was never the point — by then the params are already gone and a second
|
|
||||||
// clearParams is a no-op.
|
|
||||||
const signedIn = !!me;
|
|
||||||
useEffect(() => {
|
|
||||||
if (signedIn) clearParams("access", "verify", "reset");
|
|
||||||
}, [signedIn]);
|
|
||||||
|
|
||||||
// A sign-in as someone else that the server refused, or a reload as another account: say so, once.
|
|
||||||
useAccountSwitchNotice(signedIn);
|
|
||||||
|
|
||||||
// Any request that 401s means the session ended server-side. If we were signed in (cached
|
|
||||||
// `me` is a user object), reload to the login page at once (also covers any click that hits
|
|
||||||
// the API). Guarded on a cached user so a stray 401 while logged out (cached `me` is null)
|
|
||||||
// can't loop the public landing. A tab showing another account than the one signed in now
|
|
||||||
// reloads as that one instead, and says why (`reloadAfterRefusal`).
|
|
||||||
useEffect(() => {
|
|
||||||
setUnauthorizedHandler((err) => {
|
|
||||||
if (!queryClient.getQueryData(qk.me())) return;
|
|
||||||
queryClient.clear();
|
|
||||||
reloadAfterRefusal(err);
|
|
||||||
});
|
|
||||||
return () => setUnauthorizedHandler(null);
|
|
||||||
}, [queryClient]);
|
|
||||||
|
|
||||||
// Returning from an in-app Google link/upgrade round-trip (auth.py redirects to ?link=…).
|
|
||||||
// Surface the outcome, refresh `me` (can_read/can_write/has_google may have flipped), land on
|
|
||||||
// Settings → Account so the new state is visible, then strip the param for a clean URL.
|
|
||||||
// "Exactly once per mount" is now ENFORCED by the ref rather than asserted by an empty dep list:
|
|
||||||
// `t` changes identity on a language switch, so the honest dep list would otherwise re-fire the
|
|
||||||
// toast when the user changes language. (The old `[]` got away with it only because clearParams
|
|
||||||
// had already removed the param — an invariant held by a side effect two lines further down.)
|
|
||||||
const linkAnswered = useRef(false);
|
|
||||||
const { refetch: refetchMe } = meQuery;
|
|
||||||
useEffect(() => {
|
|
||||||
if (linkAnswered.current) return;
|
|
||||||
const result = new URLSearchParams(window.location.search).get("link");
|
|
||||||
if (!result) return;
|
|
||||||
linkAnswered.current = true;
|
|
||||||
if (result === "ok") {
|
|
||||||
notify({ level: "success", message: t("settings.account.googleLink.linked") });
|
|
||||||
void refetchMe();
|
|
||||||
focusSettingsTab("account");
|
|
||||||
setPage("settings");
|
|
||||||
} else {
|
|
||||||
const key = result === "conflict" || result === "mismatch" ? result : "error";
|
|
||||||
notify({ level: "error", message: t(`settings.account.googleLink.${key}`) });
|
|
||||||
}
|
|
||||||
clearParams("link");
|
|
||||||
}, [t, setPage, refetchMe]);
|
|
||||||
|
|
||||||
// A Google OAuth flow started while already signed in (signing in as someone else, or a Google
|
|
||||||
// connect) was cancelled at Google's consent screen. The backend only emits this for a live session, so a
|
|
||||||
// neutral toast is the right feedback — the pre-auth login banner would never render here.
|
|
||||||
// Same one-shot shape as the `?link=` handler above, same reason for the ref.
|
|
||||||
const oauthAnswered = useRef(false);
|
|
||||||
useEffect(() => {
|
|
||||||
if (oauthAnswered.current) return;
|
|
||||||
if (new URLSearchParams(window.location.search).get("oauth") !== "cancelled") return;
|
|
||||||
oauthAnswered.current = true;
|
|
||||||
notify({ level: "info", message: t("settings.account.googleCancelled") });
|
|
||||||
clearParams("oauth");
|
|
||||||
}, [t]);
|
|
||||||
|
|
||||||
// Deep-link from the "new access request" admin email (?admin=access-requests) → jump straight to
|
|
||||||
// the admin Users → Access requests view. Snapshot the param up front (read once at mount); act once
|
|
||||||
// `me` is known, and only for an admin (others just land on their default page).
|
|
||||||
const [adminDeepLink] = useState(() => new URLSearchParams(window.location.search).get("admin"));
|
|
||||||
// Ref-guarded like the two above, and here it closes a real hole rather than only satisfying the
|
|
||||||
// checker: the snapshot lives for the whole session, so with honest deps a later role change —
|
|
||||||
// or any `me` refetch, under the old id-only key — would yank the user to Users long after the
|
|
||||||
// deep link was answered.
|
|
||||||
const adminDeepLinkAnswered = useRef(false);
|
|
||||||
useEffect(() => {
|
|
||||||
if (adminDeepLinkAnswered.current) return;
|
|
||||||
if (adminDeepLink !== "access-requests" || !me) return;
|
|
||||||
adminDeepLinkAnswered.current = true;
|
|
||||||
if (me.role === "admin") {
|
|
||||||
focusAccessRequestsTab();
|
|
||||||
setPage("users");
|
|
||||||
}
|
|
||||||
clearParams("admin");
|
|
||||||
}, [adminDeepLink, me, setPage]);
|
|
||||||
|
|
||||||
// Nudge admins when access requests are waiting (in lieu of a server-push bell). Keyed on the
|
|
||||||
// pending COUNT so it re-resolves the moment an approval/denial changes it — not only on a
|
|
||||||
// fresh load (which is why the notice used to linger until F5 after an approval).
|
|
||||||
// Honest deps, and `t` among them is a fix rather than a concession: the nudge is a persisted
|
|
||||||
// toast whose text is translated at issue time, so a language switch must re-issue it. It did not
|
|
||||||
// before — the notice stayed in the old language until the count changed.
|
|
||||||
const role = me?.role;
|
|
||||||
const pendingInvites = me?.pending_invites ?? 0;
|
|
||||||
const pendingActivations = me?.pending_activations ?? 0;
|
|
||||||
useEffect(() => {
|
|
||||||
if (role !== "admin") return;
|
|
||||||
// Keep a single, current nudge: drop any prior one (persisted copies reload as dismissed
|
|
||||||
// history, so they'd otherwise pile up one-per-reload) before re-issuing the live notice.
|
|
||||||
removeByMetaKind("access-requests");
|
|
||||||
// Both kinds of "someone is waiting on you": a request needing a decision, and a sign-up whose
|
|
||||||
// address was approved before they registered. The second used to produce NO signal at all —
|
|
||||||
// nothing lands in the queue for it — which is how the 2026-09-03 registrant went unnoticed.
|
|
||||||
const pending = pendingInvites + pendingActivations;
|
|
||||||
if (pending > 0) {
|
|
||||||
notify({
|
|
||||||
level: "warning",
|
|
||||||
title: t("common.accessRequestsTitle"),
|
|
||||||
message: t("common.accessRequestsMessage", { count: pending }),
|
|
||||||
// Durable inbox link (survives reload, unlike a live action callback); the toast also
|
|
||||||
// gets a click via the action.
|
|
||||||
meta: { kind: "access-requests" },
|
|
||||||
action: {
|
|
||||||
label: t("common.accessRequestsReview"),
|
|
||||||
onClick: () => {
|
|
||||||
focusAccessRequestsTab();
|
|
||||||
// Clicked inside whatever overlay hosts the toast; setPage closes it before pushing.
|
|
||||||
setPage("users");
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}, [role, pendingInvites, pendingActivations, t, setPage]);
|
|
||||||
|
|
||||||
// Language is edited outside the Settings page (sidebar/login), so it stays instant.
|
|
||||||
function changeLanguage(code: LangCode) {
|
|
||||||
setLanguage(code);
|
|
||||||
// Also from the signed-out login page, where the save 401s — `req` marks that as handled, so
|
|
||||||
// the reporter stays quiet there.
|
|
||||||
saveLanguage(queryClient, code).catch((e: unknown) =>
|
|
||||||
notifyActionError(e, t("errors.writeFailed.prefs")),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// First-run: a fresh instance is in setup mode (all other routes are locked) → show the wizard.
|
|
||||||
if (needsSetup) return <SetupWizard />;
|
|
||||||
// /api/me answers 200 always (null body = logged out), and BootGate has already handled a failed
|
|
||||||
// probe — so no data here means "not signed in" → the public landing.
|
|
||||||
if (!me) return <Welcome verify={verifyEntry} />;
|
|
||||||
|
|
||||||
// Shown while a page's chunk is still on its way: only if its visit beats the preload above (or the
|
|
||||||
// preload failed), and like every loading state it stays invisible for a load that is quick anyway.
|
|
||||||
const pageFallback = <LoadingState className="flex-1 justify-center" />;
|
|
||||||
|
|
||||||
// Scroll restore/reset key for the shared normal-page scroller (see PageScroller): the same
|
|
||||||
// page/feed-filters returns you to where you were (bug 1); a changed feed filter is a new key, so
|
|
||||||
// the fresh result set starts at the top (bug 3). `q` is normalised out so typing in the search
|
|
||||||
// box doesn't reset the scroll on every keystroke; the live-YouTube sub-view gets its own key.
|
|
||||||
const normalScrollKey =
|
|
||||||
page !== "feed"
|
|
||||||
? `page:${page}`
|
|
||||||
: ytSearch != null
|
|
||||||
? `yt:${ytSearch}`
|
|
||||||
: `feed:${JSON.stringify({ ...filters, q: "" })}`;
|
|
||||||
|
|
||||||
// The module registry drives the render from `page` alone — no per-page chrome conditionals. A page
|
|
||||||
// the account can't reach never gets here: the navigation state already moved it to the feed
|
|
||||||
// (`reachablePage`). A channel page overlays the content column, so no side rail shows behind it.
|
|
||||||
const { Component: PageContent, maxWidth } = PAGE_CONTENT[page];
|
|
||||||
const railDef = channelView ? undefined : PAGE_RAIL[page];
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="h-dvh flex text-fg">
|
|
||||||
<SkipLink label={t("nav.skipToContent")} />
|
|
||||||
<NavSidebar
|
|
||||||
me={me!}
|
|
||||||
onOpenAbout={() => setAboutOpen(true)}
|
|
||||||
onChangeLanguage={changeLanguage}
|
|
||||||
language={i18n.language as LangCode}
|
|
||||||
/>
|
|
||||||
{/* The active page's side rail (feed filters / plex filters / playlists list), if it has one.
|
|
||||||
A floating collapsible column beside the nav rail; hidden while a channel page overlays the
|
|
||||||
content. feed + plex share the filter-rail collapse flag, playlists and files have their own. */}
|
|
||||||
{railDef && (
|
|
||||||
<Suspense fallback={null}>
|
|
||||||
<railDef.Rail
|
|
||||||
layout={panelLayouts[railDef.panelId]}
|
|
||||||
setLayout={(l) => setPanelLayout(railDef.panelId, l)}
|
|
||||||
collapsed={railCollapse[railDef.collapse][0]}
|
|
||||||
onToggleCollapse={() => {
|
|
||||||
const [collapsed, setCollapsed] = railCollapse[railDef.collapse];
|
|
||||||
setCollapsed(!collapsed);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Suspense>
|
|
||||||
)}
|
|
||||||
{/* `@container/appmain`: the floating header + its search box adapt to THIS column's width
|
|
||||||
(which shrinks when the filter sidebar is open), not the viewport — a viewport breakpoint
|
|
||||||
can't see the sidebar-driven narrowing. NB: `container-type: inline-size` implies
|
|
||||||
`contain: layout`, making this column a containing block for `position: fixed` descendants
|
|
||||||
too — safe today because floating-ui menus render via FloatingPortal (to document.body) and
|
|
||||||
the toasts below are absolute; keep any new fixed overlay inside here body-portalled. */}
|
|
||||||
<div className="@container/appmain relative flex-1 min-w-0 flex flex-col">
|
|
||||||
{channelView ? (
|
|
||||||
<PageShell scrollKey={`channel:${channelView.id}`}>
|
|
||||||
<Suspense fallback={pageFallback}>
|
|
||||||
<ChannelPage
|
|
||||||
key={channelView.id}
|
|
||||||
channelId={channelView.id}
|
|
||||||
initialName={channelView.name}
|
|
||||||
me={me!}
|
|
||||||
view={view}
|
|
||||||
setView={setView}
|
|
||||||
onShowInFeed={() => {
|
|
||||||
// Keep the rest of the feed's filters — the point is "this channel, the way I
|
|
||||||
// normally read": unwatched, my tags, my date window.
|
|
||||||
// ADD to the picked set, don't replace it: channels OR together now, so arriving
|
|
||||||
// from a second channel page should widen the feed, not silently drop the first.
|
|
||||||
// Deduped — clicking it again for a channel already picked is a no-op.
|
|
||||||
setFilters({
|
|
||||||
...filters,
|
|
||||||
channelIds: [...new Set([...filters.channelIds, channelView.id])],
|
|
||||||
});
|
|
||||||
// setPage already leaves an open channel page and pushes a clean history entry.
|
|
||||||
// Calling closeChannel() first ran history.back(), whose popstate landed AFTER this
|
|
||||||
// and restored whatever page the channel had been opened from.
|
|
||||||
setPage("feed");
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</Suspense>
|
|
||||||
</PageShell>
|
|
||||||
) : (
|
|
||||||
<PageShell
|
|
||||||
title={t(pageTitleKey(page))}
|
|
||||||
scrollKey={normalScrollKey}
|
|
||||||
maxWidth={maxWidth}
|
|
||||||
header={<Header me={me!} onYtSearch={enterYtSearch} />}
|
|
||||||
banners={
|
|
||||||
<>
|
|
||||||
<VerifyBanner entry={verifyEntry} />
|
|
||||||
<VersionBanner onOpen={() => openReleaseNotes(FRONTEND_VERSION)} />
|
|
||||||
</>
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<Suspense fallback={pageFallback}>
|
|
||||||
<PageContent />
|
|
||||||
</Suspense>
|
|
||||||
</PageShell>
|
|
||||||
)}
|
|
||||||
{/* Toasts rise from the bottom-left, by the notification bell in the nav rail.
|
|
||||||
Anchored inside the content column so they clear the sidebar automatically
|
|
||||||
(collapsed or expanded) without tracking its width. */}
|
|
||||||
<Toaster />
|
|
||||||
{/* The running downloads, bottom-right in the same column (the toasts keep the left). */}
|
|
||||||
<DownloadTray />
|
|
||||||
</div>
|
|
||||||
{/* The two app-wide overlays: the keyboard cheat sheet (`h`) and the jump-to palette (Mod+K).
|
|
||||||
Mounted once, outside the page area, so every surface answers "what can I press here" and
|
|
||||||
"take me to X" the same way. Their keys are bound by useGlobalKeymap above. */}
|
|
||||||
<KeyboardHelp />
|
|
||||||
<AppCommandPalette />
|
|
||||||
{/* The one download dialog: the Files link box, a link pasted onto any page, the palette. */}
|
|
||||||
<DownloadIntake />
|
|
||||||
{/* The debug recorder: its start dialog, and its pill docked under the toasts. */}
|
|
||||||
{me && <DebugRecorder />}
|
|
||||||
<Suspense fallback={null}>
|
|
||||||
{aboutOpen && (
|
|
||||||
<About
|
|
||||||
onClose={() => setAboutOpen(false)}
|
|
||||||
onOpenReleaseNotes={() => {
|
|
||||||
setAboutOpen(false);
|
|
||||||
openReleaseNotes();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{notesOpen && (
|
|
||||||
<ReleaseNotes onClose={() => setNotesOpen(false)} highlight={notesHighlight} />
|
|
||||||
)}
|
|
||||||
</Suspense>
|
|
||||||
<ErrorDialog />
|
|
||||||
{/* Re-binds to the active page's scroll container on navigation (page or channel change). */}
|
|
||||||
<BackToTop dep={channelView ? `chan:${channelView.id}` : page} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
+8
-8
@@ -20,20 +20,20 @@ vi.mock("react-i18next", async (importOriginal) => {
|
|||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
vi.mock("./confirmContext", () => ({ useConfirm: () => async () => true }));
|
vi.mock("./components/ui/confirmContext", () => ({ useConfirm: () => async () => true }));
|
||||||
const { notify } = vi.hoisted(() => ({
|
const { notify } = vi.hoisted(() => ({
|
||||||
notify: vi.fn((_n: { level: string; message: string }) => 0),
|
notify: vi.fn((_n: { level: string; message: string }) => 0),
|
||||||
}));
|
}));
|
||||||
vi.mock("../lib/notifications", async (importOriginal) => ({
|
vi.mock("./lib/notifications", async (importOriginal) => ({
|
||||||
...(await importOriginal<typeof import("../lib/notifications")>()),
|
...(await importOriginal<typeof import("./lib/notifications")>()),
|
||||||
notify,
|
notify,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import PlexPlaylistView from "./PlexPlaylistView";
|
import PlexPlaylistView from "./modules/plex/PlexPlaylistView";
|
||||||
import AddToPlaylist, { AddToPlaylistAt } from "./AddToPlaylist";
|
import AddToPlaylist, { AddToPlaylistAt } from "./features/video/AddToPlaylist";
|
||||||
import PlexPlaylistAdd from "./PlexPlaylistAdd";
|
import PlexPlaylistAdd from "./features/plexCards/PlexPlaylistAdd";
|
||||||
import TagManager from "./TagManager";
|
import TagManager from "./features/channel/TagManager";
|
||||||
import { api, HttpError, type PlexCard } from "../lib/api";
|
import { api, HttpError, type PlexCard } from "./lib/api";
|
||||||
|
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
class Stub {
|
class Stub {
|
||||||
+7
-7
@@ -17,19 +17,19 @@ vi.mock("react-i18next", async (importOriginal) => {
|
|||||||
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
|
useTranslation: () => ({ t: (key: string) => key, i18n: { language: "en" } }),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
vi.mock("./confirmContext", () => ({ useConfirm: () => async () => true }));
|
vi.mock("./components/ui/confirmContext", () => ({ useConfirm: () => async () => true }));
|
||||||
const { notify } = vi.hoisted(() => ({
|
const { notify } = vi.hoisted(() => ({
|
||||||
notify: vi.fn((_n: { level: string; title?: string; message: string }) => 0),
|
notify: vi.fn((_n: { level: string; title?: string; message: string }) => 0),
|
||||||
}));
|
}));
|
||||||
vi.mock("../lib/notifications", async (importOriginal) => ({
|
vi.mock("./lib/notifications", async (importOriginal) => ({
|
||||||
...(await importOriginal<typeof import("../lib/notifications")>()),
|
...(await importOriginal<typeof import("./lib/notifications")>()),
|
||||||
notify,
|
notify,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import PlexCollectionEditor from "./PlexCollectionEditor";
|
import PlexCollectionEditor from "./modules/plex/PlexCollectionEditor";
|
||||||
import UploadDialog from "./UploadDialog";
|
import UploadDialog from "./modules/files/UploadDialog";
|
||||||
import DownloadDialog from "./DownloadDialog";
|
import DownloadDialog from "./features/downloads/DownloadDialog";
|
||||||
import { api, HttpError } from "../lib/api";
|
import { api, HttpError } from "./lib/api";
|
||||||
|
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
class Stub {
|
class Stub {
|
||||||
+3
-3
@@ -26,15 +26,15 @@ const KNOWN_SKIPS: CompileReport["skipped"] = {
|
|||||||
// The files a regression hurt most: compiled, and they must stay so (a positive check too, so a
|
// The files a regression hurt most: compiled, and they must stay so (a positive check too, so a
|
||||||
// harness that compiled nothing could not pass as "no skips").
|
// harness that compiled nothing could not pass as "no skips").
|
||||||
const MUST_COMPILE: Record<string, string[]> = {
|
const MUST_COMPILE: Record<string, string[]> = {
|
||||||
"components/Channels.tsx": ["Channels"],
|
"modules/channels/Channels.tsx": ["Channels"],
|
||||||
"components/ui/DataTable.tsx": ["DataTable"],
|
"components/ui/DataTable.tsx": ["DataTable"],
|
||||||
// Under every button and every popover in the app.
|
// Under every button and every popover in the app.
|
||||||
"components/ui/FocusSafeButton.tsx": ["FocusSafeButton"],
|
"components/ui/FocusSafeButton.tsx": ["FocusSafeButton"],
|
||||||
"components/ui/anchoredPopoverHooks.ts": ["useAnchoredPopover"],
|
"components/ui/anchoredPopoverHooks.ts": ["useAnchoredPopover"],
|
||||||
"components/ui/AnchoredPopover.tsx": ["PopoverSurfaceInner"],
|
"components/ui/AnchoredPopover.tsx": ["PopoverSurfaceInner"],
|
||||||
// The two players — the largest components here, and the last to compile.
|
// The two players — the largest components here, and the last to compile.
|
||||||
"components/PlexPlayer.tsx": ["PlexPlayer"],
|
"modules/plex/PlexPlayer.tsx": ["PlexPlayer"],
|
||||||
"components/PlayerModal.tsx": ["PlayerModal"],
|
"features/player/PlayerModal.tsx": ["PlayerModal"],
|
||||||
"lib/playerModalHooks.ts": ["useYtProgress"],
|
"lib/playerModalHooks.ts": ["useYtProgress"],
|
||||||
};
|
};
|
||||||
|
|
||||||
+1
-1
@@ -7,7 +7,7 @@ import reactCompiler from "babel-plugin-react-compiler";
|
|||||||
// word (`panicThreshold` is "none" and the build has no logger), so this is the only place a skip is
|
// word (`panicThreshold` is "none" and the build has no logger), so this is the only place a skip is
|
||||||
// visible at all. Used by compilerCoverage.contract.test.ts.
|
// visible at all. Used by compilerCoverage.contract.test.ts.
|
||||||
|
|
||||||
const SRC = new URL("../", import.meta.url);
|
const SRC = new URL("./", import.meta.url);
|
||||||
|
|
||||||
/** Every file the production build compiles: src, minus tests and test kits. */
|
/** Every file the production build compiles: src, minus tests and test kits. */
|
||||||
export function shippedSources(): string[] {
|
export function shippedSources(): string[] {
|
||||||
@@ -1,137 +0,0 @@
|
|||||||
import { forwardRef, type ReactNode } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { YouTubeIcon } from "../lib/brandIcons";
|
|
||||||
import type { Me } from "../lib/api";
|
|
||||||
import SearchBar from "./SearchBar";
|
|
||||||
import { useFiles, useFilesActions } from "./filesContext";
|
|
||||||
import PlexSearchBox from "./PlexSearchBox";
|
|
||||||
import PlexScopeToggle from "./PlexScopeToggle";
|
|
||||||
import PlexViewToggle from "./PlexViewToggle";
|
|
||||||
import FeedScopeToggle from "./FeedScopeToggle";
|
|
||||||
import SyncStatus from "./SyncStatus";
|
|
||||||
import { useNavigation } from "./navigationContext";
|
|
||||||
import { useFeedFilters, useFeedFiltersActions } from "./feedFiltersContext";
|
|
||||||
import { usePlaylists, usePlaylistsActions } from "./playlistsContext";
|
|
||||||
import { useChannels, useChannelsActions } from "./channelsContext";
|
|
||||||
import { PaletteLauncher } from "./PaletteLauncher";
|
|
||||||
|
|
||||||
// The floating top header: a row of glass pills over the content — the module's SearchBar where it
|
|
||||||
// has one, and the per-user sync chip at the right end.
|
|
||||||
// `forwardRef` so PageShell can attach a ref to the header's root box and measure its real (possibly
|
|
||||||
// wrapped) height — that measurement + the `--hdr-h` content pad it drives live in PageShell, which
|
|
||||||
// owns both the header slot and the padded content wrapper.
|
|
||||||
const Header = forwardRef<
|
|
||||||
HTMLDivElement,
|
|
||||||
{
|
|
||||||
me: Me;
|
|
||||||
// Trigger a live YouTube search for the current term (feed only).
|
|
||||||
onYtSearch: (q: string) => void;
|
|
||||||
}
|
|
||||||
>(function Header({ me, onYtSearch }, ref) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const { page } = useNavigation();
|
|
||||||
const filters = useFeedFilters();
|
|
||||||
const { setFilters } = useFeedFiltersActions();
|
|
||||||
// Each module's search term lives in its own provider (shared with that module's rail/content):
|
|
||||||
// playlists name filter, Plex search term, channels name filter. The channel sync chip's
|
|
||||||
// "go to full history" jump is a ChannelsProvider action.
|
|
||||||
const { search: playlistsSearch } = usePlaylists();
|
|
||||||
const { setSearch: setPlaylistsSearch } = usePlaylistsActions();
|
|
||||||
const { search: channelsSearch } = useChannels();
|
|
||||||
const { search: filesSearch } = useFiles();
|
|
||||||
const { setSearch: setFilesSearch } = useFilesActions();
|
|
||||||
const { setSearch: setChannelsSearch, goToFullHistory: onGoToFullHistory } = useChannelsActions();
|
|
||||||
// Drop input focus on Go/Enter for the live-filter modules (feed's Go escalates to YouTube).
|
|
||||||
const blur = () => (document.activeElement as HTMLElement | null)?.blur?.();
|
|
||||||
|
|
||||||
// Per-page SearchBar wiring — null for modules without a search.
|
|
||||||
// A page without a search field still gets the palette's entry (PaletteLauncher).
|
|
||||||
let search: ReactNode = <PaletteLauncher />;
|
|
||||||
if (page === "feed") {
|
|
||||||
const trimmed = filters.q.trim();
|
|
||||||
search = (
|
|
||||||
<SearchBar
|
|
||||||
value={filters.q}
|
|
||||||
placeholder={t("header.searchPlaceholder")}
|
|
||||||
onChange={(q) => {
|
|
||||||
// When a search first appears, rank the feed by relevance — set atomically with the
|
|
||||||
// query (race-free vs. per-keystroke updates). Only overrides the default "newest"
|
|
||||||
// sort; a custom sort the user chose is left alone. Cleared in Feed.
|
|
||||||
const startSearch = !filters.q.trim() && !!q.trim() && filters.sort === "newest";
|
|
||||||
setFilters({ ...filters, q, ...(startSearch ? { sort: "relevance" } : {}) });
|
|
||||||
}}
|
|
||||||
onGo={() => onYtSearch(trimmed)}
|
|
||||||
goLabel={t("header.searchYoutube")}
|
|
||||||
goTitle={t(
|
|
||||||
me.search_source === "api" ? "header.searchYoutubeTipApi" : "header.searchYoutubeTip",
|
|
||||||
)}
|
|
||||||
goIcon={<YouTubeIcon className="w-4 h-4" />}
|
|
||||||
goDisabled={!trimmed}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
} else if (page === "plex" && me.plex_enabled) {
|
|
||||||
search = <PlexSearchBox />;
|
|
||||||
} else if (page === "channels") {
|
|
||||||
search = (
|
|
||||||
<SearchBar
|
|
||||||
value={channelsSearch}
|
|
||||||
placeholder={t("channels.searchPlaceholder")}
|
|
||||||
onChange={setChannelsSearch}
|
|
||||||
onGo={blur}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
} else if (page === "files") {
|
|
||||||
search = (
|
|
||||||
<SearchBar
|
|
||||||
value={filesSearch}
|
|
||||||
placeholder={t("files.searchPlaceholder")}
|
|
||||||
onChange={setFilesSearch}
|
|
||||||
onGo={blur}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
} else if (page === "playlists") {
|
|
||||||
search = (
|
|
||||||
<SearchBar
|
|
||||||
value={playlistsSearch}
|
|
||||||
placeholder={t("playlists.searchPlaceholder")}
|
|
||||||
onChange={setPlaylistsSearch}
|
|
||||||
onGo={blur}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
// Absolutely positioned within the (relative) content column, so its left edge tracks the
|
|
||||||
// actual left zone: it sits just inside the content, reclaiming the space when the nav rail is
|
|
||||||
// slim and/or no filter sidebar is present — no hard-coded offset.
|
|
||||||
<div
|
|
||||||
ref={ref}
|
|
||||||
// left-4 / right = 1rem + the scrollbar gutter defines the full content column; max-w-[--content-mw]
|
|
||||||
// + mx-auto then centres the header band on the SAME width the page's content uses, so on a max-width
|
|
||||||
// page (Plex/Channels/Playlists) the band lines up with the centred grid, not the whole column. `none`
|
|
||||||
// (feed) = full width. Matches the toolbar band below it. `justify-center` then centres the search-box
|
|
||||||
// group (search + scope toggle / sync chip) within that band, so the box sits centred over the cards
|
|
||||||
// once the column is wider than the box's max-w — not hugged to the left edge.
|
|
||||||
className="pointer-events-none absolute top-3 left-4 right-[calc(1rem+var(--sbw))] mx-auto max-w-[var(--content-mw,none)] z-chrome flex flex-wrap items-center justify-center gap-x-3 gap-y-2"
|
|
||||||
>
|
|
||||||
{search}
|
|
||||||
{/* Mine/Library scope sits right of the feed search box — relocated here from the filter sidebar
|
|
||||||
so the primary "what am I browsing" control is always in reach (mirrors Plex's scope toggle). */}
|
|
||||||
{page === "feed" && <FeedScopeToggle />}
|
|
||||||
{/* Library scope (movie/show/both) sits right of the search box on Plex — relocated here from
|
|
||||||
the filter sidebar so the primary "what am I browsing" control is always in reach. The
|
|
||||||
Discover ⟷ Browse toggle sits right beside it (driven via the PlexViewBridge). */}
|
|
||||||
{page === "plex" && me.plex_enabled && <PlexScopeToggle />}
|
|
||||||
{page === "plex" && me.plex_enabled && <PlexViewToggle />}
|
|
||||||
{/* Per-user sync status — it used to cost the nav rail a whole block. It sits with the other
|
|
||||||
pills rather than pinned to the far right: on a wide window that just strands it. Only on
|
|
||||||
the two modules it says anything about: the feed those subscriptions fill, and the manager
|
|
||||||
where you curate them. It's noise on Plex (a different source entirely), playlists, etc. */}
|
|
||||||
{(page === "feed" || page === "channels") && (
|
|
||||||
<SyncStatus isAdmin={me.role === "admin"} onGoToFullHistory={onGoToFullHistory} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
export default Header;
|
|
||||||
@@ -1,28 +0,0 @@
|
|||||||
import { useState, type ReactNode } from "react";
|
|
||||||
import {
|
|
||||||
PlexViewPublishContext,
|
|
||||||
PlexViewReadContext,
|
|
||||||
type PlexViewControls,
|
|
||||||
} from "./plexViewContext";
|
|
||||||
|
|
||||||
// A one-way bridge so the always-mounted Header can host the Plex Discover ⟷ Browse toggle even though
|
|
||||||
// the sub-view state lives in (and is owned by) the plex-only PlexBrowse. PlexBrowse PUBLISHES its
|
|
||||||
// current top-level view + opener here (on mount and on view change) and clears it on unmount; the
|
|
||||||
// Header READS it to render the toggle beside the library-scope pill.
|
|
||||||
//
|
|
||||||
// Why a bridge and not a global sub-view provider: PlexBrowse's `useHistorySubview` REMOUNTS every time
|
|
||||||
// you enter the plex page, re-seeding the view from history (a page switch clears ?v + drops _sub, so
|
|
||||||
// each plex entry starts at the Discover root). Lifting that hook to an always-mounted provider would
|
|
||||||
// lose the remount and let the view go stale across navigation. Keeping it in PlexBrowse preserves the
|
|
||||||
// reseed; the bridge only mirrors the small slice the header needs.
|
|
||||||
//
|
|
||||||
// The contexts + hooks live in plexViewContext.ts.
|
|
||||||
|
|
||||||
export function PlexViewBridge({ children }: { children: ReactNode }) {
|
|
||||||
const [controls, setControls] = useState<PlexViewControls | null>(null);
|
|
||||||
return (
|
|
||||||
<PlexViewPublishContext.Provider value={setControls}>
|
|
||||||
<PlexViewReadContext.Provider value={controls}>{children}</PlexViewReadContext.Provider>
|
|
||||||
</PlexViewPublishContext.Provider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,264 +0,0 @@
|
|||||||
import { useEffect, useEffectEvent, useMemo, useRef, useState, type ReactNode } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
||||||
import { qk } from "../lib/queryKeys";
|
|
||||||
import {
|
|
||||||
api,
|
|
||||||
coercePlexFilters,
|
|
||||||
plexFiltersSignature,
|
|
||||||
type FeedFilters,
|
|
||||||
type PlexFilters,
|
|
||||||
type SavedView,
|
|
||||||
} from "../lib/api";
|
|
||||||
import { filtersToParams, hasFilterParams } from "../lib/urlState";
|
|
||||||
import { pickDefaultToSeed, preferSticky } from "../lib/savedViews";
|
|
||||||
import { notifyActionError } from "../lib/actionErrors";
|
|
||||||
import { LS, ownKey } from "../lib/storage";
|
|
||||||
import { useConfirm } from "./confirmContext";
|
|
||||||
import { useNavigation } from "./navigationContext";
|
|
||||||
import { useFeedFilters, useFeedFiltersActions } from "./feedFiltersContext";
|
|
||||||
import { usePlex, usePlexActions } from "./plexContext";
|
|
||||||
import {
|
|
||||||
asFilters,
|
|
||||||
SavedViewsCtx,
|
|
||||||
type SavedViewsContext,
|
|
||||||
type ViewSource,
|
|
||||||
} from "./savedViewsContext";
|
|
||||||
|
|
||||||
// Saved "smart views" now serve TWO modules — the feed and Plex — keyed by `source`. This one provider
|
|
||||||
// switches which module it operates on by the active page (both filter providers are global, so it can
|
|
||||||
// read either), so the shared UI (popover / modal / chip) works on the feed page against feed views and
|
|
||||||
// on the plex page against plex views without a second provider or an App-layout change.
|
|
||||||
//
|
|
||||||
// The context, its value type and the hook live in savedViewsContext.ts.
|
|
||||||
|
|
||||||
// The per-source wiring the generic logic runs on: the live filters, how to apply a view's, a stable
|
|
||||||
// signature for matching, coercion, and what to strip before saving (the feed's transient shuffle seed).
|
|
||||||
interface ViewConfig {
|
|
||||||
source: ViewSource;
|
|
||||||
filters: Record<string, unknown>;
|
|
||||||
setFilters: (f: Record<string, unknown>) => void;
|
|
||||||
keyOf: (f: Record<string, unknown>) => string;
|
|
||||||
asFilters: (raw: Record<string, unknown>) => Record<string, unknown>;
|
|
||||||
stripForSave: (f: Record<string, unknown>) => Record<string, unknown>;
|
|
||||||
/** Whether this module is a genuinely fresh start the starred default may seed on first load: no
|
|
||||||
* stored filters for the active account, and — feed only — no URL filter override. */
|
|
||||||
canSeedDefault: () => boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const feedKeyOf = (f: Record<string, unknown>) =>
|
|
||||||
filtersToParams(asFilters(f as unknown as FeedFilters)).toString();
|
|
||||||
const plexKeyOf = (f: Record<string, unknown>) => plexFiltersSignature(coercePlexFilters(f));
|
|
||||||
|
|
||||||
// True once this account has its own persisted filters for `lsKey` — the signal that the last
|
|
||||||
// view / manual tweaks (not the default) should win. Account unknown or localStorage unavailable →
|
|
||||||
// report "stored" so a default never seeds blind over an indeterminate state.
|
|
||||||
function storedFiltersPresent(lsKey: string): boolean {
|
|
||||||
const key = ownKey(lsKey);
|
|
||||||
if (!key) return true;
|
|
||||||
try {
|
|
||||||
return localStorage.getItem(key) != null;
|
|
||||||
} catch {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Feed also yields to a URL that carries filter params (a shared/bookmarked view).
|
|
||||||
function feedCanSeedDefault(): boolean {
|
|
||||||
if (hasFilterParams(new URLSearchParams(window.location.search))) return false;
|
|
||||||
return !storedFiltersPresent(LS.filters);
|
|
||||||
}
|
|
||||||
// Plex views are facets-only, so LS.plexFilters is the "has the user set this module's filters" key.
|
|
||||||
function plexCanSeedDefault(): boolean {
|
|
||||||
return !storedFiltersPresent(LS.plexFilters);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SavedViewsProvider({ children }: { children: ReactNode }) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const qc = useQueryClient();
|
|
||||||
const confirm = useConfirm();
|
|
||||||
const { page } = useNavigation();
|
|
||||||
|
|
||||||
// Both filter providers are global — read both, then use the one the active page owns.
|
|
||||||
const feedFilters = useFeedFilters();
|
|
||||||
const { setFilters: setFeedFilters } = useFeedFiltersActions();
|
|
||||||
const { filters: plexFilters } = usePlex();
|
|
||||||
const { setFilters: setPlexFilters } = usePlexActions();
|
|
||||||
|
|
||||||
const cfg: ViewConfig = useMemo(() => {
|
|
||||||
if (page === "plex") {
|
|
||||||
return {
|
|
||||||
source: "plex",
|
|
||||||
filters: plexFilters as unknown as Record<string, unknown>,
|
|
||||||
setFilters: (f) => setPlexFilters(f as unknown as PlexFilters),
|
|
||||||
keyOf: plexKeyOf,
|
|
||||||
asFilters: (raw) => coercePlexFilters(raw) as unknown as Record<string, unknown>,
|
|
||||||
stripForSave: (f) => f, // Plex has no transient field to drop
|
|
||||||
canSeedDefault: plexCanSeedDefault,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
source: "feed",
|
|
||||||
filters: feedFilters as unknown as Record<string, unknown>,
|
|
||||||
setFilters: (f) => setFeedFilters(f as unknown as FeedFilters),
|
|
||||||
keyOf: feedKeyOf,
|
|
||||||
asFilters: (raw) =>
|
|
||||||
asFilters(raw as unknown as FeedFilters) as unknown as Record<string, unknown>,
|
|
||||||
// Strip the transient shuffle seed so a saved "shuffle" view re-rolls fresh on apply.
|
|
||||||
stripForSave: (f) => {
|
|
||||||
const { seed: _seed, ...rest } = f;
|
|
||||||
return rest;
|
|
||||||
},
|
|
||||||
canSeedDefault: feedCanSeedDefault,
|
|
||||||
};
|
|
||||||
}, [page, feedFilters, plexFilters, setFeedFilters, setPlexFilters]);
|
|
||||||
|
|
||||||
const source = cfg.source;
|
|
||||||
// Passive observer of the account BootGate fetches: the providers mount for a signed-out tab too
|
|
||||||
// (the public landing), and saved views are per-account, so asking for "nobody's" views only logs
|
|
||||||
// a 401 on the landing page. Enabled once a signed-in account is cached.
|
|
||||||
const { data: me } = useQuery({ queryKey: qk.me(), queryFn: api.me, enabled: false });
|
|
||||||
const viewsQuery = useQuery({
|
|
||||||
queryKey: qk.savedViews(source),
|
|
||||||
queryFn: () => api.savedViews(source),
|
|
||||||
enabled: !!me,
|
|
||||||
});
|
|
||||||
const views = useMemo(() => viewsQuery.data ?? [], [viewsQuery.data]);
|
|
||||||
|
|
||||||
const [stickyId, setStickyId] = useState<number | null>(null);
|
|
||||||
const [modalOpen, setModalOpen] = useState(false);
|
|
||||||
|
|
||||||
// Switching modules (feed ⟷ plex) starts fresh: a feed view can't be the sticky/active one on plex,
|
|
||||||
// and a feed modal shouldn't linger open on plex. Adjusted DURING RENDER against the previous
|
|
||||||
// source rather than from an effect, so the first plex render never commits carrying the feed's
|
|
||||||
// sticky id — which is exactly long enough for a stale view chip to paint.
|
|
||||||
const [prevSource, setPrevSource] = useState(source);
|
|
||||||
if (prevSource !== source) {
|
|
||||||
setPrevSource(source);
|
|
||||||
setStickyId(null);
|
|
||||||
setModalOpen(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
// The ONE default-application path for both modules. On the views query's FIRST resolve for a
|
|
||||||
// source, seed the starred default view's filters — but only into a genuinely fresh module (no
|
|
||||||
// stored filters, no URL override; see cfg.canSeedDefault). Applying via cfg.setFilters persists
|
|
||||||
// it, so it happens once and the normal last-view-wins path takes over, exactly as the storage
|
|
||||||
// semantics document. This replaced the feed-only localStorage mirror that raced FeedFiltersProvider's
|
|
||||||
// login effect (on a fresh browser the feed could seed DEFAULT_FILTERS and never re-apply until a
|
|
||||||
// reload) — and it gives Plex the default it previously never applied at all. Per-source guard so a
|
|
||||||
// 60s background refetch never re-seeds over the filters you're editing. canSeedDefault() reads the
|
|
||||||
// account's stored-filters key, which is always readable here: BootGate hands the browser's local
|
|
||||||
// state to the account (claimLocalData) before this provider mounts.
|
|
||||||
const seededRef = useRef<Set<ViewSource>>(new Set());
|
|
||||||
// `cfg` re-memoizes on every filter edit, so it is read through an effect event: the seed must
|
|
||||||
// fire on the views query's first resolve for a source and never again, which `seededRef`
|
|
||||||
// enforces. Listing cfg would re-arm nothing (the guard still holds) but would re-run the effect
|
|
||||||
// on every keystroke in the filter bar.
|
|
||||||
const latestCfg = useEffectEvent(() => cfg);
|
|
||||||
useEffect(() => {
|
|
||||||
const data = viewsQuery.data;
|
|
||||||
if (!data || seededRef.current.has(source)) return;
|
|
||||||
seededRef.current.add(source);
|
|
||||||
const c = latestCfg();
|
|
||||||
const def = pickDefaultToSeed(data, c.canSeedDefault());
|
|
||||||
// The name follows by itself: `preferSticky` picks the starred default among same-filter views.
|
|
||||||
if (def) c.setFilters(c.asFilters(def.filters));
|
|
||||||
}, [viewsQuery.data, source]);
|
|
||||||
|
|
||||||
const currentKey = cfg.keyOf(cfg.filters);
|
|
||||||
const activeView = useMemo(
|
|
||||||
() =>
|
|
||||||
preferSticky(
|
|
||||||
views.filter((v) => cfg.keyOf(v.filters) === currentKey),
|
|
||||||
stickyId,
|
|
||||||
),
|
|
||||||
[views, currentKey, stickyId, cfg],
|
|
||||||
);
|
|
||||||
// The sticky id follows whichever view is active — also adjusted during render. It converges by
|
|
||||||
// construction: preferSticky returns the sticky match when there is one, so the very write below
|
|
||||||
// makes its own condition false on the next pass.
|
|
||||||
if (activeView && activeView.id !== stickyId) setStickyId(activeView.id);
|
|
||||||
|
|
||||||
const stickyView = useMemo(
|
|
||||||
() => (stickyId == null ? null : (views.find((v) => v.id === stickyId) ?? null)),
|
|
||||||
[views, stickyId],
|
|
||||||
);
|
|
||||||
const displayView = activeView ?? stickyView;
|
|
||||||
const isDirty = displayView != null && activeView == null;
|
|
||||||
|
|
||||||
const refresh = () => void qc.invalidateQueries({ queryKey: qk.savedViews(source) });
|
|
||||||
async function runViewMutation(fn: () => Promise<unknown>, failMsg: string): Promise<boolean> {
|
|
||||||
let ok = true;
|
|
||||||
try {
|
|
||||||
await fn();
|
|
||||||
} catch (e) {
|
|
||||||
notifyActionError(e, failMsg);
|
|
||||||
ok = false;
|
|
||||||
}
|
|
||||||
refresh();
|
|
||||||
return ok;
|
|
||||||
}
|
|
||||||
|
|
||||||
const value: SavedViewsContext = {
|
|
||||||
source,
|
|
||||||
views,
|
|
||||||
loading: viewsQuery.isLoading,
|
|
||||||
activeViewId: activeView?.id ?? null,
|
|
||||||
displayView,
|
|
||||||
isDirty,
|
|
||||||
modalOpen,
|
|
||||||
openModal: () => setModalOpen(true),
|
|
||||||
closeModal: () => setModalOpen(false),
|
|
||||||
apply: (v) => {
|
|
||||||
cfg.setFilters(cfg.asFilters(v.filters));
|
|
||||||
setStickyId(v.id);
|
|
||||||
},
|
|
||||||
clearActive: () => setStickyId(null),
|
|
||||||
create: async (name) => {
|
|
||||||
const clean = name.trim();
|
|
||||||
if (!clean) return false;
|
|
||||||
return runViewMutation(
|
|
||||||
() => api.createView(clean, cfg.stripForSave(cfg.filters), source),
|
|
||||||
t("views.actionFailed"),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
rename: async (id, name) => {
|
|
||||||
const next = name.trim();
|
|
||||||
if (!next) return false;
|
|
||||||
return runViewMutation(() => api.updateView(id, { name: next }), t("views.actionFailed"));
|
|
||||||
},
|
|
||||||
updateFilters: (id, filters) =>
|
|
||||||
runViewMutation(() => api.updateView(id, { filters }), t("views.actionFailed")),
|
|
||||||
saveCurrent: async (id) => {
|
|
||||||
const filters = cfg.stripForSave(cfg.filters);
|
|
||||||
const ok = await runViewMutation(
|
|
||||||
() => api.updateView(id, { filters }),
|
|
||||||
t("views.actionFailed"),
|
|
||||||
);
|
|
||||||
return ok ? filters : null;
|
|
||||||
},
|
|
||||||
toggleDefault: (v) =>
|
|
||||||
runViewMutation(
|
|
||||||
() => api.updateView(v.id, { is_default: !v.is_default }),
|
|
||||||
t("views.actionFailed"),
|
|
||||||
).then(() => undefined),
|
|
||||||
remove: async (v) => {
|
|
||||||
const ok = await confirm({
|
|
||||||
message: t("views.deleteConfirm", { name: v.name }),
|
|
||||||
confirmLabel: t("views.delete"),
|
|
||||||
danger: true,
|
|
||||||
});
|
|
||||||
if (!ok) return false;
|
|
||||||
if (stickyId === v.id) setStickyId(null);
|
|
||||||
return runViewMutation(() => api.deleteView(v.id), t("views.actionFailed"));
|
|
||||||
},
|
|
||||||
reorder: async (nextIds) => {
|
|
||||||
qc.setQueryData<SavedView[]>(
|
|
||||||
qk.savedViews(source),
|
|
||||||
nextIds.map((id) => views.find((v) => v.id === id)!).filter(Boolean),
|
|
||||||
);
|
|
||||||
await runViewMutation(() => api.reorderViews(nextIds), t("views.reorderFailed"));
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
return <SavedViewsCtx.Provider value={value}>{children}</SavedViewsCtx.Provider>;
|
|
||||||
}
|
|
||||||
@@ -12,7 +12,7 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { setLanguage, type LangCode } from "../../i18n";
|
import { setLanguage, type LangCode } from "../../i18n";
|
||||||
import { formatDate } from "../../lib/format";
|
import { formatDate } from "../../lib/format";
|
||||||
import { send } from "../../lib/http";
|
import { send } from "../../lib/http";
|
||||||
import LanguageSwitcher from "../LanguageSwitcher";
|
import LanguageSwitcher from "../ui/LanguageSwitcher";
|
||||||
import { SURFACE } from "../ui/surfaces";
|
import { SURFACE } from "../ui/surfaces";
|
||||||
|
|
||||||
// The day the text last changed materially — bump it with every edit of either body.
|
// The day the text last changed materially — bump it with every edit of either body.
|
||||||
|
|||||||
@@ -1,102 +0,0 @@
|
|||||||
import type { ComponentType } from "react";
|
|
||||||
import { lazyWithPreload, type PreloadableComponent } from "../lib/lazyWithPreload";
|
|
||||||
import type { Page } from "../lib/urlState";
|
|
||||||
import type { PanelId, PanelLayout } from "../lib/panelLayout";
|
|
||||||
import Sidebar from "./Sidebar";
|
|
||||||
import PlaylistsRail from "./PlaylistsRail";
|
|
||||||
import { Feed, FeedPage } from "./modulePages";
|
|
||||||
|
|
||||||
// The RENDER half of the module registry: which component (and side rail) each page renders. The
|
|
||||||
// metadata half — nav order, label, icon, gate — lives in lib/modules.ts (imported by NavSidebar /
|
|
||||||
// Header / App's gate check). Together they let App drive the whole shell from `page` alone, with no
|
|
||||||
// `page === X` chrome conditionals.
|
|
||||||
//
|
|
||||||
// Every content entry is a PROP-FREE component: it reads `me` from useMe() and its module-local state
|
|
||||||
// from the scoped providers, so App renders `<Content />` uniformly. The two that need per-render
|
|
||||||
// wiring get a thin wrapper in modulePages (FeedPage supplies the GLOBAL feed scope; the channel page
|
|
||||||
// threads its own isolated scope to <Feed> directly — see the scope note in FeedFiltersProvider).
|
|
||||||
|
|
||||||
// Route-level code splitting: each page is its own lazy chunk (admin-only pages never reach
|
|
||||||
// non-admins; the logged-out landing pulls only the shell). Rendered inside App's <Suspense>. Each
|
|
||||||
// one is preloadable: App fetches the chunks of every page the account can reach once the first page
|
|
||||||
// is on screen (preloadPages below), so switching modules never shows the chunk fallback.
|
|
||||||
const Home = lazyWithPreload(() => import("./Home"));
|
|
||||||
const Channels = lazyWithPreload(() => import("./Channels"));
|
|
||||||
const Playlists = lazyWithPreload(() => import("./Playlists"));
|
|
||||||
const PlexBrowse = lazyWithPreload(() => import("./PlexBrowse"));
|
|
||||||
const PlexSidebar = lazyWithPreload(() => import("./PlexSidebar"));
|
|
||||||
const NotificationsPanel = lazyWithPreload(() => import("./NotificationsPanel"));
|
|
||||||
const Files = lazyWithPreload(() => import("./Files"));
|
|
||||||
const FilesRail = lazyWithPreload(() => import("./FilesRail"));
|
|
||||||
const Stats = lazyWithPreload(() => import("./Stats"));
|
|
||||||
const Scheduler = lazyWithPreload(() => import("./Scheduler"));
|
|
||||||
const ConfigPanel = lazyWithPreload(() => import("./ConfigPanel"));
|
|
||||||
const AdminUsers = lazyWithPreload(() => import("./AdminUsers"));
|
|
||||||
const Activity = lazyWithPreload(() => import("./Activity"));
|
|
||||||
const SettingsPanel = lazyWithPreload(() => import("./SettingsPanel"));
|
|
||||||
|
|
||||||
export type PageDef = {
|
|
||||||
// Prop-free by construction: reads `me` + module state from hooks/providers. Preloadable by type, so
|
|
||||||
// a new page cannot silently opt out of preloadPages.
|
|
||||||
Component: PreloadableComponent<object>;
|
|
||||||
// The content column's max width. The header + toolbar centre on this same width (via --content-mw)
|
|
||||||
// so the floating chrome lines up with the centred content instead of spanning the whole column.
|
|
||||||
// MUST match the max-w the page's own content wrapper uses. Omit for a full-width page (feed).
|
|
||||||
maxWidth?: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
// page id → the component App renders in the content column (+ its shell chrome config).
|
|
||||||
export const PAGE_CONTENT: Record<Page, PageDef> = {
|
|
||||||
home: { Component: Home, maxWidth: "80rem" },
|
|
||||||
// The two wrappers preload the lazy chunk they render.
|
|
||||||
feed: { Component: Object.assign(FeedPage, { preload: Feed.preload }) },
|
|
||||||
channels: { Component: Channels, maxWidth: "96rem" },
|
|
||||||
playlists: { Component: Playlists, maxWidth: "64rem" },
|
|
||||||
plex: { Component: PlexBrowse, maxWidth: "100rem" },
|
|
||||||
notifications: { Component: NotificationsPanel, maxWidth: "48rem" },
|
|
||||||
files: { Component: Files, maxWidth: "80rem" },
|
|
||||||
stats: { Component: Stats },
|
|
||||||
scheduler: { Component: Scheduler },
|
|
||||||
config: { Component: ConfigPanel },
|
|
||||||
users: { Component: AdminUsers, maxWidth: "48rem" },
|
|
||||||
activity: { Component: Activity },
|
|
||||||
settings: { Component: SettingsPanel },
|
|
||||||
};
|
|
||||||
|
|
||||||
// The uniform side-rail contract — shell layout/collapse only; the rail reads its module state from
|
|
||||||
// its provider and `me` from useMe().
|
|
||||||
type RailProps = {
|
|
||||||
layout: PanelLayout;
|
|
||||||
setLayout: (l: PanelLayout) => void;
|
|
||||||
collapsed: boolean;
|
|
||||||
onToggleCollapse: () => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Which collapse flag a rail uses (feed + plex share the filter rail's flag; playlists and files
|
|
||||||
// have their own).
|
|
||||||
type RailCollapseKey = "filter" | "playlists" | "files";
|
|
||||||
|
|
||||||
export type RailDef = {
|
|
||||||
Rail: ComponentType<RailProps>;
|
|
||||||
// The persisted panel-layout record this rail edits (App owns the panelLayouts map).
|
|
||||||
panelId: PanelId;
|
|
||||||
collapse: RailCollapseKey;
|
|
||||||
};
|
|
||||||
|
|
||||||
// page id → its side rail (only the pages that have one). App renders it in the rail slot.
|
|
||||||
export const PAGE_RAIL: Partial<Record<Page, RailDef>> = {
|
|
||||||
feed: { Rail: Sidebar, panelId: "feed", collapse: "filter" },
|
|
||||||
plex: { Rail: PlexSidebar, panelId: "plex", collapse: "filter" },
|
|
||||||
playlists: { Rail: PlaylistsRail, panelId: "playlists", collapse: "playlists" },
|
|
||||||
files: { Rail: FilesRail, panelId: "files", collapse: "files" },
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Fetch the code of these pages and of their side rails now, ahead of their first visit. Errors are
|
|
||||||
* swallowed: a chunk that fails to preload is simply fetched (and its failure surfaced) on visit. */
|
|
||||||
export function preloadPages(pages: Page[]): void {
|
|
||||||
for (const p of pages) {
|
|
||||||
PAGE_CONTENT[p].Component.preload().catch(() => {});
|
|
||||||
const rail = PAGE_RAIL[p]?.Rail as Partial<PreloadableComponent<RailProps>> | undefined;
|
|
||||||
rail?.preload?.().catch(() => {});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,15 +0,0 @@
|
|||||||
import { createContext, useContext } from "react";
|
|
||||||
import { type Sub } from "./plexSubUrl";
|
|
||||||
|
|
||||||
// The read + publish sides of the Plex view bridge (see PlexViewBridge for why it is a bridge), split
|
|
||||||
// out of that file so it exports only components (Fast Refresh).
|
|
||||||
export type PlexViewControls = {
|
|
||||||
kind: Sub["kind"]; // the current top-level plex view — the header controls derive their state from it
|
|
||||||
open: (v: Sub) => void;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const PlexViewReadContext = createContext<PlexViewControls | null>(null);
|
|
||||||
export const PlexViewPublishContext = createContext<(c: PlexViewControls | null) => void>(() => {});
|
|
||||||
|
|
||||||
export const usePlexViewControls = () => useContext(PlexViewReadContext);
|
|
||||||
export const usePublishPlexView = () => useContext(PlexViewPublishContext);
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
import { createContext, useContext } from "react";
|
|
||||||
import { DEFAULT_THEME } from "../lib/theme";
|
|
||||||
import { getNotifSettings } from "../lib/notifications";
|
|
||||||
import type { PrefsController } from "./SettingsPanel";
|
|
||||||
|
|
||||||
// The Settings-page preferences context + hook, split out of PrefsProvider so that file exports only
|
|
||||||
// components (Fast Refresh).
|
|
||||||
|
|
||||||
export const PrefsContext = createContext<PrefsController>({
|
|
||||||
theme: DEFAULT_THEME,
|
|
||||||
setTheme: () => {},
|
|
||||||
perf: false,
|
|
||||||
setPerf: () => {},
|
|
||||||
hints: true,
|
|
||||||
setHints: () => {},
|
|
||||||
railLabels: false,
|
|
||||||
setRailLabels: () => {},
|
|
||||||
notif: getNotifSettings(),
|
|
||||||
setNotif: () => {},
|
|
||||||
saveState: "idle",
|
|
||||||
});
|
|
||||||
|
|
||||||
export function usePrefs(): PrefsController {
|
|
||||||
return useContext(PrefsContext);
|
|
||||||
}
|
|
||||||
+2
-2
@@ -1,7 +1,7 @@
|
|||||||
import { useCallback, useState, type ReactNode } from "react";
|
import { useCallback, useState, type ReactNode } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import Modal, { ModalFooter } from "./ui/Modal";
|
import Modal, { ModalFooter } from "./Modal";
|
||||||
import { Button } from "./ui/Button";
|
import { Button } from "./Button";
|
||||||
import { ChoiceContext, type ChoiceFn, type ChoiceOptions } from "./confirmContext";
|
import { ChoiceContext, type ChoiceFn, type ChoiceOptions } from "./confirmContext";
|
||||||
|
|
||||||
// The dialog behind useConfirm()/useChoice() (see confirmContext.ts for the hooks and their contract).
|
// The dialog behind useConfirm()/useChoice() (see confirmContext.ts for the hooks and their contract).
|
||||||
@@ -1,18 +1,18 @@
|
|||||||
import { useState, type ReactNode } from "react";
|
import { useState, type ReactNode } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { defaultLayout, type PanelId, type PanelLayout } from "../lib/panelLayout";
|
import { type PanelLayout } from "../../lib/panelLayout";
|
||||||
import SidePanel from "./ui/SidePanel";
|
import SidePanel from "./SidePanel";
|
||||||
import PanelGroups, { type PanelItem } from "./ui/PanelGroups";
|
import PanelGroups, { type PanelItem } from "./PanelGroups";
|
||||||
|
|
||||||
// The shared filter-rail shell, extracted from the three rails that all render the same SidePanel
|
// The shared filter-rail shell, extracted from the three rails that all render the same SidePanel
|
||||||
// chrome around a PanelGroups body: the feed Sidebar, the PlexSidebar, and the PlaylistsRail. It owns
|
// chrome around a PanelGroups body: the feed Sidebar, the PlexSidebar, and the PlaylistsRail. It owns
|
||||||
// the customize (reorder/hide) `editing` state and the fixed title-bar wiring (edit toggle, reset to
|
// the customize (reorder/hide) `editing` state and the fixed title-bar wiring (edit toggle, reset to
|
||||||
// the layout default, edit hint); each rail supplies only what actually differs — its identity
|
// the layout default, edit hint); each rail supplies only what actually differs — its identity
|
||||||
// (title/icon), the active-filter count + Clear-all action, its `items`, and any content below the
|
// (title/icon), the active-filter count + Clear-all action, its `items`, and any content below the
|
||||||
// groups (`children`, e.g. the feed's tag manager). `kind` is the PanelId (also the layout key), so
|
// groups (`children`, e.g. the feed's tag manager; nothing for the rails that need none). What Reset
|
||||||
// `children` defaults to nothing for the rails that need none.
|
// goes back to is the rail's (`onResetLayout`, from lib/useRail), so this shell names no page.
|
||||||
export default function FilterRail({
|
export default function FilterRail({
|
||||||
kind,
|
onResetLayout,
|
||||||
title,
|
title,
|
||||||
icon,
|
icon,
|
||||||
count,
|
count,
|
||||||
@@ -28,7 +28,8 @@ export default function FilterRail({
|
|||||||
titleActionLabel,
|
titleActionLabel,
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
kind: PanelId;
|
/** Puts the groups back in the rail's default order (lib/useRail's `onResetLayout`). */
|
||||||
|
onResetLayout: () => void;
|
||||||
title: string;
|
title: string;
|
||||||
icon: ReactNode;
|
icon: ReactNode;
|
||||||
count?: number;
|
count?: number;
|
||||||
@@ -56,7 +57,7 @@ export default function FilterRail({
|
|||||||
onToggleCollapse={onToggleCollapse}
|
onToggleCollapse={onToggleCollapse}
|
||||||
editing={editing}
|
editing={editing}
|
||||||
onToggleEditing={() => setEditing((e) => !e)}
|
onToggleEditing={() => setEditing((e) => !e)}
|
||||||
onReset={() => setLayout(defaultLayout(kind))}
|
onReset={onResetLayout}
|
||||||
editHint={t("sidebar.editHint")}
|
editHint={t("sidebar.editHint")}
|
||||||
actions={actions}
|
actions={actions}
|
||||||
iconExpanded={iconExpanded}
|
iconExpanded={iconExpanded}
|
||||||
+7
-7
@@ -1,12 +1,12 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { Globe } from "lucide-react";
|
import { Globe } from "lucide-react";
|
||||||
import { MenuCheck } from "./ui/MenuCheck";
|
import { MenuCheck } from "./MenuCheck";
|
||||||
import { LANGUAGES, type LangCode } from "../i18n";
|
import { LANGUAGES, type LangCode } from "../../i18n";
|
||||||
import { PopoverItem, PopoverSurface } from "./ui/AnchoredPopover";
|
import { PopoverItem, PopoverSurface } from "./AnchoredPopover";
|
||||||
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
|
import { useAnchoredPopover } from "./anchoredPopoverHooks";
|
||||||
import { Pressable } from "./ui/Pressable";
|
import { Pressable } from "./Pressable";
|
||||||
import { RAIL_GLYPH, RAIL_ROW } from "./ui/NavRail";
|
import { RAIL_GLYPH, RAIL_ROW } from "./NavRail";
|
||||||
import { SURFACE } from "./ui/surfaces";
|
import { SURFACE } from "./surfaces";
|
||||||
|
|
||||||
// Compact language picker (globe + current code). Presentational: the parent decides what
|
// Compact language picker (globe + current code). Presentational: the parent decides what
|
||||||
// changing the language does (set it; persist server-side when signed in).
|
// changing the language does (set it; persist server-side when signed in).
|
||||||
@@ -119,7 +119,7 @@ describe("NavRailAction", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("the nav rail holds no toolbar icon button", () => {
|
it("the nav rail holds no toolbar icon button", () => {
|
||||||
const src = readFileSync(resolve(__dirname, "../NavSidebar.tsx"), "utf8");
|
const src = readFileSync(resolve(__dirname, "../../shell/NavSidebar.tsx"), "utf8");
|
||||||
expect(src).not.toMatch(/<IconButton[\s>]/);
|
expect(src).not.toMatch(/<IconButton[\s>]/);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -0,0 +1,176 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
//
|
||||||
|
// The floating header knows no page: a page fills it through PageHeader (a portal into the header's
|
||||||
|
// root box), and the shell's PageHeaderDefault shows only while no page does. These pin the slot's
|
||||||
|
// three promises: the page's pills land IN the header, the default steps aside for them and comes
|
||||||
|
// back when the next page has none, and a shell without a header (the channel page) renders the
|
||||||
|
// page's pills nowhere.
|
||||||
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { render, cleanup, screen } from "@testing-library/react";
|
||||||
|
import PageShell, { PageHeader, PageHeaderDefault } from "./PageShell";
|
||||||
|
|
||||||
|
vi.mock("./PageScroller", () => ({
|
||||||
|
default: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.stubGlobal(
|
||||||
|
"ResizeObserver",
|
||||||
|
class {
|
||||||
|
observe() {}
|
||||||
|
disconnect() {}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
const hdr = <div data-testid="hdr" />;
|
||||||
|
const fallback = (
|
||||||
|
<PageHeaderDefault>
|
||||||
|
<span>default</span>
|
||||||
|
</PageHeaderDefault>
|
||||||
|
);
|
||||||
|
|
||||||
|
describe("the header slot", () => {
|
||||||
|
it("puts a page's pills inside the header box", () => {
|
||||||
|
render(
|
||||||
|
<PageShell header={hdr}>
|
||||||
|
<PageHeader>
|
||||||
|
<span>search</span>
|
||||||
|
</PageHeader>
|
||||||
|
</PageShell>,
|
||||||
|
);
|
||||||
|
expect(screen.getByTestId("hdr").textContent).toBe("search");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows the default on a page with no header of its own", () => {
|
||||||
|
render(<PageShell header={hdr}>{fallback}</PageShell>);
|
||||||
|
expect(screen.getByTestId("hdr").textContent).toBe("default");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("hides the default while a page fills the header, and brings it back on the next page", () => {
|
||||||
|
const page = (own: boolean) => (
|
||||||
|
<PageShell header={hdr}>
|
||||||
|
{own && (
|
||||||
|
<PageHeader>
|
||||||
|
<span>search</span>
|
||||||
|
</PageHeader>
|
||||||
|
)}
|
||||||
|
{fallback}
|
||||||
|
</PageShell>
|
||||||
|
);
|
||||||
|
const { rerender } = render(page(true));
|
||||||
|
expect(screen.getByTestId("hdr").textContent).toBe("search");
|
||||||
|
rerender(page(false));
|
||||||
|
expect(screen.getByTestId("hdr").textContent).toBe("default");
|
||||||
|
rerender(page(true));
|
||||||
|
expect(screen.getByTestId("hdr").textContent).toBe("search");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the default hidden while one of two PageHeaders on a page remains", () => {
|
||||||
|
const page = (both: boolean) => (
|
||||||
|
<PageShell header={hdr}>
|
||||||
|
<PageHeader>
|
||||||
|
<span>search</span>
|
||||||
|
</PageHeader>
|
||||||
|
{both && (
|
||||||
|
<PageHeader>
|
||||||
|
<span>toggle</span>
|
||||||
|
</PageHeader>
|
||||||
|
)}
|
||||||
|
{fallback}
|
||||||
|
</PageShell>
|
||||||
|
);
|
||||||
|
const { rerender } = render(page(true));
|
||||||
|
rerender(page(false));
|
||||||
|
expect(screen.getByTestId("hdr").textContent).toBe("search");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("pads the content by the filled header before paint, not when the observer catches up", () => {
|
||||||
|
// jsdom does no layout: the header's height here is one 40px row per pill in it. The observer
|
||||||
|
// never fires in this test, so only a measurement made as the pills arrive can see them.
|
||||||
|
const height = (el: HTMLElement) => 40 * el.childElementCount;
|
||||||
|
const page = (own: boolean) => (
|
||||||
|
<PageShell header={hdr}>
|
||||||
|
{own && (
|
||||||
|
<PageHeader>
|
||||||
|
<span>search</span>
|
||||||
|
<span>scope</span>
|
||||||
|
</PageHeader>
|
||||||
|
)}
|
||||||
|
{fallback}
|
||||||
|
</PageShell>
|
||||||
|
);
|
||||||
|
const { rerender, container } = render(<PageShell header={hdr}>{null}</PageShell>);
|
||||||
|
Object.defineProperty(screen.getByTestId("hdr"), "offsetHeight", {
|
||||||
|
get() {
|
||||||
|
return height(this as HTMLElement);
|
||||||
|
},
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
const pad = () =>
|
||||||
|
(container.querySelector('[class*="pt-(--hdr-h)"]') as HTMLElement).style.getPropertyValue(
|
||||||
|
"--hdr-h",
|
||||||
|
);
|
||||||
|
rerender(page(false));
|
||||||
|
expect(pad()).toBe("44px"); // the default alone: one row
|
||||||
|
rerender(page(true));
|
||||||
|
expect(pad()).toBe("84px"); // the page's two pills, the default gone
|
||||||
|
});
|
||||||
|
|
||||||
|
it("re-measures when one page's pills replace another's in the same commit", () => {
|
||||||
|
// Feed → Playlists: the leaving page's claim and the arriving page's land in one commit, so the
|
||||||
|
// number of claims never changes — the pad must follow the new pills all the same.
|
||||||
|
const height = (el: HTMLElement) => 40 * el.childElementCount;
|
||||||
|
const Feed = () => (
|
||||||
|
<PageHeader>
|
||||||
|
<span>search</span>
|
||||||
|
<span>scope</span>
|
||||||
|
</PageHeader>
|
||||||
|
);
|
||||||
|
const Playlists = () => (
|
||||||
|
<PageHeader>
|
||||||
|
<span>search</span>
|
||||||
|
</PageHeader>
|
||||||
|
);
|
||||||
|
const page = (which: "feed" | "playlists") => (
|
||||||
|
<PageShell header={hdr}>
|
||||||
|
{which === "feed" ? <Feed /> : <Playlists />}
|
||||||
|
{fallback}
|
||||||
|
</PageShell>
|
||||||
|
);
|
||||||
|
const { rerender, container } = render(<PageShell header={hdr}>{null}</PageShell>);
|
||||||
|
Object.defineProperty(screen.getByTestId("hdr"), "offsetHeight", {
|
||||||
|
get() {
|
||||||
|
return height(this as HTMLElement);
|
||||||
|
},
|
||||||
|
configurable: true,
|
||||||
|
});
|
||||||
|
const pad = () =>
|
||||||
|
(container.querySelector('[class*="pt-(--hdr-h)"]') as HTMLElement).style.getPropertyValue(
|
||||||
|
"--hdr-h",
|
||||||
|
);
|
||||||
|
rerender(page("feed"));
|
||||||
|
expect(pad()).toBe("84px");
|
||||||
|
rerender(page("playlists"));
|
||||||
|
expect(pad()).toBe("44px");
|
||||||
|
rerender(page("feed"));
|
||||||
|
expect(pad()).toBe("84px");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("renders a page's pills nowhere in a shell without a header", () => {
|
||||||
|
render(
|
||||||
|
<PageShell>
|
||||||
|
<PageHeader>
|
||||||
|
<span>search</span>
|
||||||
|
</PageHeader>
|
||||||
|
{fallback}
|
||||||
|
</PageShell>,
|
||||||
|
);
|
||||||
|
expect(screen.queryByText("search")).toBeNull();
|
||||||
|
expect(screen.queryByText("default")).toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import PageScroller from "./PageScroller";
|
import PageScroller from "./PageScroller";
|
||||||
import { PageToolbarSlot } from "./pageToolbarSlot";
|
import { PageToolbarSlot } from "./pageToolbarSlot";
|
||||||
|
import { PageHeaderSlot } from "./pageHeaderSlot";
|
||||||
import { PageHeadingProvider } from "./pageHeading";
|
import { PageHeadingProvider } from "./pageHeading";
|
||||||
import { PageMenuSlot, type PageMenuOpener } from "./pageMenuSlot";
|
import { PageMenuSlot, type PageMenuOpener } from "./pageMenuSlot";
|
||||||
import { isBackgroundPoint } from "../../lib/backgroundTarget";
|
import { isBackgroundPoint } from "../../lib/backgroundTarget";
|
||||||
@@ -29,6 +30,31 @@ export function PageToolbar({ children }: { children: ReactNode }) {
|
|||||||
return slot ? createPortal(children, slot) : null;
|
return slot ? createPortal(children, slot) : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// A page renders its controls in the floating header (its search box, scope toggles, sync chip)
|
||||||
|
// through this, the same way as PageToolbar: a portal, so the controls stay in the page's own React
|
||||||
|
// subtree and reach its state directly (Plex's toggles drive the sub-view that lives in PlexBrowse).
|
||||||
|
// The header itself knows no page. Claiming the header hides the shell's default (PageHeaderDefault).
|
||||||
|
export function PageHeader({ children }: { children: ReactNode }) {
|
||||||
|
const slot = useContext(PageHeaderSlot);
|
||||||
|
const claim = slot?.claim;
|
||||||
|
useLayoutEffect(() => claim?.(), [claim]);
|
||||||
|
return slot?.target ? createPortal(children, slot.target) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// What the header shows on a page that renders no PageHeader of its own (the palette's entry). App
|
||||||
|
// renders it beside the page, inside the page's Suspense, so a page still loading shows no default
|
||||||
|
// that its own controls would replace a moment later.
|
||||||
|
export function PageHeaderDefault({ children }: { children: ReactNode }) {
|
||||||
|
const slot = useContext(PageHeaderSlot);
|
||||||
|
const shown = !!slot?.target && !slot.claimed;
|
||||||
|
const showDefault = slot?.showDefault;
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
showDefault?.(shown);
|
||||||
|
return () => showDefault?.(false);
|
||||||
|
}, [shown, showDefault]);
|
||||||
|
return shown && slot?.target ? createPortal(children, slot.target) : null;
|
||||||
|
}
|
||||||
|
|
||||||
// The page shell: the fixed chrome around a single scrollable content region. It formalizes "what
|
// The page shell: the fixed chrome around a single scrollable content region. It formalizes "what
|
||||||
// is fixed vs what scrolls" per module, so the chrome App used to hand-assemble around every page
|
// is fixed vs what scrolls" per module, so the chrome App used to hand-assemble around every page
|
||||||
// (a floating Header + a top-padded wrapper + PageScroller, plus the channel page's separate
|
// (a floating Header + a top-padded wrapper + PageScroller, plus the channel page's separate
|
||||||
@@ -38,6 +64,7 @@ export function PageToolbar({ children }: { children: ReactNode }) {
|
|||||||
// - `header` — floats (absolute, height `var(--hdr-h)`) over the content column; the wrapper pads
|
// - `header` — floats (absolute, height `var(--hdr-h)`) over the content column; the wrapper pads
|
||||||
// its top by that height so nothing hides under it. Omit it (channel page) → no pad
|
// its top by that height so nothing hides under it. Omit it (channel page) → no pad
|
||||||
// and no fixed top band, so the module's own banner can scroll from the very top.
|
// and no fixed top band, so the module's own banner can scroll from the very top.
|
||||||
|
// The header is an empty band: the page fills it through PageHeader.
|
||||||
// - `title` — the page's name as a visually hidden `h1` (B4): the nav rail and the tab title are
|
// - `title` — the page's name as a visually hidden `h1` (B4): the nav rail and the tab title are
|
||||||
// the visible identity, and a screen reader still lands on a named page. A view that
|
// the visible identity, and a screen reader still lands on a named page. A view that
|
||||||
// shows its own title renders it through `PageHeading`, and this one steps aside.
|
// shows its own title renders it through `PageHeading`, and this one steps aside.
|
||||||
@@ -123,6 +150,29 @@ export default function PageShell({
|
|||||||
// the measurement to the live node re-runs it every time the header mounts/unmounts, so it stays correct.
|
// the measurement to the live node re-runs it every time the header mounts/unmounts, so it stays correct.
|
||||||
const [headerEl, setHeaderEl] = useState<HTMLDivElement | null>(null);
|
const [headerEl, setHeaderEl] = useState<HTMLDivElement | null>(null);
|
||||||
const [contentEl, setContentEl] = useState<HTMLDivElement | null>(null);
|
const [contentEl, setContentEl] = useState<HTMLDivElement | null>(null);
|
||||||
|
// How many PageHeaders fill the header right now: a count, not a flag, so of two on one page the one
|
||||||
|
// that leaves releases only its own claim.
|
||||||
|
const [headerClaims, setHeaderClaims] = useState(0);
|
||||||
|
// Bumped by every claim and every release. The count alone cannot say the pills changed: when one
|
||||||
|
// page's PageHeader replaces another's in one commit (feed → playlists), the release and the claim
|
||||||
|
// net to the same count, and the measurement below would not re-run.
|
||||||
|
const [headerGeneration, setHeaderGeneration] = useState(0);
|
||||||
|
const claimHeader = useCallback(() => {
|
||||||
|
setHeaderClaims((n) => n + 1);
|
||||||
|
setHeaderGeneration((g) => g + 1);
|
||||||
|
return () => {
|
||||||
|
setHeaderClaims((n) => n - 1);
|
||||||
|
setHeaderGeneration((g) => g + 1);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
// Whether the shell's default is in the header (PageHeaderDefault reports it).
|
||||||
|
const [defaultShown, setDefaultShown] = useState(false);
|
||||||
|
// The pills arrive by portal AFTER this shell mounted (with the page's chunk), so the first measurement
|
||||||
|
// saw an empty header; the ResizeObserver catches up only a frame or more later, and until then the
|
||||||
|
// content sat under the pills (the feed toolbar jumped 55px on a tablet). So the measurement also re-runs
|
||||||
|
// here, before paint, whenever what fills the header changes (a claim made or released, the default
|
||||||
|
// shown or gone); the observer still covers a change within a page's own pills (Plex's view switch
|
||||||
|
// appearing).
|
||||||
useLayoutEffect(() => {
|
useLayoutEffect(() => {
|
||||||
if (!headerEl || !contentEl) {
|
if (!headerEl || !contentEl) {
|
||||||
// No header (channel page) → drop any stale pad so a leftover value can't linger.
|
// No header (channel page) → drop any stale pad so a leftover value can't linger.
|
||||||
@@ -136,7 +186,13 @@ export default function PageShell({
|
|||||||
const ro = new ResizeObserver(apply);
|
const ro = new ResizeObserver(apply);
|
||||||
ro.observe(headerEl);
|
ro.observe(headerEl);
|
||||||
return () => ro.disconnect();
|
return () => ro.disconnect();
|
||||||
}, [headerEl, contentEl]);
|
}, [headerEl, contentEl, headerGeneration, defaultShown]);
|
||||||
|
const headerSlot = {
|
||||||
|
target: headerEl,
|
||||||
|
claimed: headerClaims > 0,
|
||||||
|
claim: claimHeader,
|
||||||
|
showDefault: setDefaultShown,
|
||||||
|
};
|
||||||
// How many views below render their own page `h1` right now (see ./pageHeading).
|
// How many views below render their own page `h1` right now (see ./pageHeading).
|
||||||
const [ownHeadings, setOwnHeadings] = useState(0);
|
const [ownHeadings, setOwnHeadings] = useState(0);
|
||||||
const registerOwnHeading = useCallback(() => {
|
const registerOwnHeading = useCallback(() => {
|
||||||
@@ -173,6 +229,7 @@ export default function PageShell({
|
|||||||
: header;
|
: header;
|
||||||
return (
|
return (
|
||||||
<PageToolbarSlot.Provider value={toolbarSlot}>
|
<PageToolbarSlot.Provider value={toolbarSlot}>
|
||||||
|
<PageHeaderSlot.Provider value={headerSlot}>
|
||||||
<PageMenuSlot.Provider value={registerPageMenu}>
|
<PageMenuSlot.Provider value={registerPageMenu}>
|
||||||
<PageHeadingProvider value={registerOwnHeading}>
|
<PageHeadingProvider value={registerOwnHeading}>
|
||||||
{measuredHeader}
|
{measuredHeader}
|
||||||
@@ -193,6 +250,7 @@ export default function PageShell({
|
|||||||
</div>
|
</div>
|
||||||
</PageHeadingProvider>
|
</PageHeadingProvider>
|
||||||
</PageMenuSlot.Provider>
|
</PageMenuSlot.Provider>
|
||||||
|
</PageHeaderSlot.Provider>
|
||||||
</PageToolbarSlot.Provider>
|
</PageToolbarSlot.Provider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# `components/ui/` — the platform layer
|
# `components/ui/` — the platform layer
|
||||||
|
|
||||||
Everything in this directory is a **platform component**: generic, reusable, and **domain-free**.
|
Everything in this directory is a **platform component**: generic, reusable, and **domain-free**.
|
||||||
Everything in `components/` above it is a **module component**: it knows about videos, channels,
|
Everything above it — `features/`, `modules/` and `shell/` under `src/` — knows about videos, channels,
|
||||||
playlists, Plex or downloads.
|
playlists, Plex or downloads.
|
||||||
|
|
||||||
That boundary is the whole point of this directory. Before E10 S1 these 33 modules sat flat among
|
That boundary is the whole point of this directory. Before E10 S1 these 33 modules sat flat among
|
||||||
@@ -18,9 +18,9 @@ A component belongs here **iff its props and imports name no domain concept**. I
|
|||||||
- generic helpers from `lib/` (`layerStack`, `history`, `columnSort`, `useScrollFade`, …),
|
- generic helpers from `lib/` (`layerStack`, `history`, `columnSort`, `useScrollFade`, …),
|
||||||
- i18n, React, and third-party UI libraries.
|
- i18n, React, and third-party UI libraries.
|
||||||
|
|
||||||
It may **not** import a feature component (`../Feed`) or a domain API slice (`lib/api/*`). This is
|
It may **not** import anything from `features/`, `modules/` or `shell/`, or a domain API slice
|
||||||
enforced by `no-restricted-imports` in `eslint.config.js` — not by this README, which is only the
|
(`lib/api/*`). This is enforced in `eslint.config.js` (`no-restricted-imports` and `local/layers`) — not
|
||||||
explanation. If a primitive needs domain data, take it as a prop or a render-prop and let the module
|
by this README, which is only the explanation. If a primitive needs domain data, take it as a prop or a render-prop and let the module
|
||||||
supply it. The same goes for its words: a primitive translates only from the platform i18n
|
supply it. The same goes for its words: a primitive translates only from the platform i18n
|
||||||
namespaces (`common`, `errors`, `datatable`, `keys`, `palette`, `sidebar`, `notifications`, `time` —
|
namespaces (`common`, `errors`, `datatable`, `keys`, `palette`, `sidebar`, `notifications`, `time` —
|
||||||
`uiI18n.contract.test.ts`); what a value means where it is used arrives as a prop (`PriorityStepper`'s
|
`uiI18n.contract.test.ts`); what a value means where it is used arrives as a prop (`PriorityStepper`'s
|
||||||
@@ -36,11 +36,11 @@ Counter-examples, deliberately NOT here: `PosterTile` (knows about Plex montage
|
|||||||
## What lives here
|
## What lives here
|
||||||
|
|
||||||
| area | components |
|
| area | components |
|
||||||
| ---------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| overlays & anchoring | `AnchoredPopover` + `anchoredPopoverHooks` (the popover engine: arrows, typeahead, Escape, anchor-flip), `ActionMenu`, `MembershipMenu` (the body of a "which sets is it in" menu: checkbox rows + a field naming a new set), `ContextMenu` + `useContextMenu` (the SAME menu, opened by right-click / long-press / Shift+F10 / a row's `MoreButton` "…" via `buttonProps`; `anchor` for a panel a row opens; `PageMenu` for a page's background menu), `Modal` (+ `ModalFooter`; `initialFocus` by purpose, C4), `Overlay`, `SidePanel`, `Tooltip`, `Toaster` (+ `toastLevels`, `overlayOrder`; `lib/toastDock` docks one control under the stack, in every host), `CommandPalette`, `KeyboardHelp` |
|
| overlays & anchoring | `AnchoredPopover` + `anchoredPopoverHooks` (the popover engine: arrows, typeahead, Escape, anchor-flip), `ActionMenu`, `MembershipMenu` (the body of a "which sets is it in" menu: checkbox rows + a field naming a new set), `ContextMenu` + `useContextMenu` (the SAME menu, opened by right-click / long-press / Shift+F10 / a row's `MoreButton` "…" via `buttonProps`; `anchor` for a panel a row opens; `PageMenu` for a page's background menu), `Modal` (+ `ModalFooter`; `initialFocus` by purpose, C4), `Overlay`, `SidePanel`, `Tooltip`, `Toaster` (+ `toastLevels`, `overlayOrder`; `lib/toastDock` docks one control under the stack, in every host), `CommandPalette`, `KeyboardHelp`, `ConfirmProvider` + `confirmContext` (`useConfirm` / `useChoice`: the app's promise-based confirm, mounted once at the root; a decision with more than two outcomes is one `useChoice`, never two chained confirms), `dialogFocus` (the focus half of the dialog contract: `Modal`'s, and the wizard's own shell), `menuGroups` (one menu from its groups, a rule only between non-empty ones), `pageMenuSlot` + `pageMenuItems` (a page's background menu, mirroring its own toolbar) |
|
||||||
| actions & inputs | `Button`, `IconButton`, `Pressable` (`selectable` when its text is a title or a name), `Chip` + `chipStyles`, `DragHandle` + `OrderButtons` / `useOrderItems` (the non-drag reorder, WCAG 2.5.7) (one per button family, docs/DESIGN.md; a raw `<button>` or a bare `FocusSafeButton` outside the control primitives — eslint `BUTTON_PRIMITIVES` — is a lint error), `Input` + `fieldStyles`, `RenameField` (a name typed in place of the name: Enter or blur saves, Escape cancels; the caller hands the keyboard back), `Meter` (how full something is against a limit — one danger line for every place showing the same measure), `TimecodeField` + `useTimecodeField` / `TimecodeError` (a cut's time typed in: the draft, held by the caller, until Enter or blur takes it, then the value as it landed; unreadable stays once offered, marked invalid, and the caller places the line saying how to type one in its flow, outside a label; a draft goes when the value moves under it), `NativeControls`, `MenuCheck` (the drawn check of a menu row or a whole-row checkbox button; its tick is the `chosen` colour, which `media-marks` turns amber on the player chrome), `mediaChrome` (`MEDIA_CHOSEN`, a chosen tab or toggle on the chrome), `SelectTrigger` (the field family; a raw `<input>` is a lint error), `useReturnFocus`, `surfaces` (the B5 glass roles; a bare glass class is a lint error), `Badge` (a static label), `LinkButton` / `IconLink` (a link wearing the button face) over `buttonStyles`, `Skeleton` (B8 first loads, through `LoadingState shape`), `NavRail` (B7; `NavRailGroup` folds a set of rare pages into one row with a panel), `form`, `DraftSaveBar`, `Combobox`, `FilterSelect`, `SegmentedToggle`, `SortControl`, `ColumnSortControl`, `PriorityStepper`, `UndoToolbar` |
|
| actions & inputs | `Button`, `IconButton`, `Pressable` (`selectable` when its text is a title or a name), `Chip` + `chipStyles`, `DragHandle` + `OrderButtons` / `useOrderItems` (the non-drag reorder, WCAG 2.5.7) (one per button family, docs/DESIGN.md; a raw `<button>` or a bare `FocusSafeButton` outside the control primitives — eslint `BUTTON_PRIMITIVES` — is a lint error), `Input` + `fieldStyles`, `RenameField` (a name typed in place of the name: Enter or blur saves, Escape cancels; the caller hands the keyboard back), `Meter` (how full something is against a limit — one danger line for every place showing the same measure), `TimecodeField` + `useTimecodeField` / `TimecodeError` (a cut's time typed in: the draft, held by the caller, until Enter or blur takes it, then the value as it landed; unreadable stays once offered, marked invalid, and the caller places the line saying how to type one in its flow, outside a label; a draft goes when the value moves under it), `NativeControls`, `MenuCheck` (the drawn check of a menu row or a whole-row checkbox button; its tick is the `chosen` colour, which `media-marks` turns amber on the player chrome), `mediaChrome` (`MEDIA_CHOSEN`, a chosen tab or toggle on the chrome), `SelectTrigger` (the field family; a raw `<input>` is a lint error), `useReturnFocus`, `surfaces` (the B5 glass roles; a bare glass class is a lint error), `Badge` (a static label), `LinkButton` / `IconLink` (a link wearing the button face) over `buttonStyles`, `Skeleton` (B8 first loads, through `LoadingState shape`), `NavRail` (B7; `NavRailGroup` folds a set of rare pages into one row with a panel), `form`, `DraftSaveBar`, `Combobox`, `FilterSelect`, `SegmentedToggle`, `SortControl`, `ColumnSortControl`, `PriorityStepper`, `UndoToolbar`, `ViewSwitcher` (a list's display modes behind one toolbar icon), `copyItems` (`useCopyItems`: the "Copy title" / "Copy link" rows every object's menu ends with) + `useCopyFeedback` (a Copy button's "Copied" moment), `MatchToggle` (a multi-value filter's any/all switch), `LanguageSwitcher` (presentational: the caller decides what a change does), `sortableA11y` (what a screen reader hears during a keyboard reorder) |
|
||||||
| data display | `DataTable` (+ `progressiveRows`: a long page mounts in steps), `tableHeader` + `tableAlign`, `HiddenFilterCue` (names a filter whose column is off screen), `Pager` + `pagerContent`, `VirtualGrid` + `gridColumns` (the column/width maths, incl. the ultrawide `maxCols` cap), `readAhead`, `Carousel`, `MediaCard` + `mediaCardStyles` (the one media card, docs/DESIGN.md "Media card") + `MediaPreview` / `useMediaPreview` (its mouse-rest preview), `NavGrid` (a plain grid, list or table body the keyboard walks — `DataTable`, `VirtualGrid` and `Carousel` take `keyboardNav`), `RemoteImage` (every network picture: no Referer, retries past the negative cache, a fallback instead of the broken-image mark — a bare `<img>` is linted out), `Avatar`, `CountBadge`, `ItemCount`, `FilterChipGroup`, `RelativeTime` ("3d ago" with the exact minute on hover, docs/DESIGN.md "Copy"), `ClampedText` (a long text clamped to a few lines; More / Less only when it is cut), `StatTiles` + `StatTile` / `StatBars` (number tiles, a breakdown as horizontal bars), `RangeTrack` + `RulerTicks` (a time track over a window of a media item: regions, slider ends the arrows step, a playhead, a background slot — the editor's cuts, the players' clip panel), `CropFrame` (a crop frame over a picture, on the picture and not its bars: drag, edges, corners, the arrows; the editor's crop, the players' clip crop) |
|
| data display | `DataTable` (+ `progressiveRows`: a long page mounts in steps), `tableHeader` + `tableAlign`, `HiddenFilterCue` (names a filter whose column is off screen), `Pager` + `pagerContent`, `VirtualGrid` + `gridColumns` (the column/width maths, incl. the ultrawide `maxCols` cap), `readAhead`, `Carousel`, `MediaCard` + `mediaCardStyles` (the one media card, docs/DESIGN.md "Media card") + `MediaPreview` / `useMediaPreview` (its mouse-rest preview), `NavGrid` (a plain grid, list or table body the keyboard walks — `DataTable`, `VirtualGrid` and `Carousel` take `keyboardNav`), `RemoteImage` (every network picture: no Referer, retries past the negative cache, a fallback instead of the broken-image mark — a bare `<img>` is linted out), `Avatar`, `CountBadge`, `ItemCount`, `FilterChipGroup`, `RelativeTime` ("3d ago" with the exact minute on hover, docs/DESIGN.md "Copy"), `ClampedText` (a long text clamped to a few lines; More / Less only when it is cut), `StatTiles` + `StatTile` / `StatBars` (number tiles, a breakdown as horizontal bars), `RangeTrack` + `trackPosition` + `RulerTicks` (a time track over a window of a media item: regions, slider ends the arrows step, a playhead, a background slot — the editor's cuts, the players' clip panel), `CropFrame` (a crop frame over a picture, on the picture and not its bars: drag, edges, corners, the arrows; the editor's crop, the players' clip crop), `reveal` (a card's hover chrome: hover or keyboard focus on a mouse, never on touch) |
|
||||||
| page & panel structure | `PageShell` + `pageToolbarSlot` + `pageHeading`, `PageScroller`, `Headings` (`PageHeading`, `SectionLabel`), `SkipLink` + `landmarks`, `FilterFold` (B4), `scrollFold` (`useScrollFold`: a fixed header that folds once its content scrolls), `PanelGroup`, `PanelGroups`, `Tabs`, `BackToTop` |
|
| page & panel structure | `PageShell` + `pageToolbarSlot` + `pageHeaderSlot` (`PageHeader`: a page fills the floating header) + `pageHeading`, `PageScroller`, `Headings` (`PageHeading`, `SectionLabel`), `SkipLink` + `landmarks`, `FilterFold` (B4), `scrollFold` (`useScrollFold`: a fixed header that folds once its content scrolls), `PanelGroup`, `PanelGroups`, `Tabs` + `tabPanel`, `BackToTop` + `backToTopState` (PageScroller keeps the page's end clear of it), `FilterRail` (the filter-rail shell a rail fills with its groups: title bar, customize, reset), `stickyTop` (a sticky header at the scroller's top turns its top fade off) |
|
||||||
| state & messaging | `QueryState`, `Banner`, `InlineNotice`, `CrashBoundary` |
|
| state & messaging | `QueryState`, `Banner`, `InlineNotice`, `CrashBoundary` |
|
||||||
|
|
||||||
## The size contract (E10 S1c)
|
## The size contract (E10 S1c)
|
||||||
|
|||||||
@@ -18,12 +18,12 @@ import { readSources } from "../../lib/rawSources.testkit";
|
|||||||
// BOTH patterns: `../**/*.tsx` from this directory does not match the siblings in `ui/` itself, so
|
// BOTH patterns: `../**/*.tsx` from this directory does not match the siblings in `ui/` itself, so
|
||||||
// ui/Modal.tsx — the overlay behind every dialog in the app — was never checked by this guard at all
|
// ui/Modal.tsx — the overlay behind every dialog in the app — was never checked by this guard at all
|
||||||
// (found by a later review).
|
// (found by a later review).
|
||||||
const sources = readSources(__dirname, ["../**/*.tsx", "./*.tsx"]);
|
const sources = readSources(__dirname, ["../../**/*.tsx", "!../../design-lab/**"]);
|
||||||
|
|
||||||
// Overlays with nothing a toast could be about: a lazy chunk's black placeholder while the player
|
// Overlays with nothing a toast could be about: a lazy chunk's black placeholder while the player
|
||||||
// loads, and the signed-out landing page's screenshot zoom (the app's Toaster is not even mounted
|
// loads, and the signed-out landing page's screenshot zoom (the app's Toaster is not even mounted
|
||||||
// there — App renders Welcome instead of the shell).
|
// there — App renders Welcome instead of the shell).
|
||||||
const EXEMPT = new Set(["../PlexBrowse.tsx", "../Welcome.tsx"]);
|
const EXEMPT = new Set(["../../modules/plex/PlexBrowse.tsx", "../../shell/Welcome.tsx"]);
|
||||||
|
|
||||||
/** Every class string in the file (quoted or templated), including multi-line ones. */
|
/** Every class string in the file (quoted or templated), including multi-line ones. */
|
||||||
const classStrings = (src: string): string[] =>
|
const classStrings = (src: string): string[] =>
|
||||||
@@ -67,9 +67,9 @@ describe("full-screen overlays host their own toasts", () => {
|
|||||||
expect(paths).toEqual(
|
expect(paths).toEqual(
|
||||||
expect.arrayContaining([
|
expect.arrayContaining([
|
||||||
"./Modal.tsx",
|
"./Modal.tsx",
|
||||||
"../PlayerModal.tsx",
|
"../../features/player/PlayerModal.tsx",
|
||||||
"../PlexPlayer.tsx",
|
"../../modules/plex/PlexPlayer.tsx",
|
||||||
"../OnboardingWizard.tsx",
|
"../../features/wizard/OnboardingWizard.tsx",
|
||||||
]),
|
]),
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|||||||
+5
-5
@@ -1,11 +1,11 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { type LucideIcon } from "lucide-react";
|
import { type LucideIcon } from "lucide-react";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { PopoverItem, PopoverSurface } from "./ui/AnchoredPopover";
|
import { PopoverItem, PopoverSurface } from "./AnchoredPopover";
|
||||||
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
|
import { useAnchoredPopover } from "./anchoredPopoverHooks";
|
||||||
import { MenuCheck } from "./ui/MenuCheck";
|
import { MenuCheck } from "./MenuCheck";
|
||||||
import { SelectTrigger } from "./ui/SelectTrigger";
|
import { SelectTrigger } from "./SelectTrigger";
|
||||||
import { SURFACE } from "./ui/surfaces";
|
import { SURFACE } from "./surfaces";
|
||||||
|
|
||||||
export interface ViewOption<T extends string> {
|
export interface ViewOption<T extends string> {
|
||||||
id: T;
|
id: T;
|
||||||
@@ -1,16 +1,17 @@
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Copy, Link2 } from "lucide-react";
|
import { Copy, Link2 } from "lucide-react";
|
||||||
|
|
||||||
import { copyText } from "../lib/clipboard";
|
import { copyText } from "../../lib/clipboard";
|
||||||
import { notify } from "../lib/notifications";
|
import { notify } from "../../lib/notifications";
|
||||||
import { type ActionMenuItem } from "./ui/ActionMenu";
|
import { type ActionMenuItem } from "./ActionMenu";
|
||||||
|
|
||||||
// "Copy title" and "Copy link", the two rows every object's menu ends its middle group with
|
// "Copy title" and "Copy link", the two rows every object's menu ends its middle group with
|
||||||
// (DESIGN.md C1, "Text stays selectable"): on touch the long-press belongs to the context menu, so the text a
|
// (DESIGN.md C1, "Text stays selectable"): on touch the long-press belongs to the context menu, so the text a
|
||||||
// finger cannot select is copied from here. One builder, so every object says it the same way and
|
// finger cannot select is copied from here. One builder, so every object says it the same way and
|
||||||
// the clipboard write always runs inside the gesture (`inGesture`, which Safari insists on).
|
// the clipboard write always runs inside the gesture (`inGesture`, which Safari insists on).
|
||||||
//
|
//
|
||||||
// A feature file, not `ui/`: it speaks the app's notifications.
|
// Domain-free: it speaks through lib/notifications, the platform's toast service, so any module's menu
|
||||||
|
// can end with it.
|
||||||
|
|
||||||
type Named = "title" | "name";
|
type Named = "title" | "name";
|
||||||
|
|
||||||
@@ -6,9 +6,9 @@ import { describe, expect, it } from "vitest";
|
|||||||
// or have no header at all. Each renders the hidden-filter cue, so no filter narrows its rows unseen.
|
// or have no header at all. Each renders the hidden-filter cue, so no filter narrows its rows unseen.
|
||||||
describe("every view that filters rows by column", () => {
|
describe("every view that filters rows by column", () => {
|
||||||
it("renders the hidden-filter cue", () => {
|
it("renders the hidden-filter cue", () => {
|
||||||
const root = new URL("../", import.meta.url);
|
const root = new URL("../../", import.meta.url);
|
||||||
const sites = readdirSync(root, { recursive: true, encoding: "utf8" })
|
const sites = readdirSync(root, { recursive: true, encoding: "utf8" })
|
||||||
.filter((f) => f.endsWith(".tsx") && !f.includes(".test."))
|
.filter((f) => f.endsWith(".tsx") && !f.includes(".test.") && !f.startsWith("design-lab"))
|
||||||
.map((f) => [f, readFileSync(new URL(f.replace(/\\/g, "/"), root), "utf8")] as const)
|
.map((f) => [f, readFileSync(new URL(f.replace(/\\/g, "/"), root), "utf8")] as const)
|
||||||
.filter(([, src]) => /filterRows\(/.test(src));
|
.filter(([, src]) => /filterRows\(/.test(src));
|
||||||
expect(sites.length).toBeGreaterThanOrEqual(3);
|
expect(sites.length).toBeGreaterThanOrEqual(3);
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { readdirSync, readFileSync } from "node:fs";
|
import { readdirSync, readFileSync } from "node:fs";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
const root = new URL("../", import.meta.url);
|
// The whole app source (shell, modules, features, ui): the checkable menus live in all of them.
|
||||||
|
const root = new URL("../../", import.meta.url);
|
||||||
const sources = () =>
|
const sources = () =>
|
||||||
readdirSync(root, { recursive: true, encoding: "utf8" })
|
readdirSync(root, { recursive: true, encoding: "utf8" })
|
||||||
.filter((f) => f.endsWith(".tsx") && !f.includes(".test."))
|
.filter((f) => f.endsWith(".tsx") && !f.includes(".test."))
|
||||||
|
|||||||
@@ -0,0 +1,17 @@
|
|||||||
|
import { createContext } from "react";
|
||||||
|
|
||||||
|
// The floating header's slot (see PageHeader in PageShell): the node a page portals its header
|
||||||
|
// controls into, whether a page has claimed it, and how a page claims it. The value is null with no
|
||||||
|
// shell and the target null in a shell without a header (the channel page): a page renders its header
|
||||||
|
// nowhere there. Its own
|
||||||
|
// module so PageShell exports only components (Fast Refresh).
|
||||||
|
export type PageHeaderSlotValue = {
|
||||||
|
target: HTMLElement | null;
|
||||||
|
claimed: boolean;
|
||||||
|
/** Claim the header for this page's controls; the returned function releases it. */
|
||||||
|
claim: () => () => void;
|
||||||
|
/** The default (PageHeaderDefault) reports whether it is in the header, so PageShell re-measures. */
|
||||||
|
showDefault: (shown: boolean) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const PageHeaderSlot = createContext<PageHeaderSlotValue | null>(null);
|
||||||
@@ -7,9 +7,9 @@ import { readSources } from "../../lib/rawSources.testkit";
|
|||||||
// stats, users, downloads) keep the browser's menu. A page that starts rendering one fails here until
|
// stats, users, downloads) keep the browser's menu. A page that starts rendering one fails here until
|
||||||
// that decision is taken again.
|
// that decision is taken again.
|
||||||
const files = readSources(__dirname, [
|
const files = readSources(__dirname, [
|
||||||
"../**/*.tsx",
|
"../../**/*.tsx",
|
||||||
"!../**/*.test.*",
|
"!../../**/*.test.*",
|
||||||
"!../design-lab/**",
|
"!../../design-lab/**",
|
||||||
"!./**",
|
"!./**",
|
||||||
]) as Record<string, string>;
|
]) as Record<string, string>;
|
||||||
|
|
||||||
@@ -17,14 +17,14 @@ describe("the pages with a background menu", () => {
|
|||||||
it("are the five the owner chose", () => {
|
it("are the five the owner chose", () => {
|
||||||
const users = Object.entries(files)
|
const users = Object.entries(files)
|
||||||
.filter(([, code]) => /<PageMenu\b|\busePageMenu\(/.test(code))
|
.filter(([, code]) => /<PageMenu\b|\busePageMenu\(/.test(code))
|
||||||
.map(([path]) => path.replace(/^\.\.\//, ""))
|
.map(([path]) => path.replace(/^(\.\.\/)+/, ""))
|
||||||
.sort();
|
.sort();
|
||||||
expect(users).toEqual([
|
expect(users).toEqual([
|
||||||
"Channels.tsx",
|
"modules/channels/Channels.tsx",
|
||||||
"Feed.tsx",
|
"modules/feed/Feed.tsx",
|
||||||
"NotificationsPanel.tsx",
|
"modules/notifications/NotificationsPanel.tsx",
|
||||||
"Playlists.tsx",
|
"modules/playlists/Playlists.tsx",
|
||||||
"PlexBrowse.tsx",
|
"modules/plex/PlexBrowse.tsx",
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -10,66 +10,74 @@ import { readSources } from "../../lib/rawSources.testkit";
|
|||||||
// someone decides.
|
// someone decides.
|
||||||
|
|
||||||
const files = readSources(__dirname, [
|
const files = readSources(__dirname, [
|
||||||
"../**/*.tsx",
|
"../../**/*.tsx",
|
||||||
"!../**/*.test.*",
|
"!../../**/*.test.*",
|
||||||
"!../design-lab/**",
|
"!../../design-lab/**",
|
||||||
]) as Record<string, string>;
|
]) as Record<string, string>;
|
||||||
|
|
||||||
/** file → [how many plain Pressables, why they are not titles]. */
|
/** file → [how many plain Pressables, why they are not titles]. */
|
||||||
const PLAIN: Record<string, [number, string]> = {
|
const PLAIN: Record<string, [number, string]> = {
|
||||||
"ApiTokens.tsx": [
|
"modules/settings/ApiTokens.tsx": [
|
||||||
1,
|
1,
|
||||||
"a token kind's tile in the new-token dialog (an option: its text is its label)",
|
"a token kind's tile in the new-token dialog (an option: its text is its label)",
|
||||||
],
|
],
|
||||||
"DownloadTray.tsx": [
|
"features/downloads/DownloadTray.tsx": [
|
||||||
2,
|
2,
|
||||||
"the folded pill (its summary is its label); a failure's reason, which opens its details",
|
"the folded pill (its summary is its label); a failure's reason, which opens its details",
|
||||||
],
|
],
|
||||||
"FileCard.tsx": [1, "a card's picture (named by aria-label; its title is the caption)"],
|
"modules/files/FileCard.tsx": [
|
||||||
"FilesRail.tsx": [2, "the load-error retry; the New-folder command"],
|
1,
|
||||||
"Home.tsx": [
|
"a card's picture (named by aria-label; its title is the caption)",
|
||||||
|
],
|
||||||
|
"modules/files/FilesRail.tsx": [2, "the load-error retry; the New-folder command"],
|
||||||
|
"modules/home/Home.tsx": [
|
||||||
2,
|
2,
|
||||||
"the hero's picture and a shelf tile's picture (each named by aria-label; its title sits outside)",
|
"the hero's picture and a shelf tile's picture (each named by aria-label; its title sits outside)",
|
||||||
],
|
],
|
||||||
"LanguageSwitcher.tsx": [1, "an option row"],
|
"components/ui/LanguageSwitcher.tsx": [1, "an option row"],
|
||||||
"NavSidebar.tsx": [3, "the account trigger and its menu rows"],
|
"shell/NavSidebar.tsx": [3, "the account trigger and its menu rows"],
|
||||||
"PaletteLauncher.tsx": [2, "launcher controls"],
|
"features/header/PaletteLauncher.tsx": [2, "launcher controls"],
|
||||||
"PlaylistsRail.tsx": [1, "the load-error retry"],
|
"modules/playlists/PlaylistsRail.tsx": [1, "the load-error retry"],
|
||||||
"PlexCollectionEditor.tsx": [1, "a checkbox row in a picker"],
|
"modules/plex/PlexCollectionEditor.tsx": [1, "a checkbox row in a picker"],
|
||||||
"PlexInfo.tsx": [1, "the rating filter (a number)"],
|
"modules/plex/PlexInfo.tsx": [1, "the rating filter (a number)"],
|
||||||
"PlexPlayerSettings.tsx": [1, "the clock's date-style options"],
|
"modules/plex/PlexPlayerSettings.tsx": [1, "the clock's date-style options"],
|
||||||
"PlexPlaylistAdd.tsx": [1, "a checkbox row in a picker"],
|
"features/plexCards/PlexPlaylistAdd.tsx": [1, "a checkbox row in a picker"],
|
||||||
"PlexPlaylistView.tsx": [1, "a show's poster stack (aria-hidden)"],
|
"modules/plex/PlexPlaylistView.tsx": [1, "a show's poster stack (aria-hidden)"],
|
||||||
"PlexSearchBox.tsx": [1, "a listbox option"],
|
"modules/plex/PlexSearchBox.tsx": [1, "a listbox option"],
|
||||||
"plexCards.tsx": [1, "a card's picture (named by aria-label; its title is the caption)"],
|
"features/plexCards/plexCards.tsx": [
|
||||||
"Scheduler.tsx": [1, "the interval editor trigger"],
|
1,
|
||||||
"SettingsPanel.tsx": [1, "a colour swatch"],
|
"a card's picture (named by aria-label; its title is the caption)",
|
||||||
"SyncStatus.tsx": [1, "the status pill trigger"],
|
],
|
||||||
"TagManager.tsx": [1, "a suggestion in a picker"],
|
"modules/scheduler/Scheduler.tsx": [1, "the interval editor trigger"],
|
||||||
"UploadDialog.tsx": [1, "the drop zone"],
|
"modules/settings/SettingsPanel.tsx": [1, "a colour swatch"],
|
||||||
"VideoEditor.tsx": [2, "timeline glyphs"],
|
"modules/channels/SyncStatus.tsx": [1, "the status pill trigger"],
|
||||||
"Welcome.tsx": [1, "a screenshot (zoom)"],
|
"features/channel/TagManager.tsx": [1, "a suggestion in a picker"],
|
||||||
"playlistColumns.tsx": [1, "the thumbnail"],
|
"modules/files/UploadDialog.tsx": [1, "the drop zone"],
|
||||||
"plexDetailUi.tsx": [2, "a switch row and the poster"],
|
"modules/files/VideoEditor.tsx": [2, "timeline glyphs"],
|
||||||
"plexViews.tsx": [1, "the rating filter (a number)"],
|
"shell/Welcome.tsx": [1, "a screenshot (zoom)"],
|
||||||
"ui/ActionMenu.tsx": [1, "a menu row"],
|
"modules/playlists/playlistColumns.tsx": [1, "the thumbnail"],
|
||||||
"ui/BackToTop.tsx": [1, "a control"],
|
"modules/plex/plexDetailUi.tsx": [2, "a switch row and the poster"],
|
||||||
"ui/CommandPalette.tsx": [1, "a command row"],
|
"modules/plex/plexViews.tsx": [1, "the rating filter (a number)"],
|
||||||
"ui/NavRail.tsx": [
|
"components/ui/ActionMenu.tsx": [1, "a menu row"],
|
||||||
|
"components/ui/BackToTop.tsx": [1, "a control"],
|
||||||
|
"components/ui/CommandPalette.tsx": [1, "a command row"],
|
||||||
|
"components/ui/NavRail.tsx": [
|
||||||
2,
|
2,
|
||||||
"the action rows (About, Sign out) and the group row that opens its panel; a page row is a link",
|
"the action rows (About, Sign out) and the group row that opens its panel; a page row is a link",
|
||||||
],
|
],
|
||||||
"ui/PanelGroup.tsx": [2, "a group's collapse toggle and its label"],
|
"components/ui/PanelGroup.tsx": [2, "a group's collapse toggle and its label"],
|
||||||
"ui/SidePanel.tsx": [2, "panel controls"],
|
"components/ui/SidePanel.tsx": [2, "panel controls"],
|
||||||
"ui/tableHeader.tsx": [1, "a column's sort header"],
|
"components/ui/tableHeader.tsx": [1, "a column's sort header"],
|
||||||
};
|
};
|
||||||
|
|
||||||
function scan(): { plain: Record<string, number>; selectable: number } {
|
function scan(): { plain: Record<string, number>; selectable: number } {
|
||||||
const plain: Record<string, number> = {};
|
const plain: Record<string, number> = {};
|
||||||
let selectable = 0;
|
let selectable = 0;
|
||||||
for (const [path, code] of Object.entries(files)) {
|
for (const [path, code] of Object.entries(files)) {
|
||||||
// Glob keys are relative to this folder: `./x.tsx` is ui/, `../x.tsx` is components/.
|
// Glob keys are relative to this folder (`./x.tsx` is ui/); the table keys them from src/.
|
||||||
const rel = path.startsWith("./") ? `ui/${path.slice(2)}` : path.replace(/^\.\.\//, "");
|
const rel = path.startsWith("./")
|
||||||
|
? `components/ui/${path.slice(2)}`
|
||||||
|
: path.replace(/^(\.\.\/)+/, "");
|
||||||
for (const m of code.matchAll(/<Pressable\b/g)) {
|
for (const m of code.matchAll(/<Pressable\b/g)) {
|
||||||
if (/\sselectable\b(?!=)/.test(openingTag(code, m.index))) selectable++;
|
if (/\sselectable\b(?!=)/.test(openingTag(code, m.index))) selectable++;
|
||||||
else plain[rel] = (plain[rel] ?? 0) + 1;
|
else plain[rel] = (plain[rel] ?? 0) + 1;
|
||||||
|
|||||||
+2
-2
@@ -10,8 +10,8 @@ vi.mock("react-i18next", async (importOriginal) => ({
|
|||||||
...(await importOriginal<typeof import("react-i18next")>()),
|
...(await importOriginal<typeof import("react-i18next")>()),
|
||||||
useTranslation: () => ({ i18n: { language: "en" }, t: (key: string) => key }),
|
useTranslation: () => ({ i18n: { language: "en" }, t: (key: string) => key }),
|
||||||
}));
|
}));
|
||||||
vi.mock("../lib/clipboard", () => ({ copyText: h.copyText }));
|
vi.mock("../../lib/clipboard", () => ({ copyText: h.copyText }));
|
||||||
vi.mock("../lib/notifications", () => ({ notify: h.notify }));
|
vi.mock("../../lib/notifications", () => ({ notify: h.notify }));
|
||||||
|
|
||||||
import { useCopyFeedback } from "./useCopyFeedback";
|
import { useCopyFeedback } from "./useCopyFeedback";
|
||||||
|
|
||||||
+2
-2
@@ -1,8 +1,8 @@
|
|||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { copyText } from "../lib/clipboard";
|
import { copyText } from "../../lib/clipboard";
|
||||||
import { notify } from "../lib/notifications";
|
import { notify } from "../../lib/notifications";
|
||||||
|
|
||||||
// How long a Copy button reads "Copied" after it worked.
|
// How long a Copy button reads "Copied" after it worked.
|
||||||
const COPIED_MS = 2000;
|
const COPIED_MS = 2000;
|
||||||
@@ -1,4 +1,14 @@
|
|||||||
[
|
[
|
||||||
|
{
|
||||||
|
"version": "0.109.1",
|
||||||
|
"date": "2026-10-23",
|
||||||
|
"new": [
|
||||||
|
"Development: the frontend is one folder per page (its page, rail, store and definition), with the layers held by lint and the shared /watch page held apart by a contract test; the header knows no page, and the feed and Plex each keep their own saved views."
|
||||||
|
],
|
||||||
|
"fixed": [
|
||||||
|
"Saved views: a view you applied and then changed is still active after a visit to another page, in the feed and in Plex."
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"version": "0.109.0",
|
"version": "0.109.0",
|
||||||
"date": "2026-10-21",
|
"date": "2026-10-21",
|
||||||
|
|||||||
@@ -1,4 +1,14 @@
|
|||||||
[
|
[
|
||||||
|
{
|
||||||
|
"version": "0.109.1",
|
||||||
|
"date": "2026-10-23",
|
||||||
|
"new": [
|
||||||
|
"Fejlesztés: a frontend oldalanként egy mappa (az oldal, a sávja, a store-ja és a definíciója), a rétegeket lint őrzi, a megosztott /watch oldalt pedig contract teszt tartja külön; a fejléc nem ismer oldalt, a feednek és a Plexnek saját mentett nézetei vannak."
|
||||||
|
],
|
||||||
|
"fixed": [
|
||||||
|
"Mentett nézetek: egy alkalmazott, majd módosított nézet egy másik oldal meglátogatása után is aktív marad, a feedben és a Plexben is."
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"version": "0.109.0",
|
"version": "0.109.0",
|
||||||
"date": "2026-10-21",
|
"date": "2026-10-21",
|
||||||
|
|||||||
+4
-4
@@ -1,9 +1,9 @@
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { ExternalLink } from "lucide-react";
|
import { ExternalLink } from "lucide-react";
|
||||||
import type { ChannelDetail } from "../lib/api";
|
import type { ChannelDetail } from "../../lib/api";
|
||||||
import { linkify } from "../lib/linkify";
|
import { linkify } from "../../lib/linkify";
|
||||||
import { Badge } from "./ui/Badge";
|
import { Badge } from "../../components/ui/Badge";
|
||||||
import { languageName, regionName, vocab } from "../lib/vocab";
|
import { languageName, regionName, vocab } from "../../lib/vocab";
|
||||||
|
|
||||||
// --- About-tab metadata helpers (shared by the channel page and the manager's About overlay) ---
|
// --- About-tab metadata helpers (shared by the channel page and the manager's About overlay) ---
|
||||||
// YouTube keywords are ONE space-separated string with multi-word tags quoted: gaming "let's play".
|
// YouTube keywords are ONE space-separated string with multi-word tags quoted: gaming "let's play".
|
||||||
+1
-1
@@ -16,7 +16,7 @@ vi.mock("react-i18next", async (importOriginal) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
import { ChannelTagsControl } from "./ChannelTags";
|
import { ChannelTagsControl } from "./ChannelTags";
|
||||||
import { api } from "../lib/api";
|
import { api } from "../../lib/api";
|
||||||
|
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
class Stub {
|
class Stub {
|
||||||
+1
-1
@@ -22,7 +22,7 @@ vi.mock("react-i18next", async (importOriginal) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
import { ChannelTagsControl, type SuggestedTag } from "./ChannelTags";
|
import { ChannelTagsControl, type SuggestedTag } from "./ChannelTags";
|
||||||
import type { Tag } from "../lib/api";
|
import type { Tag } from "../../lib/api";
|
||||||
|
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
class Stub {
|
class Stub {
|
||||||
+10
-10
@@ -1,16 +1,16 @@
|
|||||||
import { useRef, useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
import { Plus, Sparkles } from "lucide-react";
|
import { Plus, Sparkles } from "lucide-react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { type Tag } from "../lib/api";
|
import { type Tag } from "../../lib/api";
|
||||||
import { PopoverSurface } from "./ui/AnchoredPopover";
|
import { PopoverSurface } from "../../components/ui/AnchoredPopover";
|
||||||
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
|
import { useAnchoredPopover } from "../../components/ui/anchoredPopoverHooks";
|
||||||
import { fitChipsWithPill } from "../lib/chipOverflow";
|
import { fitChipsWithPill } from "../../lib/chipOverflow";
|
||||||
import { chipLaneClass, useChipOverflowFit } from "../lib/useChipOverflowFit";
|
import { chipLaneClass, useChipOverflowFit } from "../../lib/useChipOverflowFit";
|
||||||
import { Chip, DismissibleChip } from "./ui/Chip";
|
import { Chip, DismissibleChip } from "../../components/ui/Chip";
|
||||||
import { chipClass } from "./ui/chipStyles";
|
import { chipClass } from "../../components/ui/chipStyles";
|
||||||
import { MembershipMenu } from "./ui/MembershipMenu";
|
import { MembershipMenu } from "../../components/ui/MembershipMenu";
|
||||||
import { useTags } from "../lib/useTags";
|
import { useTags } from "../../lib/useTags";
|
||||||
import { SURFACE } from "./ui/surfaces";
|
import { SURFACE } from "../../components/ui/surfaces";
|
||||||
|
|
||||||
// The channel tag surface, shared by the channel manager (table + cards) and the channel detail
|
// The channel tag surface, shared by the channel manager (table + cards) and the channel detail
|
||||||
// page. Built on the popover platform. Two design goals it fixes over the old inline TagsCell:
|
// page. Built on the popover platform. Two design goals it fixes over the old inline TagsCell:
|
||||||
+15
-15
@@ -1,22 +1,22 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { qk } from "../lib/queryKeys";
|
import { qk } from "../../lib/queryKeys";
|
||||||
import { Plus, Trash2 } from "lucide-react";
|
import { Plus, Trash2 } from "lucide-react";
|
||||||
import { api, type Tag } from "../lib/api";
|
import { api, type Tag } from "../../lib/api";
|
||||||
import { notify } from "../lib/notifications";
|
import { notify } from "../../lib/notifications";
|
||||||
import { notifyActionError } from "../lib/actionErrors";
|
import { notifyActionError } from "../../lib/actionErrors";
|
||||||
import { useScrollFade } from "../lib/useScrollFade";
|
import { useScrollFade } from "../../lib/useScrollFade";
|
||||||
import { Button } from "./ui/Button";
|
import { Button } from "../../components/ui/Button";
|
||||||
import Modal from "./ui/Modal";
|
import Modal from "../../components/ui/Modal";
|
||||||
import { useConfirm } from "./confirmContext";
|
import { useConfirm } from "../../components/ui/confirmContext";
|
||||||
import { PopoverSurface } from "./ui/AnchoredPopover";
|
import { PopoverSurface } from "../../components/ui/AnchoredPopover";
|
||||||
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
|
import { useAnchoredPopover } from "../../components/ui/anchoredPopoverHooks";
|
||||||
import { IconButton } from "./ui/IconButton";
|
import { IconButton } from "../../components/ui/IconButton";
|
||||||
import { Pressable } from "./ui/Pressable";
|
import { Pressable } from "../../components/ui/Pressable";
|
||||||
import { Input } from "./ui/Input";
|
import { Input } from "../../components/ui/Input";
|
||||||
import { SURFACE } from "./ui/surfaces";
|
import { SURFACE } from "../../components/ui/surfaces";
|
||||||
import { useTags } from "../lib/useTags";
|
import { useTags } from "../../lib/useTags";
|
||||||
|
|
||||||
// One editable row: rename in place (commit on Enter/blur), delete with a confirm, and a
|
// One editable row: rename in place (commit on Enter/blur), delete with a confirm, and a
|
||||||
// hover popover on the count listing the tagged channels (each a link that focuses it in the
|
// hover popover on the count listing the tagged channels (each a link that focuses it in the
|
||||||
+1
-1
@@ -8,7 +8,7 @@
|
|||||||
// disabled state shows an enabled row that no-ops.
|
// disabled state shows an enabled row that no-ops.
|
||||||
import { describe, it, expect } from "vitest";
|
import { describe, it, expect } from "vitest";
|
||||||
import { pinTargetForCard, shelfMoveTarget } from "./comfortPin";
|
import { pinTargetForCard, shelfMoveTarget } from "./comfortPin";
|
||||||
import type { DiscoverCard } from "../lib/api";
|
import type { DiscoverCard } from "../../lib/api";
|
||||||
|
|
||||||
const card = (over: Partial<DiscoverCard>): DiscoverCard =>
|
const card = (over: Partial<DiscoverCard>): DiscoverCard =>
|
||||||
({
|
({
|
||||||
+2
-2
@@ -8,8 +8,8 @@ import { describe, it, expect, afterEach, vi } from "vitest";
|
|||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { renderHook, act, waitFor, cleanup } from "@testing-library/react";
|
import { renderHook, act, waitFor, cleanup } from "@testing-library/react";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { setLanguage } from "../i18n";
|
import { setLanguage } from "../../i18n";
|
||||||
import { api } from "../lib/api";
|
import { api } from "../../lib/api";
|
||||||
import { useComfortPin } from "./comfortPin";
|
import { useComfortPin } from "./comfortPin";
|
||||||
|
|
||||||
// Unmount BEFORE the language goes back: a mounted reader re-renders on the switch, and under a loaded
|
// Unmount BEFORE the language goes back: a mounted reader re-renders on the switch, and under a loaded
|
||||||
+6
-6
@@ -3,12 +3,12 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { Loader2, Pin, PinOff } from "lucide-react";
|
import { Loader2, Pin, PinOff } from "lucide-react";
|
||||||
|
|
||||||
import { plexLang } from "../i18n";
|
import { plexLang } from "../../i18n";
|
||||||
import { api, type DiscoverCard, type PinKind } from "../lib/api";
|
import { api, type DiscoverCard, type PinKind } from "../../lib/api";
|
||||||
import { notifyActionError } from "../lib/actionErrors";
|
import { notifyActionError } from "../../lib/actionErrors";
|
||||||
import { notify } from "../lib/notifications";
|
import { notify } from "../../lib/notifications";
|
||||||
import { qk } from "../lib/queryKeys";
|
import { qk } from "../../lib/queryKeys";
|
||||||
import { type ActionMenuItem } from "./ui/ActionMenu";
|
import { type ActionMenuItem } from "../../components/ui/ActionMenu";
|
||||||
|
|
||||||
// "put this on my comfort shelf", from wherever you happen to be looking at the thing.
|
// "put this on my comfort shelf", from wherever you happen to be looking at the thing.
|
||||||
//
|
//
|
||||||
+16
-11
@@ -10,8 +10,8 @@ import { useState } from "react";
|
|||||||
import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
|
||||||
import type { DebugSession, Me } from "../lib/api";
|
import type { DebugSession, Me } from "../../lib/api";
|
||||||
import { qk } from "../lib/queryKeys";
|
import { qk } from "../../lib/queryKeys";
|
||||||
|
|
||||||
vi.mock("react-i18next", async (importOriginal) => {
|
vi.mock("react-i18next", async (importOriginal) => {
|
||||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||||
@@ -58,8 +58,8 @@ const { order, session, startDebugSession, stopDebugSession, hold, past } = vi.h
|
|||||||
});
|
});
|
||||||
return { order, session, startDebugSession, stopDebugSession, hold, past };
|
return { order, session, startDebugSession, stopDebugSession, hold, past };
|
||||||
});
|
});
|
||||||
vi.mock("../lib/api", async (importOriginal) => {
|
vi.mock("../../lib/api", async (importOriginal) => {
|
||||||
const actual = await importOriginal<typeof import("../lib/api")>();
|
const actual = await importOriginal<typeof import("../../lib/api")>();
|
||||||
return {
|
return {
|
||||||
...actual,
|
...actual,
|
||||||
api: {
|
api: {
|
||||||
@@ -70,8 +70,8 @@ vi.mock("../lib/api", async (importOriginal) => {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
vi.mock("../lib/diagnostics", async (importOriginal) => {
|
vi.mock("../../lib/diagnostics", async (importOriginal) => {
|
||||||
const actual = await importOriginal<typeof import("../lib/diagnostics")>();
|
const actual = await importOriginal<typeof import("../../lib/diagnostics")>();
|
||||||
return {
|
return {
|
||||||
...actual,
|
...actual,
|
||||||
flushNow: () => {
|
flushNow: () => {
|
||||||
@@ -82,16 +82,21 @@ vi.mock("../lib/diagnostics", async (importOriginal) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
import DebugRecorder, { RecorderSettings } from "./DebugRecorder";
|
import DebugRecorder, { RecorderSettings } from "./DebugRecorder";
|
||||||
import Modal from "./ui/Modal";
|
import Modal from "../../components/ui/Modal";
|
||||||
import Toaster from "./ui/Toaster";
|
import Toaster from "../../components/ui/Toaster";
|
||||||
import {
|
import {
|
||||||
closeComposer,
|
closeComposer,
|
||||||
openRecorderStart,
|
openRecorderStart,
|
||||||
setStepPrompt,
|
setStepPrompt,
|
||||||
useComposerSession,
|
useComposerSession,
|
||||||
} from "../lib/debugRecorder";
|
} from "../../lib/debugRecorder";
|
||||||
import { anyBlockingLayer, anyBlockingLayerAbove, pushLayer, removeLayer } from "../lib/layerStack";
|
import {
|
||||||
import { events } from "../lib/log";
|
anyBlockingLayer,
|
||||||
|
anyBlockingLayerAbove,
|
||||||
|
pushLayer,
|
||||||
|
removeLayer,
|
||||||
|
} from "../../lib/layerStack";
|
||||||
|
import { events } from "../../lib/log";
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
order.length = 0;
|
order.length = 0;
|
||||||
+15
-15
@@ -3,8 +3,8 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { Camera, Flag, Square } from "lucide-react";
|
import { Camera, Flag, Square } from "lucide-react";
|
||||||
|
|
||||||
import { api, type DebugSession, type DebugSessionLevel } from "../lib/api";
|
import { api, type DebugSession, type DebugSessionLevel } from "../../lib/api";
|
||||||
import { canCaptureTab } from "../lib/captureTab";
|
import { canCaptureTab } from "../../lib/captureTab";
|
||||||
import {
|
import {
|
||||||
closeRecorderStart,
|
closeRecorderStart,
|
||||||
onSessionChangeElsewhere,
|
onSessionChangeElsewhere,
|
||||||
@@ -15,21 +15,21 @@ import {
|
|||||||
useRecorderStartOpen,
|
useRecorderStartOpen,
|
||||||
useStepCount,
|
useStepCount,
|
||||||
useStepPrompt,
|
useStepPrompt,
|
||||||
} from "../lib/debugRecorder";
|
} from "../../lib/debugRecorder";
|
||||||
import { formatDateTimeSec, formatDuration } from "../lib/format";
|
import { formatDateTimeSec, formatDuration } from "../../lib/format";
|
||||||
import { useLayer } from "../lib/layerStack";
|
import { useLayer } from "../../lib/layerStack";
|
||||||
import { notify } from "../lib/notifications";
|
import { notify } from "../../lib/notifications";
|
||||||
import { qk } from "../lib/queryKeys";
|
import { qk } from "../../lib/queryKeys";
|
||||||
import { dockUnderToasts } from "../lib/toastDock";
|
import { dockUnderToasts } from "../../lib/toastDock";
|
||||||
import { useMe } from "../lib/useMe";
|
import { useMe } from "../../lib/useMe";
|
||||||
import { Button } from "./ui/Button";
|
import { Button } from "../../components/ui/Button";
|
||||||
import Modal, { ModalFooter } from "./ui/Modal";
|
import Modal, { ModalFooter } from "../../components/ui/Modal";
|
||||||
import { LoadingState, StateMessage } from "./ui/QueryState";
|
import { LoadingState, StateMessage } from "../../components/ui/QueryState";
|
||||||
import SegmentedToggle from "./ui/SegmentedToggle";
|
import SegmentedToggle from "../../components/ui/SegmentedToggle";
|
||||||
import IssueComposer from "./IssueComposer";
|
import IssueComposer from "./IssueComposer";
|
||||||
import { useDebugRecording } from "./useDebugRecording";
|
import { useDebugRecording } from "./useDebugRecording";
|
||||||
import { Input } from "./ui/Input";
|
import { Input } from "../../components/ui/Input";
|
||||||
import { SURFACE } from "./ui/surfaces";
|
import { SURFACE } from "../../components/ui/surfaces";
|
||||||
|
|
||||||
// The debug recorder: Start → reproduce → Stop.
|
// The debug recorder: Start → reproduce → Stop.
|
||||||
//
|
//
|
||||||
+2
-2
@@ -1,7 +1,7 @@
|
|||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
import { navItemAttrs } from "../lib/listNav";
|
import { navItemAttrs } from "../../lib/listNav";
|
||||||
import { Pressable } from "./ui/Pressable";
|
import { Pressable } from "../../components/ui/Pressable";
|
||||||
|
|
||||||
// One dense row of a history list — a background run, an admin action, a log line (./LogLine draws
|
// One dense row of a history list — a background run, an admin action, a log line (./LogLine draws
|
||||||
// its lines through this): its cells on one line, and the row opens in place to its details. One frame
|
// its lines through this): its cells on one line, and the row opens in place to its details. One frame
|
||||||
+4
-4
@@ -8,8 +8,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|||||||
import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
|
||||||
import type { DebugDraft, DebugSession, ServerLogRow } from "../lib/api";
|
import type { DebugDraft, DebugSession, ServerLogRow } from "../../lib/api";
|
||||||
import { qk } from "../lib/queryKeys";
|
import { qk } from "../../lib/queryKeys";
|
||||||
|
|
||||||
vi.mock("react-i18next", async (importOriginal) => {
|
vi.mock("react-i18next", async (importOriginal) => {
|
||||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||||
@@ -38,8 +38,8 @@ const api = vi.hoisted(() => ({
|
|||||||
// The server's half-failure: the issue is committed, then an upload dies and the call 422s.
|
// The server's half-failure: the issue is committed, then an upload dies and the call 422s.
|
||||||
failSendAfterCreate: false,
|
failSendAfterCreate: false,
|
||||||
}));
|
}));
|
||||||
vi.mock("../lib/api", async (importOriginal) => {
|
vi.mock("../../lib/api", async (importOriginal) => {
|
||||||
const actual = await importOriginal<typeof import("../lib/api")>();
|
const actual = await importOriginal<typeof import("../../lib/api")>();
|
||||||
return {
|
return {
|
||||||
...actual,
|
...actual,
|
||||||
api: {
|
api: {
|
||||||
+19
-19
@@ -3,11 +3,11 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { ExternalLink, FileText, Plus, Server, X } from "lucide-react";
|
import { ExternalLink, FileText, Plus, Server, X } from "lucide-react";
|
||||||
|
|
||||||
import { api, type DebugScreenshot, type DebugSession, type ServerLogRow } from "../lib/api";
|
import { api, type DebugScreenshot, type DebugSession, type ServerLogRow } from "../../lib/api";
|
||||||
import { closeComposer } from "../lib/debugRecorder";
|
import { closeComposer } from "../../lib/debugRecorder";
|
||||||
import { formatBytes, formatDateTimeSec, formatUnit } from "../lib/format";
|
import { formatBytes, formatDateTimeSec, formatUnit } from "../../lib/format";
|
||||||
import { notify } from "../lib/notifications";
|
import { notify } from "../../lib/notifications";
|
||||||
import { useMe } from "../lib/useMe";
|
import { useMe } from "../../lib/useMe";
|
||||||
import {
|
import {
|
||||||
draftSteps,
|
draftSteps,
|
||||||
environment,
|
environment,
|
||||||
@@ -15,21 +15,21 @@ import {
|
|||||||
type DraftForm,
|
type DraftForm,
|
||||||
type DraftStep,
|
type DraftStep,
|
||||||
type IssueText,
|
type IssueText,
|
||||||
} from "../lib/issueDraft";
|
} from "../../lib/issueDraft";
|
||||||
import { moduleDef } from "../lib/modules";
|
import { moduleDef } from "../navigation/moduleRegistry";
|
||||||
import type { Page } from "../lib/urlState";
|
import type { Page } from "../../lib/urlState";
|
||||||
import { qk } from "../lib/queryKeys";
|
import { qk } from "../../lib/queryKeys";
|
||||||
import { focusIsUnclaimed } from "../lib/useFocusKeeper";
|
import { focusIsUnclaimed } from "../../lib/useFocusKeeper";
|
||||||
import { LEVEL_TAG, logClock } from "./logLineParts";
|
import { LEVEL_TAG, logClock } from "./logLineParts";
|
||||||
import { Button } from "./ui/Button";
|
import { Button } from "../../components/ui/Button";
|
||||||
import { IconButton } from "./ui/IconButton";
|
import { IconButton } from "../../components/ui/IconButton";
|
||||||
import Modal, { ModalFooter } from "./ui/Modal";
|
import Modal, { ModalFooter } from "../../components/ui/Modal";
|
||||||
import { LoadingState, StateMessage } from "./ui/QueryState";
|
import { LoadingState, StateMessage } from "../../components/ui/QueryState";
|
||||||
import SegmentedToggle from "./ui/SegmentedToggle";
|
import SegmentedToggle from "../../components/ui/SegmentedToggle";
|
||||||
import { Textarea, Input } from "./ui/Input";
|
import { Textarea, Input } from "../../components/ui/Input";
|
||||||
import { Checkbox } from "./ui/NativeControls";
|
import { Checkbox } from "../../components/ui/NativeControls";
|
||||||
import { SURFACE } from "./ui/surfaces";
|
import { SURFACE } from "../../components/ui/surfaces";
|
||||||
import RemoteImage from "./ui/RemoteImage";
|
import RemoteImage from "../../components/ui/RemoteImage";
|
||||||
|
|
||||||
// The issue composer: one dialog — the draft on the left (title, description, the
|
// The issue composer: one dialog — the draft on the left (title, description, the
|
||||||
// steps drafted from the session, the environment), what goes with it on the right (the two logs,
|
// steps drafted from the session, the environment), what goes with it on the right (the two logs,
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
import type { Level } from "../lib/log";
|
import type { Level } from "../../lib/log";
|
||||||
import HistoryRow from "./HistoryRow";
|
import HistoryRow from "./HistoryRow";
|
||||||
import { LEVEL_TAG, logClock } from "./logLineParts";
|
import { LEVEL_TAG, logClock } from "./logLineParts";
|
||||||
|
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
import type { Level } from "../lib/log";
|
import type { Level } from "../../lib/log";
|
||||||
|
|
||||||
// What a log line is drawn with — the level tags and the clock — shared by LogLine (both log
|
// What a log line is drawn with — the level tags and the clock — shared by LogLine (both log
|
||||||
// viewers) and the issue composer's review list, so a level reads the same everywhere. Apart from
|
// viewers) and the issue composer's review list, so a level reads the same everywhere. Apart from
|
||||||
+9
-9
@@ -2,21 +2,21 @@ import { useCallback } from "react";
|
|||||||
import { useIsMutating, useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useIsMutating, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { api, type DebugSession, type DebugSessionLevel } from "../lib/api";
|
import { api, type DebugSession, type DebugSessionLevel } from "../../lib/api";
|
||||||
import { notifyActionError } from "../lib/actionErrors";
|
import { notifyActionError } from "../../lib/actionErrors";
|
||||||
import { captureTab } from "../lib/captureTab";
|
import { captureTab } from "../../lib/captureTab";
|
||||||
import {
|
import {
|
||||||
announceSessionChange,
|
announceSessionChange,
|
||||||
closeRecorderStart,
|
closeRecorderStart,
|
||||||
nextStep,
|
nextStep,
|
||||||
openComposer,
|
openComposer,
|
||||||
setStepPrompt,
|
setStepPrompt,
|
||||||
} from "../lib/debugRecorder";
|
} from "../../lib/debugRecorder";
|
||||||
import { flushNow, liveSession } from "../lib/diagnostics";
|
import { flushNow, liveSession } from "../../lib/diagnostics";
|
||||||
import { log } from "../lib/log";
|
import { log } from "../../lib/log";
|
||||||
import { notify } from "../lib/notifications";
|
import { notify } from "../../lib/notifications";
|
||||||
import { qk } from "../lib/queryKeys";
|
import { qk } from "../../lib/queryKeys";
|
||||||
import { updateMeCache, useMe } from "../lib/useMe";
|
import { updateMeCache, useMe } from "../../lib/useMe";
|
||||||
|
|
||||||
/** The debug recording's actions, shared by everything that drives it: the pill, Settings →
|
/** The debug recording's actions, shared by everything that drives it: the pill, Settings →
|
||||||
* Diagnosztika and the command palette. The session itself is `me.debug_session` — the server's
|
* Diagnosztika and the command palette. The session itself is `me.debug_session` — the server's
|
||||||
+3
-3
@@ -1,5 +1,5 @@
|
|||||||
import { lazy, Suspense, useState } from "react";
|
import { lazy, Suspense, useState } from "react";
|
||||||
import { qk } from "../lib/queryKeys";
|
import { qk } from "../../lib/queryKeys";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { Download } from "lucide-react";
|
import { Download } from "lucide-react";
|
||||||
@@ -7,8 +7,8 @@ import clsx from "clsx";
|
|||||||
// Lazy: the download dialog (+ its profile editor) loads only when a card's download button is
|
// Lazy: the download dialog (+ its profile editor) loads only when a card's download button is
|
||||||
// clicked, so it never ships in the feed chunk for the majority who just browse.
|
// clicked, so it never ships in the feed chunk for the majority who just browse.
|
||||||
const DownloadDialog = lazy(() => import("./DownloadDialog"));
|
const DownloadDialog = lazy(() => import("./DownloadDialog"));
|
||||||
import { api } from "../lib/api";
|
import { api } from "../../lib/api";
|
||||||
import { IconButton } from "./ui/IconButton";
|
import { IconButton } from "../../components/ui/IconButton";
|
||||||
|
|
||||||
// Self-contained download affordance for a video card / the player. Reflects the video's current state from
|
// Self-contained download affordance for a video card / the player. Reflects the video's current state from
|
||||||
// the shared per-user download index (one polled query shared across every card).
|
// the shared per-user download index (one polled query shared across every card).
|
||||||
+5
-5
@@ -7,7 +7,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
|||||||
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { api, type DownloadProfile } from "../lib/api";
|
import { api, type DownloadProfile } from "../../lib/api";
|
||||||
|
|
||||||
const h = vi.hoisted(() => ({
|
const h = vi.hoisted(() => ({
|
||||||
prefs: new Map<string, unknown>(),
|
prefs: new Map<string, unknown>(),
|
||||||
@@ -19,7 +19,7 @@ vi.mock("react-i18next", async (importOriginal) => ({
|
|||||||
...(await importOriginal<typeof import("react-i18next")>()),
|
...(await importOriginal<typeof import("react-i18next")>()),
|
||||||
useTranslation: () => ({ i18n: { language: "en" }, t: (key: string) => key }),
|
useTranslation: () => ({ i18n: { language: "en" }, t: (key: string) => key }),
|
||||||
}));
|
}));
|
||||||
vi.mock("../lib/usePref", () => ({
|
vi.mock("../../lib/usePref", () => ({
|
||||||
usePref: <T,>(key: string, initial: T) => {
|
usePref: <T,>(key: string, initial: T) => {
|
||||||
const [value, setValue] = useState<T>(() =>
|
const [value, setValue] = useState<T>(() =>
|
||||||
h.prefs.has(key) ? (h.prefs.get(key) as T) : initial,
|
h.prefs.has(key) ? (h.prefs.get(key) as T) : initial,
|
||||||
@@ -33,11 +33,11 @@ vi.mock("../lib/usePref", () => ({
|
|||||||
];
|
];
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
vi.mock("../lib/notifications", async (importOriginal) => ({
|
vi.mock("../../lib/notifications", async (importOriginal) => ({
|
||||||
...(await importOriginal<typeof import("../lib/notifications")>()),
|
...(await importOriginal<typeof import("../../lib/notifications")>()),
|
||||||
notify: h.notify,
|
notify: h.notify,
|
||||||
}));
|
}));
|
||||||
vi.mock("../lib/downloadQueue", () => ({ showInTray: h.showInTray }));
|
vi.mock("../../lib/downloadQueue", () => ({ showInTray: h.showInTray }));
|
||||||
|
|
||||||
import DownloadDialog from "./DownloadDialog";
|
import DownloadDialog from "./DownloadDialog";
|
||||||
|
|
||||||
+14
-14
@@ -1,22 +1,22 @@
|
|||||||
import { lazy, Suspense, useState } from "react";
|
import { lazy, Suspense, useState } from "react";
|
||||||
import { qk } from "../lib/queryKeys";
|
import { qk } from "../../lib/queryKeys";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import Modal, { ModalFooter } from "./ui/Modal";
|
import Modal, { ModalFooter } from "../../components/ui/Modal";
|
||||||
import { Button } from "./ui/Button";
|
import { Button } from "../../components/ui/Button";
|
||||||
import { api } from "../lib/api";
|
import { api } from "../../lib/api";
|
||||||
import { invalidateDownloads } from "../lib/downloads";
|
import { invalidateDownloads } from "../../lib/downloads";
|
||||||
|
|
||||||
// Lazy: the full profile editor only opens from the dialog's "manage presets" affordance.
|
// Lazy: the full profile editor only opens from the dialog's "manage presets" affordance.
|
||||||
const ProfileEditor = lazy(() => import("./ProfileEditor"));
|
const ProfileEditor = lazy(() => import("../../modules/settings/ProfileEditor"));
|
||||||
import { notify } from "../lib/notifications";
|
import { notify } from "../../lib/notifications";
|
||||||
import { notifyActionError } from "../lib/actionErrors";
|
import { notifyActionError } from "../../lib/actionErrors";
|
||||||
import { showInTray } from "../lib/downloadQueue";
|
import { showInTray } from "../../lib/downloadQueue";
|
||||||
import { profileName } from "../lib/vocab";
|
import { profileName } from "../../lib/vocab";
|
||||||
import { LS } from "../lib/storage";
|
import { LS } from "../../lib/storage";
|
||||||
import { usePref } from "../lib/usePref";
|
import { usePref } from "../../lib/usePref";
|
||||||
import FilterSelect from "./ui/FilterSelect";
|
import FilterSelect from "../../components/ui/FilterSelect";
|
||||||
import { Input } from "./ui/Input";
|
import { Input } from "../../components/ui/Input";
|
||||||
|
|
||||||
// Preset picker shown when the user hits Download on a card / in the player, or brings a link (the
|
// Preset picker shown when the user hits Download on a card / in the player, or brings a link (the
|
||||||
// Files link box, a paste anywhere). Enqueues via the API; its notice's "View" opens the tray on it.
|
// Files link box, a paste anywhere). Enqueues via the API; its notice's "View" opens the tray on it.
|
||||||
+1
-1
@@ -5,7 +5,7 @@ import {
|
|||||||
pasteIsForThePage,
|
pasteIsForThePage,
|
||||||
requestDownload,
|
requestDownload,
|
||||||
usePendingDownload,
|
usePendingDownload,
|
||||||
} from "../lib/downloadIntake";
|
} from "../../lib/downloadIntake";
|
||||||
|
|
||||||
const DownloadDialog = lazy(() => import("./DownloadDialog"));
|
const DownloadDialog = lazy(() => import("./DownloadDialog"));
|
||||||
|
|
||||||
+14
-12
@@ -8,7 +8,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|||||||
import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { api, type QueueJob } from "../lib/api";
|
import { api, type QueueJob } from "../../lib/api";
|
||||||
|
|
||||||
const h = vi.hoisted(() => ({
|
const h = vi.hoisted(() => ({
|
||||||
jobs: [] as unknown[],
|
jobs: [] as unknown[],
|
||||||
@@ -28,7 +28,7 @@ vi.mock("react-i18next", async (importOriginal) => ({
|
|||||||
`${Array.isArray(key) ? key[0] : key}${o?.count !== undefined ? `:${o.count}` : ""}${o?.title ? `:${o.title}` : ""}`,
|
`${Array.isArray(key) ? key[0] : key}${o?.count !== undefined ? `:${o.count}` : ""}${o?.title ? `:${o.title}` : ""}`,
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
vi.mock("../lib/useLiveQuery", () => ({
|
vi.mock("../../lib/useLiveQuery", () => ({
|
||||||
useLiveQuery: (_key: unknown, _fn: unknown, opts: { intervalMs: (d: unknown) => number }) => (
|
useLiveQuery: (_key: unknown, _fn: unknown, opts: { intervalMs: (d: unknown) => number }) => (
|
||||||
(h.intervalMs = opts.intervalMs),
|
(h.intervalMs = opts.intervalMs),
|
||||||
{
|
{
|
||||||
@@ -42,7 +42,7 @@ vi.mock("../lib/useLiveQuery", () => ({
|
|||||||
}));
|
}));
|
||||||
// The prefs as state over a map that outlives a mount — a reload, as far as the tray can tell. How
|
// The prefs as state over a map that outlives a mount — a reload, as far as the tray can tell. How
|
||||||
// usePref stores them is its own business (usePref.dom.test).
|
// usePref stores them is its own business (usePref.dom.test).
|
||||||
vi.mock("../lib/usePref", () => ({
|
vi.mock("../../lib/usePref", () => ({
|
||||||
usePref: <T,>(key: string, initial: T) => {
|
usePref: <T,>(key: string, initial: T) => {
|
||||||
const [value, setValue] = useState<T>(() =>
|
const [value, setValue] = useState<T>(() =>
|
||||||
h.prefs.has(key) ? (h.prefs.get(key) as T) : initial,
|
h.prefs.has(key) ? (h.prefs.get(key) as T) : initial,
|
||||||
@@ -56,21 +56,23 @@ vi.mock("../lib/usePref", () => ({
|
|||||||
];
|
];
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
vi.mock("./filesContext", () => ({ useFilesActions: () => ({ openFile: h.openFile }) }));
|
vi.mock("../../modules/files/filesContext", () => ({
|
||||||
vi.mock("./confirmContext", () => ({ useConfirm: () => async () => true }));
|
useFilesActions: () => ({ openFile: h.openFile }),
|
||||||
vi.mock("../lib/downloads", async (importOriginal) => ({
|
}));
|
||||||
...(await importOriginal<typeof import("../lib/downloads")>()),
|
vi.mock("../../components/ui/confirmContext", () => ({ useConfirm: () => async () => true }));
|
||||||
|
vi.mock("../../lib/downloads", async (importOriginal) => ({
|
||||||
|
...(await importOriginal<typeof import("../../lib/downloads")>()),
|
||||||
quickShareLink: h.share,
|
quickShareLink: h.share,
|
||||||
}));
|
}));
|
||||||
vi.mock("../lib/notifications", async (importOriginal) => ({
|
vi.mock("../../lib/notifications", async (importOriginal) => ({
|
||||||
...(await importOriginal<typeof import("../lib/notifications")>()),
|
...(await importOriginal<typeof import("../../lib/notifications")>()),
|
||||||
notify: h.notify,
|
notify: h.notify,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import DownloadTray from "./DownloadTray";
|
import DownloadTray from "./DownloadTray";
|
||||||
import { noteQueue, resetQueueWatch, showInTray } from "../lib/downloadQueue";
|
import { noteQueue, resetQueueWatch, showInTray } from "../../lib/downloadQueue";
|
||||||
import { pushLayer, removeLayer } from "../lib/layerStack";
|
import { pushLayer, removeLayer } from "../../lib/layerStack";
|
||||||
import { qk } from "../lib/queryKeys";
|
import { qk } from "../../lib/queryKeys";
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
resetQueueWatch();
|
resetQueueWatch();
|
||||||
+8
-6
@@ -10,8 +10,8 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
|||||||
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { api, type DownloadJob } from "../lib/api";
|
import { api, type DownloadJob } from "../../lib/api";
|
||||||
import { menuGroups } from "./ui/menuGroups";
|
import { menuGroups } from "../../components/ui/menuGroups";
|
||||||
|
|
||||||
const mocks = vi.hoisted(() => ({
|
const mocks = vi.hoisted(() => ({
|
||||||
jobs: [] as unknown[],
|
jobs: [] as unknown[],
|
||||||
@@ -28,8 +28,8 @@ vi.mock("react-i18next", async (importOriginal) => {
|
|||||||
useTranslation: () => ({ i18n: { language: "en" }, t: (key: string) => key }),
|
useTranslation: () => ({ i18n: { language: "en" }, t: (key: string) => key }),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
vi.mock("./confirmContext", () => ({ useConfirm: () => mocks.confirm }));
|
vi.mock("../../components/ui/confirmContext", () => ({ useConfirm: () => mocks.confirm }));
|
||||||
vi.mock("../lib/useLiveQuery", () => ({
|
vi.mock("../../lib/useLiveQuery", () => ({
|
||||||
useLiveQuery: (key: readonly unknown[]) => ({
|
useLiveQuery: (key: readonly unknown[]) => ({
|
||||||
data: key[0] === "downloads" ? mocks.jobs : undefined,
|
data: key[0] === "downloads" ? mocks.jobs : undefined,
|
||||||
isError: false,
|
isError: false,
|
||||||
@@ -37,10 +37,12 @@ vi.mock("../lib/useLiveQuery", () => ({
|
|||||||
refetch: vi.fn(),
|
refetch: vi.fn(),
|
||||||
}),
|
}),
|
||||||
}));
|
}));
|
||||||
vi.mock("../lib/usePref", () => ({
|
vi.mock("../../lib/usePref", () => ({
|
||||||
usePref: <T,>(_key: string, initial: T) => useState<T>(initial),
|
usePref: <T,>(_key: string, initial: T) => useState<T>(initial),
|
||||||
}));
|
}));
|
||||||
vi.mock("./filesContext", () => ({ useFilesActions: () => ({ openFile: vi.fn() }) }));
|
vi.mock("../../modules/files/filesContext", () => ({
|
||||||
|
useFilesActions: () => ({ openFile: vi.fn() }),
|
||||||
|
}));
|
||||||
|
|
||||||
globalThis.ResizeObserver ??= class {
|
globalThis.ResizeObserver ??= class {
|
||||||
observe() {}
|
observe() {}
|
||||||
+25
-25
@@ -13,9 +13,9 @@ import {
|
|||||||
RotateCw,
|
RotateCw,
|
||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import type { QueueJob } from "../lib/api";
|
import type { QueueJob } from "../../lib/api";
|
||||||
import { invalidateDownloads, quickShareLink } from "../lib/downloads";
|
import { invalidateDownloads, quickShareLink } from "../../lib/downloads";
|
||||||
import { failCopyKeys, failTitleKey } from "../lib/downloadFailure";
|
import { failCopyKeys, failTitleKey } from "../../lib/downloadFailure";
|
||||||
import {
|
import {
|
||||||
IN_FLIGHT,
|
IN_FLIGHT,
|
||||||
DOWNLOAD_PHASES,
|
DOWNLOAD_PHASES,
|
||||||
@@ -27,31 +27,31 @@ import {
|
|||||||
useFinished,
|
useFinished,
|
||||||
useRevealing,
|
useRevealing,
|
||||||
useUnannounced,
|
useUnannounced,
|
||||||
} from "../lib/downloadQueue";
|
} from "../../lib/downloadQueue";
|
||||||
import { anyBlockingLayer } from "../lib/layerStack";
|
import { anyBlockingLayer } from "../../lib/layerStack";
|
||||||
import { focusNavItem, navItemAttrs } from "../lib/listNav";
|
import { focusNavItem, navItemAttrs } from "../../lib/listNav";
|
||||||
import { notify } from "../lib/notifications";
|
import { notify } from "../../lib/notifications";
|
||||||
import { qk } from "../lib/queryKeys";
|
import { qk } from "../../lib/queryKeys";
|
||||||
import { LS } from "../lib/storage";
|
import { LS } from "../../lib/storage";
|
||||||
import { useLiveQuery } from "../lib/useLiveQuery";
|
import { useLiveQuery } from "../../lib/useLiveQuery";
|
||||||
import { usePref } from "../lib/usePref";
|
import { usePref } from "../../lib/usePref";
|
||||||
import { ErrorModal } from "./downloadModals";
|
import { ErrorModal } from "./downloadModals";
|
||||||
import { jobTitle } from "./downloadTitle";
|
import { jobTitle } from "./downloadTitle";
|
||||||
import { sourceLabel } from "./fileFolders";
|
import { useFilesActions } from "../../modules/files";
|
||||||
import { useFilesActions } from "./filesContext";
|
import { sourceLabel } from "../folders/fileFolders";
|
||||||
import { useDownloadActions } from "./useDownloadActions";
|
import { useDownloadActions } from "./useDownloadActions";
|
||||||
import { type ActionMenuItem } from "./ui/ActionMenu";
|
import { type ActionMenuItem } from "../../components/ui/ActionMenu";
|
||||||
import { setCornerLift } from "./ui/backToTopState";
|
import { setCornerLift } from "../../components/ui/backToTopState";
|
||||||
import { Button } from "./ui/Button";
|
import { Button } from "../../components/ui/Button";
|
||||||
import { ContextMenu } from "./ui/ContextMenu";
|
import { ContextMenu } from "../../components/ui/ContextMenu";
|
||||||
import { IconButton } from "./ui/IconButton";
|
import { IconButton } from "../../components/ui/IconButton";
|
||||||
import { menuGroups } from "./ui/menuGroups";
|
import { menuGroups } from "../../components/ui/menuGroups";
|
||||||
import { MoreButton } from "./ui/MoreButton";
|
import { MoreButton } from "../../components/ui/MoreButton";
|
||||||
import NavGrid from "./ui/NavGrid";
|
import NavGrid from "../../components/ui/NavGrid";
|
||||||
import { Pressable } from "./ui/Pressable";
|
import { Pressable } from "../../components/ui/Pressable";
|
||||||
import { CONTENT_ID } from "./ui/landmarks";
|
import { CONTENT_ID } from "../../components/ui/landmarks";
|
||||||
import { SURFACE } from "./ui/surfaces";
|
import { SURFACE } from "../../components/ui/surfaces";
|
||||||
import { useContextMenu } from "./ui/useContextMenu";
|
import { useContextMenu } from "../../components/ui/useContextMenu";
|
||||||
|
|
||||||
// The running downloads, app-wide (DESIGN.md "Running downloads"): a tray in the bottom-right corner,
|
// The running downloads, app-wide (DESIGN.md "Running downloads"): a tray in the bottom-right corner,
|
||||||
// up only while something waits, runs or failed — and what finished in this tab, "Done · Open",
|
// up only while something waits, runs or failed — and what finished in this tab, "Done · Open",
|
||||||
+1
-1
@@ -4,7 +4,7 @@
|
|||||||
// downloader did not understand (an update on the server cures it). Never for an unclassified one.
|
// downloader did not understand (an update on the server cures it). Never for an unclassified one.
|
||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
|
||||||
import type { DownloadJob } from "../lib/api";
|
import type { DownloadJob } from "../../lib/api";
|
||||||
import { ErrorModal } from "./downloadModals";
|
import { ErrorModal } from "./downloadModals";
|
||||||
|
|
||||||
vi.mock("react-i18next", async (importOriginal) => ({
|
vi.mock("react-i18next", async (importOriginal) => ({
|
||||||
+6
-6
@@ -1,14 +1,14 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { invalidateDownloads } from "../lib/downloads";
|
import { invalidateDownloads } from "../../lib/downloads";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { Plus, Trash2 } from "lucide-react";
|
import { Plus, Trash2 } from "lucide-react";
|
||||||
import Modal, { ModalFooter } from "./ui/Modal";
|
import Modal, { ModalFooter } from "../../components/ui/Modal";
|
||||||
import { api, type DownloadJob } from "../lib/api";
|
import { api, type DownloadJob } from "../../lib/api";
|
||||||
import { Button } from "./ui/Button";
|
import { Button } from "../../components/ui/Button";
|
||||||
import { IconBtn } from "./downloadRow";
|
import { IconBtn } from "./downloadRow";
|
||||||
import { failCopyKeys, failTitleKey } from "../lib/downloadFailure";
|
import { failCopyKeys, failTitleKey } from "../../lib/downloadFailure";
|
||||||
import { Input } from "./ui/Input";
|
import { Input } from "../../components/ui/Input";
|
||||||
|
|
||||||
// Per-item user modals (self-contained, prop-driven): a library file's details (Files) and a failed
|
// Per-item user modals (self-contained, prop-driven): a library file's details (Files) and a failed
|
||||||
// download's reasons (the tray).
|
// download's reasons (the tray).
|
||||||
+1
-1
@@ -14,7 +14,7 @@ vi.mock("react-i18next", async (importOriginal) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
import { JobThumb } from "./downloadRow";
|
import { JobThumb } from "./downloadRow";
|
||||||
import type { DownloadJob } from "../lib/api";
|
import type { DownloadJob } from "../../lib/api";
|
||||||
|
|
||||||
const baseJob: DownloadJob = {
|
const baseJob: DownloadJob = {
|
||||||
id: 1,
|
id: 1,
|
||||||
+5
-5
@@ -2,11 +2,11 @@ import type { ReactNode } from "react";
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { File, Film, Music } from "lucide-react";
|
import { File, Film, Music } from "lucide-react";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { DOWNLOAD_PHASES } from "../lib/downloadQueue";
|
import { DOWNLOAD_PHASES } from "../../lib/downloadQueue";
|
||||||
import { api, type DownloadJob } from "../lib/api";
|
import { api, type DownloadJob } from "../../lib/api";
|
||||||
import { IconButton } from "./ui/IconButton";
|
import { IconButton } from "../../components/ui/IconButton";
|
||||||
import { MediaProgress } from "./ui/MediaCard";
|
import { MediaProgress } from "../../components/ui/MediaCard";
|
||||||
import RemoteImage from "./ui/RemoteImage";
|
import RemoteImage from "../../components/ui/RemoteImage";
|
||||||
|
|
||||||
// The small shared primitives of a download (status pill, icon button, the job's picture), all purely
|
// The small shared primitives of a download (status pill, icon button, the job's picture), all purely
|
||||||
// prop-driven: the admin Storage tab shows the status, the Files list and grid the picture, the
|
// prop-driven: the admin Storage tab shows the status, the Files list and grid the picture, the
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
import type { DownloadJob } from "../lib/api";
|
import type { DownloadJob } from "../../lib/api";
|
||||||
|
|
||||||
// A download job's display title (a user rename wins over the extracted asset title), shared by the
|
// A download job's display title (a user rename wins over the extracted asset title), shared by the
|
||||||
// tray, the Files list, the admin Storage tab and the library player.
|
// tray, the Files list, the admin Storage tab and the library player.
|
||||||
+7
-7
@@ -1,14 +1,14 @@
|
|||||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Ban, Pause, Play, RefreshCw } from "lucide-react";
|
import { Ban, Pause, Play, RefreshCw } from "lucide-react";
|
||||||
import { api, type DownloadJob, type DownloadJobKind } from "../lib/api";
|
import { api, type DownloadJob, type DownloadJobKind } from "../../lib/api";
|
||||||
import { notifyActionError } from "../lib/actionErrors";
|
import { notifyActionError } from "../../lib/actionErrors";
|
||||||
import { invalidateDownloads } from "../lib/downloads";
|
import { invalidateDownloads } from "../../lib/downloads";
|
||||||
import { useConfirm } from "./confirmContext";
|
import { useConfirm } from "../../components/ui/confirmContext";
|
||||||
import { useCopyItems } from "./copyItems";
|
import { useCopyItems } from "../../components/ui/copyItems";
|
||||||
import { jobTitle } from "./downloadTitle";
|
import { jobTitle } from "./downloadTitle";
|
||||||
import { type ActionMenuItem } from "./ui/ActionMenu";
|
import { type ActionMenuItem } from "../../components/ui/ActionMenu";
|
||||||
import { menuGroups } from "./ui/menuGroups";
|
import { menuGroups } from "../../components/ui/menuGroups";
|
||||||
|
|
||||||
// A queued download's ONE action list: the row's "…", its right-click, long-press and Shift+F10. The
|
// A queued download's ONE action list: the row's "…", its right-click, long-press and Shift+F10. The
|
||||||
// row keeps its primary action (pause / resume / retry) and its state (the error and its details) in
|
// row keeps its primary action (pause / resume / retry) and its state (the error and its details) in
|
||||||
+10
-10
@@ -1,16 +1,16 @@
|
|||||||
import { useRef, useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Filter, X } from "lucide-react";
|
import { Filter, X } from "lucide-react";
|
||||||
import type { ActiveFilter } from "../lib/useActiveFilters";
|
import type { ActiveFilter } from "../../lib/useActiveFilters";
|
||||||
import { decideChipLayout } from "../lib/chipOverflow";
|
import { decideChipLayout } from "../../lib/chipOverflow";
|
||||||
import { chipLaneClass, useChipOverflowFit } from "../lib/useChipOverflowFit";
|
import { chipLaneClass, useChipOverflowFit } from "../../lib/useChipOverflowFit";
|
||||||
import { PopoverItem, PopoverSurface } from "./ui/AnchoredPopover";
|
import { PopoverItem, PopoverSurface } from "../../components/ui/AnchoredPopover";
|
||||||
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
|
import { useAnchoredPopover } from "../../components/ui/anchoredPopoverHooks";
|
||||||
import { Button } from "./ui/Button";
|
import { Button } from "../../components/ui/Button";
|
||||||
import { Chip } from "./ui/Chip";
|
import { Chip } from "../../components/ui/Chip";
|
||||||
import { chipClass } from "./ui/chipStyles";
|
import { chipClass } from "../../components/ui/chipStyles";
|
||||||
import { ChipLabelPending } from "./ui/Skeleton";
|
import { ChipLabelPending } from "../../components/ui/Skeleton";
|
||||||
import { SURFACE } from "./ui/surfaces";
|
import { SURFACE } from "../../components/ui/surfaces";
|
||||||
|
|
||||||
// The applied-filter row above the feed: every narrowing currently in force, each removable on its
|
// The applied-filter row above the feed: every narrowing currently in force, each removable on its
|
||||||
// own. It exists because the filter panel now sits collapsed as a tab — without this you'd have to
|
// own. It exists because the filter panel now sits collapsed as a tab — without this you'd have to
|
||||||
+4
-3
@@ -63,7 +63,8 @@ const CTX = {
|
|||||||
toggleDefault: vi.fn(async () => {}),
|
toggleDefault: vi.fn(async () => {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
vi.mock("./savedViewsContext", () => ({
|
vi.mock("./savedViewsContext", async (importOriginal) => ({
|
||||||
|
...(await importOriginal<typeof import("./savedViewsContext")>()),
|
||||||
useSavedViews: () => ({
|
useSavedViews: () => ({
|
||||||
...CTX,
|
...CTX,
|
||||||
activeViewId: h.activeViewId,
|
activeViewId: h.activeViewId,
|
||||||
@@ -80,8 +81,8 @@ vi.mock("./savedViewsContext", () => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
import PersonalViewsModal from "./PersonalViewsModal";
|
import PersonalViewsModal from "./PersonalViewsModal";
|
||||||
import { api } from "../lib/api";
|
import { api } from "../../lib/api";
|
||||||
import { qk } from "../lib/queryKeys";
|
import { qk } from "../../lib/queryKeys";
|
||||||
|
|
||||||
function mount() {
|
function mount() {
|
||||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||||
+20
-15
@@ -17,28 +17,33 @@ import {
|
|||||||
verticalListSortingStrategy,
|
verticalListSortingStrategy,
|
||||||
} from "@dnd-kit/sortable";
|
} from "@dnd-kit/sortable";
|
||||||
import { CSS } from "@dnd-kit/utilities";
|
import { CSS } from "@dnd-kit/utilities";
|
||||||
import { coercePlexFilters, type FeedFilters, type PlexFilters, type SavedView } from "../lib/api";
|
import {
|
||||||
import Modal from "./ui/Modal";
|
coercePlexFilters,
|
||||||
import { Button } from "./ui/Button";
|
type FeedFilters,
|
||||||
import { IconButton } from "./ui/IconButton";
|
type PlexFilters,
|
||||||
|
type SavedView,
|
||||||
|
} from "../../lib/api";
|
||||||
|
import Modal from "../../components/ui/Modal";
|
||||||
|
import { Button } from "../../components/ui/Button";
|
||||||
|
import { IconButton } from "../../components/ui/IconButton";
|
||||||
import { useSavedViews, asFilters } from "./savedViewsContext";
|
import { useSavedViews, asFilters } from "./savedViewsContext";
|
||||||
import { useActiveFilters } from "../lib/useActiveFilters";
|
import { useActiveFilters } from "../../lib/useActiveFilters";
|
||||||
import { buildPlexActiveFilters } from "./usePlexActiveFilters";
|
import { buildPlexActiveFilters } from "../../lib/plexActiveFilters";
|
||||||
import { useUndoable } from "../lib/useUndoable";
|
import { useUndoable } from "../../lib/useUndoable";
|
||||||
import UndoToolbar from "./ui/UndoToolbar";
|
import UndoToolbar from "../../components/ui/UndoToolbar";
|
||||||
import { useSortableA11y, useSortableItem } from "./ui/sortableA11y";
|
import { useSortableA11y, useSortableItem } from "../../components/ui/sortableA11y";
|
||||||
import { DragHandle } from "./ui/DragHandle";
|
import { DragHandle } from "../../components/ui/DragHandle";
|
||||||
import { OrderButtons } from "./ui/OrderButtons";
|
import { OrderButtons } from "../../components/ui/OrderButtons";
|
||||||
import { Pressable } from "./ui/Pressable";
|
import { Pressable } from "../../components/ui/Pressable";
|
||||||
import { DismissibleChip } from "./ui/Chip";
|
import { DismissibleChip } from "../../components/ui/Chip";
|
||||||
import { Input } from "./ui/Input";
|
import { Input } from "../../components/ui/Input";
|
||||||
|
|
||||||
// Master–detail manager for saved ("personal") views: the left column lists the views (drag to
|
// Master–detail manager for saved ("personal") views: the left column lists the views (drag to
|
||||||
// reorder, or the selected one's order buttons on the right) plus a "save the current filters" input;
|
// reorder, or the selected one's order buttons on the right) plus a "save the current filters" input;
|
||||||
// the right column shows the selected view's name (renamable), its captured filters as REMOVABLE
|
// the right column shows the selected view's name (renamable), its captured filters as REMOVABLE
|
||||||
// chips (pruning writes back to the stored view, undoable), its default flag, its place in the order,
|
// chips (pruning writes back to the stored view, undoable), its default flag, its place in the order,
|
||||||
// apply, save-current, and delete. Opened from the filter panel's title or the saved-views popover;
|
// apply, save-current, and delete. Opened from the filter panel's title or the saved-views popover;
|
||||||
// all mutations flow through SavedViewsProvider.
|
// all mutations flow through the module's saved-views store (useSavedViewsStore).
|
||||||
function ViewRow({
|
function ViewRow({
|
||||||
view,
|
view,
|
||||||
selected,
|
selected,
|
||||||
+6
-6
@@ -1,13 +1,13 @@
|
|||||||
import { useState, type ReactNode } from "react";
|
import { useState, type ReactNode } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Settings2 } from "lucide-react";
|
import { Settings2 } from "lucide-react";
|
||||||
import { PopoverItem, PopoverSurface } from "./ui/AnchoredPopover";
|
import { PopoverItem, PopoverSurface } from "../../components/ui/AnchoredPopover";
|
||||||
import { closeThenAct, useAnchoredPopover } from "./ui/anchoredPopoverHooks";
|
import { closeThenAct, useAnchoredPopover } from "../../components/ui/anchoredPopoverHooks";
|
||||||
import { useSavedViews } from "./savedViewsContext";
|
import { useSavedViews } from "./savedViewsContext";
|
||||||
import { IconButton } from "./ui/IconButton";
|
import { IconButton } from "../../components/ui/IconButton";
|
||||||
import { MenuCheck } from "./ui/MenuCheck";
|
import { MenuCheck } from "../../components/ui/MenuCheck";
|
||||||
import { SURFACE } from "./ui/surfaces";
|
import { SURFACE } from "../../components/ui/surfaces";
|
||||||
import { SelectTrigger } from "./ui/SelectTrigger";
|
import { SelectTrigger } from "../../components/ui/SelectTrigger";
|
||||||
|
|
||||||
// The saved-views quick-picker. The filter-panel header's icon (and the toolbar's view chip) use it as
|
// The saved-views quick-picker. The filter-panel header's icon (and the toolbar's view chip) use it as
|
||||||
// their trigger: click → a popover list of the user's saved views (apply on click, the active one
|
// their trigger: click → a popover list of the user's saved views (apply on click, the active one
|
||||||
+24
-6
@@ -1,8 +1,11 @@
|
|||||||
import { createContext, useContext } from "react";
|
import { createContext, useContext } from "react";
|
||||||
import { coerceTagModes, coerceUntagged, type FeedFilters, type SavedView } from "../lib/api";
|
import { coerceTagModes, coerceUntagged, type FeedFilters, type SavedView } from "../../lib/api";
|
||||||
|
|
||||||
// The saved-views context + hook (and the feed blob coercion its consumers share), split out of
|
// The saved-views contexts + hooks (and the feed blob coercion their consumers share). Each module that
|
||||||
// SavedViewsProvider so that file exports only components (Fast Refresh).
|
// has saved views (the feed, Plex) owns a store of them (useSavedViewsStore) under its own context, made
|
||||||
|
// by `createSavedViewsStore`. The shared UI here (popover, chip, modal) reads `useSavedViews()`: the
|
||||||
|
// views of the module whose page or rail it sits in, which that page or rail hands down with
|
||||||
|
// `SavedViewsScope`.
|
||||||
|
|
||||||
// A saved view's `filters` is an opaque JSON blob from the DB; coerce it to the module's shape before it
|
// A saved view's `filters` is an opaque JSON blob from the DB; coerce it to the module's shape before it
|
||||||
// reaches app state or a serializer (an older build's dropped field just falls away — NOT a migration).
|
// reaches app state or a serializer (an older build's dropped field just falls away — NOT a migration).
|
||||||
@@ -41,10 +44,25 @@ export interface SavedViewsContext {
|
|||||||
reorder: (nextIds: number[]) => Promise<void>;
|
reorder: (nextIds: number[]) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SavedViewsCtx = createContext<SavedViewsContext | null>(null);
|
// One module's store: the context its provider fills and the hook its own code (and the shell) reads.
|
||||||
|
export function createSavedViewsStore(owner: string) {
|
||||||
|
const Ctx = createContext<SavedViewsContext | null>(null);
|
||||||
|
Ctx.displayName = `${owner}SavedViews`;
|
||||||
|
function useModuleSavedViews(): SavedViewsContext {
|
||||||
|
const ctx = useContext(Ctx);
|
||||||
|
if (!ctx) throw new Error(`${owner}'s saved views are read outside its store`);
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
|
return { Provider: Ctx.Provider, useViews: useModuleSavedViews };
|
||||||
|
}
|
||||||
|
|
||||||
|
// The scope the shared UI reads: the views of the module it is rendered for
|
||||||
|
// (`<SavedViewsScope value={useFeedSavedViews()}>`).
|
||||||
|
const SavedViewsScopeCtx = createContext<SavedViewsContext | null>(null);
|
||||||
|
export const SavedViewsScope = SavedViewsScopeCtx.Provider;
|
||||||
|
|
||||||
export function useSavedViews(): SavedViewsContext {
|
export function useSavedViews(): SavedViewsContext {
|
||||||
const ctx = useContext(SavedViewsCtx);
|
const ctx = useContext(SavedViewsScopeCtx);
|
||||||
if (!ctx) throw new Error("useSavedViews must be used within a SavedViewsProvider");
|
if (!ctx) throw new Error("useSavedViews must be used within a SavedViewsScope");
|
||||||
return ctx;
|
return ctx;
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
import { useEffect, useEffectEvent, useMemo, useRef, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
|
import { qk } from "../../lib/queryKeys";
|
||||||
|
import { api, type SavedView } from "../../lib/api";
|
||||||
|
import { pickDefaultToSeed, preferSticky } from "../../lib/savedViews";
|
||||||
|
import { notifyActionError } from "../../lib/actionErrors";
|
||||||
|
import { ownKey } from "../../lib/storage";
|
||||||
|
import { useConfirm } from "../../components/ui/confirmContext";
|
||||||
|
import type { SavedViewsContext, ViewSource } from "./savedViewsContext";
|
||||||
|
|
||||||
|
// Saved "smart views" serve two modules — the feed and Plex — and each owns one store of them in its
|
||||||
|
// own folder, built on this hook. The two never switch into each other: a visit to Plex leaves the
|
||||||
|
// feed's active view where it was.
|
||||||
|
|
||||||
|
// The per-module wiring the generic logic runs on: the live filters, how to apply a view's, a stable
|
||||||
|
// signature for matching, coercion, and what to strip before saving (the feed's transient shuffle seed).
|
||||||
|
export interface SavedViewsConfig {
|
||||||
|
source: ViewSource;
|
||||||
|
filters: Record<string, unknown>;
|
||||||
|
setFilters: (f: Record<string, unknown>) => void;
|
||||||
|
keyOf: (f: Record<string, unknown>) => string;
|
||||||
|
asFilters: (raw: Record<string, unknown>) => Record<string, unknown>;
|
||||||
|
stripForSave: (f: Record<string, unknown>) => Record<string, unknown>;
|
||||||
|
/** Whether this module is a genuinely fresh start the starred default may seed on first load: no
|
||||||
|
* stored filters for the active account, and — feed only — no URL filter override. */
|
||||||
|
canSeedDefault: () => boolean;
|
||||||
|
/** Whether to ask for the views now (a signed-in account is required on top of this). */
|
||||||
|
enabled: boolean;
|
||||||
|
/** Whether the module's page is the one on screen. Its manage modal belongs to that visit: leaving
|
||||||
|
* the page closes it, so it does not reopen by itself on the way back. */
|
||||||
|
onScreen: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
// True once this account has its own persisted filters for `lsKey` — the signal that the last
|
||||||
|
// view / manual tweaks (not the default) should win. Account unknown or localStorage unavailable →
|
||||||
|
// report "stored" so a default never seeds blind over an indeterminate state.
|
||||||
|
export function storedFiltersPresent(lsKey: string): boolean {
|
||||||
|
const key = ownKey(lsKey);
|
||||||
|
if (!key) return true;
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(key) != null;
|
||||||
|
} catch {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSavedViewsStore(cfg: SavedViewsConfig): SavedViewsContext {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const qc = useQueryClient();
|
||||||
|
const confirm = useConfirm();
|
||||||
|
|
||||||
|
const source = cfg.source;
|
||||||
|
// Passive observer of the account BootGate fetches: the providers mount for a signed-out tab too
|
||||||
|
// (the public landing), and saved views are per-account, so asking for "nobody's" views only logs
|
||||||
|
// a 401 on the landing page. Enabled once a signed-in account is cached.
|
||||||
|
const { data: me } = useQuery({ queryKey: qk.me(), queryFn: api.me, enabled: false });
|
||||||
|
const viewsQuery = useQuery({
|
||||||
|
queryKey: qk.savedViews(source),
|
||||||
|
queryFn: () => api.savedViews(source),
|
||||||
|
enabled: !!me && cfg.enabled,
|
||||||
|
});
|
||||||
|
const views = useMemo(() => viewsQuery.data ?? [], [viewsQuery.data]);
|
||||||
|
|
||||||
|
const [stickyId, setStickyId] = useState<number | null>(null);
|
||||||
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
|
// Adjusted during render, not from an effect, so the frame after leaving never commits it open.
|
||||||
|
if (modalOpen && !cfg.onScreen) setModalOpen(false);
|
||||||
|
|
||||||
|
// The ONE default-application path for both modules. On the views query's FIRST resolve, seed the
|
||||||
|
// starred default view's filters — but only into a genuinely fresh module (no stored filters, no URL
|
||||||
|
// override; see cfg.canSeedDefault). Applying via cfg.setFilters persists it, so it happens once and
|
||||||
|
// the normal last-view-wins path takes over, exactly as the storage semantics document. This replaced
|
||||||
|
// the feed-only localStorage mirror that raced FeedFiltersProvider's login effect (on a fresh browser
|
||||||
|
// the feed could seed DEFAULT_FILTERS and never re-apply until a reload) — and it gives Plex the
|
||||||
|
// default it previously never applied at all. One-shot, so a background refetch never re-seeds over
|
||||||
|
// the filters you're editing. canSeedDefault() reads the account's stored-filters key, which is always
|
||||||
|
// readable here: BootGate hands the browser's local state to the account (claimLocalData) before
|
||||||
|
// this store mounts.
|
||||||
|
const seededRef = useRef(false);
|
||||||
|
// `cfg` changes on every filter edit, so it is read through an effect event: the seed must fire on
|
||||||
|
// the views query's first resolve and never again, which `seededRef` enforces. Listing cfg would
|
||||||
|
// re-arm nothing (the guard still holds) but would re-run the effect on every keystroke.
|
||||||
|
const latestCfg = useEffectEvent(() => cfg);
|
||||||
|
useEffect(() => {
|
||||||
|
const data = viewsQuery.data;
|
||||||
|
if (!data || seededRef.current) return;
|
||||||
|
seededRef.current = true;
|
||||||
|
const c = latestCfg();
|
||||||
|
const def = pickDefaultToSeed(data, c.canSeedDefault());
|
||||||
|
// The name follows by itself: `preferSticky` picks the starred default among same-filter views.
|
||||||
|
if (def) c.setFilters(c.asFilters(def.filters));
|
||||||
|
}, [viewsQuery.data]);
|
||||||
|
|
||||||
|
const currentKey = cfg.keyOf(cfg.filters);
|
||||||
|
const activeView = useMemo(
|
||||||
|
() =>
|
||||||
|
preferSticky(
|
||||||
|
views.filter((v) => cfg.keyOf(v.filters) === currentKey),
|
||||||
|
stickyId,
|
||||||
|
),
|
||||||
|
[views, currentKey, stickyId, cfg],
|
||||||
|
);
|
||||||
|
// The sticky id follows whichever view is active — also adjusted during render. It converges by
|
||||||
|
// construction: preferSticky returns the sticky match when there is one, so the very write below
|
||||||
|
// makes its own condition false on the next pass.
|
||||||
|
if (activeView && activeView.id !== stickyId) setStickyId(activeView.id);
|
||||||
|
|
||||||
|
const stickyView = useMemo(
|
||||||
|
() => (stickyId == null ? null : (views.find((v) => v.id === stickyId) ?? null)),
|
||||||
|
[views, stickyId],
|
||||||
|
);
|
||||||
|
const displayView = activeView ?? stickyView;
|
||||||
|
const isDirty = displayView != null && activeView == null;
|
||||||
|
|
||||||
|
const refresh = () => void qc.invalidateQueries({ queryKey: qk.savedViews(source) });
|
||||||
|
async function runViewMutation(fn: () => Promise<unknown>, failMsg: string): Promise<boolean> {
|
||||||
|
let ok = true;
|
||||||
|
try {
|
||||||
|
await fn();
|
||||||
|
} catch (e) {
|
||||||
|
notifyActionError(e, failMsg);
|
||||||
|
ok = false;
|
||||||
|
}
|
||||||
|
refresh();
|
||||||
|
return ok;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
source,
|
||||||
|
views,
|
||||||
|
loading: viewsQuery.isLoading,
|
||||||
|
activeViewId: activeView?.id ?? null,
|
||||||
|
displayView,
|
||||||
|
isDirty,
|
||||||
|
modalOpen,
|
||||||
|
openModal: () => setModalOpen(true),
|
||||||
|
closeModal: () => setModalOpen(false),
|
||||||
|
apply: (v) => {
|
||||||
|
cfg.setFilters(cfg.asFilters(v.filters));
|
||||||
|
setStickyId(v.id);
|
||||||
|
},
|
||||||
|
clearActive: () => setStickyId(null),
|
||||||
|
create: async (name) => {
|
||||||
|
const clean = name.trim();
|
||||||
|
if (!clean) return false;
|
||||||
|
return runViewMutation(
|
||||||
|
() => api.createView(clean, cfg.stripForSave(cfg.filters), source),
|
||||||
|
t("views.actionFailed"),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
rename: async (id, name) => {
|
||||||
|
const next = name.trim();
|
||||||
|
if (!next) return false;
|
||||||
|
return runViewMutation(() => api.updateView(id, { name: next }), t("views.actionFailed"));
|
||||||
|
},
|
||||||
|
updateFilters: (id, filters) =>
|
||||||
|
runViewMutation(() => api.updateView(id, { filters }), t("views.actionFailed")),
|
||||||
|
saveCurrent: async (id) => {
|
||||||
|
const filters = cfg.stripForSave(cfg.filters);
|
||||||
|
const ok = await runViewMutation(
|
||||||
|
() => api.updateView(id, { filters }),
|
||||||
|
t("views.actionFailed"),
|
||||||
|
);
|
||||||
|
return ok ? filters : null;
|
||||||
|
},
|
||||||
|
toggleDefault: (v) =>
|
||||||
|
runViewMutation(
|
||||||
|
() => api.updateView(v.id, { is_default: !v.is_default }),
|
||||||
|
t("views.actionFailed"),
|
||||||
|
).then(() => undefined),
|
||||||
|
remove: async (v) => {
|
||||||
|
const ok = await confirm({
|
||||||
|
message: t("views.deleteConfirm", { name: v.name }),
|
||||||
|
confirmLabel: t("views.delete"),
|
||||||
|
danger: true,
|
||||||
|
});
|
||||||
|
if (!ok) return false;
|
||||||
|
if (stickyId === v.id) setStickyId(null);
|
||||||
|
return runViewMutation(() => api.deleteView(v.id), t("views.actionFailed"));
|
||||||
|
},
|
||||||
|
reorder: async (nextIds) => {
|
||||||
|
qc.setQueryData<SavedView[]>(
|
||||||
|
qk.savedViews(source),
|
||||||
|
nextIds.map((id) => views.find((v) => v.id === id)!).filter(Boolean),
|
||||||
|
);
|
||||||
|
await runViewMutation(() => api.reorderViews(nextIds), t("views.reorderFailed"));
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
import { Film, Folder, Globe, Server, Upload, type LucideIcon } from "lucide-react";
|
import { Film, Folder, Globe, Server, Upload, type LucideIcon } from "lucide-react";
|
||||||
import { YouTubeIcon } from "../lib/brandIcons";
|
import { YouTubeIcon } from "../../lib/brandIcons";
|
||||||
import type { FileFolder } from "../lib/api";
|
import type { FileFolder } from "../../lib/api";
|
||||||
|
|
||||||
// A library folder's and a file's origin, named and drawn. The backend sends a source folder's KEY
|
// A library folder's and a file's origin, named and drawn. The backend sends a source folder's KEY
|
||||||
// (`youtube`, `plex`, `upload`, `site:<host>`, see backend downloads/folders.py) and leaves
|
// (`youtube`, `plex`, `upload`, `site:<host>`, see backend downloads/folders.py) and leaves
|
||||||
+3
-3
@@ -1,10 +1,10 @@
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Command } from "lucide-react";
|
import { Command } from "lucide-react";
|
||||||
|
|
||||||
import { SHORTCUTS, formatChord, openPalette } from "../lib/keymap";
|
import { SHORTCUTS, formatChord, openPalette } from "../../lib/keymap";
|
||||||
import { Pressable } from "./ui/Pressable";
|
import { Pressable } from "../../components/ui/Pressable";
|
||||||
import clsx from "clsx";
|
import clsx from "clsx";
|
||||||
import { fieldClass } from "./ui/fieldStyles";
|
import { fieldClass } from "../../components/ui/fieldStyles";
|
||||||
|
|
||||||
// The visible way into the command palette (and, from there, the shortcut sheet). Ctrl/Cmd+K and
|
// The visible way into the command palette (and, from there, the shortcut sheet). Ctrl/Cmd+K and
|
||||||
// `h` had no entry point anywhere but a palette row — the one in-app pointer to the cheat sheet sat
|
// `h` had no entry point anywhere but a palette row — the one in-app pointer to the cheat sheet sat
|
||||||
+3
-3
@@ -5,9 +5,9 @@
|
|||||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
import { act, cleanup, render, screen } from "@testing-library/react";
|
import { act, cleanup, render, screen } from "@testing-library/react";
|
||||||
import SearchBar from "./SearchBar";
|
import SearchBar from "./SearchBar";
|
||||||
import KeyboardHelp from "./ui/KeyboardHelp";
|
import KeyboardHelp from "../../components/ui/KeyboardHelp";
|
||||||
import { closeKeyboardHelp, toggleKeyboardHelp } from "../lib/keymap";
|
import { closeKeyboardHelp, toggleKeyboardHelp } from "../../lib/keymap";
|
||||||
import { pushLayer, removeLayer } from "../lib/layerStack";
|
import { pushLayer, removeLayer } from "../../lib/layerStack";
|
||||||
|
|
||||||
vi.mock("react-i18next", async (importOriginal) => {
|
vi.mock("react-i18next", async (importOriginal) => {
|
||||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||||
@@ -8,12 +8,12 @@ import {
|
|||||||
} from "react";
|
} from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Search, X } from "lucide-react";
|
import { Search, X } from "lucide-react";
|
||||||
import { useSearchFocusKey } from "../lib/keymap";
|
import { useSearchFocusKey } from "../../lib/keymap";
|
||||||
import { PaletteKeycap } from "./PaletteLauncher";
|
import { PaletteKeycap } from "./PaletteLauncher";
|
||||||
import { Button } from "./ui/Button";
|
import { Button } from "../../components/ui/Button";
|
||||||
import { IconButton } from "./ui/IconButton";
|
import { IconButton } from "../../components/ui/IconButton";
|
||||||
import { InnerInput } from "./ui/Input";
|
import { InnerInput } from "../../components/ui/Input";
|
||||||
import { fieldClass } from "./ui/fieldStyles";
|
import { fieldClass } from "../../components/ui/fieldStyles";
|
||||||
|
|
||||||
// The floating search pill in the top header. One component for every module that searches
|
// The floating search pill in the top header. One component for every module that searches
|
||||||
// (feed, Plex, channels, playlists): a 40px glass pill with the A4 field edge (the page's one hero
|
// (feed, Plex, channels, playlists): a 40px glass pill with the A4 field edge (the page's one hero
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
// A live-filter search box's Go: the list already filters as you type, so Go/Enter only drops the
|
||||||
|
// input's focus (the feed's Go is different: it escalates to a YouTube search).
|
||||||
|
export const blurActive = () => (document.activeElement as HTMLElement | null)?.blur?.();
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { MODULE_STORES } from "./moduleRegistry";
|
||||||
|
|
||||||
|
// Every module's store, mounted once at the root for the whole session, composed from the registry
|
||||||
|
// instead of nested by hand: a new module's store is its definition's `Store` and nothing here. They sit
|
||||||
|
// at the root and not inside their pages because they are read from outside them (the palette, the
|
||||||
|
// notifications, the download tray steering Files) and hold in-memory state — search terms, the Plex
|
||||||
|
// jump target, the Files focus — that has to outlive a visit to another page.
|
||||||
|
export function ModuleStores({ children }: { children: ReactNode }) {
|
||||||
|
return MODULE_STORES.reduceRight<ReactNode>((inner, Store) => <Store>{inner}</Store>, children);
|
||||||
|
}
|
||||||
+5
-5
@@ -27,12 +27,12 @@ vi.mock("react-i18next", async (importOriginal) => {
|
|||||||
|
|
||||||
import { NavigationProvider } from "./NavigationProvider";
|
import { NavigationProvider } from "./NavigationProvider";
|
||||||
import { useNavigation, useNavigationActions } from "./navigationContext";
|
import { useNavigation, useNavigationActions } from "./navigationContext";
|
||||||
import { useBackToClose } from "../lib/history";
|
import { useBackToClose } from "../../lib/history";
|
||||||
import { PageHeading } from "./ui/Headings";
|
import { PageHeading } from "../../components/ui/Headings";
|
||||||
|
|
||||||
import { qk } from "../lib/queryKeys";
|
import { qk } from "../../lib/queryKeys";
|
||||||
import { claimLocalData, getAccountRaw, LS, setAccountRaw } from "../lib/storage";
|
import { claimLocalData, getAccountRaw, LS, setAccountRaw } from "../../lib/storage";
|
||||||
import { api } from "../lib/api";
|
import { api } from "../../lib/api";
|
||||||
import { PAGE_KEY } from "./navigationInit";
|
import { PAGE_KEY } from "./navigationInit";
|
||||||
|
|
||||||
/** The Navigation API's view of the tab: `below` = whether each entry under the current one belongs
|
/** The Navigation API's view of the tab: `below` = whether each entry under the current one belongs
|
||||||
+9
-10
@@ -1,20 +1,19 @@
|
|||||||
import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react";
|
import { useCallback, useEffect, useMemo, useState, type ReactNode } from "react";
|
||||||
import { useLatest } from "../lib/useLatest";
|
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 { ACTIVITY_KEYS } from "../../lib/activityUrlState";
|
||||||
import { pageTitleKey } from "../lib/pageMeta";
|
import { useDocumentObjectTitle } from "../../lib/documentTitle";
|
||||||
import { useDocumentObjectTitle } from "../lib/documentTitle";
|
import { getAccountRaw, setAccountRaw } from "../../lib/storage";
|
||||||
import { getAccountRaw, setAccountRaw } from "../lib/storage";
|
|
||||||
import {
|
import {
|
||||||
backStaysInDocument,
|
backStaysInDocument,
|
||||||
closeOverlaysThenNavigate,
|
closeOverlaysThenNavigate,
|
||||||
nextNavId,
|
nextNavId,
|
||||||
notifyNav,
|
notifyNav,
|
||||||
rememberNavPosition,
|
rememberNavPosition,
|
||||||
} from "../lib/history";
|
} from "../../lib/history";
|
||||||
import { reachablePage } from "../lib/modules";
|
import { pageTitleKey, reachablePage } from "./moduleRegistry";
|
||||||
import {
|
import {
|
||||||
channelViewFrom,
|
channelViewFrom,
|
||||||
loadInitialPage,
|
loadInitialPage,
|
||||||
@@ -23,7 +22,7 @@ import {
|
|||||||
START_ON_HOME_PREF,
|
START_ON_HOME_PREF,
|
||||||
type ChannelView,
|
type ChannelView,
|
||||||
} from "./navigationInit";
|
} from "./navigationInit";
|
||||||
import { usePref } from "../lib/usePref";
|
import { usePref } from "../../lib/usePref";
|
||||||
import {
|
import {
|
||||||
NavigationActionsContext,
|
NavigationActionsContext,
|
||||||
NavigationStateContext,
|
NavigationStateContext,
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
//
|
||||||
|
// The registry reaches the modules' faces, whose stores read the browser (i18n, localStorage).
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { dirname, resolve } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { PAGES } from "../../lib/urlState";
|
||||||
|
import { REGISTERED_PAGES, moduleDef } from "./moduleRegistry";
|
||||||
|
|
||||||
|
// The registry is the one place a page is listed, so it has to hold every page id the address bar
|
||||||
|
// accepts and nothing else: a page id with no definition would throw in the nav rail at the first
|
||||||
|
// render, and a definition with no id could never be navigated to.
|
||||||
|
const SRC = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
|
||||||
|
|
||||||
|
describe("the module registry", () => {
|
||||||
|
it("defines every page id exactly once", () => {
|
||||||
|
expect([...REGISTERED_PAGES].sort()).toEqual([...PAGES].sort());
|
||||||
|
expect(new Set(REGISTERED_PAGES).size).toBe(REGISTERED_PAGES.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([...PAGES])("takes %s's definition from that module's own folder", (page) => {
|
||||||
|
// The definition's id names its page, and it lives at modules/<page>/module.ts — so a page is one
|
||||||
|
// folder, and a copied definition with a stale id cannot pass as another page's.
|
||||||
|
expect(moduleDef(page).id).toBe(page);
|
||||||
|
const source = readFileSync(resolve(SRC, `modules/${page}/module.ts`), "utf8");
|
||||||
|
expect(source).toContain(`id: "${page}",`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it.each([...PAGES])("loads %s's page (and rail) lazily", (page) => {
|
||||||
|
// A static import of the page in its definition would put it on the boot path for every
|
||||||
|
// account; the chunk guard fails the build on that too, this says which module did it. The one
|
||||||
|
// static import a definition may make of its own folder is its store (a `…Provider` / `…Store`
|
||||||
|
// file): that is root-mounted, on the boot path by design.
|
||||||
|
const source = readFileSync(resolve(SRC, `modules/${page}/module.ts`), "utf8");
|
||||||
|
expect(source).not.toMatch(/^import [^;]* from "\.\/(?!\w+(?:Provider|Store)")/m);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import type { Me } from "../../lib/api";
|
||||||
|
import type { Page } from "../../lib/urlState";
|
||||||
|
import type { ModuleDef } from "../../lib/moduleDef";
|
||||||
|
import { homeModule } from "../../modules/home";
|
||||||
|
import { feedModule } from "../../modules/feed";
|
||||||
|
import { channelsModule } from "../../modules/channels";
|
||||||
|
import { playlistsModule } from "../../modules/playlists";
|
||||||
|
import { plexModule } from "../../modules/plex";
|
||||||
|
import { notificationsModule } from "../../modules/notifications";
|
||||||
|
import { filesModule } from "../../modules/files";
|
||||||
|
import { statsModule } from "../../modules/stats";
|
||||||
|
import { schedulerModule } from "../../modules/scheduler";
|
||||||
|
import { configModule } from "../../modules/config";
|
||||||
|
import { usersModule } from "../../modules/users";
|
||||||
|
import { activityModule } from "../../modules/activity";
|
||||||
|
import { settingsModule } from "../../modules/settings";
|
||||||
|
|
||||||
|
// ── The module registry ───────────────────────────────────────────────────────────────────────
|
||||||
|
// Every page, as each module defines itself (its folder's module.ts), in one ordered list: the nav
|
||||||
|
// rail, the palette, the `g` chords, the tab title, the reachable set and the shell's render all read
|
||||||
|
// from here, so a new or newly gated module shows up everywhere at once. Each definition's page and
|
||||||
|
// rail are lazy imports, so listing them here pulls no page into the boot chunk (chunkGuard holds it).
|
||||||
|
//
|
||||||
|
// Ordered in nav-rail order — this array IS the sequence the rail and the palette list. Home leads: it
|
||||||
|
// is the landing (see navigationInit.loadInitialPage). The feed takes the YouTube mark its own toolbar
|
||||||
|
// already uses, so it and Home sit apart by shape and not just by position.
|
||||||
|
const MODULES: readonly ModuleDef[] = [
|
||||||
|
homeModule,
|
||||||
|
feedModule,
|
||||||
|
channelsModule,
|
||||||
|
playlistsModule,
|
||||||
|
plexModule,
|
||||||
|
notificationsModule,
|
||||||
|
filesModule,
|
||||||
|
statsModule,
|
||||||
|
schedulerModule,
|
||||||
|
configModule,
|
||||||
|
usersModule,
|
||||||
|
activityModule,
|
||||||
|
];
|
||||||
|
|
||||||
|
// Settings is reachable (its own rail button, a `g` chord) but is not a primary module in the ordered
|
||||||
|
// nav list, so it lives beside the array rather than in it.
|
||||||
|
const ALL: readonly ModuleDef[] = [...MODULES, settingsModule];
|
||||||
|
|
||||||
|
// Every Page has a definition: the registry contract test pins this list against lib/urlState's ids.
|
||||||
|
export const REGISTERED_PAGES: readonly Page[] = ALL.map((m) => m.id);
|
||||||
|
|
||||||
|
const BY_ID = Object.fromEntries(ALL.map((m) => [m.id, m])) as Record<Page, ModuleDef>;
|
||||||
|
|
||||||
|
// Look up a module's definition by page id (every Page is covered, incl. settings).
|
||||||
|
export function moduleDef(id: Page): ModuleDef {
|
||||||
|
return BY_ID[id];
|
||||||
|
}
|
||||||
|
|
||||||
|
// The i18n key for each module's SHORT display name, and its rail label.
|
||||||
|
export const moduleLabelKey = Object.fromEntries(ALL.map((m) => [m.id, m.labelKey])) as Record<
|
||||||
|
Page,
|
||||||
|
string
|
||||||
|
>;
|
||||||
|
export const moduleRailKey = Object.fromEntries(ALL.map((m) => [m.id, m.railKey])) as Record<
|
||||||
|
Page,
|
||||||
|
string
|
||||||
|
>;
|
||||||
|
|
||||||
|
// Admin/system modules — rendered in their own section (below a divider) in the nav rail.
|
||||||
|
export const SYSTEM_PAGES: readonly Page[] = MODULES.filter((m) => m.system).map((m) => m.id);
|
||||||
|
|
||||||
|
// The ordered list of primary module pages available to THIS user, in nav-rail order. Availability
|
||||||
|
// is derived from the account via each module's `gate`, so the reachable set changes with the
|
||||||
|
// account state, not the UI.
|
||||||
|
export function moduleOrder(me: Me): Page[] {
|
||||||
|
return MODULES.filter((m) => !m.gate || m.gate(me)).map((m) => m.id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The page this account is shown for `page`: itself when its gate lets the account in, else the
|
||||||
|
* feed (a link to an admin page, a Back onto one, an admin role taken away mid-session). Signed
|
||||||
|
* out, no page renders, so nothing is moved. */
|
||||||
|
export function reachablePage(page: Page, me: Me | null | undefined): Page {
|
||||||
|
const gate = moduleDef(page).gate;
|
||||||
|
return !me || !gate || gate(me) ? page : "feed";
|
||||||
|
}
|
||||||
|
|
||||||
|
// The i18n key for a page's human title (the browser tab): a module's optional `titleKey` is the
|
||||||
|
// longer, descriptive one (e.g. "Channel manager"), falling back to its short nav `labelKey`.
|
||||||
|
export function pageTitleKey(page: Page): string {
|
||||||
|
const def = moduleDef(page);
|
||||||
|
return def.titleKey ?? def.labelKey;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Fetch the code of these pages and of their side rails now, ahead of their first visit. Errors are
|
||||||
|
* swallowed: a chunk that fails to preload is simply fetched (and its failure surfaced) on visit. */
|
||||||
|
export function preloadPages(pages: Page[]): void {
|
||||||
|
for (const p of pages) {
|
||||||
|
const def = moduleDef(p);
|
||||||
|
def.Page.preload().catch(() => {});
|
||||||
|
def.Rail?.preload().catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The modules' stores (lib/moduleDef `Store`), in registry order: ModuleStores mounts them at the root.
|
||||||
|
export const MODULE_STORES = ALL.flatMap((m) => (m.Store ? [m.Store] : []));
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
//
|
||||||
|
// The modules' stores are mounted at the root by ModuleStores, composed from the registry in its order
|
||||||
|
// (lib/moduleDef `Store`). Two things make that safe, and this pins both: every store a reader needs is
|
||||||
|
// in the composition (a missing one throws in its first reader — the palette, the notifications, a
|
||||||
|
// rail), and no store reads another module's store, so the order they happen to nest in can never
|
||||||
|
// leave one outside a provider it depends on.
|
||||||
|
import { readFileSync, existsSync } from "node:fs";
|
||||||
|
import { dirname, resolve } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { render } from "@testing-library/react";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { ModuleStores } from "./ModuleStores";
|
||||||
|
import { REGISTERED_PAGES, moduleDef } from "./moduleRegistry";
|
||||||
|
import { useFeedFilters, useFeedSavedViews } from "../../modules/feed";
|
||||||
|
import { usePlex, usePlexSavedViews } from "../../modules/plex";
|
||||||
|
import { useChannelsActions } from "../../modules/channels";
|
||||||
|
import { useFilesActions } from "../../modules/files";
|
||||||
|
import { usePlaylistsActions } from "../../modules/playlists";
|
||||||
|
|
||||||
|
vi.mock("react-i18next", async (importOriginal) => ({
|
||||||
|
...(await importOriginal<typeof import("react-i18next")>()),
|
||||||
|
useTranslation: () => ({ t: (k: string) => k, i18n: { language: "en" } }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const SRC = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
|
||||||
|
|
||||||
|
// Each store's read side, as the app's readers reach it (the module faces).
|
||||||
|
function Readers() {
|
||||||
|
useFeedFilters();
|
||||||
|
useFeedSavedViews();
|
||||||
|
usePlex();
|
||||||
|
usePlexSavedViews();
|
||||||
|
useChannelsActions();
|
||||||
|
useFilesActions();
|
||||||
|
usePlaylistsActions();
|
||||||
|
return <p>all stores in reach</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The static (value) imports of a source file, resolved to files under src/. Type-only imports and
|
||||||
|
// lazy `import()` are not edges a store's render depends on.
|
||||||
|
function staticImports(file: string): string[] {
|
||||||
|
const text = readFileSync(file, "utf8");
|
||||||
|
const out: string[] = [];
|
||||||
|
for (const m of text.matchAll(/^import (?!type )[^;]*? from "(\.[^"]+)";/gms)) {
|
||||||
|
const base = resolve(dirname(file), m[1]!);
|
||||||
|
const path = [base + ".ts", base + ".tsx", base + "/index.ts", base + "/index.tsx", base].find(
|
||||||
|
(p) => /\.tsx?$/.test(p) && existsSync(p),
|
||||||
|
);
|
||||||
|
if (path) out.push(path);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every file a module's definition statically pulls in — its store and what the store imports.
|
||||||
|
function closure(start: string): Set<string> {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const todo = [start];
|
||||||
|
while (todo.length) {
|
||||||
|
const f = todo.pop()!;
|
||||||
|
if (seen.has(f)) continue;
|
||||||
|
seen.add(f);
|
||||||
|
todo.push(...staticImports(f));
|
||||||
|
}
|
||||||
|
return seen;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("the module stores", () => {
|
||||||
|
it("are all in reach under ModuleStores", () => {
|
||||||
|
const qc = new QueryClient();
|
||||||
|
const { getByText } = render(
|
||||||
|
<QueryClientProvider client={qc}>
|
||||||
|
<ModuleStores>
|
||||||
|
<Readers />
|
||||||
|
</ModuleStores>
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
expect(getByText("all stores in reach")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
const withStore = REGISTERED_PAGES.filter((p) => moduleDef(p).Store);
|
||||||
|
it.each(withStore)("%s's store reads no other module", (page) => {
|
||||||
|
const own = resolve(SRC, "modules", page).split("\\").join("/") + "/";
|
||||||
|
const others = [...closure(resolve(SRC, `modules/${page}/module.ts`))]
|
||||||
|
.map((f) => f.split("\\").join("/"))
|
||||||
|
.filter((f) => f.includes("/src/modules/") && !f.startsWith(own));
|
||||||
|
expect(others).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
+3
-3
@@ -11,13 +11,13 @@
|
|||||||
import { describe, it, expect, beforeEach, vi } from "vitest";
|
import { describe, it, expect, beforeEach, vi } from "vitest";
|
||||||
|
|
||||||
const { stored } = vi.hoisted(() => ({ stored: {} as Record<string, string | null> }));
|
const { stored } = vi.hoisted(() => ({ stored: {} as Record<string, string | null> }));
|
||||||
vi.mock("../lib/storage", async (importOriginal) => {
|
vi.mock("../../lib/storage", async (importOriginal) => {
|
||||||
const actual = await importOriginal<typeof import("../lib/storage")>();
|
const actual = await importOriginal<typeof import("../../lib/storage")>();
|
||||||
return { ...actual, getAccountRaw: (k: string) => stored[k] ?? null };
|
return { ...actual, getAccountRaw: (k: string) => stored[k] ?? null };
|
||||||
});
|
});
|
||||||
|
|
||||||
import { loadInitialPage, channelViewFrom } from "./navigationInit";
|
import { loadInitialPage, channelViewFrom } from "./navigationInit";
|
||||||
import { LS } from "../lib/storage";
|
import { LS } from "../../lib/storage";
|
||||||
|
|
||||||
const setUrl = (search: string) => window.history.replaceState(null, "", search || "/");
|
const setUrl = (search: string) => window.history.replaceState(null, "", search || "/");
|
||||||
|
|
||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
import { createContext, useContext } from "react";
|
import { createContext, useContext } from "react";
|
||||||
import type { Page } from "../lib/urlState";
|
import type { Page } from "../../lib/urlState";
|
||||||
import type { ChannelView } from "./navigationInit";
|
import type { ChannelView } from "./navigationInit";
|
||||||
|
|
||||||
// The navigation contexts + their hooks, split out of NavigationProvider so that file exports only
|
// The navigation contexts + their hooks, split out of NavigationProvider so that file exports only
|
||||||
+3
-3
@@ -2,9 +2,9 @@
|
|||||||
// provider file keeps react-refresh happy (component-only exports). `loadInitialPage` picks the landing
|
// provider file keeps react-refresh happy (component-only exports). `loadInitialPage` picks the landing
|
||||||
// module from a bookmark's params; `channelViewFrom` is the shared _chan/?chan resolver behind BOTH the
|
// module from a bookmark's params; `channelViewFrom` is the shared _chan/?chan resolver behind BOTH the
|
||||||
// channelView seed and the onPop handler.
|
// channelView seed and the onPop handler.
|
||||||
import { hasFilterParams, readPage, resolvePage, type Page } from "../lib/urlState";
|
import { hasFilterParams, readPage, resolvePage, type Page } from "../../lib/urlState";
|
||||||
import { getAccountRaw, LS } from "../lib/storage";
|
import { getAccountRaw, LS } from "../../lib/storage";
|
||||||
import type { UsePrefOptions } from "../lib/usePref";
|
import type { UsePrefOptions } from "../../lib/usePref";
|
||||||
|
|
||||||
export const PAGE_KEY = LS.page;
|
export const PAGE_KEY = LS.page;
|
||||||
/** The landing preference — start on Home, or on the module left open — an account preference
|
/** The landing preference — start on Home, or on the module left open — an account preference
|
||||||
+6
-3
@@ -1,8 +1,11 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
//
|
||||||
|
// The registry reaches the modules' faces, whose stores read the browser (i18n, localStorage).
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { pageTitleKey } from "./pageMeta";
|
import { pageTitleKey } from "./moduleRegistry";
|
||||||
import type { Page } from "./urlState";
|
import type { Page } from "../../lib/urlState";
|
||||||
|
|
||||||
// pageTitleKey now derives from the ModuleDef registry (titleKey ?? labelKey). These are the exact
|
// pageTitleKey derives from the module registry (titleKey ?? labelKey). These are the exact
|
||||||
// browser-tab title keys the old hand-written switch produced — pin every page so a future labelKey
|
// browser-tab title keys the old hand-written switch produced — pin every page so a future labelKey
|
||||||
// rename (or a dropped titleKey) can't silently change a tab title.
|
// rename (or a dropped titleKey) can't silently change a tab title.
|
||||||
const EXPECTED: Record<Page, string> = {
|
const EXPECTED: Record<Page, string> = {
|
||||||
+9
-9
@@ -7,8 +7,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|||||||
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import type { ClipRequest } from "../lib/api";
|
import type { ClipRequest } from "../../lib/api";
|
||||||
import type { ClipRange } from "../lib/clipRange";
|
import type { ClipRange } from "../../lib/clipRange";
|
||||||
|
|
||||||
vi.mock("react-i18next", async (importOriginal) => {
|
vi.mock("react-i18next", async (importOriginal) => {
|
||||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||||
@@ -16,20 +16,20 @@ vi.mock("react-i18next", async (importOriginal) => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const enqueueClip = vi.fn<(body: ClipRequest) => Promise<unknown>>();
|
const enqueueClip = vi.fn<(body: ClipRequest) => Promise<unknown>>();
|
||||||
vi.mock("../lib/api", async (importOriginal) => {
|
vi.mock("../../lib/api", async (importOriginal) => {
|
||||||
const actual = await importOriginal<typeof import("../lib/api")>();
|
const actual = await importOriginal<typeof import("../../lib/api")>();
|
||||||
return { ...actual, api: { ...actual.api, enqueueClip: (b: ClipRequest) => enqueueClip(b) } };
|
return { ...actual, api: { ...actual.api, enqueueClip: (b: ClipRequest) => enqueueClip(b) } };
|
||||||
});
|
});
|
||||||
const notify = vi.fn();
|
const notify = vi.fn();
|
||||||
vi.mock("../lib/notifications", async (importOriginal) => {
|
vi.mock("../../lib/notifications", async (importOriginal) => {
|
||||||
const actual = await importOriginal<typeof import("../lib/notifications")>();
|
const actual = await importOriginal<typeof import("../../lib/notifications")>();
|
||||||
return { ...actual, notify: (n: unknown) => notify(n) };
|
return { ...actual, notify: (n: unknown) => notify(n) };
|
||||||
});
|
});
|
||||||
const showInTray = vi.fn();
|
const showInTray = vi.fn();
|
||||||
vi.mock("../lib/downloadQueue", () => ({ showInTray: (id: number) => showInTray(id) }));
|
vi.mock("../../lib/downloadQueue", () => ({ showInTray: (id: number) => showInTray(id) }));
|
||||||
const notifyActionError = vi.fn();
|
const notifyActionError = vi.fn();
|
||||||
vi.mock("../lib/actionErrors", async (importOriginal) => {
|
vi.mock("../../lib/actionErrors", async (importOriginal) => {
|
||||||
const actual = await importOriginal<typeof import("../lib/actionErrors")>();
|
const actual = await importOriginal<typeof import("../../lib/actionErrors")>();
|
||||||
return { ...actual, notifyActionError: (e: unknown, t: string) => notifyActionError(e, t) };
|
return { ...actual, notifyActionError: (e: unknown, t: string) => notifyActionError(e, t) };
|
||||||
});
|
});
|
||||||
|
|
||||||
+18
-18
@@ -2,24 +2,24 @@ import { useEffect, useEffectEvent, useId, useRef, useState, type SubmitEvent }
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
import { useQueryClient } from "@tanstack/react-query";
|
||||||
import { Crop, Crosshair, Maximize, Minus, Plus, Repeat, Scissors, X } from "lucide-react";
|
import { Crop, Crosshair, Maximize, Minus, Plus, Repeat, Scissors, X } from "lucide-react";
|
||||||
import { api, type ClipRequest } from "../lib/api";
|
import { api, type ClipRequest } from "../../lib/api";
|
||||||
import { DISPLAY_NAME_MAX } from "../lib/api/downloads";
|
import { DISPLAY_NAME_MAX } from "../../lib/api/downloads";
|
||||||
import { invalidateDownloads } from "../lib/downloads";
|
import { invalidateDownloads } from "../../lib/downloads";
|
||||||
import { notify } from "../lib/notifications";
|
import { notify } from "../../lib/notifications";
|
||||||
import { notifyActionError } from "../lib/actionErrors";
|
import { notifyActionError } from "../../lib/actionErrors";
|
||||||
import { showInTray } from "../lib/downloadQueue";
|
import { showInTray } from "../../lib/downloadQueue";
|
||||||
import { log } from "../lib/log";
|
import { log } from "../../lib/log";
|
||||||
import { formatTimecode } from "../lib/timecode";
|
import { formatTimecode } from "../../lib/timecode";
|
||||||
import { handleBounds, markIn, markOut, zoomWindow, type ClipRange } from "../lib/clipRange";
|
import { handleBounds, markIn, markOut, zoomWindow, type ClipRange } from "../../lib/clipRange";
|
||||||
import { fitRatio, fracRatio, WHOLE, type Frac } from "../lib/cropGeometry";
|
import { fitRatio, fracRatio, WHOLE, type Frac } from "../../lib/cropGeometry";
|
||||||
import { RangeTrack, RulerTicks, type RangeWindow } from "./ui/RangeTrack";
|
import { RangeTrack, RulerTicks, type RangeWindow } from "../../components/ui/RangeTrack";
|
||||||
import { CropFrame } from "./ui/CropFrame";
|
import { CropFrame } from "../../components/ui/CropFrame";
|
||||||
import SegmentedToggle from "./ui/SegmentedToggle";
|
import SegmentedToggle from "../../components/ui/SegmentedToggle";
|
||||||
import { Button } from "./ui/Button";
|
import { Button } from "../../components/ui/Button";
|
||||||
import { IconButton } from "./ui/IconButton";
|
import { IconButton } from "../../components/ui/IconButton";
|
||||||
import { Input } from "./ui/Input";
|
import { Input } from "../../components/ui/Input";
|
||||||
import { TimecodeError, TimecodeField } from "./ui/TimecodeField";
|
import { TimecodeError, TimecodeField } from "../../components/ui/TimecodeField";
|
||||||
import { useTimecodeField, type TimecodeDraft } from "./ui/useTimecodeField";
|
import { useTimecodeField, type TimecodeDraft } from "../../components/ui/useTimecodeField";
|
||||||
|
|
||||||
// The clip panel: marks a range of what is playing and cuts it into a download of its own. It lives
|
// The clip panel: marks a range of what is playing and cuts it into a download of its own. It lives
|
||||||
// IN the player (the YouTube card under the video, the Plex player's control band), so the video
|
// IN the player (the YouTube card under the video, the Plex player's control band), so the video
|
||||||
+9
-7
@@ -23,16 +23,18 @@ vi.mock("react-i18next", async (importOriginal) => {
|
|||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
vi.mock("./navigationContext", () => ({ useNavigationActions: () => ({ openChannel: vi.fn() }) }));
|
vi.mock("../navigation/navigationContext", () => ({
|
||||||
|
useNavigationActions: () => ({ openChannel: vi.fn() }),
|
||||||
|
}));
|
||||||
// Their own queries and popovers are not what is under test here.
|
// Their own queries and popovers are not what is under test here.
|
||||||
vi.mock("./AddToPlaylist", () => ({ default: () => null }));
|
vi.mock("../video/AddToPlaylist", () => ({ default: () => null }));
|
||||||
vi.mock("./DownloadButton", () => ({ default: () => null }));
|
vi.mock("../downloads/DownloadButton", () => ({ default: () => null }));
|
||||||
|
|
||||||
import PlayerModal from "./PlayerModal";
|
import PlayerModal from "./PlayerModal";
|
||||||
import { ConfirmProvider } from "./ConfirmProvider";
|
import { ConfirmProvider } from "../../components/ui/ConfirmProvider";
|
||||||
import { useConfirm } from "./confirmContext";
|
import { useConfirm } from "../../components/ui/confirmContext";
|
||||||
import { api, type Video } from "../lib/api";
|
import { api, type Video } from "../../lib/api";
|
||||||
import { clearAll, notify } from "../lib/notifications";
|
import { clearAll, notify } from "../../lib/notifications";
|
||||||
|
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
class Stub {
|
class Stub {
|
||||||
+31
-31
@@ -10,12 +10,12 @@ import {
|
|||||||
} from "react";
|
} from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import { useNavigationActions } from "./navigationContext";
|
import { useNavigationActions } from "../navigation/navigationContext";
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { qk } from "../lib/queryKeys";
|
import { qk } from "../../lib/queryKeys";
|
||||||
import { usePref } from "../lib/usePref";
|
import { usePref } from "../../lib/usePref";
|
||||||
import { PlayerDescription, PlayerTransportControls } from "./playerModalControls";
|
import { PlayerDescription, PlayerTransportControls } from "./playerModalControls";
|
||||||
import { AUTO_MODES, LOOP_MODES, type AutoMode, type LoopMode } from "../lib/playerModes";
|
import { AUTO_MODES, LOOP_MODES, type AutoMode, type LoopMode } from "../../lib/playerModes";
|
||||||
import {
|
import {
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
@@ -37,52 +37,52 @@ import {
|
|||||||
VolumeX,
|
VolumeX,
|
||||||
X,
|
X,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import Avatar from "./ui/Avatar";
|
import Avatar from "../../components/ui/Avatar";
|
||||||
import { useLatest, useStableCallback } from "../lib/useLatest";
|
import { useLatest, useStableCallback } from "../../lib/useLatest";
|
||||||
import AddToPlaylist from "./AddToPlaylist";
|
import AddToPlaylist from "../video/AddToPlaylist";
|
||||||
import { PopoverSurface } from "./ui/AnchoredPopover";
|
import { PopoverSurface } from "../../components/ui/AnchoredPopover";
|
||||||
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
|
import { useAnchoredPopover } from "../../components/ui/anchoredPopoverHooks";
|
||||||
import {
|
import {
|
||||||
pushLayer,
|
pushLayer,
|
||||||
removeLayer,
|
removeLayer,
|
||||||
anyBlockingLayerAbove,
|
anyBlockingLayerAbove,
|
||||||
anyLayerAbove,
|
anyLayerAbove,
|
||||||
escapeHandled,
|
escapeHandled,
|
||||||
} from "../lib/layerStack";
|
} from "../../lib/layerStack";
|
||||||
import { trapTab, useDialogChrome } from "./ui/dialogFocus";
|
import { trapTab, useDialogChrome } from "../../components/ui/dialogFocus";
|
||||||
import DownloadButton from "./DownloadButton";
|
import DownloadButton from "../downloads/DownloadButton";
|
||||||
import ClipPanel, { ClipCropFrame, type ClipCrop } from "./ClipPanel";
|
import ClipPanel, { ClipCropFrame, type ClipCrop } from "./ClipPanel";
|
||||||
import { markInAt, markOutAt, openAt, type ClipRange } from "../lib/clipRange";
|
import { markInAt, markOutAt, openAt, type ClipRange } from "../../lib/clipRange";
|
||||||
import { api, type Video, type VideoStatus } from "../lib/api";
|
import { api, type Video, type VideoStatus } from "../../lib/api";
|
||||||
import type { YTPlayer } from "../lib/youtube";
|
import type { YTPlayer } from "../../lib/youtube";
|
||||||
import {
|
import {
|
||||||
channelYouTubeUrl,
|
channelYouTubeUrl,
|
||||||
formatDate,
|
formatDate,
|
||||||
formatDuration,
|
formatDuration,
|
||||||
formatCompact,
|
formatCompact,
|
||||||
relativeTime,
|
relativeTime,
|
||||||
} from "../lib/format";
|
} from "../../lib/format";
|
||||||
import { canFullscreenElements, fullscreenElement } from "../lib/fullscreen";
|
import { canFullscreenElements, fullscreenElement } from "../../lib/fullscreen";
|
||||||
import { dispatch, isActivationTarget, useKeyScope } from "../lib/keymap";
|
import { dispatch, isActivationTarget, useKeyScope } from "../../lib/keymap";
|
||||||
import { useBackToClose } from "../lib/history";
|
import { useBackToClose } from "../../lib/history";
|
||||||
import ChannelNameLink from "./ChannelNameLink";
|
import ChannelNameLink from "../video/ChannelNameLink";
|
||||||
import Toaster from "./ui/Toaster";
|
import Toaster from "../../components/ui/Toaster";
|
||||||
import { useOverlayOrder } from "./ui/overlayOrder";
|
import { useOverlayOrder } from "../../components/ui/overlayOrder";
|
||||||
import { afterRemoval } from "../lib/playerQueue";
|
import { afterRemoval } from "../../lib/playerQueue";
|
||||||
import {
|
import {
|
||||||
useNativeControlsYield,
|
useNativeControlsYield,
|
||||||
useYtBigView,
|
useYtBigView,
|
||||||
useYtProgress,
|
useYtProgress,
|
||||||
useYtVolume,
|
useYtVolume,
|
||||||
YT_API_LOAD_FAILED,
|
YT_API_LOAD_FAILED,
|
||||||
} from "../lib/playerModalHooks";
|
} from "../../lib/playerModalHooks";
|
||||||
import { useScrollFade } from "../lib/useScrollFade";
|
import { useScrollFade } from "../../lib/useScrollFade";
|
||||||
import { Button } from "./ui/Button";
|
import { Button } from "../../components/ui/Button";
|
||||||
import { IconButton } from "./ui/IconButton";
|
import { IconButton } from "../../components/ui/IconButton";
|
||||||
import { SURFACE } from "./ui/surfaces";
|
import { SURFACE } from "../../components/ui/surfaces";
|
||||||
import { IconLink, LinkButton } from "./ui/LinkButton";
|
import { IconLink, LinkButton } from "../../components/ui/LinkButton";
|
||||||
import { SectionLabel } from "./ui/Headings";
|
import { SectionLabel } from "../../components/ui/Headings";
|
||||||
import { useMediaQuery } from "../lib/useMediaQuery";
|
import { useMediaQuery } from "../../lib/useMediaQuery";
|
||||||
|
|
||||||
// Experiment (branch experiment/inline-player): play the video in-app via the
|
// Experiment (branch experiment/inline-player): play the video in-app via the
|
||||||
// YouTube IFrame Player API (not a bare embed) so we can read playback position
|
// YouTube IFrame Player API (not a bare embed) so we can read playback position
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user