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:
@@ -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 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
|
||||
@@ -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 = {
|
||||
rules: {
|
||||
layers,
|
||||
"glyph-link": glyphLink,
|
||||
"sortable-a11y": sortableA11y,
|
||||
"jsx-comment-text": jsxCommentText,
|
||||
@@ -567,6 +650,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.
|
||||
@@ -761,7 +845,7 @@ 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"],
|
||||
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2022,
|
||||
|
||||
@@ -32,7 +32,7 @@ vi.mock("./lib/notifications", async (importOriginal) => ({
|
||||
import PlexPlaylistView from "./modules/plex/PlexPlaylistView";
|
||||
import AddToPlaylist, { AddToPlaylistAt } from "./features/video/AddToPlaylist";
|
||||
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";
|
||||
|
||||
beforeAll(() => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -51,7 +51,7 @@ const PLAIN: Record<string, [number, string]> = {
|
||||
"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"],
|
||||
"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"],
|
||||
"features/player/VideoEditor.tsx": [2, "timeline glyphs"],
|
||||
"shell/Welcome.tsx": [1, "a screenshot (zoom)"],
|
||||
|
||||
@@ -37,8 +37,7 @@ import { useLiveQuery } from "../../lib/useLiveQuery";
|
||||
import { usePref } from "../../lib/usePref";
|
||||
import { ErrorModal } from "./downloadModals";
|
||||
import { jobTitle } from "./downloadTitle";
|
||||
import { sourceLabel } from "../../modules/files/fileFolders";
|
||||
import { useFilesActions } from "../../modules/files/filesContext";
|
||||
import { sourceLabel, useFilesActions } from "../../modules/files";
|
||||
import { useDownloadActions } from "./useDownloadActions";
|
||||
import { type ActionMenuItem } from "../../components/ui/ActionMenu";
|
||||
import { setCornerLift } from "../../components/ui/backToTopState";
|
||||
|
||||
@@ -63,7 +63,7 @@ const CTX = {
|
||||
toggleDefault: vi.fn(async () => {}),
|
||||
};
|
||||
|
||||
vi.mock("../../modules/feed/savedViewsContext", () => ({
|
||||
vi.mock("./savedViewsContext", () => ({
|
||||
useSavedViews: () => ({
|
||||
...CTX,
|
||||
activeViewId: h.activeViewId,
|
||||
|
||||
@@ -26,9 +26,9 @@ import {
|
||||
import Modal from "../../components/ui/Modal";
|
||||
import { Button } from "../../components/ui/Button";
|
||||
import { IconButton } from "../../components/ui/IconButton";
|
||||
import { useSavedViews, asFilters } from "../../modules/feed/savedViewsContext";
|
||||
import { useSavedViews, asFilters } from "./savedViewsContext";
|
||||
import { useActiveFilters } from "../../lib/useActiveFilters";
|
||||
import { buildPlexActiveFilters } from "../../modules/plex/usePlexActiveFilters";
|
||||
import { buildPlexActiveFilters } from "../../modules/plex";
|
||||
import { useUndoable } from "../../lib/useUndoable";
|
||||
import UndoToolbar from "../../components/ui/UndoToolbar";
|
||||
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")>();
|
||||
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
|
||||
});
|
||||
vi.mock("../../modules/feed/savedViewsContext", () => ({
|
||||
vi.mock("./savedViewsContext", () => ({
|
||||
useSavedViews: () => ({ views: [], activeViewId: null, apply: vi.fn(), openModal }),
|
||||
}));
|
||||
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { Settings2 } from "lucide-react";
|
||||
import { PopoverItem, PopoverSurface } from "../../components/ui/AnchoredPopover";
|
||||
import { closeThenAct, useAnchoredPopover } from "../../components/ui/anchoredPopoverHooks";
|
||||
import { useSavedViews } from "../../modules/feed/savedViewsContext";
|
||||
import { useSavedViews } from "./savedViewsContext";
|
||||
import { IconButton } from "../../components/ui/IconButton";
|
||||
import { MenuCheck } from "../../components/ui/MenuCheck";
|
||||
import { SURFACE } from "../../components/ui/surfaces";
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Bookmark } from "lucide-react";
|
||||
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 toolbar's FilterSelect pills; a warn-coloured dot appears when the live filters have diverged
|
||||
|
||||
@@ -24,8 +24,7 @@ import {
|
||||
} from "../../lib/notifications";
|
||||
import { hintsEnabled, setHintsEnabled } from "../../lib/hints";
|
||||
import { getAccountRaw, LS, setAccountRaw } from "../../lib/storage";
|
||||
import type { PrefsController } from "../../modules/settings/SettingsPanel";
|
||||
import { PrefsContext } from "./prefsContext";
|
||||
import { PrefsContext, type PrefsController } from "./prefsContext";
|
||||
|
||||
const PERF_KEY = LS.perfMode;
|
||||
|
||||
|
||||
@@ -1,11 +1,29 @@
|
||||
import { createContext, useContext } from "react";
|
||||
import { DEFAULT_THEME } from "../../lib/theme";
|
||||
import { getNotifSettings } from "../../lib/notifications";
|
||||
import type { PrefsController } from "../../modules/settings/SettingsPanel";
|
||||
import { DEFAULT_THEME, type ThemePrefs } from "../../lib/theme";
|
||||
import { getNotifSettings, type NotifSettings } from "../../lib/notifications";
|
||||
import type { SaveState } from "../../components/ui/DraftSaveBar";
|
||||
|
||||
// The Settings-page preferences context + hook, split out of PrefsProvider so that file exports only
|
||||
// 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>({
|
||||
theme: DEFAULT_THEME,
|
||||
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",
|
||||
"reactPlugin.ts",
|
||||
"devOnlyGuard.ts",
|
||||
"chunkGuard.ts",
|
||||
"src/lib/chunkLeaks.ts",
|
||||
"playwright.config.ts",
|
||||
"postcss.config.js",
|
||||
],
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
"local/glyph-link",
|
||||
"local/input-primitive",
|
||||
"local/jsx-comment-text",
|
||||
"local/layers",
|
||||
"local/literal-a11y-text",
|
||||
"local/locale-format",
|
||||
"local/sortable-a11y",
|
||||
@@ -117,6 +118,7 @@
|
||||
"local/glyph-link",
|
||||
"local/input-primitive",
|
||||
"local/jsx-comment-text",
|
||||
"local/layers",
|
||||
"local/literal-a11y-text",
|
||||
"local/locale-format",
|
||||
"local/sortable-a11y",
|
||||
@@ -642,6 +644,7 @@
|
||||
"local/glyph-link": [2],
|
||||
"local/input-primitive": [2],
|
||||
"local/jsx-comment-text": [2],
|
||||
"local/layers": [2],
|
||||
"local/literal-a11y-text": [2],
|
||||
"local/locale-format": [2],
|
||||
"local/sortable-a11y": [2],
|
||||
@@ -1055,6 +1058,7 @@
|
||||
"local/glyph-link",
|
||||
"local/input-primitive",
|
||||
"local/jsx-comment-text",
|
||||
"local/layers",
|
||||
"local/literal-a11y-text",
|
||||
"local/locale-format",
|
||||
"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
|
||||
// probe still runs the real config, typed rules included, rather than a copy with them switched off.
|
||||
const PROBE_FILES = [
|
||||
"src/__*probe.tsx",
|
||||
"src/*/__*probe.ts",
|
||||
"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/lib/api/__*probe.ts",
|
||||
"src/components/Button.tsx",
|
||||
|
||||
@@ -7,14 +7,11 @@ import ErrorBoundary from "./shell/ErrorBoundary";
|
||||
import { ConfirmProvider } from "./components/ui/ConfirmProvider";
|
||||
import BootGate, { BootScreen } from "./shell/BootGate";
|
||||
import { NavigationProvider } from "./features/navigation/NavigationProvider";
|
||||
import { FeedFiltersProvider } from "./modules/feed/FeedFiltersProvider";
|
||||
import { SavedViewsProvider } from "./modules/feed/SavedViewsProvider";
|
||||
import { PlaylistsProvider } from "./modules/playlists/PlaylistsProvider";
|
||||
import { FilesProvider } from "./modules/files/FilesProvider";
|
||||
import { PlexProvider } from "./modules/plex/PlexProvider";
|
||||
import { PlexViewBridge } from "./modules/plex/PlexViewBridge";
|
||||
import { ChannelsProvider } from "./modules/channels/ChannelsProvider";
|
||||
import { FeedViewProvider } from "./modules/feed/FeedViewProvider";
|
||||
import { FeedFiltersProvider, FeedViewProvider, SavedViewsProvider } from "./modules/feed";
|
||||
import { PlaylistsProvider } from "./modules/playlists";
|
||||
import { FilesProvider } from "./modules/files";
|
||||
import { PlexProvider, PlexViewBridge } from "./modules/plex";
|
||||
import { ChannelsProvider } from "./modules/channels";
|
||||
import { PrefsProvider } from "./features/prefs/PrefsProvider";
|
||||
import { WizardProvider } from "./features/wizard/WizardProvider";
|
||||
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 { qk } from "../../lib/queryKeys";
|
||||
import { api } from "../../lib/api";
|
||||
import ChannelAboutContent from "./ChannelAbout";
|
||||
import ChannelAboutContent from "../../features/channel/ChannelAbout";
|
||||
import { PopoverSurface } from "../../components/ui/AnchoredPopover";
|
||||
import { useAnchoredPopover } from "../../components/ui/anchoredPopoverHooks";
|
||||
import { SURFACE } from "../../components/ui/surfaces";
|
||||
|
||||
@@ -33,14 +33,14 @@ import { useChannelActions } from "./useChannelActions";
|
||||
import { useContextMenu } from "../../components/ui/useContextMenu";
|
||||
import { LoadingState, StateMessage } from "../../components/ui/QueryState";
|
||||
import { useChannelTagActions } from "../../lib/useChannelTagActions";
|
||||
import { useTagSuggestions } from "./useTagSuggestions";
|
||||
import { useTagSuggestions } from "../../features/channel/useTagSuggestions";
|
||||
import { useChannelPriority } from "../../lib/useChannelPriority";
|
||||
import { placeByPriority } from "../../lib/channelOrder";
|
||||
import { focusNavItem } from "../../lib/listNav";
|
||||
import ViewSwitcher, { type ViewOption } from "../../components/ui/ViewSwitcher";
|
||||
import ColumnSortControl from "../../components/ui/ColumnSortControl";
|
||||
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 ChannelStatsPopover from "./ChannelStatsPopover";
|
||||
import Pager from "../../components/ui/Pager";
|
||||
@@ -57,10 +57,10 @@ import { menuGroups } from "../../components/ui/menuGroups";
|
||||
import { choiceSubmenu, sortSubmenu } from "../../components/ui/pageMenuItems";
|
||||
import { optionsFromColumns } from "../../lib/sortOptions";
|
||||
import ChannelDiscovery from "./ChannelDiscovery";
|
||||
import TagManager from "./TagManager";
|
||||
import TagManager from "../../features/channel/TagManager";
|
||||
import { useChannelLayout, useChannels, useChannelsActions } from "./channelsContext";
|
||||
import { useNavigationActions } from "../../features/navigation/navigationContext";
|
||||
import { useFeedFilters, useFeedFiltersActions } from "../feed/feedFiltersContext";
|
||||
import { useFeedFilters, useFeedFiltersActions } from "../feed";
|
||||
import { useWizard } from "../../features/wizard/wizardContext";
|
||||
import { useMe } from "../../lib/useMe";
|
||||
import { useTags } from "../../lib/useTags";
|
||||
|
||||
@@ -7,11 +7,11 @@ import type { Column } from "../../components/ui/DataTable";
|
||||
import { PriorityStepper } from "../../components/ui/PriorityStepper";
|
||||
import ChannelLink from "./ChannelLink";
|
||||
import AboutCell from "./AboutCell";
|
||||
import { ChannelTagsControl, type SuggestedTag } from "./ChannelTags";
|
||||
import { ChannelTagsControl, type SuggestedTag } from "../../features/channel/ChannelTags";
|
||||
import { StoredCell } from "./channelCells";
|
||||
import { isFullySynced } from "./channelSync";
|
||||
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
|
||||
* 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 }),
|
||||
}));
|
||||
vi.mock("./Feed", () => ({ default: () => null }));
|
||||
vi.mock("../channels/ChannelAbout", () => ({ default: () => null }));
|
||||
vi.mock("../../features/channel/ChannelAbout", () => ({ default: () => null }));
|
||||
|
||||
import ChannelPage from "./ChannelPage";
|
||||
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 }),
|
||||
}));
|
||||
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.
|
||||
vi.mock("../../components/ui/PageShell", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("../../components/ui/PageShell")>()),
|
||||
|
||||
@@ -22,7 +22,7 @@ vi.mock("./feedFiltersContext", () => ({
|
||||
useFeedFilters: () => ({ includeNormal: true, includeShorts: false, includeLive: false }),
|
||||
}));
|
||||
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.
|
||||
vi.mock("../../components/ui/PageShell", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("../../components/ui/PageShell")>()),
|
||||
|
||||
@@ -21,7 +21,7 @@ vi.mock("./feedFiltersContext", () => ({
|
||||
useFeedFilters: () => ({ includeNormal: true, includeShorts: false, includeLive: false }),
|
||||
}));
|
||||
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) => ({
|
||||
...(await importOriginal<typeof import("../../components/ui/PageShell")>()),
|
||||
PageToolbar: ({ children }: { children?: ReactNode }) => <>{children}</>,
|
||||
|
||||
@@ -26,7 +26,7 @@ import Tabs from "../../components/ui/Tabs";
|
||||
import { useTabPanel } from "../../components/ui/tabPanel";
|
||||
import { useScrollFold } from "../../components/ui/scrollFold";
|
||||
import Feed from "./Feed";
|
||||
import ChannelAboutContent from "../channels/ChannelAbout";
|
||||
import ChannelAboutContent from "../../features/channel/ChannelAbout";
|
||||
import { PageToolbar } from "../../components/ui/PageShell";
|
||||
import { PageToolbarSlot } from "../../components/ui/pageToolbarSlot";
|
||||
import { useFeedFilters } from "./feedFiltersContext";
|
||||
@@ -46,7 +46,7 @@ import {
|
||||
} from "../../lib/api";
|
||||
import type { FeedView } from "../../lib/feedView";
|
||||
import { channelYouTubeUrl, formatDate, formatCompact } from "../../lib/format";
|
||||
import { ChannelTagsControl } from "../channels/ChannelTags";
|
||||
import { ChannelTagsControl } from "../../features/channel/ChannelTags";
|
||||
import { useChannelTagActions } from "../../lib/useChannelTagActions";
|
||||
import { Button } from "../../components/ui/Button";
|
||||
import { IconButton } from "../../components/ui/IconButton";
|
||||
|
||||
@@ -28,7 +28,7 @@ vi.mock("./Feed", () => ({
|
||||
return null;
|
||||
},
|
||||
}));
|
||||
vi.mock("../channels/ChannelAbout", () => ({ default: () => null }));
|
||||
vi.mock("../../features/channel/ChannelAbout", () => ({ default: () => null }));
|
||||
vi.mock("../../components/ui/PageShell", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("../../components/ui/PageShell")>()),
|
||||
PageToolbar: ({ children }: { children?: ReactNode }) => <>{children}</>,
|
||||
|
||||
@@ -45,7 +45,7 @@ vi.mock("../plex/plexContext", () => ({
|
||||
vi.mock("../../components/ui/confirmContext", () => ({ useConfirm: () => async () => true }));
|
||||
|
||||
import { SavedViewsProvider } from "./SavedViewsProvider";
|
||||
import { useSavedViews } from "./savedViewsContext";
|
||||
import { useSavedViews } from "../../features/filters/savedViewsContext";
|
||||
import { api, type SavedView } from "../../lib/api";
|
||||
import { claimLocalData, LS } from "../../lib/storage";
|
||||
import { qk } from "../../lib/queryKeys";
|
||||
|
||||
@@ -17,13 +17,13 @@ import { LS, ownKey } from "../../lib/storage";
|
||||
import { useConfirm } from "../../components/ui/confirmContext";
|
||||
import { useNavigation } from "../../features/navigation/navigationContext";
|
||||
import { useFeedFilters, useFeedFiltersActions } from "./feedFiltersContext";
|
||||
import { usePlex, usePlexActions } from "../plex/plexContext";
|
||||
import { usePlex, usePlexActions } from "../plex";
|
||||
import {
|
||||
asFilters,
|
||||
SavedViewsCtx,
|
||||
type SavedViewsContext,
|
||||
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
|
||||
// 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 { useDebounced } from "../../lib/useDebounced";
|
||||
import { useActiveFilters } from "../../lib/useActiveFilters";
|
||||
import TagManager from "../channels/TagManager";
|
||||
import TagManager from "../../features/channel/TagManager";
|
||||
import SavedViewsPopover from "../../features/filters/SavedViewsPopover";
|
||||
import { useSavedViews } from "./savedViewsContext";
|
||||
import { useSavedViews } from "../../features/filters/savedViewsContext";
|
||||
import FilterRail from "../../components/ui/FilterRail";
|
||||
import { type PanelItem } from "../../components/ui/PanelGroups";
|
||||
import { type MediaSourceKind } from "../../lib/mediaSource";
|
||||
@@ -20,7 +20,7 @@ import YearFilter from "./YearFilter";
|
||||
import Combobox from "../../components/ui/Combobox";
|
||||
import MatchToggle from "../../components/ui/MatchToggle";
|
||||
import { useFeedFilters, useFeedFiltersActions } from "./feedFiltersContext";
|
||||
import { useChannelsActions } from "../channels/channelsContext";
|
||||
import { useChannelsActions } from "../channels";
|
||||
import { useTags } from "../../lib/useTags";
|
||||
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 { useNavigation, useNavigationActions } from "../../features/navigation/navigationContext";
|
||||
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
|
||||
// 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")>()),
|
||||
useTranslation: () => ({ i18n: { language: "en" }, t: (key: string) => key }),
|
||||
}));
|
||||
vi.mock("./DownloadDialog", () => ({
|
||||
vi.mock("../../features/downloads/DownloadDialog", () => ({
|
||||
default: ({ source, onClose }: { source: string; onClose: () => void }) => (
|
||||
<div role="dialog" aria-label="download">
|
||||
{source}
|
||||
@@ -20,8 +20,8 @@ vi.mock("./DownloadDialog", () => ({
|
||||
),
|
||||
}));
|
||||
|
||||
import DownloadIntake from "./DownloadIntake";
|
||||
import { LinkBox } from "../../modules/files/LinkBox";
|
||||
import DownloadIntake from "../../features/downloads/DownloadIntake";
|
||||
import { LinkBox } from "./LinkBox";
|
||||
import { clearPendingDownload, requestLinkBox } from "../../lib/downloadIntake";
|
||||
import { ensureNavId } from "../../lib/history";
|
||||
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 { usePlexCardActions } from "../../features/plexCards/usePlexCardActions";
|
||||
import { PlexHoverBar, PlexPosterCard } from "../../features/plexCards/plexCards";
|
||||
import { usePlaylistsActions } from "../playlists/playlistsContext";
|
||||
import { usePlaylistsActions } from "../playlists";
|
||||
import { useNavigationActions } from "../../features/navigation/navigationContext";
|
||||
import { usePlexActions } from "../plex/plexContext";
|
||||
import { usePlexActions } from "../plex";
|
||||
import { navItemAttrs } from "../../lib/listNav";
|
||||
import { Pressable } from "../../components/ui/Pressable";
|
||||
import { SURFACE } from "../../components/ui/surfaces";
|
||||
|
||||
@@ -47,9 +47,9 @@ import { PageToolbar } from "../../components/ui/PageShell";
|
||||
import { PageMenu } from "../../components/ui/PageMenu";
|
||||
import { LoadingState, StateMessage } from "../../components/ui/QueryState";
|
||||
import { useNavigationActions } from "../../features/navigation/navigationContext";
|
||||
import { useFilesActions } from "../files/filesContext";
|
||||
import { useChannelsActions } from "../channels/channelsContext";
|
||||
import { useFeedFilters, useFeedFiltersActions } from "../feed/feedFiltersContext";
|
||||
import { useFilesActions } from "../files";
|
||||
import { useChannelsActions } from "../channels";
|
||||
import { useFeedFilters, useFeedFiltersActions } from "../feed";
|
||||
import { SURFACE } from "../../components/ui/surfaces";
|
||||
import { SectionLabel } from "../../components/ui/Headings";
|
||||
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 SavedViewsPopover from "../../features/filters/SavedViewsPopover";
|
||||
import PersonalViewsModal from "../../features/filters/PersonalViewsModal";
|
||||
import { useSavedViews } from "../feed/savedViewsContext";
|
||||
import { useSavedViews } from "../../features/filters/savedViewsContext";
|
||||
import { usePlex, usePlexActions } from "./plexContext";
|
||||
import { DismissibleChip } from "../../components/ui/Chip";
|
||||
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 { type ActionMenuItem } from "../../components/ui/ActionMenu";
|
||||
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 { urlWithParams } from "../../lib/urlState";
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ import { compareText } from "../../lib/collate";
|
||||
import { formatDate } from "../../lib/format";
|
||||
import { navItemAttrs } from "../../lib/listNav";
|
||||
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 { Badge } from "../../components/ui/Badge";
|
||||
import { Button } from "../../components/ui/Button";
|
||||
|
||||
@@ -20,7 +20,7 @@ import { DownloadFormats } from "./ProfileEditor";
|
||||
import DiagnosticsSettings from "./DiagnosticsSettings";
|
||||
import ApiTokens from "./ApiTokens";
|
||||
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 { displayName, roleKey } from "../../lib/api/account";
|
||||
import { notifyActionError } from "../../lib/actionErrors";
|
||||
@@ -40,7 +40,7 @@ import {
|
||||
START_ON_HOME_PREF,
|
||||
} from "../../features/navigation/navigationInit";
|
||||
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 { useTabPanel } from "../../components/ui/tabPanel";
|
||||
import { useMe } from "../../lib/useMe";
|
||||
@@ -49,25 +49,6 @@ import { Slider } from "../../components/ui/NativeControls";
|
||||
import { Input, Textarea } from "../../components/ui/Input";
|
||||
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;
|
||||
const TABS: { id: TabId; icon: typeof 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
|
||||
// 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();
|
||||
if (state === "idle") return null;
|
||||
return (
|
||||
|
||||
@@ -21,8 +21,12 @@ import { LS } from "../lib/storage";
|
||||
import { usePref } from "../lib/usePref";
|
||||
import { useVerifyEntry } from "../lib/useVerifyEntry";
|
||||
import { useNavigation, useNavigationActions } from "../features/navigation/navigationContext";
|
||||
import { useFeedFilters, useFeedFiltersActions } from "../modules/feed/feedFiltersContext";
|
||||
import { useFeedView, useFeedViewActions } from "../modules/feed/feedViewContext";
|
||||
import {
|
||||
useFeedFilters,
|
||||
useFeedFiltersActions,
|
||||
useFeedView,
|
||||
useFeedViewActions,
|
||||
} from "../modules/feed";
|
||||
import { moduleOrder } from "../lib/modules";
|
||||
import { PAGE_CONTENT, PAGE_RAIL, preloadPages } from "./moduleRegistry";
|
||||
import { lazyWithPreload } from "../lib/lazyWithPreload";
|
||||
|
||||
@@ -57,7 +57,7 @@ vi.mock("../modules/playlists/playlistsContext", () => ({
|
||||
vi.mock("../features/prefs/prefsContext", () => ({
|
||||
usePrefs: () => ({ theme: { scheme: "default" }, setTheme: vi.fn() }),
|
||||
}));
|
||||
vi.mock("../modules/feed/savedViewsContext", () => ({
|
||||
vi.mock("../features/filters/savedViewsContext", () => ({
|
||||
useSavedViews: () => ({ views: [], apply: vi.fn() }),
|
||||
}));
|
||||
const requestSurprise = vi.fn();
|
||||
|
||||
@@ -24,9 +24,9 @@ import { requestLinkBox } from "../lib/downloadIntake";
|
||||
import { openRecorderStart } from "../lib/debugRecorder";
|
||||
import { useDebugRecording } from "../features/diagnostics/useDebugRecording";
|
||||
import { useNavigationActions } from "../features/navigation/navigationContext";
|
||||
import { usePlaylistsActions } from "../modules/playlists/playlistsContext";
|
||||
import { usePlaylistsActions } from "../modules/playlists";
|
||||
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
|
||||
// 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";
|
||||
vi.mock("../navigation/navigationContext", () => ({ useNavigation: () => ({ page }) }));
|
||||
vi.mock("../../modules/feed/feedFiltersContext", () => ({
|
||||
vi.mock("../features/navigation/navigationContext", () => ({ useNavigation: () => ({ page }) }));
|
||||
vi.mock("../modules/feed/feedFiltersContext", () => ({
|
||||
useFeedFilters: () => ({ q: "", sort: "newest" }),
|
||||
useFeedFiltersActions: () => ({ setFilters: vi.fn() }),
|
||||
}));
|
||||
vi.mock("../../modules/playlists/playlistsContext", () => ({
|
||||
vi.mock("../modules/playlists/playlistsContext", () => ({
|
||||
usePlaylists: () => ({ search: "" }),
|
||||
usePlaylistsActions: () => ({ setSearch: vi.fn() }),
|
||||
}));
|
||||
vi.mock("../../modules/channels/channelsContext", () => ({
|
||||
vi.mock("../modules/channels/channelsContext", () => ({
|
||||
useChannels: () => ({ search: "" }),
|
||||
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 { closePalette, usePaletteOpen } from "../../lib/keymap";
|
||||
import type { Me } from "../../lib/api";
|
||||
import Header from "./Header";
|
||||
import { closePalette, usePaletteOpen } from "../lib/keymap";
|
||||
import type { Me } from "../lib/api";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
@@ -3,16 +3,21 @@ import { useTranslation } from "react-i18next";
|
||||
import { YouTubeIcon } from "../lib/brandIcons";
|
||||
import type { Me } from "../lib/api";
|
||||
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";
|
||||
// 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";
|
||||
// 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";
|
||||
// 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";
|
||||
// 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 { useNavigation } from "../features/navigation/navigationContext";
|
||||
import { useFeedFilters, useFeedFiltersActions } from "../modules/feed/feedFiltersContext";
|
||||
import { usePlaylists, usePlaylistsActions } from "../modules/playlists/playlistsContext";
|
||||
import { useChannels, useChannelsActions } from "../modules/channels/channelsContext";
|
||||
import { useFeedFilters, useFeedFiltersActions } from "../modules/feed";
|
||||
import { usePlaylists, usePlaylistsActions } from "../modules/playlists";
|
||||
import { useChannels, useChannelsActions } from "../modules/channels";
|
||||
import { PaletteLauncher } from "../features/header/PaletteLauncher";
|
||||
|
||||
// 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 type { Page } from "../lib/urlState";
|
||||
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";
|
||||
// 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";
|
||||
// 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";
|
||||
|
||||
// The RENDER half of the module registry: which component (and side rail) each page renders. The
|
||||
|
||||
@@ -43,6 +43,8 @@
|
||||
"vitest.config.ts",
|
||||
"reactPlugin.ts",
|
||||
"devOnlyGuard.ts",
|
||||
"chunkGuard.ts",
|
||||
"src/lib/chunkLeaks.ts",
|
||||
"playwright.config.ts",
|
||||
"postcss.config.js"
|
||||
]
|
||||
|
||||
@@ -2,6 +2,7 @@ import { ServerResponse } from "node:http";
|
||||
import { defineConfig, type Plugin, type ProxyOptions } from "vite";
|
||||
import { reactWithCompiler } from "./reactPlugin";
|
||||
import { devOnlyGuard } from "./devOnlyGuard";
|
||||
import { chunkGuard } from "./chunkGuard";
|
||||
|
||||
// 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
|
||||
@@ -54,7 +55,7 @@ const watchEntry = (): Plugin => ({
|
||||
});
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [reactWithCompiler(), devOnlyGuard(), watchEntry()],
|
||||
plugins: [reactWithCompiler(), devOnlyGuard(), chunkGuard(), watchEntry()],
|
||||
server: { port: 5173, proxy },
|
||||
build: { outDir: "dist", rollupOptions: { input: ["index.html", "watch.html"] } },
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user