feat(frontend): the folders are layers — an import only goes down, a module is reached through its index.ts
`local/layers` (eslint.config.js) resolves every import to its file and refuses one that goes up (lib/ → components/ui/ → features/ → modules/ → shell/) or that reaches into another module's own files. A module's index.ts is its public face: the stores, mounted at the root and read app-wide, which features/ may read too; a page is still opened lazily by import(). Its contract test probes every edge, and three mutants of the rule (off, no face check, a door for everyone) each turn it red. Six modules got that face; the shared channel UI (about, tags, tag manager) moved to features/channel, the filter chips and the saved-views context to features/filters, and the prefs store's controller type moved next to the store instead of importing it from its own page. The header's per-module controls and the eagerly loaded rails keep eight inline suppressions, each naming why: they go when the header gets slots and every rail loads lazily. `chunkGuard` fails the production build when a lazily imported module lands in a boot chunk (the entry, the shell, what they import) — e.g. a page re-exported from an index.ts, which makes its import() a silent no-op. Proved on a build with that mutant; today's build has none.
This commit is contained in:
@@ -73,14 +73,19 @@ only along a declared edge and writes them only through that unit's service; cor
|
|||||||
`queries`. **Fence-held, with no exemptions** (`backend/tests/test_unit_fences.py`) — as far as an AST sees: what it
|
`queries`. **Fence-held, with no exemptions** (`backend/tests/test_unit_fences.py`) — as far as an AST sees: what it
|
||||||
cannot (an ORM write on another unit's loaded row, SQL in `text()`, a commit by `begin()`) is review's to catch, listed in
|
cannot (an ORM write on another unit's loaded row, SQL in `text()`, a commit by `begin()`) is review's to catch, listed in
|
||||||
[`backend/app/README.md`](backend/app/README.md) with the conventions.
|
[`backend/app/README.md`](backend/app/README.md) with the conventions.
|
||||||
Frontend: `main.tsx` provider tree → `App.tsx` shell (a shared `/watch` link boots from its own
|
Frontend, by folder under `src/`: `main.tsx` provider tree → `shell/` (`App`, header, nav rail, palette,
|
||||||
entry, `watch.html` → `watchMain.tsx`, held app-free by a contract test) → `moduleRegistry` route-chunks → module
|
boot; a shared `/watch` link boots from its own entry, `watch.html` → `watchMain.tsx` → `watch/`, held
|
||||||
components → the **platform layer `components/ui/`** (domain-free primitives: the popover engine and
|
app-free by a contract test) → `shell/moduleRegistry` route-chunks → **`modules/<page>/`** (one folder per page: the page,
|
||||||
its menus, `Modal`/`Overlay`, `Button`/`form`, `DataTable`/`VirtualGrid`, `QueryState`, …). That
|
its rail, its store, its tests) → **`features/<name>/`** (what several pages share: video cards, filters,
|
||||||
boundary is one-way and **lint-enforced**, not just described: a platform component may not import a
|
downloads, the player, navigation) → the **platform layer `components/ui/`** (domain-free primitives: the
|
||||||
feature component or a domain API slice, and one layer down `lib/` may not import from `components/` at
|
popover engine and its menus, `Modal`/`Overlay`, `Button`/`form`, `DataTable`/`VirtualGrid`, `QueryState`,
|
||||||
all, not even a type (`no-restricted-imports` in `eslint.config.js`, with its own test). A new generic
|
…) → `lib/`. The direction is one-way and **lint-enforced**, not just described (`local/layers`, with
|
||||||
component goes in `ui/`; see [`frontend/src/components/ui/README.md`](frontend/src/components/ui/README.md).
|
its own test): an import only goes down; another module is reached only through its **`index.ts`** (its
|
||||||
|
public face — the stores, mounted at the root and read app-wide, which `features/` may read too) or
|
||||||
|
lazily by `import()`; a platform component may not import a domain API slice, and `lib/` nothing above
|
||||||
|
it, not even a type. A lazily imported page, rail or dialog that a static import drags onto the boot
|
||||||
|
path fails the build (`chunkGuard.ts`). A new generic component goes in `ui/`; see
|
||||||
|
[`frontend/src/components/ui/README.md`](frontend/src/components/ui/README.md).
|
||||||
Data via react-query (`qk` factory), the signed-in account's `localStorage` (`storage.ts`), and server
|
Data via react-query (`qk` factory), the signed-in account's `localStorage` (`storage.ts`), and server
|
||||||
prefs; API through `lib/api/*` slices over `core.req<T>`.
|
prefs; API through `lib/api/*` slices over `core.req<T>`.
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -105,7 +105,7 @@ npm run lint # eslint (typed rules included; warnings fail)
|
|||||||
npm run format:check # prettier
|
npm run format:check # prettier
|
||||||
npm run knip # unused files/exports/deps
|
npm run knip # unused files/exports/deps
|
||||||
npm run test # vitest (incl. the compiler and gate-register contracts)
|
npm run test # vitest (incl. the compiler and gate-register contracts)
|
||||||
npx vite build # the production bundle (devOnlyGuard fails it on dev-only code)
|
npx vite build # the production bundle (devOnlyGuard fails it on dev-only code, chunkGuard on a lazy page loaded at boot)
|
||||||
npm audit --omit=dev --audit-level=high # what ships to the browser, at high or above
|
npm audit --omit=dev --audit-level=high # what ships to the browser, at high or above
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import type { Plugin } from "vite";
|
||||||
|
import { bootLeaks, type ChunkLike } from "./src/lib/chunkLeaks";
|
||||||
|
|
||||||
|
// A lazily imported page, rail, dialog or player must stay out of the chunks loaded at boot (the
|
||||||
|
// entry, the shell and what they import): one static import of it on the boot path — a module's
|
||||||
|
// index.ts re-exporting it is the easy way — makes its import() a silent no-op. That rule used to be
|
||||||
|
// a hope; this makes it a build failure, naming each module and the boot chunk it landed in.
|
||||||
|
const SHELL = "src/shell/App.tsx";
|
||||||
|
|
||||||
|
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.",
|
||||||
|
);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -7,6 +7,8 @@ import globals from "globals";
|
|||||||
import eslintComments from "@eslint-community/eslint-plugin-eslint-comments";
|
import eslintComments from "@eslint-community/eslint-plugin-eslint-comments";
|
||||||
import vitest from "@vitest/eslint-plugin";
|
import vitest from "@vitest/eslint-plugin";
|
||||||
import playwright from "eslint-plugin-playwright";
|
import playwright from "eslint-plugin-playwright";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
// Flat config (ESLint 9). Deliberately NARROW: the high-value rules here are react-hooks' (the
|
// Flat config (ESLint 9). Deliberately NARROW: the high-value rules here are react-hooks' (the
|
||||||
// hook rules and the React Compiler's diagnostics). The 33 inline `exhaustive-deps` suppressions this
|
// hook rules and the React Compiler's diagnostics). The 33 inline `exhaustive-deps` suppressions this
|
||||||
@@ -419,8 +421,89 @@ 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
|
||||||
|
return { layer: "other", top: parts.length === 1 ? "root" : top };
|
||||||
|
}
|
||||||
|
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().",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
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 || to.layer === "other") 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) => n.source.type === "Literal" && check(n.source, n.source.value, true),
|
||||||
|
TSImportType: (n) => {
|
||||||
|
const lit = n.argument?.literal ?? n.argument;
|
||||||
|
if (lit?.type === "Literal") check(lit, lit.value, false);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
const localRules = {
|
const localRules = {
|
||||||
rules: {
|
rules: {
|
||||||
|
layers,
|
||||||
"glyph-link": glyphLink,
|
"glyph-link": glyphLink,
|
||||||
"sortable-a11y": sortableA11y,
|
"sortable-a11y": sortableA11y,
|
||||||
"jsx-comment-text": jsxCommentText,
|
"jsx-comment-text": jsxCommentText,
|
||||||
@@ -567,6 +650,7 @@ export default tseslint.config(
|
|||||||
"local/literal-a11y-text": "error",
|
"local/literal-a11y-text": "error",
|
||||||
"local/glyph-link": "error",
|
"local/glyph-link": "error",
|
||||||
"local/sortable-a11y": "error",
|
"local/sortable-a11y": "error",
|
||||||
|
"local/layers": "error",
|
||||||
"react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
|
"react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
|
||||||
// tsc already enforces these under strict + noUnusedLocals/Parameters — let it own them so a
|
// tsc already enforces these under strict + noUnusedLocals/Parameters — let it own them so a
|
||||||
// finding is reported once, by one tool, and this config stays about hooks + refresh safety.
|
// finding is reported once, by one tool, and this config stays about hooks + refresh safety.
|
||||||
@@ -761,7 +845,7 @@ export default tseslint.config(
|
|||||||
// Outside src: the e2e suite and the TypeScript build-tool configs went unlinted — the
|
// Outside src: the e2e suite and the TypeScript build-tool configs went unlinted — the
|
||||||
// flat config named only src/**, so `eslint .` skipped them without a word. The same base and
|
// flat config named only src/**, so `eslint .` skipped them without a word. The same base and
|
||||||
// typed rules as the app; Node globals, since none of it runs in the browser.
|
// typed rules as the app; Node globals, since none of it runs in the browser.
|
||||||
files: ["e2e/**/*.ts", "*.config.ts", "reactPlugin.ts", "devOnlyGuard.ts"],
|
files: ["e2e/**/*.ts", "*.config.ts", "reactPlugin.ts", "devOnlyGuard.ts", "chunkGuard.ts"],
|
||||||
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||||
languageOptions: {
|
languageOptions: {
|
||||||
ecmaVersion: 2022,
|
ecmaVersion: 2022,
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ vi.mock("./lib/notifications", async (importOriginal) => ({
|
|||||||
import PlexPlaylistView from "./modules/plex/PlexPlaylistView";
|
import PlexPlaylistView from "./modules/plex/PlexPlaylistView";
|
||||||
import AddToPlaylist, { AddToPlaylistAt } from "./features/video/AddToPlaylist";
|
import AddToPlaylist, { AddToPlaylistAt } from "./features/video/AddToPlaylist";
|
||||||
import PlexPlaylistAdd from "./features/plexCards/PlexPlaylistAdd";
|
import PlexPlaylistAdd from "./features/plexCards/PlexPlaylistAdd";
|
||||||
import TagManager from "./modules/channels/TagManager";
|
import TagManager from "./features/channel/TagManager";
|
||||||
import { api, HttpError, type PlexCard } from "./lib/api";
|
import { api, HttpError, type PlexCard } from "./lib/api";
|
||||||
|
|
||||||
beforeAll(() => {
|
beforeAll(() => {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# `components/ui/` — the platform layer
|
# `components/ui/` — the platform layer
|
||||||
|
|
||||||
Everything in this directory is a **platform component**: generic, reusable, and **domain-free**.
|
Everything in this directory is a **platform component**: generic, reusable, and **domain-free**.
|
||||||
Everything in `components/` above it is a **module component**: it knows about videos, channels,
|
Everything above it — `features/`, `modules/` and `shell/` under `src/` — knows about videos, channels,
|
||||||
playlists, Plex or downloads.
|
playlists, Plex or downloads.
|
||||||
|
|
||||||
That boundary is the whole point of this directory. Before E10 S1 these 33 modules sat flat among
|
That boundary is the whole point of this directory. Before E10 S1 these 33 modules sat flat among
|
||||||
@@ -18,9 +18,9 @@ A component belongs here **iff its props and imports name no domain concept**. I
|
|||||||
- generic helpers from `lib/` (`layerStack`, `history`, `columnSort`, `useScrollFade`, …),
|
- generic helpers from `lib/` (`layerStack`, `history`, `columnSort`, `useScrollFade`, …),
|
||||||
- i18n, React, and third-party UI libraries.
|
- i18n, React, and third-party UI libraries.
|
||||||
|
|
||||||
It may **not** import a feature component (`../Feed`) or a domain API slice (`lib/api/*`). This is
|
It may **not** import anything from `features/`, `modules/` or `shell/`, or a domain API slice
|
||||||
enforced by `no-restricted-imports` in `eslint.config.js` — not by this README, which is only the
|
(`lib/api/*`). This is enforced in `eslint.config.js` (`no-restricted-imports` and `local/layers`) — not
|
||||||
explanation. If a primitive needs domain data, take it as a prop or a render-prop and let the module
|
by this README, which is only the explanation. If a primitive needs domain data, take it as a prop or a render-prop and let the module
|
||||||
supply it. The same goes for its words: a primitive translates only from the platform i18n
|
supply it. The same goes for its words: a primitive translates only from the platform i18n
|
||||||
namespaces (`common`, `errors`, `datatable`, `keys`, `palette`, `sidebar`, `notifications`, `time` —
|
namespaces (`common`, `errors`, `datatable`, `keys`, `palette`, `sidebar`, `notifications`, `time` —
|
||||||
`uiI18n.contract.test.ts`); what a value means where it is used arrives as a prop (`PriorityStepper`'s
|
`uiI18n.contract.test.ts`); what a value means where it is used arrives as a prop (`PriorityStepper`'s
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ const PLAIN: Record<string, [number, string]> = {
|
|||||||
"modules/scheduler/Scheduler.tsx": [1, "the interval editor trigger"],
|
"modules/scheduler/Scheduler.tsx": [1, "the interval editor trigger"],
|
||||||
"modules/settings/SettingsPanel.tsx": [1, "a colour swatch"],
|
"modules/settings/SettingsPanel.tsx": [1, "a colour swatch"],
|
||||||
"modules/channels/SyncStatus.tsx": [1, "the status pill trigger"],
|
"modules/channels/SyncStatus.tsx": [1, "the status pill trigger"],
|
||||||
"modules/channels/TagManager.tsx": [1, "a suggestion in a picker"],
|
"features/channel/TagManager.tsx": [1, "a suggestion in a picker"],
|
||||||
"modules/files/UploadDialog.tsx": [1, "the drop zone"],
|
"modules/files/UploadDialog.tsx": [1, "the drop zone"],
|
||||||
"features/player/VideoEditor.tsx": [2, "timeline glyphs"],
|
"features/player/VideoEditor.tsx": [2, "timeline glyphs"],
|
||||||
"shell/Welcome.tsx": [1, "a screenshot (zoom)"],
|
"shell/Welcome.tsx": [1, "a screenshot (zoom)"],
|
||||||
|
|||||||
@@ -37,8 +37,7 @@ import { useLiveQuery } from "../../lib/useLiveQuery";
|
|||||||
import { usePref } from "../../lib/usePref";
|
import { usePref } from "../../lib/usePref";
|
||||||
import { ErrorModal } from "./downloadModals";
|
import { ErrorModal } from "./downloadModals";
|
||||||
import { jobTitle } from "./downloadTitle";
|
import { jobTitle } from "./downloadTitle";
|
||||||
import { sourceLabel } from "../../modules/files/fileFolders";
|
import { sourceLabel, useFilesActions } from "../../modules/files";
|
||||||
import { useFilesActions } from "../../modules/files/filesContext";
|
|
||||||
import { useDownloadActions } from "./useDownloadActions";
|
import { useDownloadActions } from "./useDownloadActions";
|
||||||
import { type ActionMenuItem } from "../../components/ui/ActionMenu";
|
import { type ActionMenuItem } from "../../components/ui/ActionMenu";
|
||||||
import { setCornerLift } from "../../components/ui/backToTopState";
|
import { setCornerLift } from "../../components/ui/backToTopState";
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ const CTX = {
|
|||||||
toggleDefault: vi.fn(async () => {}),
|
toggleDefault: vi.fn(async () => {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
vi.mock("../../modules/feed/savedViewsContext", () => ({
|
vi.mock("./savedViewsContext", () => ({
|
||||||
useSavedViews: () => ({
|
useSavedViews: () => ({
|
||||||
...CTX,
|
...CTX,
|
||||||
activeViewId: h.activeViewId,
|
activeViewId: h.activeViewId,
|
||||||
|
|||||||
@@ -26,9 +26,9 @@ import {
|
|||||||
import Modal from "../../components/ui/Modal";
|
import Modal from "../../components/ui/Modal";
|
||||||
import { Button } from "../../components/ui/Button";
|
import { Button } from "../../components/ui/Button";
|
||||||
import { IconButton } from "../../components/ui/IconButton";
|
import { IconButton } from "../../components/ui/IconButton";
|
||||||
import { useSavedViews, asFilters } from "../../modules/feed/savedViewsContext";
|
import { useSavedViews, asFilters } from "./savedViewsContext";
|
||||||
import { useActiveFilters } from "../../lib/useActiveFilters";
|
import { useActiveFilters } from "../../lib/useActiveFilters";
|
||||||
import { buildPlexActiveFilters } from "../../modules/plex/usePlexActiveFilters";
|
import { buildPlexActiveFilters } from "../../modules/plex";
|
||||||
import { useUndoable } from "../../lib/useUndoable";
|
import { useUndoable } from "../../lib/useUndoable";
|
||||||
import UndoToolbar from "../../components/ui/UndoToolbar";
|
import UndoToolbar from "../../components/ui/UndoToolbar";
|
||||||
import { useSortableA11y, useSortableItem } from "../../components/ui/sortableA11y";
|
import { useSortableA11y, useSortableItem } from "../../components/ui/sortableA11y";
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ vi.mock("react-i18next", async (importOriginal) => {
|
|||||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||||
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
|
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
|
||||||
});
|
});
|
||||||
vi.mock("../../modules/feed/savedViewsContext", () => ({
|
vi.mock("./savedViewsContext", () => ({
|
||||||
useSavedViews: () => ({ views: [], activeViewId: null, apply: vi.fn(), openModal }),
|
useSavedViews: () => ({ views: [], activeViewId: null, apply: vi.fn(), openModal }),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { Settings2 } from "lucide-react";
|
import { Settings2 } from "lucide-react";
|
||||||
import { PopoverItem, PopoverSurface } from "../../components/ui/AnchoredPopover";
|
import { PopoverItem, PopoverSurface } from "../../components/ui/AnchoredPopover";
|
||||||
import { closeThenAct, useAnchoredPopover } from "../../components/ui/anchoredPopoverHooks";
|
import { closeThenAct, useAnchoredPopover } from "../../components/ui/anchoredPopoverHooks";
|
||||||
import { useSavedViews } from "../../modules/feed/savedViewsContext";
|
import { useSavedViews } from "./savedViewsContext";
|
||||||
import { IconButton } from "../../components/ui/IconButton";
|
import { IconButton } from "../../components/ui/IconButton";
|
||||||
import { MenuCheck } from "../../components/ui/MenuCheck";
|
import { MenuCheck } from "../../components/ui/MenuCheck";
|
||||||
import { SURFACE } from "../../components/ui/surfaces";
|
import { SURFACE } from "../../components/ui/surfaces";
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Bookmark } from "lucide-react";
|
import { Bookmark } from "lucide-react";
|
||||||
import SavedViewsPopover from "./SavedViewsPopover";
|
import SavedViewsPopover from "./SavedViewsPopover";
|
||||||
import { useSavedViews } from "../../modules/feed/savedViewsContext";
|
import { useSavedViews } from "./savedViewsContext";
|
||||||
|
|
||||||
// The active personal view, shown as the leading pill before the active-filter chip row. Styled like
|
// The active personal view, shown as the leading pill before the active-filter chip row. Styled like
|
||||||
// the toolbar's FilterSelect pills; a warn-coloured dot appears when the live filters have diverged
|
// the toolbar's FilterSelect pills; a warn-coloured dot appears when the live filters have diverged
|
||||||
|
|||||||
@@ -24,8 +24,7 @@ import {
|
|||||||
} from "../../lib/notifications";
|
} from "../../lib/notifications";
|
||||||
import { hintsEnabled, setHintsEnabled } from "../../lib/hints";
|
import { hintsEnabled, setHintsEnabled } from "../../lib/hints";
|
||||||
import { getAccountRaw, LS, setAccountRaw } from "../../lib/storage";
|
import { getAccountRaw, LS, setAccountRaw } from "../../lib/storage";
|
||||||
import type { PrefsController } from "../../modules/settings/SettingsPanel";
|
import { PrefsContext, type PrefsController } from "./prefsContext";
|
||||||
import { PrefsContext } from "./prefsContext";
|
|
||||||
|
|
||||||
const PERF_KEY = LS.perfMode;
|
const PERF_KEY = LS.perfMode;
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,29 @@
|
|||||||
import { createContext, useContext } from "react";
|
import { createContext, useContext } from "react";
|
||||||
import { DEFAULT_THEME } from "../../lib/theme";
|
import { DEFAULT_THEME, type ThemePrefs } from "../../lib/theme";
|
||||||
import { getNotifSettings } from "../../lib/notifications";
|
import { getNotifSettings, type NotifSettings } from "../../lib/notifications";
|
||||||
import type { PrefsController } from "../../modules/settings/SettingsPanel";
|
import type { SaveState } from "../../components/ui/DraftSaveBar";
|
||||||
|
|
||||||
// The Settings-page preferences context + hook, split out of PrefsProvider so that file exports only
|
// The Settings-page preferences context + hook, split out of PrefsProvider so that file exports only
|
||||||
// components (Fast Refresh).
|
// components (Fast Refresh).
|
||||||
|
|
||||||
|
// The Settings page edits server-persisted preferences. Each change applies locally for instant
|
||||||
|
// preview AND auto-saves (debounced) — no draft, no Save button; a small indicator flashes the
|
||||||
|
// outcome. PrefsProvider owns the state + the save; this controller is how the page reads/writes it.
|
||||||
|
// It lives with the store rather than with the page, so the store does not depend on its page.
|
||||||
|
export interface PrefsController {
|
||||||
|
theme: ThemePrefs;
|
||||||
|
setTheme: (t: ThemePrefs) => void;
|
||||||
|
perf: boolean;
|
||||||
|
setPerf: (v: boolean) => void;
|
||||||
|
hints: boolean;
|
||||||
|
setHints: (v: boolean) => void;
|
||||||
|
railLabels: boolean;
|
||||||
|
setRailLabels: (v: boolean) => void;
|
||||||
|
notif: NotifSettings;
|
||||||
|
setNotif: (n: NotifSettings) => void;
|
||||||
|
saveState: SaveState;
|
||||||
|
}
|
||||||
|
|
||||||
export const PrefsContext = createContext<PrefsController>({
|
export const PrefsContext = createContext<PrefsController>({
|
||||||
theme: DEFAULT_THEME,
|
theme: DEFAULT_THEME,
|
||||||
setTheme: () => {},
|
setTheme: () => {},
|
||||||
|
|||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { bootLeaks, type ChunkLike } from "./chunkLeaks";
|
||||||
|
|
||||||
|
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,40 @@
|
|||||||
|
// Which lazily imported modules a production build loads at boot anyway — the logic behind the
|
||||||
|
// chunkGuard build plugin (frontend/chunkGuard.ts), kept free of Vite so a unit test can drive it.
|
||||||
|
//
|
||||||
|
// A page, a rail, a dialog or a player is imported with import()/lazy() so it is fetched only when it
|
||||||
|
// is opened. Rollup honours that only while nothing on the boot path imports the same module
|
||||||
|
// statically: one static import — a re-export from a module's index.ts is the easy way to add one —
|
||||||
|
// and the module joins a boot chunk, the import() becomes a no-op, and nothing says so. 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.
|
||||||
|
|
||||||
|
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, e.g. "src/shell/App.tsx") 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 roots = chunks.filter((c) => c.isEntry || c.moduleIds.some(isShell));
|
||||||
|
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)));
|
||||||
|
};
|
||||||
|
roots.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();
|
||||||
|
}
|
||||||
@@ -471,6 +471,8 @@ describe("the gate register (docs/gates.md)", () => {
|
|||||||
"vitest.config.ts",
|
"vitest.config.ts",
|
||||||
"reactPlugin.ts",
|
"reactPlugin.ts",
|
||||||
"devOnlyGuard.ts",
|
"devOnlyGuard.ts",
|
||||||
|
"chunkGuard.ts",
|
||||||
|
"src/lib/chunkLeaks.ts",
|
||||||
"playwright.config.ts",
|
"playwright.config.ts",
|
||||||
"postcss.config.js",
|
"postcss.config.js",
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -35,6 +35,7 @@
|
|||||||
"local/glyph-link",
|
"local/glyph-link",
|
||||||
"local/input-primitive",
|
"local/input-primitive",
|
||||||
"local/jsx-comment-text",
|
"local/jsx-comment-text",
|
||||||
|
"local/layers",
|
||||||
"local/literal-a11y-text",
|
"local/literal-a11y-text",
|
||||||
"local/locale-format",
|
"local/locale-format",
|
||||||
"local/sortable-a11y",
|
"local/sortable-a11y",
|
||||||
@@ -117,6 +118,7 @@
|
|||||||
"local/glyph-link",
|
"local/glyph-link",
|
||||||
"local/input-primitive",
|
"local/input-primitive",
|
||||||
"local/jsx-comment-text",
|
"local/jsx-comment-text",
|
||||||
|
"local/layers",
|
||||||
"local/literal-a11y-text",
|
"local/literal-a11y-text",
|
||||||
"local/locale-format",
|
"local/locale-format",
|
||||||
"local/sortable-a11y",
|
"local/sortable-a11y",
|
||||||
@@ -642,6 +644,7 @@
|
|||||||
"local/glyph-link": [2],
|
"local/glyph-link": [2],
|
||||||
"local/input-primitive": [2],
|
"local/input-primitive": [2],
|
||||||
"local/jsx-comment-text": [2],
|
"local/jsx-comment-text": [2],
|
||||||
|
"local/layers": [2],
|
||||||
"local/literal-a11y-text": [2],
|
"local/literal-a11y-text": [2],
|
||||||
"local/locale-format": [2],
|
"local/locale-format": [2],
|
||||||
"local/sortable-a11y": [2],
|
"local/sortable-a11y": [2],
|
||||||
@@ -1055,6 +1058,7 @@
|
|||||||
"local/glyph-link",
|
"local/glyph-link",
|
||||||
"local/input-primitive",
|
"local/input-primitive",
|
||||||
"local/jsx-comment-text",
|
"local/jsx-comment-text",
|
||||||
|
"local/layers",
|
||||||
"local/literal-a11y-text",
|
"local/literal-a11y-text",
|
||||||
"local/locale-format",
|
"local/locale-format",
|
||||||
"local/sortable-a11y",
|
"local/sortable-a11y",
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
|
||||||
|
import { eslint } from "./lintProbe.testkit";
|
||||||
|
|
||||||
|
// The layer rule (local/layers in eslint.config.js) is what makes the folders a structure rather
|
||||||
|
// than a filing habit: lib/ → components/ui/ → features/ → modules/ → shell/, an import only goes
|
||||||
|
// down, and a module's own files are reached from outside only through its index.ts or lazily. A
|
||||||
|
// rule that matched nothing would look exactly like a clean codebase, so each edge is probed here.
|
||||||
|
async function refused(source: string, filePath: string): Promise<string[]> {
|
||||||
|
const [result] = await eslint.lintText(source, { filePath });
|
||||||
|
return (result?.messages ?? []).filter((m) => m.ruleId === "local/layers").map((m) => m.message);
|
||||||
|
}
|
||||||
|
const imp = (...specs: string[]) =>
|
||||||
|
specs.map((s, i) => `import x${i} from "${s}";\n`).join("") +
|
||||||
|
`export default [${specs.map((_, i) => `x${i}`).join(", ")}];\n`;
|
||||||
|
|
||||||
|
const IN_FEED = "src/modules/feed/__probe.tsx";
|
||||||
|
const IN_FEATURE = "src/features/video/__probe.tsx";
|
||||||
|
|
||||||
|
describe("a module", () => {
|
||||||
|
it("reaches another module only through its index.ts", async () => {
|
||||||
|
expect(await refused(imp("../plex/PlexBrowse"), IN_FEED)).toHaveLength(1);
|
||||||
|
expect(await refused(imp("../plex", "../plex/index"), IN_FEED)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("by every spelling: a type, a re-export, a type-only import", async () => {
|
||||||
|
const src =
|
||||||
|
'import type { P } from "../plex/plexContext";\n' +
|
||||||
|
'export { usePlex } from "../plex/plexContext";\n' +
|
||||||
|
'export * from "../channels/channelsContext";\n' +
|
||||||
|
"export type Q = P;\n";
|
||||||
|
expect(await refused(src, IN_FEED)).toHaveLength(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("may open another module's page lazily, and use its own files and anything below", async () => {
|
||||||
|
const src =
|
||||||
|
'const P = () => import("../plex/PlexBrowse");\n' +
|
||||||
|
'import F from "./Feed";\nimport V from "../../features/video/VideoCard";\n' +
|
||||||
|
'import B from "../../components/ui/Button";\nimport { qk } from "../../lib/queryKeys";\n' +
|
||||||
|
"export default [P, F, V, B, qk];\n";
|
||||||
|
expect(await refused(src, IN_FEED)).toEqual([]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never imports the shell", async () => {
|
||||||
|
expect(await refused(imp("../../shell/Header"), IN_FEED)).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("a feature", () => {
|
||||||
|
it("reads a module only through its face, and never the shell", async () => {
|
||||||
|
expect(await refused(imp("../../modules/plex"), IN_FEATURE)).toEqual([]);
|
||||||
|
const up = await refused(imp("../../modules/plex/plexContext", "../../shell/App"), IN_FEATURE);
|
||||||
|
expect(up).toHaveLength(2);
|
||||||
|
expect(up.every((m) => m.includes("sits below"))).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("uses other features freely", async () => {
|
||||||
|
expect(await refused(imp("../player/ClipPanel", "../filters/SearchBar"), IN_FEATURE)).toEqual(
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("the platform and lib layers", () => {
|
||||||
|
it("import nothing above them — not even a module's face", async () => {
|
||||||
|
const ui = await refused(
|
||||||
|
imp("../../features/video/VideoCard", "../../modules/feed", "../../shell/App"),
|
||||||
|
"src/components/ui/__layers_probe.tsx",
|
||||||
|
);
|
||||||
|
expect(ui).toHaveLength(3);
|
||||||
|
const lib = await refused(
|
||||||
|
imp("../features/navigation/navigationContext", "../modules/feed", "../shell/App"),
|
||||||
|
"src/lib/__layers_probe.ts",
|
||||||
|
);
|
||||||
|
expect(lib).toHaveLength(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("the shell and the entry", () => {
|
||||||
|
it("reach a module through its face, or lazily", async () => {
|
||||||
|
const src =
|
||||||
|
imp("../modules/feed/Feed", "../modules/feed") +
|
||||||
|
'export const L = () => import("../modules/feed/Feed");\n';
|
||||||
|
expect(await refused(src, "src/shell/__layers_probe.tsx")).toHaveLength(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("only the root entry mounts the shell", async () => {
|
||||||
|
expect(await refused(imp("./shell/App"), "src/__layers_probe.tsx")).toEqual([]);
|
||||||
|
expect(await refused(imp("./modules/feed/Feed"), "src/__layers_probe.tsx")).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -15,8 +15,11 @@ const FRONTEND = fileURLToPath(new URL("../../", import.meta.url));
|
|||||||
// file no tsconfig includes, so those names are let into TypeScript's default project here — the
|
// file no tsconfig includes, so those names are let into TypeScript's default project here — the
|
||||||
// probe still runs the real config, typed rules included, rather than a copy with them switched off.
|
// probe still runs the real config, typed rules included, rather than a copy with them switched off.
|
||||||
const PROBE_FILES = [
|
const PROBE_FILES = [
|
||||||
|
"src/__*probe.tsx",
|
||||||
"src/*/__*probe.ts",
|
"src/*/__*probe.ts",
|
||||||
"src/*/__*probe.tsx",
|
"src/*/__*probe.tsx",
|
||||||
|
// a file inside one module or feature (src/modules/feed/…), for the layer rule
|
||||||
|
"src/*/*/__*probe.tsx",
|
||||||
"src/components/ui/__*probe.tsx",
|
"src/components/ui/__*probe.tsx",
|
||||||
"src/lib/api/__*probe.ts",
|
"src/lib/api/__*probe.ts",
|
||||||
"src/components/Button.tsx",
|
"src/components/Button.tsx",
|
||||||
|
|||||||
@@ -7,14 +7,11 @@ import ErrorBoundary from "./shell/ErrorBoundary";
|
|||||||
import { ConfirmProvider } from "./components/ui/ConfirmProvider";
|
import { ConfirmProvider } from "./components/ui/ConfirmProvider";
|
||||||
import BootGate, { BootScreen } from "./shell/BootGate";
|
import BootGate, { BootScreen } from "./shell/BootGate";
|
||||||
import { NavigationProvider } from "./features/navigation/NavigationProvider";
|
import { NavigationProvider } from "./features/navigation/NavigationProvider";
|
||||||
import { FeedFiltersProvider } from "./modules/feed/FeedFiltersProvider";
|
import { FeedFiltersProvider, FeedViewProvider, SavedViewsProvider } from "./modules/feed";
|
||||||
import { SavedViewsProvider } from "./modules/feed/SavedViewsProvider";
|
import { PlaylistsProvider } from "./modules/playlists";
|
||||||
import { PlaylistsProvider } from "./modules/playlists/PlaylistsProvider";
|
import { FilesProvider } from "./modules/files";
|
||||||
import { FilesProvider } from "./modules/files/FilesProvider";
|
import { PlexProvider, PlexViewBridge } from "./modules/plex";
|
||||||
import { PlexProvider } from "./modules/plex/PlexProvider";
|
import { ChannelsProvider } from "./modules/channels";
|
||||||
import { PlexViewBridge } from "./modules/plex/PlexViewBridge";
|
|
||||||
import { ChannelsProvider } from "./modules/channels/ChannelsProvider";
|
|
||||||
import { FeedViewProvider } from "./modules/feed/FeedViewProvider";
|
|
||||||
import { PrefsProvider } from "./features/prefs/PrefsProvider";
|
import { PrefsProvider } from "./features/prefs/PrefsProvider";
|
||||||
import { WizardProvider } from "./features/wizard/WizardProvider";
|
import { WizardProvider } from "./features/wizard/WizardProvider";
|
||||||
import "./i18n";
|
import "./i18n";
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
// The activity page's public face: what another unit may use of it.
|
||||||
|
export { StatusDot, type DotKey } from "./RunStatusDot";
|
||||||
@@ -2,7 +2,7 @@ import { useState } from "react";
|
|||||||
import { useQuery } from "@tanstack/react-query";
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { qk } from "../../lib/queryKeys";
|
import { qk } from "../../lib/queryKeys";
|
||||||
import { api } from "../../lib/api";
|
import { api } from "../../lib/api";
|
||||||
import ChannelAboutContent from "./ChannelAbout";
|
import ChannelAboutContent from "../../features/channel/ChannelAbout";
|
||||||
import { PopoverSurface } from "../../components/ui/AnchoredPopover";
|
import { PopoverSurface } from "../../components/ui/AnchoredPopover";
|
||||||
import { useAnchoredPopover } from "../../components/ui/anchoredPopoverHooks";
|
import { useAnchoredPopover } from "../../components/ui/anchoredPopoverHooks";
|
||||||
import { SURFACE } from "../../components/ui/surfaces";
|
import { SURFACE } from "../../components/ui/surfaces";
|
||||||
|
|||||||
@@ -33,14 +33,14 @@ import { useChannelActions } from "./useChannelActions";
|
|||||||
import { useContextMenu } from "../../components/ui/useContextMenu";
|
import { useContextMenu } from "../../components/ui/useContextMenu";
|
||||||
import { LoadingState, StateMessage } from "../../components/ui/QueryState";
|
import { LoadingState, StateMessage } from "../../components/ui/QueryState";
|
||||||
import { useChannelTagActions } from "../../lib/useChannelTagActions";
|
import { useChannelTagActions } from "../../lib/useChannelTagActions";
|
||||||
import { useTagSuggestions } from "./useTagSuggestions";
|
import { useTagSuggestions } from "../../features/channel/useTagSuggestions";
|
||||||
import { useChannelPriority } from "../../lib/useChannelPriority";
|
import { useChannelPriority } from "../../lib/useChannelPriority";
|
||||||
import { placeByPriority } from "../../lib/channelOrder";
|
import { placeByPriority } from "../../lib/channelOrder";
|
||||||
import { focusNavItem } from "../../lib/listNav";
|
import { focusNavItem } from "../../lib/listNav";
|
||||||
import ViewSwitcher, { type ViewOption } from "../../components/ui/ViewSwitcher";
|
import ViewSwitcher, { type ViewOption } from "../../components/ui/ViewSwitcher";
|
||||||
import ColumnSortControl from "../../components/ui/ColumnSortControl";
|
import ColumnSortControl from "../../components/ui/ColumnSortControl";
|
||||||
import FilterChipGroup from "../../components/ui/FilterChipGroup";
|
import FilterChipGroup from "../../components/ui/FilterChipGroup";
|
||||||
import TagFilterChips from "../feed/TagFilterChips";
|
import TagFilterChips from "../../features/filters/TagFilterChips";
|
||||||
import ActionMenu, { type ActionMenuItem } from "../../components/ui/ActionMenu";
|
import ActionMenu, { type ActionMenuItem } from "../../components/ui/ActionMenu";
|
||||||
import ChannelStatsPopover from "./ChannelStatsPopover";
|
import ChannelStatsPopover from "./ChannelStatsPopover";
|
||||||
import Pager from "../../components/ui/Pager";
|
import Pager from "../../components/ui/Pager";
|
||||||
@@ -57,10 +57,10 @@ import { menuGroups } from "../../components/ui/menuGroups";
|
|||||||
import { choiceSubmenu, sortSubmenu } from "../../components/ui/pageMenuItems";
|
import { choiceSubmenu, sortSubmenu } from "../../components/ui/pageMenuItems";
|
||||||
import { optionsFromColumns } from "../../lib/sortOptions";
|
import { optionsFromColumns } from "../../lib/sortOptions";
|
||||||
import ChannelDiscovery from "./ChannelDiscovery";
|
import ChannelDiscovery from "./ChannelDiscovery";
|
||||||
import TagManager from "./TagManager";
|
import TagManager from "../../features/channel/TagManager";
|
||||||
import { useChannelLayout, useChannels, useChannelsActions } from "./channelsContext";
|
import { useChannelLayout, useChannels, useChannelsActions } from "./channelsContext";
|
||||||
import { useNavigationActions } from "../../features/navigation/navigationContext";
|
import { useNavigationActions } from "../../features/navigation/navigationContext";
|
||||||
import { useFeedFilters, useFeedFiltersActions } from "../feed/feedFiltersContext";
|
import { useFeedFilters, useFeedFiltersActions } from "../feed";
|
||||||
import { useWizard } from "../../features/wizard/wizardContext";
|
import { useWizard } from "../../features/wizard/wizardContext";
|
||||||
import { useMe } from "../../lib/useMe";
|
import { useMe } from "../../lib/useMe";
|
||||||
import { useTags } from "../../lib/useTags";
|
import { useTags } from "../../lib/useTags";
|
||||||
|
|||||||
@@ -7,11 +7,11 @@ import type { Column } from "../../components/ui/DataTable";
|
|||||||
import { PriorityStepper } from "../../components/ui/PriorityStepper";
|
import { PriorityStepper } from "../../components/ui/PriorityStepper";
|
||||||
import ChannelLink from "./ChannelLink";
|
import ChannelLink from "./ChannelLink";
|
||||||
import AboutCell from "./AboutCell";
|
import AboutCell from "./AboutCell";
|
||||||
import { ChannelTagsControl, type SuggestedTag } from "./ChannelTags";
|
import { ChannelTagsControl, type SuggestedTag } from "../../features/channel/ChannelTags";
|
||||||
import { StoredCell } from "./channelCells";
|
import { StoredCell } from "./channelCells";
|
||||||
import { isFullySynced } from "./channelSync";
|
import { isFullySynced } from "./channelSync";
|
||||||
import { RelativeTime } from "../../components/ui/RelativeTime";
|
import { RelativeTime } from "../../components/ui/RelativeTime";
|
||||||
import type { FilterChip } from "../feed/TagFilterChips";
|
import type { FilterChip } from "../../features/filters/TagFilterChips";
|
||||||
|
|
||||||
/** The shared "subscribers" DataTable column (right-aligned, sortable, em-dash for null) — identical
|
/** The shared "subscribers" DataTable column (right-aligned, sortable, em-dash for null) — identical
|
||||||
* in the Channels manager and the ChannelDiscovery table, which show subscriber counts on different
|
* in the Channels manager and the ChannelDiscovery table, which show subscriber counts on different
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
// The channel manager's public face: its store, mounted at the root and read app-wide.
|
||||||
|
export { ChannelsProvider } from "./ChannelsProvider";
|
||||||
|
export { useChannels, useChannelsActions } from "./channelsContext";
|
||||||
@@ -24,7 +24,7 @@ vi.mock("./feedFiltersContext", () => ({
|
|||||||
useFeedFilters: () => ({ includeNormal: true, includeShorts: false, includeLive: false }),
|
useFeedFilters: () => ({ includeNormal: true, includeShorts: false, includeLive: false }),
|
||||||
}));
|
}));
|
||||||
vi.mock("./Feed", () => ({ default: () => null }));
|
vi.mock("./Feed", () => ({ default: () => null }));
|
||||||
vi.mock("../channels/ChannelAbout", () => ({ default: () => null }));
|
vi.mock("../../features/channel/ChannelAbout", () => ({ default: () => null }));
|
||||||
|
|
||||||
import ChannelPage from "./ChannelPage";
|
import ChannelPage from "./ChannelPage";
|
||||||
import { api, type ChannelDetail, type FeedFilters, type Me } from "../../lib/api";
|
import { api, type ChannelDetail, type FeedFilters, type Me } from "../../lib/api";
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ vi.mock("./feedFiltersContext", () => ({
|
|||||||
useFeedFilters: () => ({ includeNormal: true, includeShorts: false, includeLive: false }),
|
useFeedFilters: () => ({ includeNormal: true, includeShorts: false, includeLive: false }),
|
||||||
}));
|
}));
|
||||||
vi.mock("./Feed", () => ({ default: () => null }));
|
vi.mock("./Feed", () => ({ default: () => null }));
|
||||||
vi.mock("../channels/ChannelAbout", () => ({ default: () => null }));
|
vi.mock("../../features/channel/ChannelAbout", () => ({ default: () => null }));
|
||||||
// The page's chrome portals into the shell's fixed toolbar band, which this test has no shell for.
|
// The page's chrome portals into the shell's fixed toolbar band, which this test has no shell for.
|
||||||
vi.mock("../../components/ui/PageShell", async (importOriginal) => ({
|
vi.mock("../../components/ui/PageShell", async (importOriginal) => ({
|
||||||
...(await importOriginal<typeof import("../../components/ui/PageShell")>()),
|
...(await importOriginal<typeof import("../../components/ui/PageShell")>()),
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ vi.mock("./feedFiltersContext", () => ({
|
|||||||
useFeedFilters: () => ({ includeNormal: true, includeShorts: false, includeLive: false }),
|
useFeedFilters: () => ({ includeNormal: true, includeShorts: false, includeLive: false }),
|
||||||
}));
|
}));
|
||||||
vi.mock("./Feed", () => ({ default: () => null }));
|
vi.mock("./Feed", () => ({ default: () => null }));
|
||||||
vi.mock("../channels/ChannelAbout", () => ({ default: () => null }));
|
vi.mock("../../features/channel/ChannelAbout", () => ({ default: () => null }));
|
||||||
// The page's chrome portals into the shell's fixed toolbar band, which this test has no shell for.
|
// The page's chrome portals into the shell's fixed toolbar band, which this test has no shell for.
|
||||||
vi.mock("../../components/ui/PageShell", async (importOriginal) => ({
|
vi.mock("../../components/ui/PageShell", async (importOriginal) => ({
|
||||||
...(await importOriginal<typeof import("../../components/ui/PageShell")>()),
|
...(await importOriginal<typeof import("../../components/ui/PageShell")>()),
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ vi.mock("./feedFiltersContext", () => ({
|
|||||||
useFeedFilters: () => ({ includeNormal: true, includeShorts: false, includeLive: false }),
|
useFeedFilters: () => ({ includeNormal: true, includeShorts: false, includeLive: false }),
|
||||||
}));
|
}));
|
||||||
vi.mock("./Feed", () => ({ default: () => null }));
|
vi.mock("./Feed", () => ({ default: () => null }));
|
||||||
vi.mock("../channels/ChannelAbout", () => ({ default: () => null }));
|
vi.mock("../../features/channel/ChannelAbout", () => ({ default: () => null }));
|
||||||
vi.mock("../../components/ui/PageShell", async (importOriginal) => ({
|
vi.mock("../../components/ui/PageShell", async (importOriginal) => ({
|
||||||
...(await importOriginal<typeof import("../../components/ui/PageShell")>()),
|
...(await importOriginal<typeof import("../../components/ui/PageShell")>()),
|
||||||
PageToolbar: ({ children }: { children?: ReactNode }) => <>{children}</>,
|
PageToolbar: ({ children }: { children?: ReactNode }) => <>{children}</>,
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ import Tabs from "../../components/ui/Tabs";
|
|||||||
import { useTabPanel } from "../../components/ui/tabPanel";
|
import { useTabPanel } from "../../components/ui/tabPanel";
|
||||||
import { useScrollFold } from "../../components/ui/scrollFold";
|
import { useScrollFold } from "../../components/ui/scrollFold";
|
||||||
import Feed from "./Feed";
|
import Feed from "./Feed";
|
||||||
import ChannelAboutContent from "../channels/ChannelAbout";
|
import ChannelAboutContent from "../../features/channel/ChannelAbout";
|
||||||
import { PageToolbar } from "../../components/ui/PageShell";
|
import { PageToolbar } from "../../components/ui/PageShell";
|
||||||
import { PageToolbarSlot } from "../../components/ui/pageToolbarSlot";
|
import { PageToolbarSlot } from "../../components/ui/pageToolbarSlot";
|
||||||
import { useFeedFilters } from "./feedFiltersContext";
|
import { useFeedFilters } from "./feedFiltersContext";
|
||||||
@@ -46,7 +46,7 @@ import {
|
|||||||
} from "../../lib/api";
|
} from "../../lib/api";
|
||||||
import type { FeedView } from "../../lib/feedView";
|
import type { FeedView } from "../../lib/feedView";
|
||||||
import { channelYouTubeUrl, formatDate, formatCompact } from "../../lib/format";
|
import { channelYouTubeUrl, formatDate, formatCompact } from "../../lib/format";
|
||||||
import { ChannelTagsControl } from "../channels/ChannelTags";
|
import { ChannelTagsControl } from "../../features/channel/ChannelTags";
|
||||||
import { useChannelTagActions } from "../../lib/useChannelTagActions";
|
import { useChannelTagActions } from "../../lib/useChannelTagActions";
|
||||||
import { Button } from "../../components/ui/Button";
|
import { Button } from "../../components/ui/Button";
|
||||||
import { IconButton } from "../../components/ui/IconButton";
|
import { IconButton } from "../../components/ui/IconButton";
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ vi.mock("./Feed", () => ({
|
|||||||
return null;
|
return null;
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
vi.mock("../channels/ChannelAbout", () => ({ default: () => null }));
|
vi.mock("../../features/channel/ChannelAbout", () => ({ default: () => null }));
|
||||||
vi.mock("../../components/ui/PageShell", async (importOriginal) => ({
|
vi.mock("../../components/ui/PageShell", async (importOriginal) => ({
|
||||||
...(await importOriginal<typeof import("../../components/ui/PageShell")>()),
|
...(await importOriginal<typeof import("../../components/ui/PageShell")>()),
|
||||||
PageToolbar: ({ children }: { children?: ReactNode }) => <>{children}</>,
|
PageToolbar: ({ children }: { children?: ReactNode }) => <>{children}</>,
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ vi.mock("../plex/plexContext", () => ({
|
|||||||
vi.mock("../../components/ui/confirmContext", () => ({ useConfirm: () => async () => true }));
|
vi.mock("../../components/ui/confirmContext", () => ({ useConfirm: () => async () => true }));
|
||||||
|
|
||||||
import { SavedViewsProvider } from "./SavedViewsProvider";
|
import { SavedViewsProvider } from "./SavedViewsProvider";
|
||||||
import { useSavedViews } from "./savedViewsContext";
|
import { useSavedViews } from "../../features/filters/savedViewsContext";
|
||||||
import { api, type SavedView } from "../../lib/api";
|
import { api, type SavedView } from "../../lib/api";
|
||||||
import { claimLocalData, LS } from "../../lib/storage";
|
import { claimLocalData, LS } from "../../lib/storage";
|
||||||
import { qk } from "../../lib/queryKeys";
|
import { qk } from "../../lib/queryKeys";
|
||||||
|
|||||||
@@ -17,13 +17,13 @@ import { LS, ownKey } from "../../lib/storage";
|
|||||||
import { useConfirm } from "../../components/ui/confirmContext";
|
import { useConfirm } from "../../components/ui/confirmContext";
|
||||||
import { useNavigation } from "../../features/navigation/navigationContext";
|
import { useNavigation } from "../../features/navigation/navigationContext";
|
||||||
import { useFeedFilters, useFeedFiltersActions } from "./feedFiltersContext";
|
import { useFeedFilters, useFeedFiltersActions } from "./feedFiltersContext";
|
||||||
import { usePlex, usePlexActions } from "../plex/plexContext";
|
import { usePlex, usePlexActions } from "../plex";
|
||||||
import {
|
import {
|
||||||
asFilters,
|
asFilters,
|
||||||
SavedViewsCtx,
|
SavedViewsCtx,
|
||||||
type SavedViewsContext,
|
type SavedViewsContext,
|
||||||
type ViewSource,
|
type ViewSource,
|
||||||
} from "./savedViewsContext";
|
} from "../../features/filters/savedViewsContext";
|
||||||
|
|
||||||
// Saved "smart views" now serve TWO modules — the feed and Plex — keyed by `source`. This one provider
|
// 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
|
// switches which module it operates on by the active page (both filter providers are global, so it can
|
||||||
|
|||||||
@@ -7,9 +7,9 @@ import { api, NO_TAG_SENTINEL, type FeedFilters, type Tag, type TagCategory } fr
|
|||||||
import { defaultLayout, type PanelLayout } from "../../lib/panelLayout";
|
import { defaultLayout, type PanelLayout } from "../../lib/panelLayout";
|
||||||
import { useDebounced } from "../../lib/useDebounced";
|
import { useDebounced } from "../../lib/useDebounced";
|
||||||
import { useActiveFilters } from "../../lib/useActiveFilters";
|
import { useActiveFilters } from "../../lib/useActiveFilters";
|
||||||
import TagManager from "../channels/TagManager";
|
import TagManager from "../../features/channel/TagManager";
|
||||||
import SavedViewsPopover from "../../features/filters/SavedViewsPopover";
|
import SavedViewsPopover from "../../features/filters/SavedViewsPopover";
|
||||||
import { useSavedViews } from "./savedViewsContext";
|
import { useSavedViews } from "../../features/filters/savedViewsContext";
|
||||||
import FilterRail from "../../components/ui/FilterRail";
|
import FilterRail from "../../components/ui/FilterRail";
|
||||||
import { type PanelItem } from "../../components/ui/PanelGroups";
|
import { type PanelItem } from "../../components/ui/PanelGroups";
|
||||||
import { type MediaSourceKind } from "../../lib/mediaSource";
|
import { type MediaSourceKind } from "../../lib/mediaSource";
|
||||||
@@ -20,7 +20,7 @@ import YearFilter from "./YearFilter";
|
|||||||
import Combobox from "../../components/ui/Combobox";
|
import Combobox from "../../components/ui/Combobox";
|
||||||
import MatchToggle from "../../components/ui/MatchToggle";
|
import MatchToggle from "../../components/ui/MatchToggle";
|
||||||
import { useFeedFilters, useFeedFiltersActions } from "./feedFiltersContext";
|
import { useFeedFilters, useFeedFiltersActions } from "./feedFiltersContext";
|
||||||
import { useChannelsActions } from "../channels/channelsContext";
|
import { useChannelsActions } from "../channels";
|
||||||
import { useTags } from "../../lib/useTags";
|
import { useTags } from "../../lib/useTags";
|
||||||
import { compareText } from "../../lib/collate";
|
import { compareText } from "../../lib/collate";
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
// The feed's public face: its stores (filters, view, saved views), mounted at the root and read app-wide.
|
||||||
|
export { FeedFiltersProvider } from "./FeedFiltersProvider";
|
||||||
|
export { FeedViewProvider } from "./FeedViewProvider";
|
||||||
|
export { SavedViewsProvider } from "./SavedViewsProvider";
|
||||||
|
export { useFeedFilters, useFeedFiltersActions } from "./feedFiltersContext";
|
||||||
|
export { useFeedView, useFeedViewActions } from "./feedViewContext";
|
||||||
@@ -4,7 +4,7 @@ import { useFeedFilters, useFeedFiltersActions } from "./feedFiltersContext";
|
|||||||
import { useFeedView, useFeedViewActions } from "./feedViewContext";
|
import { useFeedView, useFeedViewActions } from "./feedViewContext";
|
||||||
import { useNavigation, useNavigationActions } from "../../features/navigation/navigationContext";
|
import { useNavigation, useNavigationActions } from "../../features/navigation/navigationContext";
|
||||||
import PersonalViewsModal from "../../features/filters/PersonalViewsModal";
|
import PersonalViewsModal from "../../features/filters/PersonalViewsModal";
|
||||||
import { useSavedViews } from "./savedViewsContext";
|
import { useSavedViews } from "../../features/filters/savedViewsContext";
|
||||||
|
|
||||||
// The two module-registry pages that need per-render wiring (see moduleRegistry), split out of it so
|
// The two module-registry pages that need per-render wiring (see moduleRegistry), split out of it so
|
||||||
// that file exports only data and this one only components (Fast Refresh). Their lazy chunks are
|
// that file exports only data and this one only components (Fast Refresh). Their lazy chunks are
|
||||||
|
|||||||
+3
-3
@@ -11,7 +11,7 @@ vi.mock("react-i18next", async (importOriginal) => ({
|
|||||||
...(await importOriginal<typeof import("react-i18next")>()),
|
...(await importOriginal<typeof import("react-i18next")>()),
|
||||||
useTranslation: () => ({ i18n: { language: "en" }, t: (key: string) => key }),
|
useTranslation: () => ({ i18n: { language: "en" }, t: (key: string) => key }),
|
||||||
}));
|
}));
|
||||||
vi.mock("./DownloadDialog", () => ({
|
vi.mock("../../features/downloads/DownloadDialog", () => ({
|
||||||
default: ({ source, onClose }: { source: string; onClose: () => void }) => (
|
default: ({ source, onClose }: { source: string; onClose: () => void }) => (
|
||||||
<div role="dialog" aria-label="download">
|
<div role="dialog" aria-label="download">
|
||||||
{source}
|
{source}
|
||||||
@@ -20,8 +20,8 @@ vi.mock("./DownloadDialog", () => ({
|
|||||||
),
|
),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import DownloadIntake from "./DownloadIntake";
|
import DownloadIntake from "../../features/downloads/DownloadIntake";
|
||||||
import { LinkBox } from "../../modules/files/LinkBox";
|
import { LinkBox } from "./LinkBox";
|
||||||
import { clearPendingDownload, requestLinkBox } from "../../lib/downloadIntake";
|
import { clearPendingDownload, requestLinkBox } from "../../lib/downloadIntake";
|
||||||
import { ensureNavId } from "../../lib/history";
|
import { ensureNavId } from "../../lib/history";
|
||||||
import { pushLayer, removeLayer } from "../../lib/layerStack";
|
import { pushLayer, removeLayer } from "../../lib/layerStack";
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
// The Files page's public face: its store, mounted at the root, and how a folder is named and pictured.
|
||||||
|
export { FilesProvider } from "./FilesProvider";
|
||||||
|
export { useFiles, useFilesActions } from "./filesContext";
|
||||||
|
export { folderIcon, folderLabel, sourceLabel, takesUploads } from "./fileFolders";
|
||||||
@@ -55,9 +55,9 @@ import VideoCard, { VideoHoverActions } from "../../features/video/VideoCard";
|
|||||||
import { useVideoActions } from "../../features/video/useVideoActions";
|
import { useVideoActions } from "../../features/video/useVideoActions";
|
||||||
import { usePlexCardActions } from "../../features/plexCards/usePlexCardActions";
|
import { usePlexCardActions } from "../../features/plexCards/usePlexCardActions";
|
||||||
import { PlexHoverBar, PlexPosterCard } from "../../features/plexCards/plexCards";
|
import { PlexHoverBar, PlexPosterCard } from "../../features/plexCards/plexCards";
|
||||||
import { usePlaylistsActions } from "../playlists/playlistsContext";
|
import { usePlaylistsActions } from "../playlists";
|
||||||
import { useNavigationActions } from "../../features/navigation/navigationContext";
|
import { useNavigationActions } from "../../features/navigation/navigationContext";
|
||||||
import { usePlexActions } from "../plex/plexContext";
|
import { usePlexActions } from "../plex";
|
||||||
import { navItemAttrs } from "../../lib/listNav";
|
import { navItemAttrs } from "../../lib/listNav";
|
||||||
import { Pressable } from "../../components/ui/Pressable";
|
import { Pressable } from "../../components/ui/Pressable";
|
||||||
import { SURFACE } from "../../components/ui/surfaces";
|
import { SURFACE } from "../../components/ui/surfaces";
|
||||||
|
|||||||
@@ -47,9 +47,9 @@ import { PageToolbar } from "../../components/ui/PageShell";
|
|||||||
import { PageMenu } from "../../components/ui/PageMenu";
|
import { PageMenu } from "../../components/ui/PageMenu";
|
||||||
import { LoadingState, StateMessage } from "../../components/ui/QueryState";
|
import { LoadingState, StateMessage } from "../../components/ui/QueryState";
|
||||||
import { useNavigationActions } from "../../features/navigation/navigationContext";
|
import { useNavigationActions } from "../../features/navigation/navigationContext";
|
||||||
import { useFilesActions } from "../files/filesContext";
|
import { useFilesActions } from "../files";
|
||||||
import { useChannelsActions } from "../channels/channelsContext";
|
import { useChannelsActions } from "../channels";
|
||||||
import { useFeedFilters, useFeedFiltersActions } from "../feed/feedFiltersContext";
|
import { useFeedFilters, useFeedFiltersActions } from "../feed";
|
||||||
import { SURFACE } from "../../components/ui/surfaces";
|
import { SURFACE } from "../../components/ui/surfaces";
|
||||||
import { SectionLabel } from "../../components/ui/Headings";
|
import { SectionLabel } from "../../components/ui/Headings";
|
||||||
import NavGrid from "../../components/ui/NavGrid";
|
import NavGrid from "../../components/ui/NavGrid";
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
// The playlists page's public face: its store, mounted at the root and read app-wide.
|
||||||
|
export { PlaylistsProvider } from "./PlaylistsProvider";
|
||||||
|
export { usePlaylists, usePlaylistsActions } from "./playlistsContext";
|
||||||
@@ -18,7 +18,7 @@ import FilterChipGroup from "../../components/ui/FilterChipGroup";
|
|||||||
import Combobox, { type ComboboxOption } from "../../components/ui/Combobox";
|
import Combobox, { type ComboboxOption } from "../../components/ui/Combobox";
|
||||||
import SavedViewsPopover from "../../features/filters/SavedViewsPopover";
|
import SavedViewsPopover from "../../features/filters/SavedViewsPopover";
|
||||||
import PersonalViewsModal from "../../features/filters/PersonalViewsModal";
|
import PersonalViewsModal from "../../features/filters/PersonalViewsModal";
|
||||||
import { useSavedViews } from "../feed/savedViewsContext";
|
import { useSavedViews } from "../../features/filters/savedViewsContext";
|
||||||
import { usePlex, usePlexActions } from "./plexContext";
|
import { usePlex, usePlexActions } from "./plexContext";
|
||||||
import { DismissibleChip } from "../../components/ui/Chip";
|
import { DismissibleChip } from "../../components/ui/Chip";
|
||||||
import { Input } from "../../components/ui/Input";
|
import { Input } from "../../components/ui/Input";
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
// The Plex module's public face: its store, mounted at the root and read app-wide, and how its filters read as chips.
|
||||||
|
export { PlexProvider } from "./PlexProvider";
|
||||||
|
export { PlexViewBridge } from "./PlexViewBridge";
|
||||||
|
export { usePlex, usePlexActions } from "./plexContext";
|
||||||
|
export { buildPlexActiveFilters } from "./usePlexActiveFilters";
|
||||||
@@ -44,7 +44,7 @@ import { ContextMenu } from "../../components/ui/ContextMenu";
|
|||||||
import { MoreButton } from "../../components/ui/MoreButton";
|
import { MoreButton } from "../../components/ui/MoreButton";
|
||||||
import { type ActionMenuItem } from "../../components/ui/ActionMenu";
|
import { type ActionMenuItem } from "../../components/ui/ActionMenu";
|
||||||
import { jobFailureText, jobResultText } from "../../lib/jobResult";
|
import { jobFailureText, jobResultText } from "../../lib/jobResult";
|
||||||
import { StatusDot, type DotKey } from "../activity/RunStatusDot";
|
import { StatusDot, type DotKey } from "../activity";
|
||||||
import { useNavigationActions } from "../../features/navigation/navigationContext";
|
import { useNavigationActions } from "../../features/navigation/navigationContext";
|
||||||
import { urlWithParams } from "../../lib/urlState";
|
import { urlWithParams } from "../../lib/urlState";
|
||||||
|
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ import { compareText } from "../../lib/collate";
|
|||||||
import { formatDate } from "../../lib/format";
|
import { formatDate } from "../../lib/format";
|
||||||
import { navItemAttrs } from "../../lib/listNav";
|
import { navItemAttrs } from "../../lib/listNav";
|
||||||
import { useConfirm } from "../../components/ui/confirmContext";
|
import { useConfirm } from "../../components/ui/confirmContext";
|
||||||
import { folderIcon, folderLabel, takesUploads } from "../files/fileFolders";
|
import { folderIcon, folderLabel, takesUploads } from "../files";
|
||||||
import { type ActionMenuItem } from "../../components/ui/ActionMenu";
|
import { type ActionMenuItem } from "../../components/ui/ActionMenu";
|
||||||
import { Badge } from "../../components/ui/Badge";
|
import { Badge } from "../../components/ui/Badge";
|
||||||
import { Button } from "../../components/ui/Button";
|
import { Button } from "../../components/ui/Button";
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ import { DownloadFormats } from "./ProfileEditor";
|
|||||||
import DiagnosticsSettings from "./DiagnosticsSettings";
|
import DiagnosticsSettings from "./DiagnosticsSettings";
|
||||||
import ApiTokens from "./ApiTokens";
|
import ApiTokens from "./ApiTokens";
|
||||||
import { type SettingsTabId } from "../../lib/settingsTab";
|
import { type SettingsTabId } from "../../lib/settingsTab";
|
||||||
import { SCHEMES, type Scheme, type ThemePrefs } from "../../lib/theme";
|
import { SCHEMES, type Scheme } from "../../lib/theme";
|
||||||
import { api, HttpError, type Me } from "../../lib/api";
|
import { api, HttpError, type Me } from "../../lib/api";
|
||||||
import { displayName, roleKey } from "../../lib/api/account";
|
import { displayName, roleKey } from "../../lib/api/account";
|
||||||
import { notifyActionError } from "../../lib/actionErrors";
|
import { notifyActionError } from "../../lib/actionErrors";
|
||||||
@@ -40,7 +40,7 @@ import {
|
|||||||
START_ON_HOME_PREF,
|
START_ON_HOME_PREF,
|
||||||
} from "../../features/navigation/navigationInit";
|
} from "../../features/navigation/navigationInit";
|
||||||
import { usePref } from "../../lib/usePref";
|
import { usePref } from "../../lib/usePref";
|
||||||
import { usePrefs } from "../../features/prefs/prefsContext";
|
import { usePrefs, type PrefsController } from "../../features/prefs/prefsContext";
|
||||||
import Tabs from "../../components/ui/Tabs";
|
import Tabs from "../../components/ui/Tabs";
|
||||||
import { useTabPanel } from "../../components/ui/tabPanel";
|
import { useTabPanel } from "../../components/ui/tabPanel";
|
||||||
import { useMe } from "../../lib/useMe";
|
import { useMe } from "../../lib/useMe";
|
||||||
@@ -49,25 +49,6 @@ import { Slider } from "../../components/ui/NativeControls";
|
|||||||
import { Input, Textarea } from "../../components/ui/Input";
|
import { Input, Textarea } from "../../components/ui/Input";
|
||||||
import { SURFACE } from "../../components/ui/surfaces";
|
import { SURFACE } from "../../components/ui/surfaces";
|
||||||
|
|
||||||
// The Settings page edits server-persisted preferences. Each change applies locally for instant
|
|
||||||
// preview AND auto-saves (debounced) — no draft, no Save button; a small indicator flashes the
|
|
||||||
// outcome. PrefsProvider owns the state + the save; this controller
|
|
||||||
// is how the panel reads/writes it.
|
|
||||||
export type PrefsSaveState = SaveState;
|
|
||||||
export interface PrefsController {
|
|
||||||
theme: ThemePrefs;
|
|
||||||
setTheme: (t: ThemePrefs) => void;
|
|
||||||
perf: boolean;
|
|
||||||
setPerf: (v: boolean) => void;
|
|
||||||
hints: boolean;
|
|
||||||
setHints: (v: boolean) => void;
|
|
||||||
railLabels: boolean;
|
|
||||||
setRailLabels: (v: boolean) => void;
|
|
||||||
notif: NotifSettings;
|
|
||||||
setNotif: (n: NotifSettings) => void;
|
|
||||||
saveState: PrefsSaveState;
|
|
||||||
}
|
|
||||||
|
|
||||||
type TabId = SettingsTabId;
|
type TabId = SettingsTabId;
|
||||||
const TABS: { id: TabId; icon: typeof Monitor }[] = [
|
const TABS: { id: TabId; icon: typeof Monitor }[] = [
|
||||||
{ id: "appearance", icon: Monitor },
|
{ id: "appearance", icon: Monitor },
|
||||||
@@ -138,7 +119,7 @@ export default function SettingsPanel() {
|
|||||||
|
|
||||||
// A small transient indicator of the auto-save outcome. Renders nothing while idle; it sits at the
|
// A small transient indicator of the auto-save outcome. Renders nothing while idle; it sits at the
|
||||||
// bottom of the settings card and fades after a success (the state resets to "idle" in App).
|
// bottom of the settings card and fades after a success (the state resets to "idle" in App).
|
||||||
function SaveIndicator({ state }: { state: PrefsSaveState }) {
|
function SaveIndicator({ state }: { state: SaveState }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
if (state === "idle") return null;
|
if (state === "idle") return null;
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -21,8 +21,12 @@ import { LS } from "../lib/storage";
|
|||||||
import { usePref } from "../lib/usePref";
|
import { usePref } from "../lib/usePref";
|
||||||
import { useVerifyEntry } from "../lib/useVerifyEntry";
|
import { useVerifyEntry } from "../lib/useVerifyEntry";
|
||||||
import { useNavigation, useNavigationActions } from "../features/navigation/navigationContext";
|
import { useNavigation, useNavigationActions } from "../features/navigation/navigationContext";
|
||||||
import { useFeedFilters, useFeedFiltersActions } from "../modules/feed/feedFiltersContext";
|
import {
|
||||||
import { useFeedView, useFeedViewActions } from "../modules/feed/feedViewContext";
|
useFeedFilters,
|
||||||
|
useFeedFiltersActions,
|
||||||
|
useFeedView,
|
||||||
|
useFeedViewActions,
|
||||||
|
} from "../modules/feed";
|
||||||
import { moduleOrder } from "../lib/modules";
|
import { moduleOrder } from "../lib/modules";
|
||||||
import { PAGE_CONTENT, PAGE_RAIL, preloadPages } from "./moduleRegistry";
|
import { PAGE_CONTENT, PAGE_RAIL, preloadPages } from "./moduleRegistry";
|
||||||
import { lazyWithPreload } from "../lib/lazyWithPreload";
|
import { lazyWithPreload } from "../lib/lazyWithPreload";
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ vi.mock("../modules/playlists/playlistsContext", () => ({
|
|||||||
vi.mock("../features/prefs/prefsContext", () => ({
|
vi.mock("../features/prefs/prefsContext", () => ({
|
||||||
usePrefs: () => ({ theme: { scheme: "default" }, setTheme: vi.fn() }),
|
usePrefs: () => ({ theme: { scheme: "default" }, setTheme: vi.fn() }),
|
||||||
}));
|
}));
|
||||||
vi.mock("../modules/feed/savedViewsContext", () => ({
|
vi.mock("../features/filters/savedViewsContext", () => ({
|
||||||
useSavedViews: () => ({ views: [], apply: vi.fn() }),
|
useSavedViews: () => ({ views: [], apply: vi.fn() }),
|
||||||
}));
|
}));
|
||||||
const requestSurprise = vi.fn();
|
const requestSurprise = vi.fn();
|
||||||
|
|||||||
@@ -24,9 +24,9 @@ import { requestLinkBox } from "../lib/downloadIntake";
|
|||||||
import { openRecorderStart } from "../lib/debugRecorder";
|
import { openRecorderStart } from "../lib/debugRecorder";
|
||||||
import { useDebugRecording } from "../features/diagnostics/useDebugRecording";
|
import { useDebugRecording } from "../features/diagnostics/useDebugRecording";
|
||||||
import { useNavigationActions } from "../features/navigation/navigationContext";
|
import { useNavigationActions } from "../features/navigation/navigationContext";
|
||||||
import { usePlaylistsActions } from "../modules/playlists/playlistsContext";
|
import { usePlaylistsActions } from "../modules/playlists";
|
||||||
import { usePrefs } from "../features/prefs/prefsContext";
|
import { usePrefs } from "../features/prefs/prefsContext";
|
||||||
import { useSavedViews } from "../modules/feed/savedViewsContext";
|
import { useSavedViews } from "../features/filters/savedViewsContext";
|
||||||
|
|
||||||
// What Ctrl/Cmd+K can reach. The generic list/filter/keyboard shell is `ui/CommandPalette`; this is
|
// What Ctrl/Cmd+K can reach. The generic list/filter/keyboard shell is `ui/CommandPalette`; this is
|
||||||
// the domain half — the layer boundary says a primitive may not import `lib/api` or a module.
|
// the domain half — the layer boundary says a primitive may not import `lib/api` or a module.
|
||||||
|
|||||||
+8
-8
@@ -11,24 +11,24 @@ vi.mock("react-i18next", async (importOriginal) => ({
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
let page = "home";
|
let page = "home";
|
||||||
vi.mock("../navigation/navigationContext", () => ({ useNavigation: () => ({ page }) }));
|
vi.mock("../features/navigation/navigationContext", () => ({ useNavigation: () => ({ page }) }));
|
||||||
vi.mock("../../modules/feed/feedFiltersContext", () => ({
|
vi.mock("../modules/feed/feedFiltersContext", () => ({
|
||||||
useFeedFilters: () => ({ q: "", sort: "newest" }),
|
useFeedFilters: () => ({ q: "", sort: "newest" }),
|
||||||
useFeedFiltersActions: () => ({ setFilters: vi.fn() }),
|
useFeedFiltersActions: () => ({ setFilters: vi.fn() }),
|
||||||
}));
|
}));
|
||||||
vi.mock("../../modules/playlists/playlistsContext", () => ({
|
vi.mock("../modules/playlists/playlistsContext", () => ({
|
||||||
usePlaylists: () => ({ search: "" }),
|
usePlaylists: () => ({ search: "" }),
|
||||||
usePlaylistsActions: () => ({ setSearch: vi.fn() }),
|
usePlaylistsActions: () => ({ setSearch: vi.fn() }),
|
||||||
}));
|
}));
|
||||||
vi.mock("../../modules/channels/channelsContext", () => ({
|
vi.mock("../modules/channels/channelsContext", () => ({
|
||||||
useChannels: () => ({ search: "" }),
|
useChannels: () => ({ search: "" }),
|
||||||
useChannelsActions: () => ({ setSearch: vi.fn(), goToFullHistory: vi.fn() }),
|
useChannelsActions: () => ({ setSearch: vi.fn(), goToFullHistory: vi.fn() }),
|
||||||
}));
|
}));
|
||||||
vi.mock("../../modules/channels/SyncStatus", () => ({ default: () => null }));
|
vi.mock("../modules/channels/SyncStatus", () => ({ default: () => null }));
|
||||||
|
|
||||||
import Header from "../../shell/Header";
|
import Header from "./Header";
|
||||||
import { closePalette, usePaletteOpen } from "../../lib/keymap";
|
import { closePalette, usePaletteOpen } from "../lib/keymap";
|
||||||
import type { Me } from "../../lib/api";
|
import type { Me } from "../lib/api";
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
cleanup();
|
cleanup();
|
||||||
@@ -3,16 +3,21 @@ import { useTranslation } from "react-i18next";
|
|||||||
import { YouTubeIcon } from "../lib/brandIcons";
|
import { YouTubeIcon } from "../lib/brandIcons";
|
||||||
import type { Me } from "../lib/api";
|
import type { Me } from "../lib/api";
|
||||||
import SearchBar from "../features/header/SearchBar";
|
import SearchBar from "../features/header/SearchBar";
|
||||||
import { useFiles, useFilesActions } from "../modules/files/filesContext";
|
import { useFiles, useFilesActions } from "../modules/files";
|
||||||
|
// eslint-disable-next-line local/layers -- the header still hosts this module's control; it goes once each page renders its own controls into the header's slots.
|
||||||
import PlexSearchBox from "../modules/plex/PlexSearchBox";
|
import PlexSearchBox from "../modules/plex/PlexSearchBox";
|
||||||
|
// eslint-disable-next-line local/layers -- the header still hosts this module's control; it goes once each page renders its own controls into the header's slots.
|
||||||
import PlexScopeToggle from "../modules/plex/PlexScopeToggle";
|
import PlexScopeToggle from "../modules/plex/PlexScopeToggle";
|
||||||
|
// eslint-disable-next-line local/layers -- the header still hosts this module's control; it goes once each page renders its own controls into the header's slots.
|
||||||
import PlexViewToggle from "../modules/plex/PlexViewToggle";
|
import PlexViewToggle from "../modules/plex/PlexViewToggle";
|
||||||
|
// eslint-disable-next-line local/layers -- the header still hosts this module's control; it goes once each page renders its own controls into the header's slots.
|
||||||
import FeedScopeToggle from "../modules/feed/FeedScopeToggle";
|
import FeedScopeToggle from "../modules/feed/FeedScopeToggle";
|
||||||
|
// eslint-disable-next-line local/layers -- the header still hosts this module's control; it goes once each page renders its own controls into the header's slots.
|
||||||
import SyncStatus from "../modules/channels/SyncStatus";
|
import SyncStatus from "../modules/channels/SyncStatus";
|
||||||
import { useNavigation } from "../features/navigation/navigationContext";
|
import { useNavigation } from "../features/navigation/navigationContext";
|
||||||
import { useFeedFilters, useFeedFiltersActions } from "../modules/feed/feedFiltersContext";
|
import { useFeedFilters, useFeedFiltersActions } from "../modules/feed";
|
||||||
import { usePlaylists, usePlaylistsActions } from "../modules/playlists/playlistsContext";
|
import { usePlaylists, usePlaylistsActions } from "../modules/playlists";
|
||||||
import { useChannels, useChannelsActions } from "../modules/channels/channelsContext";
|
import { useChannels, useChannelsActions } from "../modules/channels";
|
||||||
import { PaletteLauncher } from "../features/header/PaletteLauncher";
|
import { PaletteLauncher } from "../features/header/PaletteLauncher";
|
||||||
|
|
||||||
// The floating top header: a row of glass pills over the content — the module's SearchBar where it
|
// The floating top header: a row of glass pills over the content — the module's SearchBar where it
|
||||||
|
|||||||
@@ -2,8 +2,11 @@ import type { ComponentType } from "react";
|
|||||||
import { lazyWithPreload, type PreloadableComponent } from "../lib/lazyWithPreload";
|
import { lazyWithPreload, type PreloadableComponent } from "../lib/lazyWithPreload";
|
||||||
import type { Page } from "../lib/urlState";
|
import type { Page } from "../lib/urlState";
|
||||||
import type { PanelId, PanelLayout } from "../lib/panelLayout";
|
import type { PanelId, PanelLayout } from "../lib/panelLayout";
|
||||||
|
// eslint-disable-next-line local/layers -- a rail (or the feed page's wrapper) the shell still loads eagerly; it goes once every rail loads lazily, like the pages.
|
||||||
import Sidebar from "../modules/feed/Sidebar";
|
import Sidebar from "../modules/feed/Sidebar";
|
||||||
|
// eslint-disable-next-line local/layers -- a rail (or the feed page's wrapper) the shell still loads eagerly; it goes once every rail loads lazily, like the pages.
|
||||||
import PlaylistsRail from "../modules/playlists/PlaylistsRail";
|
import PlaylistsRail from "../modules/playlists/PlaylistsRail";
|
||||||
|
// eslint-disable-next-line local/layers -- a rail (or the feed page's wrapper) the shell still loads eagerly; it goes once every rail loads lazily, like the pages.
|
||||||
import { Feed, FeedPage } from "../modules/feed/modulePages";
|
import { Feed, FeedPage } from "../modules/feed/modulePages";
|
||||||
|
|
||||||
// The RENDER half of the module registry: which component (and side rail) each page renders. The
|
// The RENDER half of the module registry: which component (and side rail) each page renders. The
|
||||||
|
|||||||
@@ -43,6 +43,8 @@
|
|||||||
"vitest.config.ts",
|
"vitest.config.ts",
|
||||||
"reactPlugin.ts",
|
"reactPlugin.ts",
|
||||||
"devOnlyGuard.ts",
|
"devOnlyGuard.ts",
|
||||||
|
"chunkGuard.ts",
|
||||||
|
"src/lib/chunkLeaks.ts",
|
||||||
"playwright.config.ts",
|
"playwright.config.ts",
|
||||||
"postcss.config.js"
|
"postcss.config.js"
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { ServerResponse } from "node:http";
|
|||||||
import { defineConfig, type Plugin, type ProxyOptions } from "vite";
|
import { defineConfig, type Plugin, type ProxyOptions } from "vite";
|
||||||
import { reactWithCompiler } from "./reactPlugin";
|
import { reactWithCompiler } from "./reactPlugin";
|
||||||
import { devOnlyGuard } from "./devOnlyGuard";
|
import { devOnlyGuard } from "./devOnlyGuard";
|
||||||
|
import { chunkGuard } from "./chunkGuard";
|
||||||
|
|
||||||
// During `vite dev` (host with Node), proxy API calls to the backend container.
|
// During `vite dev` (host with Node), proxy API calls to the backend container.
|
||||||
// Use 127.0.0.1 (not "localhost") so Node doesn't resolve to IPv6 ::1, which the
|
// Use 127.0.0.1 (not "localhost") so Node doesn't resolve to IPv6 ::1, which the
|
||||||
@@ -54,7 +55,7 @@ const watchEntry = (): Plugin => ({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [reactWithCompiler(), devOnlyGuard(), watchEntry()],
|
plugins: [reactWithCompiler(), devOnlyGuard(), chunkGuard(), watchEntry()],
|
||||||
server: { port: 5173, proxy },
|
server: { port: 5173, proxy },
|
||||||
build: { outDir: "dist", rollupOptions: { input: ["index.html", "watch.html"] } },
|
build: { outDir: "dist", rollupOptions: { input: ["index.html", "watch.html"] } },
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user