release: 0.109.1 (improvement/e44-fe-shell)

This commit is contained in:
2026-10-23 14:21:45 +02:00
441 changed files with 5630 additions and 3998 deletions
+10
View File
@@ -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).
## 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
### New
+19 -8
View File
@@ -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
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.
Frontend: `main.tsx` provider tree → `App.tsx` shell (a shared `/watch` link boots from its own
entry, `watch.html` → `watchMain.tsx`, held app-free by a contract test) → `moduleRegistry` route-chunks → module
components → the **platform layer `components/ui/`** (domain-free primitives: the popover engine and
its menus, `Modal`/`Overlay`, `Button`/`form`, `DataTable`/`VirtualGrid`, `QueryState`, …). That
boundary is one-way and **lint-enforced**, not just described: a platform component may not import a
feature component or a domain API slice, and one layer down `lib/` may not import from `components/` at
all, not even a type (`no-restricted-imports` in `eslint.config.js`, with its own test). A new generic
component goes in `ui/`; see [`frontend/src/components/ui/README.md`](frontend/src/components/ui/README.md).
Frontend, by folder under `src/`: `main.tsx` provider tree → `shell/` (`App`, header, nav rail, palette,
boot; a shared `/watch` link boots from its own entry, `watch.html` → `watchMain.tsx` → `watch/`, held
app-free by a contract test) → **`modules/<page>/`** (one folder per page: the page, its rail, its store, its tests,
and its `module.ts` definition — label, icon, gate, the lazy page and rail, its store — collected in nav order by
`features/navigation/moduleRegistry`, which also mounts every store at the root; a new page is its folder, its id in
`lib/urlState` and its entry there, plus its panel in `lib/panelLayout` if it has a rail) →
**`features/<name>/`** (what several pages share: video cards, filters, downloads, the player, navigation) → the
**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
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 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 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 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 |
+1 -1
View File
@@ -1 +1 @@
0.109.0
0.109.1
+3 -1
View File
@@ -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
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]
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
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
+27 -1
View File
@@ -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
(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
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 knip # unused files/exports/deps
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
```
+3 -2
View File
@@ -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. | — |
| 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. | — |
| 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. | — |
| 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. | — |
@@ -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) |
| 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 |
| 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
+43
View File
@@ -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);
});
});
+73
View File
@@ -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
View File
@@ -7,6 +7,8 @@ import globals from "globals";
import eslintComments from "@eslint-community/eslint-plugin-eslint-comments";
import vitest from "@vitest/eslint-plugin";
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
// 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.
const formats = /\/src\/lib\/(format|plexPlayerPrefs)\.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) => {
for (let scope = context.sourceCode.getScope(node); scope; scope = scope.upper) {
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 = {
rules: {
layers,
"glyph-link": glyphLink,
"sortable-a11y": sortableA11y,
"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.
// `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
// per-function picture is components/compilerCoverage.contract.test.ts.
// per-function picture is src/compilerCoverage.contract.test.ts.
"react-hooks/todo": "error",
"react-hooks/memo-dependencies": "error",
"react-hooks/invariant": "error",
@@ -566,6 +666,7 @@ export default tseslint.config(
"local/literal-a11y-text": "error",
"local/glyph-link": "error",
"local/sortable-a11y": "error",
"local/layers": "error",
"react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
// 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.
@@ -760,7 +861,14 @@ export default tseslint.config(
// 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
// 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],
languageOptions: {
ecmaVersion: 2022,
@@ -775,6 +883,16 @@ export default tseslint.config(
"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"],
plugins: { playwright },
+1 -1
View File
@@ -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
// 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
// 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
// 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
-499
View File
@@ -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>
);
}
@@ -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(() => ({
notify: vi.fn((_n: { level: string; message: string }) => 0),
}));
vi.mock("../lib/notifications", async (importOriginal) => ({
...(await importOriginal<typeof import("../lib/notifications")>()),
vi.mock("./lib/notifications", async (importOriginal) => ({
...(await importOriginal<typeof import("./lib/notifications")>()),
notify,
}));
import PlexPlaylistView from "./PlexPlaylistView";
import AddToPlaylist, { AddToPlaylistAt } from "./AddToPlaylist";
import PlexPlaylistAdd from "./PlexPlaylistAdd";
import TagManager from "./TagManager";
import { api, HttpError, type PlexCard } from "../lib/api";
import PlexPlaylistView from "./modules/plex/PlexPlaylistView";
import AddToPlaylist, { AddToPlaylistAt } from "./features/video/AddToPlaylist";
import PlexPlaylistAdd from "./features/plexCards/PlexPlaylistAdd";
import TagManager from "./features/channel/TagManager";
import { api, HttpError, type PlexCard } from "./lib/api";
beforeAll(() => {
class Stub {
@@ -17,19 +17,19 @@ vi.mock("react-i18next", async (importOriginal) => {
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(() => ({
notify: vi.fn((_n: { level: string; title?: string; message: string }) => 0),
}));
vi.mock("../lib/notifications", async (importOriginal) => ({
...(await importOriginal<typeof import("../lib/notifications")>()),
vi.mock("./lib/notifications", async (importOriginal) => ({
...(await importOriginal<typeof import("./lib/notifications")>()),
notify,
}));
import PlexCollectionEditor from "./PlexCollectionEditor";
import UploadDialog from "./UploadDialog";
import DownloadDialog from "./DownloadDialog";
import { api, HttpError } from "../lib/api";
import PlexCollectionEditor from "./modules/plex/PlexCollectionEditor";
import UploadDialog from "./modules/files/UploadDialog";
import DownloadDialog from "./features/downloads/DownloadDialog";
import { api, HttpError } from "./lib/api";
beforeAll(() => {
class Stub {
@@ -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
// harness that compiled nothing could not pass as "no skips").
const MUST_COMPILE: Record<string, string[]> = {
"components/Channels.tsx": ["Channels"],
"modules/channels/Channels.tsx": ["Channels"],
"components/ui/DataTable.tsx": ["DataTable"],
// Under every button and every popover in the app.
"components/ui/FocusSafeButton.tsx": ["FocusSafeButton"],
"components/ui/anchoredPopoverHooks.ts": ["useAnchoredPopover"],
"components/ui/AnchoredPopover.tsx": ["PopoverSurfaceInner"],
// The two players — the largest components here, and the last to compile.
"components/PlexPlayer.tsx": ["PlexPlayer"],
"components/PlayerModal.tsx": ["PlayerModal"],
"modules/plex/PlexPlayer.tsx": ["PlexPlayer"],
"features/player/PlayerModal.tsx": ["PlayerModal"],
"lib/playerModalHooks.ts": ["useYtProgress"],
};
@@ -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
// 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. */
export function shippedSources(): string[] {
-137
View File
@@ -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 { formatDate } from "../../lib/format";
import { send } from "../../lib/http";
import LanguageSwitcher from "../LanguageSwitcher";
import LanguageSwitcher from "../ui/LanguageSwitcher";
import { SURFACE } from "../ui/surfaces";
// The day the text last changed materially — bump it with every edit of either body.
-102
View File
@@ -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);
-25
View File
@@ -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);
}
@@ -1,7 +1,7 @@
import { useCallback, useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import Modal, { ModalFooter } from "./ui/Modal";
import { Button } from "./ui/Button";
import Modal, { ModalFooter } from "./Modal";
import { Button } from "./Button";
import { ChoiceContext, type ChoiceFn, type ChoiceOptions } from "./confirmContext";
// 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 { useTranslation } from "react-i18next";
import { defaultLayout, type PanelId, type PanelLayout } from "../lib/panelLayout";
import SidePanel from "./ui/SidePanel";
import PanelGroups, { type PanelItem } from "./ui/PanelGroups";
import { type PanelLayout } from "../../lib/panelLayout";
import SidePanel from "./SidePanel";
import PanelGroups, { type PanelItem } from "./PanelGroups";
// 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
// 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
// (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
// `children` defaults to nothing for the rails that need none.
// groups (`children`, e.g. the feed's tag manager; nothing for the rails that need none). What Reset
// goes back to is the rail's (`onResetLayout`, from lib/useRail), so this shell names no page.
export default function FilterRail({
kind,
onResetLayout,
title,
icon,
count,
@@ -28,7 +28,8 @@ export default function FilterRail({
titleActionLabel,
children,
}: {
kind: PanelId;
/** Puts the groups back in the rail's default order (lib/useRail's `onResetLayout`). */
onResetLayout: () => void;
title: string;
icon: ReactNode;
count?: number;
@@ -56,7 +57,7 @@ export default function FilterRail({
onToggleCollapse={onToggleCollapse}
editing={editing}
onToggleEditing={() => setEditing((e) => !e)}
onReset={() => setLayout(defaultLayout(kind))}
onReset={onResetLayout}
editHint={t("sidebar.editHint")}
actions={actions}
iconExpanded={iconExpanded}
@@ -1,12 +1,12 @@
import { useState } from "react";
import { Globe } from "lucide-react";
import { MenuCheck } from "./ui/MenuCheck";
import { LANGUAGES, type LangCode } from "../i18n";
import { PopoverItem, PopoverSurface } from "./ui/AnchoredPopover";
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
import { Pressable } from "./ui/Pressable";
import { RAIL_GLYPH, RAIL_ROW } from "./ui/NavRail";
import { SURFACE } from "./ui/surfaces";
import { MenuCheck } from "./MenuCheck";
import { LANGUAGES, type LangCode } from "../../i18n";
import { PopoverItem, PopoverSurface } from "./AnchoredPopover";
import { useAnchoredPopover } from "./anchoredPopoverHooks";
import { Pressable } from "./Pressable";
import { RAIL_GLYPH, RAIL_ROW } from "./NavRail";
import { SURFACE } from "./surfaces";
// Compact language picker (globe + current code). Presentational: the parent decides what
// 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", () => {
const src = readFileSync(resolve(__dirname, "../NavSidebar.tsx"), "utf8");
const src = readFileSync(resolve(__dirname, "../../shell/NavSidebar.tsx"), "utf8");
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();
});
});
+59 -1
View File
@@ -13,6 +13,7 @@ import {
import { createPortal } from "react-dom";
import PageScroller from "./PageScroller";
import { PageToolbarSlot } from "./pageToolbarSlot";
import { PageHeaderSlot } from "./pageHeaderSlot";
import { PageHeadingProvider } from "./pageHeading";
import { PageMenuSlot, type PageMenuOpener } from "./pageMenuSlot";
import { isBackgroundPoint } from "../../lib/backgroundTarget";
@@ -29,6 +30,31 @@ export function PageToolbar({ children }: { children: ReactNode }) {
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
// 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
@@ -38,6 +64,7 @@ export function PageToolbar({ children }: { children: ReactNode }) {
// - `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
// 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
// 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.
@@ -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.
const [headerEl, setHeaderEl] = 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(() => {
if (!headerEl || !contentEl) {
// 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);
ro.observe(headerEl);
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).
const [ownHeadings, setOwnHeadings] = useState(0);
const registerOwnHeading = useCallback(() => {
@@ -173,6 +229,7 @@ export default function PageShell({
: header;
return (
<PageToolbarSlot.Provider value={toolbarSlot}>
<PageHeaderSlot.Provider value={headerSlot}>
<PageMenuSlot.Provider value={registerPageMenu}>
<PageHeadingProvider value={registerOwnHeading}>
{measuredHeader}
@@ -193,6 +250,7 @@ export default function PageShell({
</div>
</PageHeadingProvider>
</PageMenuSlot.Provider>
</PageHeaderSlot.Provider>
</PageToolbarSlot.Provider>
);
}
+9 -9
View File
@@ -1,7 +1,7 @@
# `components/ui/` — the platform layer
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.
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`, …),
- i18n, React, and third-party UI libraries.
It may **not** import a feature component (`../Feed`) or a domain API slice (`lib/api/*`). This is
enforced by `no-restricted-imports` in `eslint.config.js` — not 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
It may **not** import anything from `features/`, `modules/` or `shell/`, or a domain API slice
(`lib/api/*`). This is enforced in `eslint.config.js` (`no-restricted-imports` and `local/layers`) — not
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
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
@@ -36,11 +36,11 @@ Counter-examples, deliberately NOT here: `PosterTile` (knows about Plex montage
## What lives here
| 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` |
| 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` |
| 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) |
| 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` |
| ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 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`, `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` + `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` + `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` |
## 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
// ui/Modal.tsx — the overlay behind every dialog in the app — was never checked by this guard at all
// (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
// 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).
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. */
const classStrings = (src: string): string[] =>
@@ -67,9 +67,9 @@ describe("full-screen overlays host their own toasts", () => {
expect(paths).toEqual(
expect.arrayContaining([
"./Modal.tsx",
"../PlayerModal.tsx",
"../PlexPlayer.tsx",
"../OnboardingWizard.tsx",
"../../features/player/PlayerModal.tsx",
"../../modules/plex/PlexPlayer.tsx",
"../../features/wizard/OnboardingWizard.tsx",
]),
);
});
@@ -1,11 +1,11 @@
import { useState } from "react";
import { type LucideIcon } from "lucide-react";
import clsx from "clsx";
import { PopoverItem, PopoverSurface } from "./ui/AnchoredPopover";
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
import { MenuCheck } from "./ui/MenuCheck";
import { SelectTrigger } from "./ui/SelectTrigger";
import { SURFACE } from "./ui/surfaces";
import { PopoverItem, PopoverSurface } from "./AnchoredPopover";
import { useAnchoredPopover } from "./anchoredPopoverHooks";
import { MenuCheck } from "./MenuCheck";
import { SelectTrigger } from "./SelectTrigger";
import { SURFACE } from "./surfaces";
export interface ViewOption<T extends string> {
id: T;
@@ -1,16 +1,17 @@
import { useTranslation } from "react-i18next";
import { Copy, Link2 } from "lucide-react";
import { copyText } from "../lib/clipboard";
import { notify } from "../lib/notifications";
import { type ActionMenuItem } from "./ui/ActionMenu";
import { copyText } from "../../lib/clipboard";
import { notify } from "../../lib/notifications";
import { type ActionMenuItem } from "./ActionMenu";
// "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
// 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).
//
// 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";
@@ -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.
describe("every view that filters rows by column", () => {
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" })
.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)
.filter(([, src]) => /filterRows\(/.test(src));
expect(sites.length).toBeGreaterThanOrEqual(3);
@@ -1,7 +1,8 @@
import { readdirSync, readFileSync } from "node:fs";
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 = () =>
readdirSync(root, { recursive: true, encoding: "utf8" })
.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
// that decision is taken again.
const files = readSources(__dirname, [
"../**/*.tsx",
"!../**/*.test.*",
"!../design-lab/**",
"../../**/*.tsx",
"!../../**/*.test.*",
"!../../design-lab/**",
"!./**",
]) as Record<string, string>;
@@ -17,14 +17,14 @@ describe("the pages with a background menu", () => {
it("are the five the owner chose", () => {
const users = Object.entries(files)
.filter(([, code]) => /<PageMenu\b|\busePageMenu\(/.test(code))
.map(([path]) => path.replace(/^\.\.\//, ""))
.map(([path]) => path.replace(/^(\.\.\/)+/, ""))
.sort();
expect(users).toEqual([
"Channels.tsx",
"Feed.tsx",
"NotificationsPanel.tsx",
"Playlists.tsx",
"PlexBrowse.tsx",
"modules/channels/Channels.tsx",
"modules/feed/Feed.tsx",
"modules/notifications/NotificationsPanel.tsx",
"modules/playlists/Playlists.tsx",
"modules/plex/PlexBrowse.tsx",
]);
});
});
@@ -10,66 +10,74 @@ import { readSources } from "../../lib/rawSources.testkit";
// someone decides.
const files = readSources(__dirname, [
"../**/*.tsx",
"!../**/*.test.*",
"!../design-lab/**",
"../../**/*.tsx",
"!../../**/*.test.*",
"!../../design-lab/**",
]) as Record<string, string>;
/** file → [how many plain Pressables, why they are not titles]. */
const PLAIN: Record<string, [number, string]> = {
"ApiTokens.tsx": [
"modules/settings/ApiTokens.tsx": [
1,
"a token kind's tile in the new-token dialog (an option: its text is its label)",
],
"DownloadTray.tsx": [
"features/downloads/DownloadTray.tsx": [
2,
"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)"],
"FilesRail.tsx": [2, "the load-error retry; the New-folder command"],
"Home.tsx": [
"modules/files/FileCard.tsx": [
1,
"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,
"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"],
"NavSidebar.tsx": [3, "the account trigger and its menu rows"],
"PaletteLauncher.tsx": [2, "launcher controls"],
"PlaylistsRail.tsx": [1, "the load-error retry"],
"PlexCollectionEditor.tsx": [1, "a checkbox row in a picker"],
"PlexInfo.tsx": [1, "the rating filter (a number)"],
"PlexPlayerSettings.tsx": [1, "the clock's date-style options"],
"PlexPlaylistAdd.tsx": [1, "a checkbox row in a picker"],
"PlexPlaylistView.tsx": [1, "a show's poster stack (aria-hidden)"],
"PlexSearchBox.tsx": [1, "a listbox option"],
"plexCards.tsx": [1, "a card's picture (named by aria-label; its title is the caption)"],
"Scheduler.tsx": [1, "the interval editor trigger"],
"SettingsPanel.tsx": [1, "a colour swatch"],
"SyncStatus.tsx": [1, "the status pill trigger"],
"TagManager.tsx": [1, "a suggestion in a picker"],
"UploadDialog.tsx": [1, "the drop zone"],
"VideoEditor.tsx": [2, "timeline glyphs"],
"Welcome.tsx": [1, "a screenshot (zoom)"],
"playlistColumns.tsx": [1, "the thumbnail"],
"plexDetailUi.tsx": [2, "a switch row and the poster"],
"plexViews.tsx": [1, "the rating filter (a number)"],
"ui/ActionMenu.tsx": [1, "a menu row"],
"ui/BackToTop.tsx": [1, "a control"],
"ui/CommandPalette.tsx": [1, "a command row"],
"ui/NavRail.tsx": [
"components/ui/LanguageSwitcher.tsx": [1, "an option row"],
"shell/NavSidebar.tsx": [3, "the account trigger and its menu rows"],
"features/header/PaletteLauncher.tsx": [2, "launcher controls"],
"modules/playlists/PlaylistsRail.tsx": [1, "the load-error retry"],
"modules/plex/PlexCollectionEditor.tsx": [1, "a checkbox row in a picker"],
"modules/plex/PlexInfo.tsx": [1, "the rating filter (a number)"],
"modules/plex/PlexPlayerSettings.tsx": [1, "the clock's date-style options"],
"features/plexCards/PlexPlaylistAdd.tsx": [1, "a checkbox row in a picker"],
"modules/plex/PlexPlaylistView.tsx": [1, "a show's poster stack (aria-hidden)"],
"modules/plex/PlexSearchBox.tsx": [1, "a listbox option"],
"features/plexCards/plexCards.tsx": [
1,
"a card's picture (named by aria-label; its title is the caption)",
],
"modules/scheduler/Scheduler.tsx": [1, "the interval editor trigger"],
"modules/settings/SettingsPanel.tsx": [1, "a colour swatch"],
"modules/channels/SyncStatus.tsx": [1, "the status pill trigger"],
"features/channel/TagManager.tsx": [1, "a suggestion in a picker"],
"modules/files/UploadDialog.tsx": [1, "the drop zone"],
"modules/files/VideoEditor.tsx": [2, "timeline glyphs"],
"shell/Welcome.tsx": [1, "a screenshot (zoom)"],
"modules/playlists/playlistColumns.tsx": [1, "the thumbnail"],
"modules/plex/plexDetailUi.tsx": [2, "a switch row and the poster"],
"modules/plex/plexViews.tsx": [1, "the rating filter (a number)"],
"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,
"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"],
"ui/SidePanel.tsx": [2, "panel controls"],
"ui/tableHeader.tsx": [1, "a column's sort header"],
"components/ui/PanelGroup.tsx": [2, "a group's collapse toggle and its label"],
"components/ui/SidePanel.tsx": [2, "panel controls"],
"components/ui/tableHeader.tsx": [1, "a column's sort header"],
};
function scan(): { plain: Record<string, number>; selectable: number } {
const plain: Record<string, number> = {};
let selectable = 0;
for (const [path, code] of Object.entries(files)) {
// Glob keys are relative to this folder: `./x.tsx` is ui/, `../x.tsx` is components/.
const rel = path.startsWith("./") ? `ui/${path.slice(2)}` : path.replace(/^\.\.\//, "");
// Glob keys are relative to this folder (`./x.tsx` is ui/); the table keys them from src/.
const rel = path.startsWith("./")
? `components/ui/${path.slice(2)}`
: path.replace(/^(\.\.\/)+/, "");
for (const m of code.matchAll(/<Pressable\b/g)) {
if (/\sselectable\b(?!=)/.test(openingTag(code, m.index))) selectable++;
else plain[rel] = (plain[rel] ?? 0) + 1;
@@ -10,8 +10,8 @@ vi.mock("react-i18next", async (importOriginal) => ({
...(await importOriginal<typeof import("react-i18next")>()),
useTranslation: () => ({ i18n: { language: "en" }, t: (key: string) => key }),
}));
vi.mock("../lib/clipboard", () => ({ copyText: h.copyText }));
vi.mock("../lib/notifications", () => ({ notify: h.notify }));
vi.mock("../../lib/clipboard", () => ({ copyText: h.copyText }));
vi.mock("../../lib/notifications", () => ({ notify: h.notify }));
import { useCopyFeedback } from "./useCopyFeedback";
@@ -1,8 +1,8 @@
import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { copyText } from "../lib/clipboard";
import { notify } from "../lib/notifications";
import { copyText } from "../../lib/clipboard";
import { notify } from "../../lib/notifications";
// How long a Copy button reads "Copied" after it worked.
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",
"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",
"date": "2026-10-21",
@@ -1,9 +1,9 @@
import { useTranslation } from "react-i18next";
import { ExternalLink } from "lucide-react";
import type { ChannelDetail } from "../lib/api";
import { linkify } from "../lib/linkify";
import { Badge } from "./ui/Badge";
import { languageName, regionName, vocab } from "../lib/vocab";
import type { ChannelDetail } from "../../lib/api";
import { linkify } from "../../lib/linkify";
import { Badge } from "../../components/ui/Badge";
import { languageName, regionName, vocab } from "../../lib/vocab";
// --- 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".
@@ -16,7 +16,7 @@ vi.mock("react-i18next", async (importOriginal) => {
});
import { ChannelTagsControl } from "./ChannelTags";
import { api } from "../lib/api";
import { api } from "../../lib/api";
beforeAll(() => {
class Stub {
@@ -22,7 +22,7 @@ vi.mock("react-i18next", async (importOriginal) => {
});
import { ChannelTagsControl, type SuggestedTag } from "./ChannelTags";
import type { Tag } from "../lib/api";
import type { Tag } from "../../lib/api";
beforeAll(() => {
class Stub {
@@ -1,16 +1,16 @@
import { useRef, useState } from "react";
import { Plus, Sparkles } from "lucide-react";
import { useTranslation } from "react-i18next";
import { type Tag } from "../lib/api";
import { PopoverSurface } from "./ui/AnchoredPopover";
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
import { fitChipsWithPill } from "../lib/chipOverflow";
import { chipLaneClass, useChipOverflowFit } from "../lib/useChipOverflowFit";
import { Chip, DismissibleChip } from "./ui/Chip";
import { chipClass } from "./ui/chipStyles";
import { MembershipMenu } from "./ui/MembershipMenu";
import { useTags } from "../lib/useTags";
import { SURFACE } from "./ui/surfaces";
import { type Tag } from "../../lib/api";
import { PopoverSurface } from "../../components/ui/AnchoredPopover";
import { useAnchoredPopover } from "../../components/ui/anchoredPopoverHooks";
import { fitChipsWithPill } from "../../lib/chipOverflow";
import { chipLaneClass, useChipOverflowFit } from "../../lib/useChipOverflowFit";
import { Chip, DismissibleChip } from "../../components/ui/Chip";
import { chipClass } from "../../components/ui/chipStyles";
import { MembershipMenu } from "../../components/ui/MembershipMenu";
import { useTags } from "../../lib/useTags";
import { SURFACE } from "../../components/ui/surfaces";
// 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:
@@ -1,22 +1,22 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
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 { api, type Tag } from "../lib/api";
import { notify } from "../lib/notifications";
import { notifyActionError } from "../lib/actionErrors";
import { useScrollFade } from "../lib/useScrollFade";
import { Button } from "./ui/Button";
import Modal from "./ui/Modal";
import { useConfirm } from "./confirmContext";
import { PopoverSurface } from "./ui/AnchoredPopover";
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
import { IconButton } from "./ui/IconButton";
import { Pressable } from "./ui/Pressable";
import { Input } from "./ui/Input";
import { SURFACE } from "./ui/surfaces";
import { useTags } from "../lib/useTags";
import { api, type Tag } from "../../lib/api";
import { notify } from "../../lib/notifications";
import { notifyActionError } from "../../lib/actionErrors";
import { useScrollFade } from "../../lib/useScrollFade";
import { Button } from "../../components/ui/Button";
import Modal from "../../components/ui/Modal";
import { useConfirm } from "../../components/ui/confirmContext";
import { PopoverSurface } from "../../components/ui/AnchoredPopover";
import { useAnchoredPopover } from "../../components/ui/anchoredPopoverHooks";
import { IconButton } from "../../components/ui/IconButton";
import { Pressable } from "../../components/ui/Pressable";
import { Input } from "../../components/ui/Input";
import { SURFACE } from "../../components/ui/surfaces";
import { useTags } from "../../lib/useTags";
// 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
@@ -8,7 +8,7 @@
// disabled state shows an enabled row that no-ops.
import { describe, it, expect } from "vitest";
import { pinTargetForCard, shelfMoveTarget } from "./comfortPin";
import type { DiscoverCard } from "../lib/api";
import type { DiscoverCard } from "../../lib/api";
const card = (over: Partial<DiscoverCard>): DiscoverCard =>
({
@@ -8,8 +8,8 @@ import { describe, it, expect, afterEach, vi } from "vitest";
import type { ReactNode } from "react";
import { renderHook, act, waitFor, cleanup } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { setLanguage } from "../i18n";
import { api } from "../lib/api";
import { setLanguage } from "../../i18n";
import { api } from "../../lib/api";
import { useComfortPin } from "./comfortPin";
// Unmount BEFORE the language goes back: a mounted reader re-renders on the switch, and under a loaded
@@ -3,12 +3,12 @@ import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Loader2, Pin, PinOff } from "lucide-react";
import { plexLang } from "../i18n";
import { api, type DiscoverCard, type PinKind } from "../lib/api";
import { notifyActionError } from "../lib/actionErrors";
import { notify } from "../lib/notifications";
import { qk } from "../lib/queryKeys";
import { type ActionMenuItem } from "./ui/ActionMenu";
import { plexLang } from "../../i18n";
import { api, type DiscoverCard, type PinKind } from "../../lib/api";
import { notifyActionError } from "../../lib/actionErrors";
import { notify } from "../../lib/notifications";
import { qk } from "../../lib/queryKeys";
import { type ActionMenuItem } from "../../components/ui/ActionMenu";
// "put this on my comfort shelf", from wherever you happen to be looking at the thing.
//
@@ -10,8 +10,8 @@ import { useState } from "react";
import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { DebugSession, Me } from "../lib/api";
import { qk } from "../lib/queryKeys";
import type { DebugSession, Me } from "../../lib/api";
import { qk } from "../../lib/queryKeys";
vi.mock("react-i18next", async (importOriginal) => {
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 };
});
vi.mock("../lib/api", async (importOriginal) => {
const actual = await importOriginal<typeof import("../lib/api")>();
vi.mock("../../lib/api", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../lib/api")>();
return {
...actual,
api: {
@@ -70,8 +70,8 @@ vi.mock("../lib/api", async (importOriginal) => {
},
};
});
vi.mock("../lib/diagnostics", async (importOriginal) => {
const actual = await importOriginal<typeof import("../lib/diagnostics")>();
vi.mock("../../lib/diagnostics", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../lib/diagnostics")>();
return {
...actual,
flushNow: () => {
@@ -82,16 +82,21 @@ vi.mock("../lib/diagnostics", async (importOriginal) => {
});
import DebugRecorder, { RecorderSettings } from "./DebugRecorder";
import Modal from "./ui/Modal";
import Toaster from "./ui/Toaster";
import Modal from "../../components/ui/Modal";
import Toaster from "../../components/ui/Toaster";
import {
closeComposer,
openRecorderStart,
setStepPrompt,
useComposerSession,
} from "../lib/debugRecorder";
import { anyBlockingLayer, anyBlockingLayerAbove, pushLayer, removeLayer } from "../lib/layerStack";
import { events } from "../lib/log";
} from "../../lib/debugRecorder";
import {
anyBlockingLayer,
anyBlockingLayerAbove,
pushLayer,
removeLayer,
} from "../../lib/layerStack";
import { events } from "../../lib/log";
beforeEach(() => {
order.length = 0;
@@ -3,8 +3,8 @@ import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { Camera, Flag, Square } from "lucide-react";
import { api, type DebugSession, type DebugSessionLevel } from "../lib/api";
import { canCaptureTab } from "../lib/captureTab";
import { api, type DebugSession, type DebugSessionLevel } from "../../lib/api";
import { canCaptureTab } from "../../lib/captureTab";
import {
closeRecorderStart,
onSessionChangeElsewhere,
@@ -15,21 +15,21 @@ import {
useRecorderStartOpen,
useStepCount,
useStepPrompt,
} from "../lib/debugRecorder";
import { formatDateTimeSec, formatDuration } from "../lib/format";
import { useLayer } from "../lib/layerStack";
import { notify } from "../lib/notifications";
import { qk } from "../lib/queryKeys";
import { dockUnderToasts } from "../lib/toastDock";
import { useMe } from "../lib/useMe";
import { Button } from "./ui/Button";
import Modal, { ModalFooter } from "./ui/Modal";
import { LoadingState, StateMessage } from "./ui/QueryState";
import SegmentedToggle from "./ui/SegmentedToggle";
} from "../../lib/debugRecorder";
import { formatDateTimeSec, formatDuration } from "../../lib/format";
import { useLayer } from "../../lib/layerStack";
import { notify } from "../../lib/notifications";
import { qk } from "../../lib/queryKeys";
import { dockUnderToasts } from "../../lib/toastDock";
import { useMe } from "../../lib/useMe";
import { Button } from "../../components/ui/Button";
import Modal, { ModalFooter } from "../../components/ui/Modal";
import { LoadingState, StateMessage } from "../../components/ui/QueryState";
import SegmentedToggle from "../../components/ui/SegmentedToggle";
import IssueComposer from "./IssueComposer";
import { useDebugRecording } from "./useDebugRecording";
import { Input } from "./ui/Input";
import { SURFACE } from "./ui/surfaces";
import { Input } from "../../components/ui/Input";
import { SURFACE } from "../../components/ui/surfaces";
// The debug recorder: Start → reproduce → Stop.
//
@@ -1,7 +1,7 @@
import type { ReactNode } from "react";
import { navItemAttrs } from "../lib/listNav";
import { Pressable } from "./ui/Pressable";
import { navItemAttrs } from "../../lib/listNav";
import { Pressable } from "../../components/ui/Pressable";
// 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
@@ -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 { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { DebugDraft, DebugSession, ServerLogRow } from "../lib/api";
import { qk } from "../lib/queryKeys";
import type { DebugDraft, DebugSession, ServerLogRow } from "../../lib/api";
import { qk } from "../../lib/queryKeys";
vi.mock("react-i18next", async (importOriginal) => {
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.
failSendAfterCreate: false,
}));
vi.mock("../lib/api", async (importOriginal) => {
const actual = await importOriginal<typeof import("../lib/api")>();
vi.mock("../../lib/api", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../lib/api")>();
return {
...actual,
api: {
@@ -3,11 +3,11 @@ import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { ExternalLink, FileText, Plus, Server, X } from "lucide-react";
import { api, type DebugScreenshot, type DebugSession, type ServerLogRow } from "../lib/api";
import { closeComposer } from "../lib/debugRecorder";
import { formatBytes, formatDateTimeSec, formatUnit } from "../lib/format";
import { notify } from "../lib/notifications";
import { useMe } from "../lib/useMe";
import { api, type DebugScreenshot, type DebugSession, type ServerLogRow } from "../../lib/api";
import { closeComposer } from "../../lib/debugRecorder";
import { formatBytes, formatDateTimeSec, formatUnit } from "../../lib/format";
import { notify } from "../../lib/notifications";
import { useMe } from "../../lib/useMe";
import {
draftSteps,
environment,
@@ -15,21 +15,21 @@ import {
type DraftForm,
type DraftStep,
type IssueText,
} from "../lib/issueDraft";
import { moduleDef } from "../lib/modules";
import type { Page } from "../lib/urlState";
import { qk } from "../lib/queryKeys";
import { focusIsUnclaimed } from "../lib/useFocusKeeper";
} from "../../lib/issueDraft";
import { moduleDef } from "../navigation/moduleRegistry";
import type { Page } from "../../lib/urlState";
import { qk } from "../../lib/queryKeys";
import { focusIsUnclaimed } from "../../lib/useFocusKeeper";
import { LEVEL_TAG, logClock } from "./logLineParts";
import { Button } from "./ui/Button";
import { IconButton } from "./ui/IconButton";
import Modal, { ModalFooter } from "./ui/Modal";
import { LoadingState, StateMessage } from "./ui/QueryState";
import SegmentedToggle from "./ui/SegmentedToggle";
import { Textarea, Input } from "./ui/Input";
import { Checkbox } from "./ui/NativeControls";
import { SURFACE } from "./ui/surfaces";
import RemoteImage from "./ui/RemoteImage";
import { Button } from "../../components/ui/Button";
import { IconButton } from "../../components/ui/IconButton";
import Modal, { ModalFooter } from "../../components/ui/Modal";
import { LoadingState, StateMessage } from "../../components/ui/QueryState";
import SegmentedToggle from "../../components/ui/SegmentedToggle";
import { Textarea, Input } from "../../components/ui/Input";
import { Checkbox } from "../../components/ui/NativeControls";
import { SURFACE } from "../../components/ui/surfaces";
import RemoteImage from "../../components/ui/RemoteImage";
// 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,
@@ -1,6 +1,6 @@
import type { ReactNode } from "react";
import type { Level } from "../lib/log";
import type { Level } from "../../lib/log";
import HistoryRow from "./HistoryRow";
import { LEVEL_TAG, logClock } from "./logLineParts";
@@ -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
// viewers) and the issue composer's review list, so a level reads the same everywhere. Apart from
@@ -2,21 +2,21 @@ import { useCallback } from "react";
import { useIsMutating, useMutation, useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { api, type DebugSession, type DebugSessionLevel } from "../lib/api";
import { notifyActionError } from "../lib/actionErrors";
import { captureTab } from "../lib/captureTab";
import { api, type DebugSession, type DebugSessionLevel } from "../../lib/api";
import { notifyActionError } from "../../lib/actionErrors";
import { captureTab } from "../../lib/captureTab";
import {
announceSessionChange,
closeRecorderStart,
nextStep,
openComposer,
setStepPrompt,
} from "../lib/debugRecorder";
import { flushNow, liveSession } from "../lib/diagnostics";
import { log } from "../lib/log";
import { notify } from "../lib/notifications";
import { qk } from "../lib/queryKeys";
import { updateMeCache, useMe } from "../lib/useMe";
} from "../../lib/debugRecorder";
import { flushNow, liveSession } from "../../lib/diagnostics";
import { log } from "../../lib/log";
import { notify } from "../../lib/notifications";
import { qk } from "../../lib/queryKeys";
import { updateMeCache, useMe } from "../../lib/useMe";
/** 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
@@ -1,5 +1,5 @@
import { lazy, Suspense, useState } from "react";
import { qk } from "../lib/queryKeys";
import { qk } from "../../lib/queryKeys";
import { useTranslation } from "react-i18next";
import { useQuery } from "@tanstack/react-query";
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
// clicked, so it never ships in the feed chunk for the majority who just browse.
const DownloadDialog = lazy(() => import("./DownloadDialog"));
import { api } from "../lib/api";
import { IconButton } from "./ui/IconButton";
import { api } from "../../lib/api";
import { IconButton } from "../../components/ui/IconButton";
// 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).
@@ -7,7 +7,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useState } from "react";
import { api, type DownloadProfile } from "../lib/api";
import { api, type DownloadProfile } from "../../lib/api";
const h = vi.hoisted(() => ({
prefs: new Map<string, unknown>(),
@@ -19,7 +19,7 @@ vi.mock("react-i18next", async (importOriginal) => ({
...(await importOriginal<typeof import("react-i18next")>()),
useTranslation: () => ({ i18n: { language: "en" }, t: (key: string) => key }),
}));
vi.mock("../lib/usePref", () => ({
vi.mock("../../lib/usePref", () => ({
usePref: <T,>(key: string, initial: T) => {
const [value, setValue] = useState<T>(() =>
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) => ({
...(await importOriginal<typeof import("../lib/notifications")>()),
vi.mock("../../lib/notifications", async (importOriginal) => ({
...(await importOriginal<typeof import("../../lib/notifications")>()),
notify: h.notify,
}));
vi.mock("../lib/downloadQueue", () => ({ showInTray: h.showInTray }));
vi.mock("../../lib/downloadQueue", () => ({ showInTray: h.showInTray }));
import DownloadDialog from "./DownloadDialog";
@@ -1,22 +1,22 @@
import { lazy, Suspense, useState } from "react";
import { qk } from "../lib/queryKeys";
import { qk } from "../../lib/queryKeys";
import { useTranslation } from "react-i18next";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import Modal, { ModalFooter } from "./ui/Modal";
import { Button } from "./ui/Button";
import { api } from "../lib/api";
import { invalidateDownloads } from "../lib/downloads";
import Modal, { ModalFooter } from "../../components/ui/Modal";
import { Button } from "../../components/ui/Button";
import { api } from "../../lib/api";
import { invalidateDownloads } from "../../lib/downloads";
// Lazy: the full profile editor only opens from the dialog's "manage presets" affordance.
const ProfileEditor = lazy(() => import("./ProfileEditor"));
import { notify } from "../lib/notifications";
import { notifyActionError } from "../lib/actionErrors";
import { showInTray } from "../lib/downloadQueue";
import { profileName } from "../lib/vocab";
import { LS } from "../lib/storage";
import { usePref } from "../lib/usePref";
import FilterSelect from "./ui/FilterSelect";
import { Input } from "./ui/Input";
const ProfileEditor = lazy(() => import("../../modules/settings/ProfileEditor"));
import { notify } from "../../lib/notifications";
import { notifyActionError } from "../../lib/actionErrors";
import { showInTray } from "../../lib/downloadQueue";
import { profileName } from "../../lib/vocab";
import { LS } from "../../lib/storage";
import { usePref } from "../../lib/usePref";
import FilterSelect from "../../components/ui/FilterSelect";
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
// Files link box, a paste anywhere). Enqueues via the API; its notice's "View" opens the tray on it.
@@ -5,7 +5,7 @@ import {
pasteIsForThePage,
requestDownload,
usePendingDownload,
} from "../lib/downloadIntake";
} from "../../lib/downloadIntake";
const DownloadDialog = lazy(() => import("./DownloadDialog"));
@@ -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 { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useState } from "react";
import { api, type QueueJob } from "../lib/api";
import { api, type QueueJob } from "../../lib/api";
const h = vi.hoisted(() => ({
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}` : ""}`,
}),
}));
vi.mock("../lib/useLiveQuery", () => ({
vi.mock("../../lib/useLiveQuery", () => ({
useLiveQuery: (_key: unknown, _fn: unknown, opts: { intervalMs: (d: unknown) => number }) => (
(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
// usePref stores them is its own business (usePref.dom.test).
vi.mock("../lib/usePref", () => ({
vi.mock("../../lib/usePref", () => ({
usePref: <T,>(key: string, initial: T) => {
const [value, setValue] = useState<T>(() =>
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("./confirmContext", () => ({ useConfirm: () => async () => true }));
vi.mock("../lib/downloads", async (importOriginal) => ({
...(await importOriginal<typeof import("../lib/downloads")>()),
vi.mock("../../modules/files/filesContext", () => ({
useFilesActions: () => ({ openFile: h.openFile }),
}));
vi.mock("../../components/ui/confirmContext", () => ({ useConfirm: () => async () => true }));
vi.mock("../../lib/downloads", async (importOriginal) => ({
...(await importOriginal<typeof import("../../lib/downloads")>()),
quickShareLink: h.share,
}));
vi.mock("../lib/notifications", async (importOriginal) => ({
...(await importOriginal<typeof import("../lib/notifications")>()),
vi.mock("../../lib/notifications", async (importOriginal) => ({
...(await importOriginal<typeof import("../../lib/notifications")>()),
notify: h.notify,
}));
import DownloadTray from "./DownloadTray";
import { noteQueue, resetQueueWatch, showInTray } from "../lib/downloadQueue";
import { pushLayer, removeLayer } from "../lib/layerStack";
import { qk } from "../lib/queryKeys";
import { noteQueue, resetQueueWatch, showInTray } from "../../lib/downloadQueue";
import { pushLayer, removeLayer } from "../../lib/layerStack";
import { qk } from "../../lib/queryKeys";
beforeEach(() => {
resetQueueWatch();
@@ -10,8 +10,8 @@ import { afterEach, describe, expect, it, vi } from "vitest";
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useState } from "react";
import { api, type DownloadJob } from "../lib/api";
import { menuGroups } from "./ui/menuGroups";
import { api, type DownloadJob } from "../../lib/api";
import { menuGroups } from "../../components/ui/menuGroups";
const mocks = vi.hoisted(() => ({
jobs: [] as unknown[],
@@ -28,8 +28,8 @@ vi.mock("react-i18next", async (importOriginal) => {
useTranslation: () => ({ i18n: { language: "en" }, t: (key: string) => key }),
};
});
vi.mock("./confirmContext", () => ({ useConfirm: () => mocks.confirm }));
vi.mock("../lib/useLiveQuery", () => ({
vi.mock("../../components/ui/confirmContext", () => ({ useConfirm: () => mocks.confirm }));
vi.mock("../../lib/useLiveQuery", () => ({
useLiveQuery: (key: readonly unknown[]) => ({
data: key[0] === "downloads" ? mocks.jobs : undefined,
isError: false,
@@ -37,10 +37,12 @@ vi.mock("../lib/useLiveQuery", () => ({
refetch: vi.fn(),
}),
}));
vi.mock("../lib/usePref", () => ({
vi.mock("../../lib/usePref", () => ({
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 {
observe() {}
@@ -13,9 +13,9 @@ import {
RotateCw,
X,
} from "lucide-react";
import type { QueueJob } from "../lib/api";
import { invalidateDownloads, quickShareLink } from "../lib/downloads";
import { failCopyKeys, failTitleKey } from "../lib/downloadFailure";
import type { QueueJob } from "../../lib/api";
import { invalidateDownloads, quickShareLink } from "../../lib/downloads";
import { failCopyKeys, failTitleKey } from "../../lib/downloadFailure";
import {
IN_FLIGHT,
DOWNLOAD_PHASES,
@@ -27,31 +27,31 @@ import {
useFinished,
useRevealing,
useUnannounced,
} from "../lib/downloadQueue";
import { anyBlockingLayer } from "../lib/layerStack";
import { focusNavItem, navItemAttrs } from "../lib/listNav";
import { notify } from "../lib/notifications";
import { qk } from "../lib/queryKeys";
import { LS } from "../lib/storage";
import { useLiveQuery } from "../lib/useLiveQuery";
import { usePref } from "../lib/usePref";
} from "../../lib/downloadQueue";
import { anyBlockingLayer } from "../../lib/layerStack";
import { focusNavItem, navItemAttrs } from "../../lib/listNav";
import { notify } from "../../lib/notifications";
import { qk } from "../../lib/queryKeys";
import { LS } from "../../lib/storage";
import { useLiveQuery } from "../../lib/useLiveQuery";
import { usePref } from "../../lib/usePref";
import { ErrorModal } from "./downloadModals";
import { jobTitle } from "./downloadTitle";
import { sourceLabel } from "./fileFolders";
import { useFilesActions } from "./filesContext";
import { useFilesActions } from "../../modules/files";
import { sourceLabel } from "../folders/fileFolders";
import { useDownloadActions } from "./useDownloadActions";
import { type ActionMenuItem } from "./ui/ActionMenu";
import { setCornerLift } from "./ui/backToTopState";
import { Button } from "./ui/Button";
import { ContextMenu } from "./ui/ContextMenu";
import { IconButton } from "./ui/IconButton";
import { menuGroups } from "./ui/menuGroups";
import { MoreButton } from "./ui/MoreButton";
import NavGrid from "./ui/NavGrid";
import { Pressable } from "./ui/Pressable";
import { CONTENT_ID } from "./ui/landmarks";
import { SURFACE } from "./ui/surfaces";
import { useContextMenu } from "./ui/useContextMenu";
import { type ActionMenuItem } from "../../components/ui/ActionMenu";
import { setCornerLift } from "../../components/ui/backToTopState";
import { Button } from "../../components/ui/Button";
import { ContextMenu } from "../../components/ui/ContextMenu";
import { IconButton } from "../../components/ui/IconButton";
import { menuGroups } from "../../components/ui/menuGroups";
import { MoreButton } from "../../components/ui/MoreButton";
import NavGrid from "../../components/ui/NavGrid";
import { Pressable } from "../../components/ui/Pressable";
import { CONTENT_ID } from "../../components/ui/landmarks";
import { SURFACE } from "../../components/ui/surfaces";
import { useContextMenu } from "../../components/ui/useContextMenu";
// 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",
@@ -4,7 +4,7 @@
// 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 { cleanup, fireEvent, render, screen } from "@testing-library/react";
import type { DownloadJob } from "../lib/api";
import type { DownloadJob } from "../../lib/api";
import { ErrorModal } from "./downloadModals";
vi.mock("react-i18next", async (importOriginal) => ({
@@ -1,14 +1,14 @@
import { useState } from "react";
import { invalidateDownloads } from "../lib/downloads";
import { invalidateDownloads } from "../../lib/downloads";
import { useTranslation } from "react-i18next";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { Plus, Trash2 } from "lucide-react";
import Modal, { ModalFooter } from "./ui/Modal";
import { api, type DownloadJob } from "../lib/api";
import { Button } from "./ui/Button";
import Modal, { ModalFooter } from "../../components/ui/Modal";
import { api, type DownloadJob } from "../../lib/api";
import { Button } from "../../components/ui/Button";
import { IconBtn } from "./downloadRow";
import { failCopyKeys, failTitleKey } from "../lib/downloadFailure";
import { Input } from "./ui/Input";
import { failCopyKeys, failTitleKey } from "../../lib/downloadFailure";
import { Input } from "../../components/ui/Input";
// Per-item user modals (self-contained, prop-driven): a library file's details (Files) and a failed
// download's reasons (the tray).
@@ -14,7 +14,7 @@ vi.mock("react-i18next", async (importOriginal) => {
});
import { JobThumb } from "./downloadRow";
import type { DownloadJob } from "../lib/api";
import type { DownloadJob } from "../../lib/api";
const baseJob: DownloadJob = {
id: 1,
@@ -2,11 +2,11 @@ import type { ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { File, Film, Music } from "lucide-react";
import clsx from "clsx";
import { DOWNLOAD_PHASES } from "../lib/downloadQueue";
import { api, type DownloadJob } from "../lib/api";
import { IconButton } from "./ui/IconButton";
import { MediaProgress } from "./ui/MediaCard";
import RemoteImage from "./ui/RemoteImage";
import { DOWNLOAD_PHASES } from "../../lib/downloadQueue";
import { api, type DownloadJob } from "../../lib/api";
import { IconButton } from "../../components/ui/IconButton";
import { MediaProgress } from "../../components/ui/MediaCard";
import RemoteImage from "../../components/ui/RemoteImage";
// 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
@@ -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
// tray, the Files list, the admin Storage tab and the library player.
@@ -1,14 +1,14 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useTranslation } from "react-i18next";
import { Ban, Pause, Play, RefreshCw } from "lucide-react";
import { api, type DownloadJob, type DownloadJobKind } from "../lib/api";
import { notifyActionError } from "../lib/actionErrors";
import { invalidateDownloads } from "../lib/downloads";
import { useConfirm } from "./confirmContext";
import { useCopyItems } from "./copyItems";
import { api, type DownloadJob, type DownloadJobKind } from "../../lib/api";
import { notifyActionError } from "../../lib/actionErrors";
import { invalidateDownloads } from "../../lib/downloads";
import { useConfirm } from "../../components/ui/confirmContext";
import { useCopyItems } from "../../components/ui/copyItems";
import { jobTitle } from "./downloadTitle";
import { type ActionMenuItem } from "./ui/ActionMenu";
import { menuGroups } from "./ui/menuGroups";
import { type ActionMenuItem } from "../../components/ui/ActionMenu";
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
// row keeps its primary action (pause / resume / retry) and its state (the error and its details) in
@@ -1,16 +1,16 @@
import { useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Filter, X } from "lucide-react";
import type { ActiveFilter } from "../lib/useActiveFilters";
import { decideChipLayout } from "../lib/chipOverflow";
import { chipLaneClass, useChipOverflowFit } from "../lib/useChipOverflowFit";
import { PopoverItem, PopoverSurface } from "./ui/AnchoredPopover";
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
import { Button } from "./ui/Button";
import { Chip } from "./ui/Chip";
import { chipClass } from "./ui/chipStyles";
import { ChipLabelPending } from "./ui/Skeleton";
import { SURFACE } from "./ui/surfaces";
import type { ActiveFilter } from "../../lib/useActiveFilters";
import { decideChipLayout } from "../../lib/chipOverflow";
import { chipLaneClass, useChipOverflowFit } from "../../lib/useChipOverflowFit";
import { PopoverItem, PopoverSurface } from "../../components/ui/AnchoredPopover";
import { useAnchoredPopover } from "../../components/ui/anchoredPopoverHooks";
import { Button } from "../../components/ui/Button";
import { Chip } from "../../components/ui/Chip";
import { chipClass } from "../../components/ui/chipStyles";
import { ChipLabelPending } from "../../components/ui/Skeleton";
import { SURFACE } from "../../components/ui/surfaces";
// 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
@@ -63,7 +63,8 @@ const CTX = {
toggleDefault: vi.fn(async () => {}),
};
vi.mock("./savedViewsContext", () => ({
vi.mock("./savedViewsContext", async (importOriginal) => ({
...(await importOriginal<typeof import("./savedViewsContext")>()),
useSavedViews: () => ({
...CTX,
activeViewId: h.activeViewId,
@@ -80,8 +81,8 @@ vi.mock("./savedViewsContext", () => ({
}));
import PersonalViewsModal from "./PersonalViewsModal";
import { api } from "../lib/api";
import { qk } from "../lib/queryKeys";
import { api } from "../../lib/api";
import { qk } from "../../lib/queryKeys";
function mount() {
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
@@ -17,28 +17,33 @@ import {
verticalListSortingStrategy,
} from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import { coercePlexFilters, type FeedFilters, type PlexFilters, type SavedView } from "../lib/api";
import Modal from "./ui/Modal";
import { Button } from "./ui/Button";
import { IconButton } from "./ui/IconButton";
import {
coercePlexFilters,
type FeedFilters,
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 { useActiveFilters } from "../lib/useActiveFilters";
import { buildPlexActiveFilters } from "./usePlexActiveFilters";
import { useUndoable } from "../lib/useUndoable";
import UndoToolbar from "./ui/UndoToolbar";
import { useSortableA11y, useSortableItem } from "./ui/sortableA11y";
import { DragHandle } from "./ui/DragHandle";
import { OrderButtons } from "./ui/OrderButtons";
import { Pressable } from "./ui/Pressable";
import { DismissibleChip } from "./ui/Chip";
import { Input } from "./ui/Input";
import { useActiveFilters } from "../../lib/useActiveFilters";
import { buildPlexActiveFilters } from "../../lib/plexActiveFilters";
import { useUndoable } from "../../lib/useUndoable";
import UndoToolbar from "../../components/ui/UndoToolbar";
import { useSortableA11y, useSortableItem } from "../../components/ui/sortableA11y";
import { DragHandle } from "../../components/ui/DragHandle";
import { OrderButtons } from "../../components/ui/OrderButtons";
import { Pressable } from "../../components/ui/Pressable";
import { DismissibleChip } from "../../components/ui/Chip";
import { Input } from "../../components/ui/Input";
// 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;
// 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,
// 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({
view,
selected,
@@ -1,13 +1,13 @@
import { useState, type ReactNode } from "react";
import { useTranslation } from "react-i18next";
import { Settings2 } from "lucide-react";
import { PopoverItem, PopoverSurface } from "./ui/AnchoredPopover";
import { closeThenAct, useAnchoredPopover } from "./ui/anchoredPopoverHooks";
import { PopoverItem, PopoverSurface } from "../../components/ui/AnchoredPopover";
import { closeThenAct, useAnchoredPopover } from "../../components/ui/anchoredPopoverHooks";
import { useSavedViews } from "./savedViewsContext";
import { IconButton } from "./ui/IconButton";
import { MenuCheck } from "./ui/MenuCheck";
import { SURFACE } from "./ui/surfaces";
import { SelectTrigger } from "./ui/SelectTrigger";
import { IconButton } from "../../components/ui/IconButton";
import { MenuCheck } from "../../components/ui/MenuCheck";
import { SURFACE } from "../../components/ui/surfaces";
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
// their trigger: click → a popover list of the user's saved views (apply on click, the active one
@@ -1,8 +1,11 @@
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
// SavedViewsProvider so that file exports only components (Fast Refresh).
// The saved-views contexts + hooks (and the feed blob coercion their consumers share). Each module that
// 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
// 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>;
}
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 {
const ctx = useContext(SavedViewsCtx);
if (!ctx) throw new Error("useSavedViews must be used within a SavedViewsProvider");
const ctx = useContext(SavedViewsScopeCtx);
if (!ctx) throw new Error("useSavedViews must be used within a SavedViewsScope");
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"));
},
};
}
@@ -1,6 +1,6 @@
import { Film, Folder, Globe, Server, Upload, type LucideIcon } from "lucide-react";
import { YouTubeIcon } from "../lib/brandIcons";
import type { FileFolder } from "../lib/api";
import { YouTubeIcon } from "../../lib/brandIcons";
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
// (`youtube`, `plex`, `upload`, `site:<host>`, see backend downloads/folders.py) and leaves
@@ -1,10 +1,10 @@
import { useTranslation } from "react-i18next";
import { Command } from "lucide-react";
import { SHORTCUTS, formatChord, openPalette } from "../lib/keymap";
import { Pressable } from "./ui/Pressable";
import { SHORTCUTS, formatChord, openPalette } from "../../lib/keymap";
import { Pressable } from "../../components/ui/Pressable";
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
// `h` had no entry point anywhere but a palette row — the one in-app pointer to the cheat sheet sat
@@ -5,9 +5,9 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { act, cleanup, render, screen } from "@testing-library/react";
import SearchBar from "./SearchBar";
import KeyboardHelp from "./ui/KeyboardHelp";
import { closeKeyboardHelp, toggleKeyboardHelp } from "../lib/keymap";
import { pushLayer, removeLayer } from "../lib/layerStack";
import KeyboardHelp from "../../components/ui/KeyboardHelp";
import { closeKeyboardHelp, toggleKeyboardHelp } from "../../lib/keymap";
import { pushLayer, removeLayer } from "../../lib/layerStack";
vi.mock("react-i18next", async (importOriginal) => {
const actual = await importOriginal<typeof import("react-i18next")>();
@@ -8,12 +8,12 @@ import {
} from "react";
import { useTranslation } from "react-i18next";
import { Search, X } from "lucide-react";
import { useSearchFocusKey } from "../lib/keymap";
import { useSearchFocusKey } from "../../lib/keymap";
import { PaletteKeycap } from "./PaletteLauncher";
import { Button } from "./ui/Button";
import { IconButton } from "./ui/IconButton";
import { InnerInput } from "./ui/Input";
import { fieldClass } from "./ui/fieldStyles";
import { Button } from "../../components/ui/Button";
import { IconButton } from "../../components/ui/IconButton";
import { InnerInput } from "../../components/ui/Input";
import { fieldClass } from "../../components/ui/fieldStyles";
// 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
@@ -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);
}
@@ -27,12 +27,12 @@ vi.mock("react-i18next", async (importOriginal) => {
import { NavigationProvider } from "./NavigationProvider";
import { useNavigation, useNavigationActions } from "./navigationContext";
import { useBackToClose } from "../lib/history";
import { PageHeading } from "./ui/Headings";
import { useBackToClose } from "../../lib/history";
import { PageHeading } from "../../components/ui/Headings";
import { qk } from "../lib/queryKeys";
import { claimLocalData, getAccountRaw, LS, setAccountRaw } from "../lib/storage";
import { api } from "../lib/api";
import { qk } from "../../lib/queryKeys";
import { claimLocalData, getAccountRaw, LS, setAccountRaw } from "../../lib/storage";
import { api } from "../../lib/api";
import { PAGE_KEY } from "./navigationInit";
/** The Navigation API's view of the tab: `below` = whether each entry under the current one belongs
@@ -1,20 +1,19 @@
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 { useMe } from "../lib/useMe";
import { resolvePage, retiredParams, urlWithParams, type Page } from "../lib/urlState";
import { ACTIVITY_KEYS } from "../lib/activityUrlState";
import { pageTitleKey } from "../lib/pageMeta";
import { useDocumentObjectTitle } from "../lib/documentTitle";
import { getAccountRaw, setAccountRaw } from "../lib/storage";
import { useMe } from "../../lib/useMe";
import { resolvePage, retiredParams, urlWithParams, type Page } from "../../lib/urlState";
import { ACTIVITY_KEYS } from "../../lib/activityUrlState";
import { useDocumentObjectTitle } from "../../lib/documentTitle";
import { getAccountRaw, setAccountRaw } from "../../lib/storage";
import {
backStaysInDocument,
closeOverlaysThenNavigate,
nextNavId,
notifyNav,
rememberNavPosition,
} from "../lib/history";
import { reachablePage } from "../lib/modules";
} from "../../lib/history";
import { pageTitleKey, reachablePage } from "./moduleRegistry";
import {
channelViewFrom,
loadInitialPage,
@@ -23,7 +22,7 @@ import {
START_ON_HOME_PREF,
type ChannelView,
} from "./navigationInit";
import { usePref } from "../lib/usePref";
import { usePref } from "../../lib/usePref";
import {
NavigationActionsContext,
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([]);
});
});
@@ -11,13 +11,13 @@
import { describe, it, expect, beforeEach, vi } from "vitest";
const { stored } = vi.hoisted(() => ({ stored: {} as Record<string, string | null> }));
vi.mock("../lib/storage", async (importOriginal) => {
const actual = await importOriginal<typeof import("../lib/storage")>();
vi.mock("../../lib/storage", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../lib/storage")>();
return { ...actual, getAccountRaw: (k: string) => stored[k] ?? null };
});
import { loadInitialPage, channelViewFrom } from "./navigationInit";
import { LS } from "../lib/storage";
import { LS } from "../../lib/storage";
const setUrl = (search: string) => window.history.replaceState(null, "", search || "/");
@@ -1,5 +1,5 @@
import { createContext, useContext } from "react";
import type { Page } from "../lib/urlState";
import type { Page } from "../../lib/urlState";
import type { ChannelView } from "./navigationInit";
// The navigation contexts + their hooks, split out of NavigationProvider so that file exports only
@@ -2,9 +2,9 @@
// 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
// channelView seed and the onPop handler.
import { hasFilterParams, readPage, resolvePage, type Page } from "../lib/urlState";
import { getAccountRaw, LS } from "../lib/storage";
import type { UsePrefOptions } from "../lib/usePref";
import { hasFilterParams, readPage, resolvePage, type Page } from "../../lib/urlState";
import { getAccountRaw, LS } from "../../lib/storage";
import type { UsePrefOptions } from "../../lib/usePref";
export const PAGE_KEY = LS.page;
/** The landing preference — start on Home, or on the module left open — an account preference
@@ -1,8 +1,11 @@
// @vitest-environment jsdom
//
// The registry reaches the modules' faces, whose stores read the browser (i18n, localStorage).
import { describe, expect, it } from "vitest";
import { pageTitleKey } from "./pageMeta";
import type { Page } from "./urlState";
import { pageTitleKey } from "./moduleRegistry";
import type { Page } from "../../lib/urlState";
// pageTitleKey now derives from the ModuleDef registry (titleKey ?? labelKey). These are the exact
// pageTitleKey derives from the module registry (titleKey ?? labelKey). These are the exact
// browser-tab title keys the old hand-written switch produced — pin every page so a future labelKey
// rename (or a dropped titleKey) can't silently change a tab title.
const EXPECTED: Record<Page, string> = {
@@ -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 { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { useState } from "react";
import type { ClipRequest } from "../lib/api";
import type { ClipRange } from "../lib/clipRange";
import type { ClipRequest } from "../../lib/api";
import type { ClipRange } from "../../lib/clipRange";
vi.mock("react-i18next", async (importOriginal) => {
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>>();
vi.mock("../lib/api", async (importOriginal) => {
const actual = await importOriginal<typeof import("../lib/api")>();
vi.mock("../../lib/api", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../lib/api")>();
return { ...actual, api: { ...actual.api, enqueueClip: (b: ClipRequest) => enqueueClip(b) } };
});
const notify = vi.fn();
vi.mock("../lib/notifications", async (importOriginal) => {
const actual = await importOriginal<typeof import("../lib/notifications")>();
vi.mock("../../lib/notifications", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../lib/notifications")>();
return { ...actual, notify: (n: unknown) => notify(n) };
});
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();
vi.mock("../lib/actionErrors", async (importOriginal) => {
const actual = await importOriginal<typeof import("../lib/actionErrors")>();
vi.mock("../../lib/actionErrors", async (importOriginal) => {
const actual = await importOriginal<typeof import("../../lib/actionErrors")>();
return { ...actual, notifyActionError: (e: unknown, t: string) => notifyActionError(e, t) };
});
@@ -2,24 +2,24 @@ import { useEffect, useEffectEvent, useId, useRef, useState, type SubmitEvent }
import { useTranslation } from "react-i18next";
import { useQueryClient } from "@tanstack/react-query";
import { Crop, Crosshair, Maximize, Minus, Plus, Repeat, Scissors, X } from "lucide-react";
import { api, type ClipRequest } from "../lib/api";
import { DISPLAY_NAME_MAX } from "../lib/api/downloads";
import { invalidateDownloads } from "../lib/downloads";
import { notify } from "../lib/notifications";
import { notifyActionError } from "../lib/actionErrors";
import { showInTray } from "../lib/downloadQueue";
import { log } from "../lib/log";
import { formatTimecode } from "../lib/timecode";
import { handleBounds, markIn, markOut, zoomWindow, type ClipRange } from "../lib/clipRange";
import { fitRatio, fracRatio, WHOLE, type Frac } from "../lib/cropGeometry";
import { RangeTrack, RulerTicks, type RangeWindow } from "./ui/RangeTrack";
import { CropFrame } from "./ui/CropFrame";
import SegmentedToggle from "./ui/SegmentedToggle";
import { Button } from "./ui/Button";
import { IconButton } from "./ui/IconButton";
import { Input } from "./ui/Input";
import { TimecodeError, TimecodeField } from "./ui/TimecodeField";
import { useTimecodeField, type TimecodeDraft } from "./ui/useTimecodeField";
import { api, type ClipRequest } from "../../lib/api";
import { DISPLAY_NAME_MAX } from "../../lib/api/downloads";
import { invalidateDownloads } from "../../lib/downloads";
import { notify } from "../../lib/notifications";
import { notifyActionError } from "../../lib/actionErrors";
import { showInTray } from "../../lib/downloadQueue";
import { log } from "../../lib/log";
import { formatTimecode } from "../../lib/timecode";
import { handleBounds, markIn, markOut, zoomWindow, type ClipRange } from "../../lib/clipRange";
import { fitRatio, fracRatio, WHOLE, type Frac } from "../../lib/cropGeometry";
import { RangeTrack, RulerTicks, type RangeWindow } from "../../components/ui/RangeTrack";
import { CropFrame } from "../../components/ui/CropFrame";
import SegmentedToggle from "../../components/ui/SegmentedToggle";
import { Button } from "../../components/ui/Button";
import { IconButton } from "../../components/ui/IconButton";
import { Input } from "../../components/ui/Input";
import { TimecodeError, TimecodeField } from "../../components/ui/TimecodeField";
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
// IN the player (the YouTube card under the video, the Plex player's control band), so the video
@@ -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.
vi.mock("./AddToPlaylist", () => ({ default: () => null }));
vi.mock("./DownloadButton", () => ({ default: () => null }));
vi.mock("../video/AddToPlaylist", () => ({ default: () => null }));
vi.mock("../downloads/DownloadButton", () => ({ default: () => null }));
import PlayerModal from "./PlayerModal";
import { ConfirmProvider } from "./ConfirmProvider";
import { useConfirm } from "./confirmContext";
import { api, type Video } from "../lib/api";
import { clearAll, notify } from "../lib/notifications";
import { ConfirmProvider } from "../../components/ui/ConfirmProvider";
import { useConfirm } from "../../components/ui/confirmContext";
import { api, type Video } from "../../lib/api";
import { clearAll, notify } from "../../lib/notifications";
beforeAll(() => {
class Stub {
@@ -10,12 +10,12 @@ import {
} from "react";
import { useTranslation } from "react-i18next";
import { createPortal } from "react-dom";
import { useNavigationActions } from "./navigationContext";
import { useNavigationActions } from "../navigation/navigationContext";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { qk } from "../lib/queryKeys";
import { usePref } from "../lib/usePref";
import { qk } from "../../lib/queryKeys";
import { usePref } from "../../lib/usePref";
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 {
AlertTriangle,
ArrowLeft,
@@ -37,52 +37,52 @@ import {
VolumeX,
X,
} from "lucide-react";
import Avatar from "./ui/Avatar";
import { useLatest, useStableCallback } from "../lib/useLatest";
import AddToPlaylist from "./AddToPlaylist";
import { PopoverSurface } from "./ui/AnchoredPopover";
import { useAnchoredPopover } from "./ui/anchoredPopoverHooks";
import Avatar from "../../components/ui/Avatar";
import { useLatest, useStableCallback } from "../../lib/useLatest";
import AddToPlaylist from "../video/AddToPlaylist";
import { PopoverSurface } from "../../components/ui/AnchoredPopover";
import { useAnchoredPopover } from "../../components/ui/anchoredPopoverHooks";
import {
pushLayer,
removeLayer,
anyBlockingLayerAbove,
anyLayerAbove,
escapeHandled,
} from "../lib/layerStack";
import { trapTab, useDialogChrome } from "./ui/dialogFocus";
import DownloadButton from "./DownloadButton";
} from "../../lib/layerStack";
import { trapTab, useDialogChrome } from "../../components/ui/dialogFocus";
import DownloadButton from "../downloads/DownloadButton";
import ClipPanel, { ClipCropFrame, type ClipCrop } from "./ClipPanel";
import { markInAt, markOutAt, openAt, type ClipRange } from "../lib/clipRange";
import { api, type Video, type VideoStatus } from "../lib/api";
import type { YTPlayer } from "../lib/youtube";
import { markInAt, markOutAt, openAt, type ClipRange } from "../../lib/clipRange";
import { api, type Video, type VideoStatus } from "../../lib/api";
import type { YTPlayer } from "../../lib/youtube";
import {
channelYouTubeUrl,
formatDate,
formatDuration,
formatCompact,
relativeTime,
} from "../lib/format";
import { canFullscreenElements, fullscreenElement } from "../lib/fullscreen";
import { dispatch, isActivationTarget, useKeyScope } from "../lib/keymap";
import { useBackToClose } from "../lib/history";
import ChannelNameLink from "./ChannelNameLink";
import Toaster from "./ui/Toaster";
import { useOverlayOrder } from "./ui/overlayOrder";
import { afterRemoval } from "../lib/playerQueue";
} from "../../lib/format";
import { canFullscreenElements, fullscreenElement } from "../../lib/fullscreen";
import { dispatch, isActivationTarget, useKeyScope } from "../../lib/keymap";
import { useBackToClose } from "../../lib/history";
import ChannelNameLink from "../video/ChannelNameLink";
import Toaster from "../../components/ui/Toaster";
import { useOverlayOrder } from "../../components/ui/overlayOrder";
import { afterRemoval } from "../../lib/playerQueue";
import {
useNativeControlsYield,
useYtBigView,
useYtProgress,
useYtVolume,
YT_API_LOAD_FAILED,
} from "../lib/playerModalHooks";
import { useScrollFade } from "../lib/useScrollFade";
import { Button } from "./ui/Button";
import { IconButton } from "./ui/IconButton";
import { SURFACE } from "./ui/surfaces";
import { IconLink, LinkButton } from "./ui/LinkButton";
import { SectionLabel } from "./ui/Headings";
import { useMediaQuery } from "../lib/useMediaQuery";
} from "../../lib/playerModalHooks";
import { useScrollFade } from "../../lib/useScrollFade";
import { Button } from "../../components/ui/Button";
import { IconButton } from "../../components/ui/IconButton";
import { SURFACE } from "../../components/ui/surfaces";
import { IconLink, LinkButton } from "../../components/ui/LinkButton";
import { SectionLabel } from "../../components/ui/Headings";
import { useMediaQuery } from "../../lib/useMediaQuery";
// 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

Some files were not shown because too many files have changed in this diff Show More