Compare commits
12
Commits
fdf8076013
...
cd5b26efa7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cd5b26efa7 | ||
|
|
0892a1a400 | ||
|
|
b443ac0c84 | ||
|
|
ad05932217 | ||
|
|
6ae7825a14 | ||
|
|
cde5a98fd2 | ||
|
|
e83c5e2d97 | ||
|
|
6a0c1d377f | ||
|
|
4ef963b005 | ||
|
|
0b24f2d8fa | ||
|
|
d5867bacca | ||
|
|
63a0fecc8b |
@@ -7,6 +7,16 @@ edit the release notes and re-run the generator.
|
||||
|
||||
Upgrading, pinning a version and rolling back: [docs/self-hosting.md](docs/self-hosting.md#upgrading).
|
||||
|
||||
## 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
|
||||
|
||||
@@ -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 |
|
||||
|
||||
+3
-1
@@ -275,7 +275,9 @@ Also in force:
|
||||
- **Page chrome (B4):** a page's tabs and filters sit in ONE sticky band of the page colour, with the fade
|
||||
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
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { bootLeaks, type ChunkLike } from "./chunkGuard";
|
||||
|
||||
const APP = "/r/src/shell/App.tsx";
|
||||
const chunk = (fileName: string, moduleIds: string[], imports: string[] = [], isEntry = false) =>
|
||||
({ fileName, moduleIds, imports, isEntry }) satisfies ChunkLike;
|
||||
|
||||
describe("a lazily imported module", () => {
|
||||
const lazy = new Set([APP, "/r/src/modules/plex/PlexBrowse.tsx"]);
|
||||
|
||||
it("is a leak in the entry chunk, in a chunk the entry imports, or in the App chunk", () => {
|
||||
const P = "/r/src/modules/plex/PlexBrowse.tsx";
|
||||
for (const chunks of [
|
||||
[chunk("index.js", ["/r/src/main.tsx", P], [], true)],
|
||||
[chunk("index.js", ["/r/src/main.tsx"], ["shared.js"], true), chunk("shared.js", [P])],
|
||||
[chunk("index.js", ["/r/src/main.tsx"], [], true), chunk("App.js", [APP, P])],
|
||||
[
|
||||
chunk("index.js", ["/r/src/main.tsx"], [], true),
|
||||
chunk("App.js", [APP], ["s.js"]),
|
||||
chunk("s.js", [P]),
|
||||
],
|
||||
])
|
||||
expect(bootLeaks(chunks, lazy, "src/shell/App.tsx")).toEqual([
|
||||
`${P} (${chunks[chunks.length - 1]!.fileName})`,
|
||||
]);
|
||||
});
|
||||
|
||||
it("is not a leak in its own chunk, or in one only another lazy chunk loads — and App itself is lazy by design", () => {
|
||||
const chunks = [
|
||||
chunk("index.js", ["/r/src/main.tsx"], ["react.js"], true),
|
||||
chunk("react.js", ["/r/node_modules/react/index.js"]),
|
||||
chunk("App.js", [APP], ["react.js"]),
|
||||
chunk("PlexBrowse.js", ["/r/src/modules/plex/PlexBrowse.tsx"], ["react.js"]),
|
||||
];
|
||||
expect(bootLeaks(chunks, lazy, "src/shell/App.tsx")).toEqual([]);
|
||||
});
|
||||
|
||||
it("is checked on Windows paths too", () => {
|
||||
const win = "D:\\r\\src\\modules\\plex\\PlexBrowse.tsx";
|
||||
const chunks = [chunk("index.js", ["D:\\r\\src\\main.tsx", win], [], true)];
|
||||
expect(bootLeaks(chunks, new Set([win]), "src/shell/App.tsx")).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,73 @@
|
||||
import type { Plugin } from "vite";
|
||||
|
||||
// A lazily imported page, rail, dialog or player must stay out of the chunks loaded at boot. A page
|
||||
// is imported with import()/lazy() so it is fetched only when it is opened, and Rollup honours that
|
||||
// only while nothing on the boot path imports the same module statically: one static import — a
|
||||
// module's index.ts re-exporting it is the easy way — and the module joins a boot chunk, the import()
|
||||
// becomes a no-op, and nothing says so. That rule used to be a hope; this makes it a build failure,
|
||||
// naming each module and the boot chunk it landed in. The boot path is the entry chunks and what they
|
||||
// import, plus the shell (App, itself loaded lazily but fetched at boot alongside the identity probe)
|
||||
// and what it imports.
|
||||
const SHELL = "src/shell/App.tsx";
|
||||
|
||||
export interface ChunkLike {
|
||||
fileName: string;
|
||||
isEntry: boolean;
|
||||
/** The chunks this one imports statically. */
|
||||
imports: string[];
|
||||
moduleIds: string[];
|
||||
}
|
||||
|
||||
const posix = (id: string) => id.split("\\").join("/");
|
||||
|
||||
/** Every lazily imported module (`lazy`: module ids) that sits in a boot chunk, as "id (chunk)". The
|
||||
* shell's own module (`shell`, a path under the project) is exempt: it is lazy so that the public
|
||||
* pages never load it, and its chunk is the shell. */
|
||||
export function bootLeaks(chunks: ChunkLike[], lazy: Set<string>, shell: string): string[] {
|
||||
const byName = new Map(chunks.map((c) => [c.fileName, c]));
|
||||
const isShell = (id: string) => posix(id).endsWith(`/${shell}`);
|
||||
const boot = new Set<ChunkLike>();
|
||||
const visit = (c: ChunkLike | undefined) => {
|
||||
if (!c || boot.has(c)) return;
|
||||
boot.add(c);
|
||||
c.imports.forEach((name) => visit(byName.get(name)));
|
||||
};
|
||||
chunks.filter((c) => c.isEntry || c.moduleIds.some(isShell)).forEach(visit);
|
||||
const leaks: string[] = [];
|
||||
for (const c of boot)
|
||||
for (const id of c.moduleIds)
|
||||
if (lazy.has(id) && !isShell(id)) leaks.push(`${posix(id)} (${c.fileName})`);
|
||||
return leaks.sort();
|
||||
}
|
||||
|
||||
export function chunkGuard(): Plugin {
|
||||
return {
|
||||
name: "siftlode-chunk-guard",
|
||||
apply: "build",
|
||||
generateBundle(_options, bundle) {
|
||||
const lazy = new Set(
|
||||
[...this.getModuleIds()].filter(
|
||||
(id) => (this.getModuleInfo(id)?.dynamicImporters.length ?? 0) > 0,
|
||||
),
|
||||
);
|
||||
const chunks: ChunkLike[] = Object.values(bundle).flatMap((o) =>
|
||||
o.type === "chunk"
|
||||
? [
|
||||
{
|
||||
fileName: o.fileName,
|
||||
isEntry: o.isEntry,
|
||||
imports: o.imports,
|
||||
moduleIds: o.moduleIds,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
);
|
||||
const leaks = bootLeaks(chunks, lazy, SHELL);
|
||||
if (leaks.length)
|
||||
this.error(
|
||||
`lazily imported, yet loaded at boot: ${leaks.join(", ")}. ` +
|
||||
"Something on the boot path imports it statically (an index.ts re-export?): import it lazily there too, or not at all.",
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
+121
-3
@@ -7,6 +7,8 @@ import globals from "globals";
|
||||
import eslintComments from "@eslint-community/eslint-plugin-eslint-comments";
|
||||
import 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 },
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,499 +0,0 @@
|
||||
import { lazy, Suspense, useEffect, useRef, useState } from "react";
|
||||
import { qk } from "./lib/queryKeys";
|
||||
import { saveLanguage } from "./lib/saveLanguage";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { api, setUnauthorizedHandler, type Me } from "./lib/api";
|
||||
import { notifyActionError } from "./lib/actionErrors";
|
||||
import { setLanguage, type LangCode } from "./i18n";
|
||||
import { clearParams } from "./lib/urlState";
|
||||
import { reloadAfterRefusal } from "./lib/entryUrl";
|
||||
import { useAccountSwitchNotice } from "./lib/useAccountSwitchNotice";
|
||||
import {
|
||||
PREF_KEY,
|
||||
saveLayoutLocal,
|
||||
seedLayout,
|
||||
type PanelId,
|
||||
type PanelLayout,
|
||||
} from "./lib/panelLayout";
|
||||
import { notify, removeByMetaKind } from "./lib/notifications";
|
||||
import { LS } from "./lib/storage";
|
||||
import { usePref } from "./lib/usePref";
|
||||
import { useVerifyEntry } from "./lib/useVerifyEntry";
|
||||
import { useNavigation, useNavigationActions } from "./components/navigationContext";
|
||||
import { useFeedFilters, useFeedFiltersActions } from "./components/feedFiltersContext";
|
||||
import { useFeedView, useFeedViewActions } from "./components/feedViewContext";
|
||||
import { moduleOrder } from "./lib/modules";
|
||||
import { PAGE_CONTENT, PAGE_RAIL, preloadPages } from "./components/moduleRegistry";
|
||||
import { lazyWithPreload } from "./lib/lazyWithPreload";
|
||||
import { LoadingState } from "./components/ui/QueryState";
|
||||
// Persistent shell — always mounted, so eagerly imported.
|
||||
import Welcome from "./components/Welcome";
|
||||
import SetupWizard from "./components/SetupWizard";
|
||||
import Header from "./components/Header";
|
||||
import NavSidebar from "./components/NavSidebar";
|
||||
import BackToTop from "./components/ui/BackToTop";
|
||||
import PageShell from "./components/ui/PageShell";
|
||||
import { SkipLink } from "./components/ui/SkipLink";
|
||||
import { pageTitleKey } from "./lib/pageMeta";
|
||||
import DebugRecorder from "./components/DebugRecorder";
|
||||
import Toaster from "./components/ui/Toaster";
|
||||
import KeyboardHelp from "./components/ui/KeyboardHelp";
|
||||
import AppCommandPalette from "./components/AppCommandPalette";
|
||||
import DownloadIntake from "./components/DownloadIntake";
|
||||
import DownloadTray from "./components/DownloadTray";
|
||||
import { useGlobalKeymap } from "./lib/keymap";
|
||||
import ErrorDialog from "./components/ErrorDialog";
|
||||
import VersionBanner from "./components/VersionBanner";
|
||||
import VerifyBanner from "./components/VerifyBanner";
|
||||
import { focusAccessRequestsTab } from "./lib/adminUsersTab";
|
||||
import { focusSettingsTab } from "./lib/settingsTab";
|
||||
// The built app version, NOT imported from releaseNotes — importing anything from that module
|
||||
// pulls the release-notes loader into the eager App chunk and defeats the lazy ReleaseNotes
|
||||
// split. FRONTEND_VERSION equals the newest release note's version by release convention (both bump together).
|
||||
import { FRONTEND_VERSION } from "./lib/version";
|
||||
import { applyDiagnostics } from "./lib/diagnostics";
|
||||
|
||||
// The page modules (and their side rails) are code-split per route via the module registry
|
||||
// (components/moduleRegistry). App keeps only the persistent-shell chunks + the channel page and the
|
||||
// two modals, which sit outside the registry-driven content column.
|
||||
const ChannelPage = lazyWithPreload(() => import("./components/ChannelPage"));
|
||||
const About = lazy(() => import("./components/About"));
|
||||
const ReleaseNotes = lazy(() => import("./components/ReleaseNotes"));
|
||||
|
||||
export default function App() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const { page, channelView, ytSearch } = useNavigation();
|
||||
const { setPage, enterYtSearch } = useNavigationActions();
|
||||
// An emailed verification link is confirmed HERE, above the signed-in/signed-out branch below, so
|
||||
// it works in a browser that already holds a session. It used to be handled inside Welcome, which
|
||||
// that branch never renders when signed in — the token was silently dropped. Signed out, the
|
||||
// outcome goes to the auth card's banners; signed in, to the top-of-app bar.
|
||||
const verifyEntry = useVerifyEntry();
|
||||
// The global feed filters live in FeedFiltersProvider (per-account persistence + the account-load
|
||||
// effect + share-link capture all moved there). App reads them to hand to <Feed> and to the
|
||||
// handlers that mutate the feed view (onShowInFeed, tag-click, the feed scrollKey).
|
||||
const filters = useFeedFilters();
|
||||
const { setFilters } = useFeedFiltersActions();
|
||||
// The feed's view mode (card/row/tile density) lives in FeedViewProvider — server-synced, shared
|
||||
// by the feed page and the channel page. App reads it only to hand to <Feed>/<ChannelPage>.
|
||||
const view = useFeedView();
|
||||
const { setView } = useFeedViewActions();
|
||||
const queryClient = useQueryClient();
|
||||
// Per-panel "customize" layouts (order/collapsed/hidden of each side panel's group cards),
|
||||
// persisted per-account. One record so feed/plex/playlists share the same mechanism. Seeded from
|
||||
// the account's server preferences — BootGate mounts App only once `me` is cached — so a layout
|
||||
// changed on another device is right on the first paint; the local copy covers a key the server
|
||||
// has never stored.
|
||||
const [panelLayouts, setPanelLayouts] = useState<Record<PanelId, PanelLayout>>(() => {
|
||||
const prefs = queryClient.getQueryData<Me | null>(qk.me())?.preferences;
|
||||
return {
|
||||
feed: seedLayout("feed", prefs),
|
||||
plex: seedLayout("plex", prefs),
|
||||
playlists: seedLayout("playlists", prefs),
|
||||
files: seedLayout("files", prefs),
|
||||
};
|
||||
});
|
||||
const [aboutOpen, setAboutOpen] = useState(false);
|
||||
const [notesOpen, setNotesOpen] = useState(false);
|
||||
const [notesHighlight, setNotesHighlight] = useState<string | undefined>(undefined);
|
||||
|
||||
function openReleaseNotes(highlight?: string) {
|
||||
setNotesHighlight(highlight);
|
||||
setNotesOpen(true);
|
||||
}
|
||||
|
||||
function setPanelLayout(panel: PanelId, next: PanelLayout) {
|
||||
setPanelLayouts((prev) => ({ ...prev, [panel]: next }));
|
||||
saveLayoutLocal(panel, next);
|
||||
api
|
||||
.savePrefs({ [PREF_KEY[panel]]: next })
|
||||
.catch((e: unknown) => notifyActionError(e, t("errors.writeFailed.prefs")));
|
||||
}
|
||||
|
||||
// Collapse state for the two full-height panels (left nav + filter sidebar). Persisted to the
|
||||
// user's preferences (via usePref) so it follows the account across devices, seeded from the `me`
|
||||
// cache at mount; the localStorage mirror covers an account whose server prefs never stored it.
|
||||
// Default unpinned (tab + hover-expand overlay); accounts that explicitly pin keep their choice.
|
||||
const [filterCollapsed, setFilterCollapsed] = usePref<boolean>("filterCollapsed", true, {
|
||||
localKey: LS.filterCollapsed,
|
||||
parse: Boolean,
|
||||
});
|
||||
// The Playlists rail has its own collapse flag (it's a list/navigator, not filters).
|
||||
const [playlistsCollapsed, setPlaylistsCollapsed] = usePref<boolean>("playlistsCollapsed", true, {
|
||||
localKey: LS.playlistsCollapsed,
|
||||
parse: Boolean,
|
||||
});
|
||||
// The Files rail is the library's folder list — how you move around it, not a filter you set once —
|
||||
// so it starts pinned open (the owner's mockup), and has its own flag.
|
||||
const [filesCollapsed, setFilesCollapsed] = usePref<boolean>("filesCollapsed", false, {
|
||||
localKey: LS.filesCollapsed,
|
||||
parse: Boolean,
|
||||
});
|
||||
const railCollapse = {
|
||||
filter: [filterCollapsed, setFilterCollapsed],
|
||||
playlists: [playlistsCollapsed, setPlaylistsCollapsed],
|
||||
files: [filesCollapsed, setFilesCollapsed],
|
||||
} as const;
|
||||
// (The feed filters + page/channel now LIVE in the address bar — FeedFiltersProvider and
|
||||
// NavigationProvider read them on load and keep them mirrored — so there's no query to strip here.)
|
||||
|
||||
// Passive observers (enabled:false) of the setup-status and `me` queries BootGate fetches, polls
|
||||
// and waits for: App mounts only once both have answered, so their caches are always populated here.
|
||||
const setupQuery = useQuery({
|
||||
queryKey: qk.setupStatus(),
|
||||
queryFn: api.setupStatus,
|
||||
enabled: false,
|
||||
});
|
||||
const needsSetup = setupQuery.data?.configured === false;
|
||||
const meQuery = useQuery({ queryKey: qk.me(), queryFn: api.me, enabled: false });
|
||||
// The signed-in account, or null/undefined while signed out. Hoisted here because half a dozen
|
||||
// effects below depend on it, and a dep list is only honest if it names the value the body reads.
|
||||
const me = meQuery.data;
|
||||
|
||||
// The `global` key scope: `h` opens the cheat sheet, Mod+K the jump-to palette, Shift+W jumps to
|
||||
// Home ("mit nézzek?"). Bound only once signed in, because that is where the overlays are mounted
|
||||
// (bottom of this tree) and where the modules exist at all — see useGlobalKeymap on why binding it
|
||||
// above the signed-out branch is worse than not binding it.
|
||||
// A global key can fire while an overlay is open; `setPage` closes every overlay before it pushes,
|
||||
// so Shift+W over the player lands on Home with the player closed — also when Home is already the
|
||||
// page underneath (owner decision 2026-09-09: the key always ends with the answer on screen).
|
||||
// The `page` scope rides the same binding: the `g` chords jump to a module, but only to one this
|
||||
// account can reach (the nav rail's own list, plus Settings, which is not in the rail's order).
|
||||
useGlobalKeymap(!!me, setPage, me ? [...moduleOrder(me), "settings"] : []);
|
||||
|
||||
// The account's consented diagnostics level, applied to this tab's log: raised, the log is
|
||||
// kept in more detail and streamed to the server until the level lapses. A debug session that is
|
||||
// recording raises it for its own run and tags what it keeps. A lapse (its own timer, or the
|
||||
// server saying it is off) refetches `me`, so Settings and the recorder show what is in force.
|
||||
// Re-running this on every `me` refetch is FREE and therefore the deps are the honest ones:
|
||||
// applyDiagnostics computes an effective-level key and returns early when it has not moved
|
||||
// (lib/diagnostics.ts), so an unchanged level is a no-op. It does refresh the `lapsed` callback
|
||||
// before that early return, which is the half we WANT re-run. The old dep list picked out four
|
||||
// primitive fields to dodge the fresh object identity a refetch produces; that is the checker's
|
||||
// job to verify, not a comment's to assert, and it silently missed any other field the module
|
||||
// may come to read.
|
||||
const diagnostics = me?.diagnostics;
|
||||
const debugSession = me?.debug_session;
|
||||
useEffect(() => {
|
||||
applyDiagnostics(
|
||||
diagnostics,
|
||||
debugSession,
|
||||
() => void queryClient.invalidateQueries({ queryKey: qk.me() }),
|
||||
);
|
||||
}, [diagnostics, debugSession, queryClient]);
|
||||
|
||||
// Fetch the code of every module this account can open — and the channel page — once the first page
|
||||
// is on screen, so a module's first visit renders at once instead of flashing the chunk fallback.
|
||||
// Idle-scheduled so it never competes with the first page's own requests (Safari has no
|
||||
// requestIdleCallback, hence the timer).
|
||||
// Depending on the whole `me` rather than its id is both honest and MORE correct: the module set
|
||||
// is derived from `me` through each module's own gate (lib/modules moduleOrder), so a permission
|
||||
// change — an admin grant, a Plex server appearing — alters it without the id moving, and the old
|
||||
// `[me.id]` dep never re-preloaded for those. Re-running costs nothing: `preload()` is idempotent
|
||||
// (lazyWithPreload memoises the pending promise), so a refetch that changes nothing fetches
|
||||
// nothing; only the idle callback is re-scheduled.
|
||||
useEffect(() => {
|
||||
if (!me) return;
|
||||
const run = () => {
|
||||
preloadPages([...moduleOrder(me), "settings"]);
|
||||
ChannelPage.preload().catch(() => {});
|
||||
};
|
||||
const idle = window.requestIdleCallback as typeof window.requestIdleCallback | undefined;
|
||||
if (idle) {
|
||||
const id = idle(run, { timeout: 3000 });
|
||||
return () => window.cancelIdleCallback(id);
|
||||
}
|
||||
const id = window.setTimeout(run, 1000);
|
||||
return () => window.clearTimeout(id);
|
||||
}, [me]);
|
||||
|
||||
// Measure the scrollbar's gutter width once and publish it as --sbw, so the floating header and the
|
||||
// toolbar band can pad their right edge to line up with the scrolled cards (the app scroller reserves
|
||||
// that gutter via scrollbar-gutter: stable). It's a device constant, so a one-shot mount measure is enough.
|
||||
useEffect(() => {
|
||||
const probe = document.createElement("div");
|
||||
probe.style.cssText =
|
||||
"position:absolute;top:-9999px;width:100px;height:100px;overflow:scroll;scrollbar-gutter:stable";
|
||||
document.body.appendChild(probe);
|
||||
const w = probe.offsetWidth - probe.clientWidth;
|
||||
document.body.removeChild(probe);
|
||||
document.documentElement.style.setProperty("--sbw", `${w}px`);
|
||||
}, []);
|
||||
|
||||
// Once signed in, drop any leftover ONE-TIME query params from the pre-login flow (?access, ?verify,
|
||||
// ?reset) — but NOT the nav/filter params, which the logged-in app now reads from and keeps in the
|
||||
// URL. Gated on auth so the logged-out Welcome page still reads its params first.
|
||||
// Keyed on "is there a session at all", which is the whole condition the body tests. Re-running
|
||||
// on an account switch was never the point — by then the params are already gone and a second
|
||||
// clearParams is a no-op.
|
||||
const signedIn = !!me;
|
||||
useEffect(() => {
|
||||
if (signedIn) clearParams("access", "verify", "reset");
|
||||
}, [signedIn]);
|
||||
|
||||
// A sign-in as someone else that the server refused, or a reload as another account: say so, once.
|
||||
useAccountSwitchNotice(signedIn);
|
||||
|
||||
// Any request that 401s means the session ended server-side. If we were signed in (cached
|
||||
// `me` is a user object), reload to the login page at once (also covers any click that hits
|
||||
// the API). Guarded on a cached user so a stray 401 while logged out (cached `me` is null)
|
||||
// can't loop the public landing. A tab showing another account than the one signed in now
|
||||
// reloads as that one instead, and says why (`reloadAfterRefusal`).
|
||||
useEffect(() => {
|
||||
setUnauthorizedHandler((err) => {
|
||||
if (!queryClient.getQueryData(qk.me())) return;
|
||||
queryClient.clear();
|
||||
reloadAfterRefusal(err);
|
||||
});
|
||||
return () => setUnauthorizedHandler(null);
|
||||
}, [queryClient]);
|
||||
|
||||
// Returning from an in-app Google link/upgrade round-trip (auth.py redirects to ?link=…).
|
||||
// Surface the outcome, refresh `me` (can_read/can_write/has_google may have flipped), land on
|
||||
// Settings → Account so the new state is visible, then strip the param for a clean URL.
|
||||
// "Exactly once per mount" is now ENFORCED by the ref rather than asserted by an empty dep list:
|
||||
// `t` changes identity on a language switch, so the honest dep list would otherwise re-fire the
|
||||
// toast when the user changes language. (The old `[]` got away with it only because clearParams
|
||||
// had already removed the param — an invariant held by a side effect two lines further down.)
|
||||
const linkAnswered = useRef(false);
|
||||
const { refetch: refetchMe } = meQuery;
|
||||
useEffect(() => {
|
||||
if (linkAnswered.current) return;
|
||||
const result = new URLSearchParams(window.location.search).get("link");
|
||||
if (!result) return;
|
||||
linkAnswered.current = true;
|
||||
if (result === "ok") {
|
||||
notify({ level: "success", message: t("settings.account.googleLink.linked") });
|
||||
void refetchMe();
|
||||
focusSettingsTab("account");
|
||||
setPage("settings");
|
||||
} else {
|
||||
const key = result === "conflict" || result === "mismatch" ? result : "error";
|
||||
notify({ level: "error", message: t(`settings.account.googleLink.${key}`) });
|
||||
}
|
||||
clearParams("link");
|
||||
}, [t, setPage, refetchMe]);
|
||||
|
||||
// A Google OAuth flow started while already signed in (signing in as someone else, or a Google
|
||||
// connect) was cancelled at Google's consent screen. The backend only emits this for a live session, so a
|
||||
// neutral toast is the right feedback — the pre-auth login banner would never render here.
|
||||
// Same one-shot shape as the `?link=` handler above, same reason for the ref.
|
||||
const oauthAnswered = useRef(false);
|
||||
useEffect(() => {
|
||||
if (oauthAnswered.current) return;
|
||||
if (new URLSearchParams(window.location.search).get("oauth") !== "cancelled") return;
|
||||
oauthAnswered.current = true;
|
||||
notify({ level: "info", message: t("settings.account.googleCancelled") });
|
||||
clearParams("oauth");
|
||||
}, [t]);
|
||||
|
||||
// Deep-link from the "new access request" admin email (?admin=access-requests) → jump straight to
|
||||
// the admin Users → Access requests view. Snapshot the param up front (read once at mount); act once
|
||||
// `me` is known, and only for an admin (others just land on their default page).
|
||||
const [adminDeepLink] = useState(() => new URLSearchParams(window.location.search).get("admin"));
|
||||
// Ref-guarded like the two above, and here it closes a real hole rather than only satisfying the
|
||||
// checker: the snapshot lives for the whole session, so with honest deps a later role change —
|
||||
// or any `me` refetch, under the old id-only key — would yank the user to Users long after the
|
||||
// deep link was answered.
|
||||
const adminDeepLinkAnswered = useRef(false);
|
||||
useEffect(() => {
|
||||
if (adminDeepLinkAnswered.current) return;
|
||||
if (adminDeepLink !== "access-requests" || !me) return;
|
||||
adminDeepLinkAnswered.current = true;
|
||||
if (me.role === "admin") {
|
||||
focusAccessRequestsTab();
|
||||
setPage("users");
|
||||
}
|
||||
clearParams("admin");
|
||||
}, [adminDeepLink, me, setPage]);
|
||||
|
||||
// Nudge admins when access requests are waiting (in lieu of a server-push bell). Keyed on the
|
||||
// pending COUNT so it re-resolves the moment an approval/denial changes it — not only on a
|
||||
// fresh load (which is why the notice used to linger until F5 after an approval).
|
||||
// Honest deps, and `t` among them is a fix rather than a concession: the nudge is a persisted
|
||||
// toast whose text is translated at issue time, so a language switch must re-issue it. It did not
|
||||
// before — the notice stayed in the old language until the count changed.
|
||||
const role = me?.role;
|
||||
const pendingInvites = me?.pending_invites ?? 0;
|
||||
const pendingActivations = me?.pending_activations ?? 0;
|
||||
useEffect(() => {
|
||||
if (role !== "admin") return;
|
||||
// Keep a single, current nudge: drop any prior one (persisted copies reload as dismissed
|
||||
// history, so they'd otherwise pile up one-per-reload) before re-issuing the live notice.
|
||||
removeByMetaKind("access-requests");
|
||||
// Both kinds of "someone is waiting on you": a request needing a decision, and a sign-up whose
|
||||
// address was approved before they registered. The second used to produce NO signal at all —
|
||||
// nothing lands in the queue for it — which is how the 2026-09-03 registrant went unnoticed.
|
||||
const pending = pendingInvites + pendingActivations;
|
||||
if (pending > 0) {
|
||||
notify({
|
||||
level: "warning",
|
||||
title: t("common.accessRequestsTitle"),
|
||||
message: t("common.accessRequestsMessage", { count: pending }),
|
||||
// Durable inbox link (survives reload, unlike a live action callback); the toast also
|
||||
// gets a click via the action.
|
||||
meta: { kind: "access-requests" },
|
||||
action: {
|
||||
label: t("common.accessRequestsReview"),
|
||||
onClick: () => {
|
||||
focusAccessRequestsTab();
|
||||
// Clicked inside whatever overlay hosts the toast; setPage closes it before pushing.
|
||||
setPage("users");
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
}, [role, pendingInvites, pendingActivations, t, setPage]);
|
||||
|
||||
// Language is edited outside the Settings page (sidebar/login), so it stays instant.
|
||||
function changeLanguage(code: LangCode) {
|
||||
setLanguage(code);
|
||||
// Also from the signed-out login page, where the save 401s — `req` marks that as handled, so
|
||||
// the reporter stays quiet there.
|
||||
saveLanguage(queryClient, code).catch((e: unknown) =>
|
||||
notifyActionError(e, t("errors.writeFailed.prefs")),
|
||||
);
|
||||
}
|
||||
|
||||
// First-run: a fresh instance is in setup mode (all other routes are locked) → show the wizard.
|
||||
if (needsSetup) return <SetupWizard />;
|
||||
// /api/me answers 200 always (null body = logged out), and BootGate has already handled a failed
|
||||
// probe — so no data here means "not signed in" → the public landing.
|
||||
if (!me) return <Welcome verify={verifyEntry} />;
|
||||
|
||||
// Shown while a page's chunk is still on its way: only if its visit beats the preload above (or the
|
||||
// preload failed), and like every loading state it stays invisible for a load that is quick anyway.
|
||||
const pageFallback = <LoadingState className="flex-1 justify-center" />;
|
||||
|
||||
// Scroll restore/reset key for the shared normal-page scroller (see PageScroller): the same
|
||||
// page/feed-filters returns you to where you were (bug 1); a changed feed filter is a new key, so
|
||||
// the fresh result set starts at the top (bug 3). `q` is normalised out so typing in the search
|
||||
// box doesn't reset the scroll on every keystroke; the live-YouTube sub-view gets its own key.
|
||||
const normalScrollKey =
|
||||
page !== "feed"
|
||||
? `page:${page}`
|
||||
: ytSearch != null
|
||||
? `yt:${ytSearch}`
|
||||
: `feed:${JSON.stringify({ ...filters, q: "" })}`;
|
||||
|
||||
// The module registry drives the render from `page` alone — no per-page chrome conditionals. A page
|
||||
// the account can't reach never gets here: the navigation state already moved it to the feed
|
||||
// (`reachablePage`). A channel page overlays the content column, so no side rail shows behind it.
|
||||
const { Component: PageContent, maxWidth } = PAGE_CONTENT[page];
|
||||
const railDef = channelView ? undefined : PAGE_RAIL[page];
|
||||
|
||||
return (
|
||||
<div className="h-dvh flex text-fg">
|
||||
<SkipLink label={t("nav.skipToContent")} />
|
||||
<NavSidebar
|
||||
me={me!}
|
||||
onOpenAbout={() => setAboutOpen(true)}
|
||||
onChangeLanguage={changeLanguage}
|
||||
language={i18n.language as LangCode}
|
||||
/>
|
||||
{/* The active page's side rail (feed filters / plex filters / playlists list), if it has one.
|
||||
A floating collapsible column beside the nav rail; hidden while a channel page overlays the
|
||||
content. feed + plex share the filter-rail collapse flag, playlists and files have their own. */}
|
||||
{railDef && (
|
||||
<Suspense fallback={null}>
|
||||
<railDef.Rail
|
||||
layout={panelLayouts[railDef.panelId]}
|
||||
setLayout={(l) => setPanelLayout(railDef.panelId, l)}
|
||||
collapsed={railCollapse[railDef.collapse][0]}
|
||||
onToggleCollapse={() => {
|
||||
const [collapsed, setCollapsed] = railCollapse[railDef.collapse];
|
||||
setCollapsed(!collapsed);
|
||||
}}
|
||||
/>
|
||||
</Suspense>
|
||||
)}
|
||||
{/* `@container/appmain`: the floating header + its search box adapt to THIS column's width
|
||||
(which shrinks when the filter sidebar is open), not the viewport — a viewport breakpoint
|
||||
can't see the sidebar-driven narrowing. NB: `container-type: inline-size` implies
|
||||
`contain: layout`, making this column a containing block for `position: fixed` descendants
|
||||
too — safe today because floating-ui menus render via FloatingPortal (to document.body) and
|
||||
the toasts below are absolute; keep any new fixed overlay inside here body-portalled. */}
|
||||
<div className="@container/appmain relative flex-1 min-w-0 flex flex-col">
|
||||
{channelView ? (
|
||||
<PageShell scrollKey={`channel:${channelView.id}`}>
|
||||
<Suspense fallback={pageFallback}>
|
||||
<ChannelPage
|
||||
key={channelView.id}
|
||||
channelId={channelView.id}
|
||||
initialName={channelView.name}
|
||||
me={me!}
|
||||
view={view}
|
||||
setView={setView}
|
||||
onShowInFeed={() => {
|
||||
// Keep the rest of the feed's filters — the point is "this channel, the way I
|
||||
// normally read": unwatched, my tags, my date window.
|
||||
// ADD to the picked set, don't replace it: channels OR together now, so arriving
|
||||
// from a second channel page should widen the feed, not silently drop the first.
|
||||
// Deduped — clicking it again for a channel already picked is a no-op.
|
||||
setFilters({
|
||||
...filters,
|
||||
channelIds: [...new Set([...filters.channelIds, channelView.id])],
|
||||
});
|
||||
// setPage already leaves an open channel page and pushes a clean history entry.
|
||||
// Calling closeChannel() first ran history.back(), whose popstate landed AFTER this
|
||||
// and restored whatever page the channel had been opened from.
|
||||
setPage("feed");
|
||||
}}
|
||||
/>
|
||||
</Suspense>
|
||||
</PageShell>
|
||||
) : (
|
||||
<PageShell
|
||||
title={t(pageTitleKey(page))}
|
||||
scrollKey={normalScrollKey}
|
||||
maxWidth={maxWidth}
|
||||
header={<Header me={me!} onYtSearch={enterYtSearch} />}
|
||||
banners={
|
||||
<>
|
||||
<VerifyBanner entry={verifyEntry} />
|
||||
<VersionBanner onOpen={() => openReleaseNotes(FRONTEND_VERSION)} />
|
||||
</>
|
||||
}
|
||||
>
|
||||
<Suspense fallback={pageFallback}>
|
||||
<PageContent />
|
||||
</Suspense>
|
||||
</PageShell>
|
||||
)}
|
||||
{/* Toasts rise from the bottom-left, by the notification bell in the nav rail.
|
||||
Anchored inside the content column so they clear the sidebar automatically
|
||||
(collapsed or expanded) without tracking its width. */}
|
||||
<Toaster />
|
||||
{/* The running downloads, bottom-right in the same column (the toasts keep the left). */}
|
||||
<DownloadTray />
|
||||
</div>
|
||||
{/* The two app-wide overlays: the keyboard cheat sheet (`h`) and the jump-to palette (Mod+K).
|
||||
Mounted once, outside the page area, so every surface answers "what can I press here" and
|
||||
"take me to X" the same way. Their keys are bound by useGlobalKeymap above. */}
|
||||
<KeyboardHelp />
|
||||
<AppCommandPalette />
|
||||
{/* The one download dialog: the Files link box, a link pasted onto any page, the palette. */}
|
||||
<DownloadIntake />
|
||||
{/* The debug recorder: its start dialog, and its pill docked under the toasts. */}
|
||||
{me && <DebugRecorder />}
|
||||
<Suspense fallback={null}>
|
||||
{aboutOpen && (
|
||||
<About
|
||||
onClose={() => setAboutOpen(false)}
|
||||
onOpenReleaseNotes={() => {
|
||||
setAboutOpen(false);
|
||||
openReleaseNotes();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{notesOpen && (
|
||||
<ReleaseNotes onClose={() => setNotesOpen(false)} highlight={notesHighlight} />
|
||||
)}
|
||||
</Suspense>
|
||||
<ErrorDialog />
|
||||
{/* Re-binds to the active page's scroll container on navigation (page or channel change). */}
|
||||
<BackToTop dep={channelView ? `chan:${channelView.id}` : page} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+8
-8
@@ -20,20 +20,20 @@ vi.mock("react-i18next", async (importOriginal) => {
|
||||
}),
|
||||
};
|
||||
});
|
||||
vi.mock("./confirmContext", () => ({ useConfirm: () => async () => true }));
|
||||
vi.mock("./components/ui/confirmContext", () => ({ useConfirm: () => async () => true }));
|
||||
const { notify } = vi.hoisted(() => ({
|
||||
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 {
|
||||
+7
-7
@@ -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 {
|
||||
+3
-3
@@ -26,15 +26,15 @@ const KNOWN_SKIPS: CompileReport["skipped"] = {
|
||||
// The files a regression hurt most: compiled, and they must stay so (a positive check too, so a
|
||||
// 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"],
|
||||
};
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@ import reactCompiler from "babel-plugin-react-compiler";
|
||||
// word (`panicThreshold` is "none" and the build has no logger), so this is the only place a skip is
|
||||
// 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[] {
|
||||
@@ -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.
|
||||
|
||||
@@ -1,102 +0,0 @@
|
||||
import type { ComponentType } from "react";
|
||||
import { lazyWithPreload, type PreloadableComponent } from "../lib/lazyWithPreload";
|
||||
import type { Page } from "../lib/urlState";
|
||||
import type { PanelId, PanelLayout } from "../lib/panelLayout";
|
||||
import Sidebar from "./Sidebar";
|
||||
import PlaylistsRail from "./PlaylistsRail";
|
||||
import { Feed, FeedPage } from "./modulePages";
|
||||
|
||||
// The RENDER half of the module registry: which component (and side rail) each page renders. The
|
||||
// metadata half — nav order, label, icon, gate — lives in lib/modules.ts (imported by NavSidebar /
|
||||
// Header / App's gate check). Together they let App drive the whole shell from `page` alone, with no
|
||||
// `page === X` chrome conditionals.
|
||||
//
|
||||
// Every content entry is a PROP-FREE component: it reads `me` from useMe() and its module-local state
|
||||
// from the scoped providers, so App renders `<Content />` uniformly. The two that need per-render
|
||||
// wiring get a thin wrapper in modulePages (FeedPage supplies the GLOBAL feed scope; the channel page
|
||||
// threads its own isolated scope to <Feed> directly — see the scope note in FeedFiltersProvider).
|
||||
|
||||
// Route-level code splitting: each page is its own lazy chunk (admin-only pages never reach
|
||||
// non-admins; the logged-out landing pulls only the shell). Rendered inside App's <Suspense>. Each
|
||||
// one is preloadable: App fetches the chunks of every page the account can reach once the first page
|
||||
// is on screen (preloadPages below), so switching modules never shows the chunk fallback.
|
||||
const Home = lazyWithPreload(() => import("./Home"));
|
||||
const Channels = lazyWithPreload(() => import("./Channels"));
|
||||
const Playlists = lazyWithPreload(() => import("./Playlists"));
|
||||
const PlexBrowse = lazyWithPreload(() => import("./PlexBrowse"));
|
||||
const PlexSidebar = lazyWithPreload(() => import("./PlexSidebar"));
|
||||
const NotificationsPanel = lazyWithPreload(() => import("./NotificationsPanel"));
|
||||
const Files = lazyWithPreload(() => import("./Files"));
|
||||
const FilesRail = lazyWithPreload(() => import("./FilesRail"));
|
||||
const Stats = lazyWithPreload(() => import("./Stats"));
|
||||
const Scheduler = lazyWithPreload(() => import("./Scheduler"));
|
||||
const ConfigPanel = lazyWithPreload(() => import("./ConfigPanel"));
|
||||
const AdminUsers = lazyWithPreload(() => import("./AdminUsers"));
|
||||
const Activity = lazyWithPreload(() => import("./Activity"));
|
||||
const SettingsPanel = lazyWithPreload(() => import("./SettingsPanel"));
|
||||
|
||||
export type PageDef = {
|
||||
// Prop-free by construction: reads `me` + module state from hooks/providers. Preloadable by type, so
|
||||
// a new page cannot silently opt out of preloadPages.
|
||||
Component: PreloadableComponent<object>;
|
||||
// The content column's max width. The header + toolbar centre on this same width (via --content-mw)
|
||||
// so the floating chrome lines up with the centred content instead of spanning the whole column.
|
||||
// MUST match the max-w the page's own content wrapper uses. Omit for a full-width page (feed).
|
||||
maxWidth?: string;
|
||||
};
|
||||
|
||||
// page id → the component App renders in the content column (+ its shell chrome config).
|
||||
export const PAGE_CONTENT: Record<Page, PageDef> = {
|
||||
home: { Component: Home, maxWidth: "80rem" },
|
||||
// The two wrappers preload the lazy chunk they render.
|
||||
feed: { Component: Object.assign(FeedPage, { preload: Feed.preload }) },
|
||||
channels: { Component: Channels, maxWidth: "96rem" },
|
||||
playlists: { Component: Playlists, maxWidth: "64rem" },
|
||||
plex: { Component: PlexBrowse, maxWidth: "100rem" },
|
||||
notifications: { Component: NotificationsPanel, maxWidth: "48rem" },
|
||||
files: { Component: Files, maxWidth: "80rem" },
|
||||
stats: { Component: Stats },
|
||||
scheduler: { Component: Scheduler },
|
||||
config: { Component: ConfigPanel },
|
||||
users: { Component: AdminUsers, maxWidth: "48rem" },
|
||||
activity: { Component: Activity },
|
||||
settings: { Component: SettingsPanel },
|
||||
};
|
||||
|
||||
// The uniform side-rail contract — shell layout/collapse only; the rail reads its module state from
|
||||
// its provider and `me` from useMe().
|
||||
type RailProps = {
|
||||
layout: PanelLayout;
|
||||
setLayout: (l: PanelLayout) => void;
|
||||
collapsed: boolean;
|
||||
onToggleCollapse: () => void;
|
||||
};
|
||||
|
||||
// Which collapse flag a rail uses (feed + plex share the filter rail's flag; playlists and files
|
||||
// have their own).
|
||||
type RailCollapseKey = "filter" | "playlists" | "files";
|
||||
|
||||
export type RailDef = {
|
||||
Rail: ComponentType<RailProps>;
|
||||
// The persisted panel-layout record this rail edits (App owns the panelLayouts map).
|
||||
panelId: PanelId;
|
||||
collapse: RailCollapseKey;
|
||||
};
|
||||
|
||||
// page id → its side rail (only the pages that have one). App renders it in the rail slot.
|
||||
export const PAGE_RAIL: Partial<Record<Page, RailDef>> = {
|
||||
feed: { Rail: Sidebar, panelId: "feed", collapse: "filter" },
|
||||
plex: { Rail: PlexSidebar, panelId: "plex", collapse: "filter" },
|
||||
playlists: { Rail: PlaylistsRail, panelId: "playlists", collapse: "playlists" },
|
||||
files: { Rail: FilesRail, panelId: "files", collapse: "files" },
|
||||
};
|
||||
|
||||
/** Fetch the code of these pages and of their side rails now, ahead of their first visit. Errors are
|
||||
* swallowed: a chunk that fails to preload is simply fetched (and its failure surfaced) on visit. */
|
||||
export function preloadPages(pages: Page[]): void {
|
||||
for (const p of pages) {
|
||||
PAGE_CONTENT[p].Component.preload().catch(() => {});
|
||||
const rail = PAGE_RAIL[p]?.Rail as Partial<PreloadableComponent<RailProps>> | undefined;
|
||||
rail?.preload?.().catch(() => {});
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
import { createContext, useContext } from "react";
|
||||
import { type Sub } from "./plexSubUrl";
|
||||
|
||||
// The read + publish sides of the Plex view bridge (see PlexViewBridge for why it is a bridge), split
|
||||
// out of that file so it exports only components (Fast Refresh).
|
||||
export type PlexViewControls = {
|
||||
kind: Sub["kind"]; // the current top-level plex view — the header controls derive their state from it
|
||||
open: (v: Sub) => void;
|
||||
};
|
||||
|
||||
export const PlexViewReadContext = createContext<PlexViewControls | null>(null);
|
||||
export const PlexViewPublishContext = createContext<(c: PlexViewControls | null) => void>(() => {});
|
||||
|
||||
export const usePlexViewControls = () => useContext(PlexViewReadContext);
|
||||
export const usePublishPlexView = () => useContext(PlexViewPublishContext);
|
||||
@@ -1,25 +0,0 @@
|
||||
import { createContext, useContext } from "react";
|
||||
import { DEFAULT_THEME } from "../lib/theme";
|
||||
import { getNotifSettings } from "../lib/notifications";
|
||||
import type { PrefsController } from "./SettingsPanel";
|
||||
|
||||
// The Settings-page preferences context + hook, split out of PrefsProvider so that file exports only
|
||||
// components (Fast Refresh).
|
||||
|
||||
export const PrefsContext = createContext<PrefsController>({
|
||||
theme: DEFAULT_THEME,
|
||||
setTheme: () => {},
|
||||
perf: false,
|
||||
setPerf: () => {},
|
||||
hints: true,
|
||||
setHints: () => {},
|
||||
railLabels: false,
|
||||
setRailLabels: () => {},
|
||||
notif: getNotifSettings(),
|
||||
setNotif: () => {},
|
||||
saveState: "idle",
|
||||
});
|
||||
|
||||
export function usePrefs(): PrefsController {
|
||||
return useContext(PrefsContext);
|
||||
}
|
||||
+2
-2
@@ -1,7 +1,7 @@
|
||||
import { useCallback, useState, type ReactNode } from "react";
|
||||
import { 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}
|
||||
+7
-7
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
+5
-5
@@ -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;
|
||||
|
||||
+2
-2
@@ -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";
|
||||
|
||||
+2
-2
@@ -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",
|
||||
|
||||
+4
-4
@@ -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".
|
||||
+1
-1
@@ -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 {
|
||||
+1
-1
@@ -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 {
|
||||
+10
-10
@@ -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:
|
||||
+15
-15
@@ -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
|
||||
+1
-1
@@ -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 =>
|
||||
({
|
||||
+2
-2
@@ -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
|
||||
+6
-6
@@ -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.
|
||||
//
|
||||
+16
-11
@@ -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;
|
||||
+15
-15
@@ -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.
|
||||
//
|
||||
+2
-2
@@ -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
|
||||
+4
-4
@@ -8,8 +8,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import { 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: {
|
||||
+19
-19
@@ -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
-1
@@ -1,4 +1,4 @@
|
||||
import type { Level } from "../lib/log";
|
||||
import type { Level } from "../../lib/log";
|
||||
|
||||
// What a log line is drawn with — the level tags and the clock — shared by LogLine (both log
|
||||
// viewers) and the issue composer's review list, so a level reads the same everywhere. Apart from
|
||||
+9
-9
@@ -2,21 +2,21 @@ import { useCallback } from "react";
|
||||
import { useIsMutating, useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { 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
|
||||
+3
-3
@@ -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).
|
||||
+5
-5
@@ -7,7 +7,7 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { 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";
|
||||
|
||||
+14
-14
@@ -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.
|
||||
+1
-1
@@ -5,7 +5,7 @@ import {
|
||||
pasteIsForThePage,
|
||||
requestDownload,
|
||||
usePendingDownload,
|
||||
} from "../lib/downloadIntake";
|
||||
} from "../../lib/downloadIntake";
|
||||
|
||||
const DownloadDialog = lazy(() => import("./DownloadDialog"));
|
||||
|
||||
+14
-12
@@ -8,7 +8,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
||||
import { 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();
|
||||
+8
-6
@@ -10,8 +10,8 @@ import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { 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() {}
|
||||
+25
-25
@@ -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",
|
||||
+1
-1
@@ -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) => ({
|
||||
+6
-6
@@ -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).
|
||||
+1
-1
@@ -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,
|
||||
+5
-5
@@ -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
-1
@@ -1,4 +1,4 @@
|
||||
import type { DownloadJob } from "../lib/api";
|
||||
import type { DownloadJob } from "../../lib/api";
|
||||
|
||||
// A download job's display title (a user rename wins over the extracted asset title), shared by the
|
||||
// tray, the Files list, the admin Storage tab and the library player.
|
||||
+7
-7
@@ -1,14 +1,14 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { 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
|
||||
+10
-10
@@ -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
|
||||
+4
-3
@@ -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 } } });
|
||||
+20
-15
@@ -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,
|
||||
+6
-6
@@ -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
|
||||
+24
-6
@@ -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"));
|
||||
},
|
||||
};
|
||||
}
|
||||
+2
-2
@@ -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
|
||||
+3
-3
@@ -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
|
||||
+3
-3
@@ -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);
|
||||
}
|
||||
+5
-5
@@ -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
|
||||
+9
-10
@@ -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([]);
|
||||
});
|
||||
});
|
||||
+3
-3
@@ -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
-1
@@ -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
|
||||
+3
-3
@@ -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
|
||||
+6
-3
@@ -1,8 +1,11 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The registry reaches the modules' faces, whose stores read the browser (i18n, localStorage).
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { 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> = {
|
||||
+9
-9
@@ -7,8 +7,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import { 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) };
|
||||
});
|
||||
|
||||
+18
-18
@@ -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
|
||||
+9
-7
@@ -23,16 +23,18 @@ vi.mock("react-i18next", async (importOriginal) => {
|
||||
}),
|
||||
};
|
||||
});
|
||||
vi.mock("./navigationContext", () => ({ useNavigationActions: () => ({ openChannel: vi.fn() }) }));
|
||||
vi.mock("../navigation/navigationContext", () => ({
|
||||
useNavigationActions: () => ({ openChannel: vi.fn() }),
|
||||
}));
|
||||
// Their own queries and popovers are not what is under test here.
|
||||
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 {
|
||||
+31
-31
@@ -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
Reference in New Issue
Block a user