fix(frontend): the layer gates cover what the move left outside them, and each page keeps its own logic
Gates the folder move had narrowed or left open: the MenuCheck contract scans the whole app again (it saw only components/); local/layers refuses an import of an entry (main.tsx, the /watch page) from any layer, and reads a backtick import() as the path it is; the /watch contract forbids the shell, the modules and the features by folder (its one path to App had gone stale); chunkGuard's own test gets the vitest focus/skip rules. The root tests' exemption from local/layers is in the gate register, with its three imports. Placement: Plex's chip builder moves into lib beside the feed's, the folder names into features/folders, and three files only one page uses into that page. ui/FilterRail takes its reset from useRail instead of a page id. The channel page reads the feed's view and builds "Show in feed" itself, and the feed's scroll key is the feed's hook, so the shell holds no feed logic. The floating header re-measures when one page's pills replace another's in the same commit (the claim count stayed equal, so only the observer caught it). A Plex view applied and edited now stays across a visit to another page, as the feed's does: pinned by a test. docs/development.md says what a page with a rail adds in lib.
This commit is contained in:
@@ -443,8 +443,11 @@ function placeOf(abs) {
|
||||
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 };
|
||||
// main.tsx, watch/, i18n/, content/, design-lab/, the app-wide tests at the root. The two entries —
|
||||
// the app's main.tsx and the shared /watch page (watch/ + watchMain.tsx) — sit above every layer.
|
||||
const root = parts.length === 1;
|
||||
const entry = top === "watch" || (root && (top === "main" || top === "watchMain"));
|
||||
return { layer: "other", top: root ? "root" : top, entry };
|
||||
}
|
||||
const layers = {
|
||||
meta: {
|
||||
@@ -452,6 +455,8 @@ const layers = {
|
||||
messages: {
|
||||
up: "{{from}} sits below {{to}}: it may not import from it (`{{spec}}`). Move the shared piece down, or take it as a prop.",
|
||||
face: "`{{spec}}` is one of {{unit}}'s own files: reach another unit through its index.ts (its public face), or lazily with import().",
|
||||
entry:
|
||||
"`{{spec}}` is an entry (the app's main.tsx or the shared /watch page): no layer imports one, or the app and the /watch page stop being separable.",
|
||||
},
|
||||
},
|
||||
create(context) {
|
||||
@@ -465,7 +470,12 @@ const layers = {
|
||||
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;
|
||||
if (!to) return;
|
||||
if (to.layer === "other") {
|
||||
if (to.entry && from.layer !== "other")
|
||||
context.report({ node, messageId: "entry", data: { spec } });
|
||||
return;
|
||||
}
|
||||
const fromRank = from.layer === "other" ? 5 : RANK[from.layer];
|
||||
if (
|
||||
to.layer === "shell" &&
|
||||
@@ -492,7 +502,13 @@ const layers = {
|
||||
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),
|
||||
ImportExpression: (n) => {
|
||||
// A template literal with nothing substituted is a plain path to TypeScript and Rollup too.
|
||||
const s = n.source;
|
||||
if (s.type === "Literal") check(s, s.value, true);
|
||||
else if (s.type === "TemplateLiteral" && s.expressions.length === 0)
|
||||
check(s, s.quasis[0].value.cooked, true);
|
||||
},
|
||||
TSImportType: (n) => {
|
||||
const lit = n.argument?.literal ?? n.argument;
|
||||
if (lit?.type === "Literal") check(lit, lit.value, false);
|
||||
@@ -867,6 +883,16 @@ export default tseslint.config(
|
||||
"no-undef": "off",
|
||||
},
|
||||
},
|
||||
{
|
||||
// The unit tests of the build tools beside them (chunkGuard's): vitest runs them, so a stray
|
||||
// `.only` / `.skip` there is the same silent skip as in src.
|
||||
files: ["*.test.ts"],
|
||||
plugins: { vitest },
|
||||
rules: {
|
||||
"vitest/no-focused-tests": "error",
|
||||
"vitest/no-disabled-tests": "error",
|
||||
},
|
||||
},
|
||||
{
|
||||
files: ["e2e/**/*.ts"],
|
||||
plugins: { playwright },
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useState, type ReactNode } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { defaultLayout, type PanelId, type PanelLayout } from "../../lib/panelLayout";
|
||||
import { type PanelLayout } from "../../lib/panelLayout";
|
||||
import SidePanel from "./SidePanel";
|
||||
import PanelGroups, { type PanelItem } from "./PanelGroups";
|
||||
|
||||
@@ -9,10 +9,10 @@ import PanelGroups, { type PanelItem } from "./PanelGroups";
|
||||
// the customize (reorder/hide) `editing` state and the fixed title-bar wiring (edit toggle, reset to
|
||||
// the layout default, edit hint); each rail supplies only what actually differs — its identity
|
||||
// (title/icon), the active-filter count + Clear-all action, its `items`, and any content below the
|
||||
// groups (`children`, e.g. the feed's tag manager). `kind` is the PanelId (also the layout key), so
|
||||
// `children` defaults to nothing for the rails that need none.
|
||||
// groups (`children`, e.g. the feed's tag manager; nothing for the rails that need none). What Reset
|
||||
// goes back to is the rail's (`onResetLayout`, from lib/useRail), so this shell names no page.
|
||||
export default function FilterRail({
|
||||
kind,
|
||||
onResetLayout,
|
||||
title,
|
||||
icon,
|
||||
count,
|
||||
@@ -28,7 +28,8 @@ export default function FilterRail({
|
||||
titleActionLabel,
|
||||
children,
|
||||
}: {
|
||||
kind: PanelId;
|
||||
/** Puts the groups back in the rail's default order (lib/useRail's `onResetLayout`). */
|
||||
onResetLayout: () => void;
|
||||
title: string;
|
||||
icon: ReactNode;
|
||||
count?: number;
|
||||
@@ -56,7 +57,7 @@ export default function FilterRail({
|
||||
onToggleCollapse={onToggleCollapse}
|
||||
editing={editing}
|
||||
onToggleEditing={() => setEditing((e) => !e)}
|
||||
onReset={() => setLayout(defaultLayout(kind))}
|
||||
onReset={onResetLayout}
|
||||
editHint={t("sidebar.editHint")}
|
||||
actions={actions}
|
||||
iconExpanded={iconExpanded}
|
||||
|
||||
@@ -121,6 +121,46 @@ describe("the header slot", () => {
|
||||
expect(pad()).toBe("84px"); // the page's two pills, the default gone
|
||||
});
|
||||
|
||||
it("re-measures when one page's pills replace another's in the same commit", () => {
|
||||
// Feed → Playlists: the leaving page's claim and the arriving page's land in one commit, so the
|
||||
// number of claims never changes — the pad must follow the new pills all the same.
|
||||
const height = (el: HTMLElement) => 40 * el.childElementCount;
|
||||
const Feed = () => (
|
||||
<PageHeader>
|
||||
<span>search</span>
|
||||
<span>scope</span>
|
||||
</PageHeader>
|
||||
);
|
||||
const Playlists = () => (
|
||||
<PageHeader>
|
||||
<span>search</span>
|
||||
</PageHeader>
|
||||
);
|
||||
const page = (which: "feed" | "playlists") => (
|
||||
<PageShell header={hdr}>
|
||||
{which === "feed" ? <Feed /> : <Playlists />}
|
||||
{fallback}
|
||||
</PageShell>
|
||||
);
|
||||
const { rerender, container } = render(<PageShell header={hdr}>{null}</PageShell>);
|
||||
Object.defineProperty(screen.getByTestId("hdr"), "offsetHeight", {
|
||||
get() {
|
||||
return height(this as HTMLElement);
|
||||
},
|
||||
configurable: true,
|
||||
});
|
||||
const pad = () =>
|
||||
(container.querySelector('[class*="pt-(--hdr-h)"]') as HTMLElement).style.getPropertyValue(
|
||||
"--hdr-h",
|
||||
);
|
||||
rerender(page("feed"));
|
||||
expect(pad()).toBe("84px");
|
||||
rerender(page("playlists"));
|
||||
expect(pad()).toBe("44px");
|
||||
rerender(page("feed"));
|
||||
expect(pad()).toBe("84px");
|
||||
});
|
||||
|
||||
it("renders a page's pills nowhere in a shell without a header", () => {
|
||||
render(
|
||||
<PageShell>
|
||||
|
||||
@@ -153,17 +153,26 @@ export default function PageShell({
|
||||
// How many PageHeaders fill the header right now: a count, not a flag, so of two on one page the one
|
||||
// that leaves releases only its own claim.
|
||||
const [headerClaims, setHeaderClaims] = useState(0);
|
||||
// Bumped by every claim and every release. The count alone cannot say the pills changed: when one
|
||||
// page's PageHeader replaces another's in one commit (feed → playlists), the release and the claim
|
||||
// net to the same count, and the measurement below would not re-run.
|
||||
const [headerGeneration, setHeaderGeneration] = useState(0);
|
||||
const claimHeader = useCallback(() => {
|
||||
setHeaderClaims((n) => n + 1);
|
||||
return () => setHeaderClaims((n) => n - 1);
|
||||
setHeaderGeneration((g) => g + 1);
|
||||
return () => {
|
||||
setHeaderClaims((n) => n - 1);
|
||||
setHeaderGeneration((g) => g + 1);
|
||||
};
|
||||
}, []);
|
||||
// Whether the shell's default is in the header (PageHeaderDefault reports it).
|
||||
const [defaultShown, setDefaultShown] = useState(false);
|
||||
// The pills arrive by portal AFTER this shell mounted (with the page's chunk), so the first measurement
|
||||
// saw an empty header; the ResizeObserver catches up only a frame or more later, and until then the
|
||||
// content sat under the pills (the feed toolbar jumped 55px on a tablet). So the measurement also re-runs
|
||||
// here, before paint, whenever what fills the header changes; the observer still covers a change within
|
||||
// a page's own pills (Plex's view switch appearing).
|
||||
// here, before paint, whenever what fills the header changes (a claim made or released, the default
|
||||
// shown or gone); the observer still covers a change within a page's own pills (Plex's view switch
|
||||
// appearing).
|
||||
useLayoutEffect(() => {
|
||||
if (!headerEl || !contentEl) {
|
||||
// No header (channel page) → drop any stale pad so a leftover value can't linger.
|
||||
@@ -177,7 +186,7 @@ export default function PageShell({
|
||||
const ro = new ResizeObserver(apply);
|
||||
ro.observe(headerEl);
|
||||
return () => ro.disconnect();
|
||||
}, [headerEl, contentEl, headerClaims, defaultShown]);
|
||||
}, [headerEl, contentEl, headerGeneration, defaultShown]);
|
||||
const headerSlot = {
|
||||
target: headerEl,
|
||||
claimed: headerClaims > 0,
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { readdirSync, readFileSync } from "node:fs";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
const root = new URL("../", import.meta.url);
|
||||
// The whole app source (shell, modules, features, ui): the checkable menus live in all of them.
|
||||
const root = new URL("../../", import.meta.url);
|
||||
const sources = () =>
|
||||
readdirSync(root, { recursive: true, encoding: "utf8" })
|
||||
.filter((f) => f.endsWith(".tsx") && !f.includes(".test."))
|
||||
|
||||
@@ -53,7 +53,7 @@ const PLAIN: Record<string, [number, string]> = {
|
||||
"modules/channels/SyncStatus.tsx": [1, "the status pill trigger"],
|
||||
"features/channel/TagManager.tsx": [1, "a suggestion in a picker"],
|
||||
"modules/files/UploadDialog.tsx": [1, "the drop zone"],
|
||||
"features/player/VideoEditor.tsx": [2, "timeline glyphs"],
|
||||
"modules/files/VideoEditor.tsx": [2, "timeline glyphs"],
|
||||
"shell/Welcome.tsx": [1, "a screenshot (zoom)"],
|
||||
"modules/playlists/playlistColumns.tsx": [1, "the thumbnail"],
|
||||
"modules/plex/plexDetailUi.tsx": [2, "a switch row and the poster"],
|
||||
|
||||
@@ -37,7 +37,8 @@ import { useLiveQuery } from "../../lib/useLiveQuery";
|
||||
import { usePref } from "../../lib/usePref";
|
||||
import { ErrorModal } from "./downloadModals";
|
||||
import { jobTitle } from "./downloadTitle";
|
||||
import { sourceLabel, useFilesActions } from "../../modules/files";
|
||||
import { useFilesActions } from "../../modules/files";
|
||||
import { sourceLabel } from "../folders/fileFolders";
|
||||
import { useDownloadActions } from "./useDownloadActions";
|
||||
import { type ActionMenuItem } from "../../components/ui/ActionMenu";
|
||||
import { setCornerLift } from "../../components/ui/backToTopState";
|
||||
|
||||
@@ -28,7 +28,7 @@ import { Button } from "../../components/ui/Button";
|
||||
import { IconButton } from "../../components/ui/IconButton";
|
||||
import { useSavedViews, asFilters } from "./savedViewsContext";
|
||||
import { useActiveFilters } from "../../lib/useActiveFilters";
|
||||
import { buildPlexActiveFilters } from "../../modules/plex";
|
||||
import { buildPlexActiveFilters } from "../../lib/plexActiveFilters";
|
||||
import { useUndoable } from "../../lib/useUndoable";
|
||||
import UndoToolbar from "../../components/ui/UndoToolbar";
|
||||
import { useSortableA11y, useSortableItem } from "../../components/ui/sortableA11y";
|
||||
|
||||
@@ -13,7 +13,7 @@ import { render } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { ModuleStores } from "./ModuleStores";
|
||||
import { REGISTERED_PAGES, moduleDef } from "./moduleRegistry";
|
||||
import { useFeedFilters, useFeedSavedViews, useFeedView } from "../../modules/feed";
|
||||
import { useFeedFilters, useFeedSavedViews } from "../../modules/feed";
|
||||
import { usePlex, usePlexSavedViews } from "../../modules/plex";
|
||||
import { useChannelsActions } from "../../modules/channels";
|
||||
import { useFilesActions } from "../../modules/files";
|
||||
@@ -29,7 +29,6 @@ const SRC = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
// Each store's read side, as the app's readers reach it (the module faces).
|
||||
function Readers() {
|
||||
useFeedFilters();
|
||||
useFeedView();
|
||||
useFeedSavedViews();
|
||||
usePlex();
|
||||
usePlexSavedViews();
|
||||
|
||||
@@ -133,7 +133,12 @@ type Snapshot = {
|
||||
|
||||
/** The register scope a code file belongs to, by its path (relative to frontend/). */
|
||||
function scopeOf(file: string): string {
|
||||
if (!file.includes("/")) return /[.][mc]?js$/.test(file) ? "js configs" : "ts configs";
|
||||
if (!file.includes("/"))
|
||||
return /[.][mc]?js$/.test(file)
|
||||
? "js configs"
|
||||
: /[.]test[.]ts$/.test(file)
|
||||
? "tool tests"
|
||||
: "ts configs";
|
||||
if (file.startsWith("e2e/")) return "e2e";
|
||||
if (file.startsWith("src/design-lab/")) return "design-lab";
|
||||
// the network owners (NETWORK_OWNERS in eslint.config.js)
|
||||
|
||||
@@ -1044,6 +1044,67 @@
|
||||
},
|
||||
"settings": null
|
||||
},
|
||||
"tool tests": {
|
||||
"languageOptions": {
|
||||
"ecmaVersion": 2022,
|
||||
"globals": "82 names, sha1 29467d490e30",
|
||||
"parser": "typescript-eslint/parser",
|
||||
"parserOptions": { "projectService": true, "tsconfigRootDir": "." },
|
||||
"sourceType": "module"
|
||||
},
|
||||
"linterOptions": { "reportUnusedDisableDirectives": 2 },
|
||||
"notApplied": [
|
||||
"local/button-primitive",
|
||||
"local/glyph-link",
|
||||
"local/input-primitive",
|
||||
"local/jsx-comment-text",
|
||||
"local/layers",
|
||||
"local/literal-a11y-text",
|
||||
"local/locale-format",
|
||||
"local/sortable-a11y",
|
||||
"local/surface-role",
|
||||
"no-console",
|
||||
"no-restricted-globals",
|
||||
"no-restricted-properties",
|
||||
"no-restricted-syntax",
|
||||
"react-hooks/capitalized-calls",
|
||||
"react-hooks/component-hook-factories",
|
||||
"react-hooks/config",
|
||||
"react-hooks/error-boundaries",
|
||||
"react-hooks/exhaustive-deps",
|
||||
"react-hooks/gating",
|
||||
"react-hooks/globals",
|
||||
"react-hooks/hooks",
|
||||
"react-hooks/immutability",
|
||||
"react-hooks/incompatible-library",
|
||||
"react-hooks/invariant",
|
||||
"react-hooks/memo-dependencies",
|
||||
"react-hooks/no-deriving-state-in-effects",
|
||||
"react-hooks/preserve-manual-memoization",
|
||||
"react-hooks/purity",
|
||||
"react-hooks/refs",
|
||||
"react-hooks/rules-of-hooks",
|
||||
"react-hooks/set-state-in-effect",
|
||||
"react-hooks/set-state-in-render",
|
||||
"react-hooks/static-components",
|
||||
"react-hooks/syntax",
|
||||
"react-hooks/todo",
|
||||
"react-hooks/unsupported-syntax",
|
||||
"react-hooks/use-memo",
|
||||
"react-hooks/void-use-memo",
|
||||
"react-refresh/only-export-components"
|
||||
],
|
||||
"plugins": ["@", "@eslint-community/eslint-comments", "@typescript-eslint", "vitest"],
|
||||
"rules": {
|
||||
"@typescript-eslint/no-unused-expressions": [
|
||||
2,
|
||||
{ "allowShortCircuit": false, "allowTaggedTemplates": false, "allowTernary": false }
|
||||
],
|
||||
"vitest/no-disabled-tests": [2],
|
||||
"vitest/no-focused-tests": [2, { "fixable": true }]
|
||||
},
|
||||
"settings": null
|
||||
},
|
||||
"ts configs": {
|
||||
"languageOptions": {
|
||||
"ecmaVersion": 2022,
|
||||
|
||||
@@ -76,6 +76,35 @@ describe("the platform and lib layers", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("the entries", () => {
|
||||
// main.tsx and the /watch page (watch/, its own entry) sit above every layer: no layer imports
|
||||
// them, or the app and the shared page would stop being separable.
|
||||
it("are imported by no layer", async () => {
|
||||
const lib =
|
||||
imp("../watch/WatchPage") + 'import type { T } from "../main";\nexport type U = T;\n';
|
||||
expect(await refused(lib, "src/lib/__layers_probe.ts")).toHaveLength(2);
|
||||
expect(
|
||||
await refused(imp("../../watch/WatchPage"), "src/components/ui/__layers_probe.tsx"),
|
||||
).toHaveLength(1);
|
||||
expect(await refused(imp("../../watch/watchStrings"), IN_FEATURE)).toHaveLength(1);
|
||||
expect(await refused(imp("../../watch/WatchPage"), IN_FEED)).toHaveLength(1);
|
||||
expect(await refused(imp("../watch/WatchPage"), "src/shell/__layers_probe.tsx")).toHaveLength(
|
||||
1,
|
||||
);
|
||||
});
|
||||
|
||||
it("while the /watch entry itself imports its page", async () => {
|
||||
expect(await refused(imp("./watch/WatchPage"), "src/__layers_probe.tsx")).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("an import()", () => {
|
||||
it("is the same path in backticks", async () => {
|
||||
const src = "export const L = () => import(`../modules/feed/Feed`);\n";
|
||||
expect(await refused(src, "src/lib/__layers_probe.ts")).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("the shell and the entry", () => {
|
||||
it("reach a module through its face, or lazily", async () => {
|
||||
const src =
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
import { vocabI18nKey } from "./vocab";
|
||||
import { type ActiveFilter } from "./useActiveFilters";
|
||||
import { type PlexFilters } from "./api";
|
||||
|
||||
type TFn = (key: string, options?: Record<string, unknown>) => string;
|
||||
|
||||
// The pure builder behind Plex's chip row (modules/plex usePlexActiveFilters): turns a Plex filter
|
||||
// set + watch-state into the source-agnostic ActiveFilter[]. Apart from the hook so the saved-views
|
||||
// manager (features/filters) can DESCRIBE an arbitrary view's captured filters (with no-op removes)
|
||||
// without reaching into the Plex module — the labels stay in one place, no duplication with the live
|
||||
// chip row. Store-free, like its feed twin useActiveFilters.
|
||||
export function buildPlexActiveFilters(
|
||||
filters: PlexFilters,
|
||||
show: string,
|
||||
patch: (p: Partial<PlexFilters>) => void,
|
||||
setShow: (s: string) => void,
|
||||
t: TFn,
|
||||
/** Collection titles for the chip labels, resolved beyond what the filter state carries. Omitted by
|
||||
* the saved-views manager, which describes a stored view and has only the captured titles. */
|
||||
collectionTitles?: Record<string, string>,
|
||||
/** The missing titles are still loading: their chips show a placeholder, not the generic word. */
|
||||
collectionsPending = false,
|
||||
): ActiveFilter[] {
|
||||
const out: ActiveFilter[] = [];
|
||||
|
||||
const has = (x: number | null | undefined) => x != null;
|
||||
|
||||
for (const g of filters.genres) {
|
||||
out.push({
|
||||
key: `genre:${g}`,
|
||||
label: g,
|
||||
remove: () => patch({ genres: filters.genres.filter((x) => x !== g) }),
|
||||
});
|
||||
}
|
||||
for (const r of filters.contentRatings) {
|
||||
out.push({
|
||||
key: `contentRating:${r}`,
|
||||
label: vocabI18nKey("certificate", r)
|
||||
? t(vocabI18nKey("certificate", r), { defaultValue: r })
|
||||
: r,
|
||||
remove: () => patch({ contentRatings: filters.contentRatings.filter((x) => x !== r) }),
|
||||
});
|
||||
}
|
||||
if (has(filters.yearMin) || has(filters.yearMax)) {
|
||||
const range =
|
||||
has(filters.yearMin) && has(filters.yearMax)
|
||||
? `${filters.yearMin}–${filters.yearMax}`
|
||||
: has(filters.yearMin)
|
||||
? `≥${filters.yearMin}`
|
||||
: `≤${filters.yearMax}`;
|
||||
out.push({
|
||||
key: "year",
|
||||
label: `${t("plex.filter.year")}: ${range}`,
|
||||
remove: () => patch({ yearMin: null, yearMax: null }),
|
||||
});
|
||||
}
|
||||
if (has(filters.ratingMin)) {
|
||||
out.push({
|
||||
key: "ratingMin",
|
||||
label: `${t("plex.filter.rating")}: ≥${filters.ratingMin}`,
|
||||
remove: () => patch({ ratingMin: null }),
|
||||
});
|
||||
}
|
||||
if (has(filters.durationMin) || has(filters.durationMax)) {
|
||||
const lo = has(filters.durationMin) ? Math.round(filters.durationMin! / 60) : null;
|
||||
const hi = has(filters.durationMax) ? Math.round(filters.durationMax! / 60) : null;
|
||||
const u = t("plex.filter.durationUnit");
|
||||
const range =
|
||||
lo != null && hi != null ? `${lo}–${hi} ${u}` : lo != null ? `≥${lo} ${u}` : `≤${hi} ${u}`;
|
||||
out.push({
|
||||
key: "duration",
|
||||
label: `${t("plex.filter.duration")}: ${range}`,
|
||||
remove: () => patch({ durationMin: null, durationMax: null }),
|
||||
});
|
||||
}
|
||||
if (filters.addedWithin) {
|
||||
out.push({
|
||||
key: "added",
|
||||
label: `${t("plex.filter.added")}: ${t("plex.filter.addedOpt." + filters.addedWithin)}`,
|
||||
remove: () => patch({ addedWithin: "" }),
|
||||
});
|
||||
}
|
||||
for (const d of filters.directors) {
|
||||
out.push({
|
||||
key: `director:${d}`,
|
||||
label: d,
|
||||
remove: () => patch({ directors: filters.directors.filter((x) => x !== d) }),
|
||||
});
|
||||
}
|
||||
for (const a of filters.actors) {
|
||||
out.push({
|
||||
key: `actor:${a}`,
|
||||
label: a,
|
||||
remove: () => patch({ actors: filters.actors.filter((x) => x !== a) }),
|
||||
});
|
||||
}
|
||||
for (const s of filters.studios) {
|
||||
out.push({
|
||||
key: `studio:${s}`,
|
||||
label: s,
|
||||
remove: () => patch({ studios: filters.studios.filter((x) => x !== s) }),
|
||||
});
|
||||
}
|
||||
for (const p of filters.playable) {
|
||||
out.push({
|
||||
key: `playable:${p}`,
|
||||
label: t(`plex.filter.playableOpt.${p}`),
|
||||
remove: () => patch({ playable: filters.playable.filter((x) => x !== p) }),
|
||||
});
|
||||
}
|
||||
for (const k of filters.keywords) {
|
||||
out.push({
|
||||
key: `keyword:${k}`,
|
||||
label: k,
|
||||
remove: () => patch({ keywords: filters.keywords.filter((x) => x !== k) }),
|
||||
});
|
||||
}
|
||||
out.push(...collectionChips(filters, patch, t, collectionTitles, collectionsPending));
|
||||
if (show !== "all") {
|
||||
out.push({
|
||||
key: "show",
|
||||
label: t("plex.filter.showOpt." + show),
|
||||
remove: () => setShow("all"),
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// One chip per selected collection, named by the resolved titles (a placeholder while a link's titles
|
||||
// load), else by the ones the state carries, else the generic word.
|
||||
function collectionChips(
|
||||
filters: PlexFilters,
|
||||
patch: (p: Partial<PlexFilters>) => void,
|
||||
t: TFn,
|
||||
collectionTitles: Record<string, string> | undefined,
|
||||
pending: boolean,
|
||||
): ActiveFilter[] {
|
||||
return filters.collections.map((c) => ({
|
||||
key: `collection:${c}`,
|
||||
label: collectionTitles?.[c] ?? filters.collectionTitles?.[c] ?? t("plex.filter.collection"),
|
||||
pending: pending && collectionTitles?.[c] === undefined,
|
||||
// Prune the title too — collectionTitles is hashed into the plex query keys, so a stale entry
|
||||
// splits the cache from the logically-identical no-collection state (mirrors CollectionPicker).
|
||||
// Collapse an empty map back to undefined so the last-removed state key-matches the pristine
|
||||
// one (an empty `{}` still hashes differently from an absent key).
|
||||
remove: () => {
|
||||
const { [c]: _drop, ...restTitles } = filters.collectionTitles ?? {};
|
||||
patch({
|
||||
collections: filters.collections.filter((x) => x !== c),
|
||||
collectionTitles: Object.keys(restTitles).length ? restTitles : undefined,
|
||||
});
|
||||
},
|
||||
}));
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import { LS } from "./storage";
|
||||
import { usePref } from "./usePref";
|
||||
import { updateMeCache } from "./useMe";
|
||||
import {
|
||||
defaultLayout,
|
||||
PREF_KEY,
|
||||
saveLayoutLocal,
|
||||
seedLayout,
|
||||
@@ -68,10 +69,17 @@ const COLLAPSE = {
|
||||
},
|
||||
} as const;
|
||||
|
||||
/** What a rail hands its FilterRail: the layout and the collapse flag, each with its setter. */
|
||||
/** What a rail hands its FilterRail: the layout and the collapse flag, each with its setter, and the
|
||||
* reset to the rail's default layout. */
|
||||
export function useRail(panel: PanelId) {
|
||||
const [layout, setLayout] = usePanelLayout(panel);
|
||||
const c = COLLAPSE[panel];
|
||||
const [collapsed, setCollapsed] = usePref<boolean>(c.key, c.initial, c.opts);
|
||||
return { layout, setLayout, collapsed, onToggleCollapse: () => setCollapsed(!collapsed) };
|
||||
return {
|
||||
layout,
|
||||
setLayout,
|
||||
onResetLayout: () => setLayout(defaultLayout(panel)),
|
||||
collapsed,
|
||||
onToggleCollapse: () => setCollapsed(!collapsed),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 "../../features/channel/useTagSuggestions";
|
||||
import { useTagSuggestions } from "./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 "../../features/filters/TagFilterChips";
|
||||
import TagFilterChips from "./TagFilterChips";
|
||||
import ActionMenu, { type ActionMenuItem } from "../../components/ui/ActionMenu";
|
||||
import ChannelStatsPopover from "./ChannelStatsPopover";
|
||||
import Pager from "../../components/ui/Pager";
|
||||
|
||||
@@ -11,7 +11,7 @@ import { ChannelTagsControl, type SuggestedTag } from "../../features/channel/Ch
|
||||
import { StoredCell } from "./channelCells";
|
||||
import { isFullySynced } from "./channelSync";
|
||||
import { RelativeTime } from "../../components/ui/RelativeTime";
|
||||
import type { FilterChip } from "../../features/filters/TagFilterChips";
|
||||
import type { FilterChip } from "./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
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { api, type Tag, type TagSuggestions } from "../../lib/api";
|
||||
import { qk } from "../../lib/queryKeys";
|
||||
import { useConfirm } from "../../components/ui/confirmContext";
|
||||
import type { SuggestedTag } from "./ChannelTags";
|
||||
import type { SuggestedTag } from "../../features/channel/ChannelTags";
|
||||
|
||||
// A tag suggestion at/above this score is "high confidence" — the only ones the header's bulk
|
||||
// "accept all" acts on. Mirrors AUTOAPPLY_THRESHOLD in backend app/sync/tagsuggest.py (kept in
|
||||
@@ -22,6 +22,7 @@ vi.mock("../../features/navigation/navigationContext", () => ({
|
||||
vi.mock("../../components/ui/confirmContext", () => ({ useConfirm: () => async () => true }));
|
||||
vi.mock("./feedFiltersContext", () => ({
|
||||
useFeedFilters: () => ({ includeNormal: true, includeShorts: false, includeLive: false }),
|
||||
useFeedFiltersActions: () => ({ setFilters: () => {} }),
|
||||
}));
|
||||
vi.mock("./Feed", () => ({ default: () => null }));
|
||||
vi.mock("../../features/channel/ChannelAbout", () => ({ default: () => null }));
|
||||
@@ -71,9 +72,6 @@ describe("ChannelPage — the grid follows a running backfill", () => {
|
||||
channelId={CH}
|
||||
initialName="LEMMiNO"
|
||||
me={{ id: 1, can_write: true } as unknown as Me}
|
||||
view="cards"
|
||||
setView={() => {}}
|
||||
onShowInFeed={() => {}}
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
@@ -20,6 +20,7 @@ vi.mock("../../features/navigation/navigationContext", () => ({
|
||||
vi.mock("../../components/ui/confirmContext", () => ({ useConfirm: () => async () => true }));
|
||||
vi.mock("./feedFiltersContext", () => ({
|
||||
useFeedFilters: () => ({ includeNormal: true, includeShorts: false, includeLive: false }),
|
||||
useFeedFiltersActions: () => ({ setFilters: () => {} }),
|
||||
}));
|
||||
vi.mock("./Feed", () => ({ default: () => null }));
|
||||
vi.mock("../../features/channel/ChannelAbout", () => ({ default: () => null }));
|
||||
@@ -73,9 +74,6 @@ describe("ChannelPage — the banner", () => {
|
||||
channelId={CH}
|
||||
initialName="StarTalk"
|
||||
me={{ id: 1, can_write: true } as unknown as Me}
|
||||
view="cards"
|
||||
setView={() => {}}
|
||||
onShowInFeed={() => {}}
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
@@ -20,6 +20,7 @@ vi.mock("../../features/navigation/navigationContext", () => ({
|
||||
vi.mock("../../components/ui/confirmContext", () => ({ useConfirm: () => async () => true }));
|
||||
vi.mock("./feedFiltersContext", () => ({
|
||||
useFeedFilters: () => ({ includeNormal: true, includeShorts: false, includeLive: false }),
|
||||
useFeedFiltersActions: () => ({ setFilters: () => {} }),
|
||||
}));
|
||||
vi.mock("./Feed", () => ({ default: () => null }));
|
||||
vi.mock("../../features/channel/ChannelAbout", () => ({ default: () => null }));
|
||||
@@ -80,9 +81,6 @@ describe("ChannelPage — a busy toggle keeps the keyboard", () => {
|
||||
channelId={CH}
|
||||
initialName="LEMMiNO"
|
||||
me={{ id: 1, can_write: true } as unknown as Me}
|
||||
view="cards"
|
||||
setView={() => {}}
|
||||
onShowInFeed={() => {}}
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
@@ -127,9 +125,6 @@ describe("ChannelPage — the banner", () => {
|
||||
channelId={CH}
|
||||
initialName="LEMMiNO"
|
||||
me={{ id: 1, can_write: true } as unknown as Me}
|
||||
view="cards"
|
||||
setView={() => {}}
|
||||
onShowInFeed={() => {}}
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
@@ -19,6 +19,7 @@ vi.mock("../../features/navigation/navigationContext", () => ({
|
||||
vi.mock("../../components/ui/confirmContext", () => ({ useConfirm: () => async () => true }));
|
||||
vi.mock("./feedFiltersContext", () => ({
|
||||
useFeedFilters: () => ({ includeNormal: true, includeShorts: false, includeLive: false }),
|
||||
useFeedFiltersActions: () => ({ setFilters: () => {} }),
|
||||
}));
|
||||
vi.mock("./Feed", () => ({ default: () => null }));
|
||||
vi.mock("../../features/channel/ChannelAbout", () => ({ default: () => null }));
|
||||
@@ -91,9 +92,6 @@ async function renderPage() {
|
||||
channelId={CH}
|
||||
initialName="StarTalk"
|
||||
me={{ id: 1, can_write: true } as unknown as Me}
|
||||
view="cards"
|
||||
setView={() => {}}
|
||||
onShowInFeed={() => {}}
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
@@ -30,6 +30,8 @@ import ChannelAboutContent from "../../features/channel/ChannelAbout";
|
||||
import { PageToolbar } from "../../components/ui/PageShell";
|
||||
import { PageToolbarSlot } from "../../components/ui/pageToolbarSlot";
|
||||
import { useFeedFilters } from "./feedFiltersContext";
|
||||
import { useFeedView, useFeedViewActions } from "./feedViewContext";
|
||||
import { useShowChannelInFeed } from "./useFeedPageHooks";
|
||||
import { SHOW_IDS } from "./feedFiltersInit";
|
||||
import { usePref } from "../../lib/usePref";
|
||||
import { LS } from "../../lib/storage";
|
||||
@@ -44,7 +46,6 @@ import {
|
||||
type Me,
|
||||
type ChannelDetail,
|
||||
} from "../../lib/api";
|
||||
import type { FeedView } from "../../lib/feedView";
|
||||
import { channelYouTubeUrl, formatDate, formatCompact } from "../../lib/format";
|
||||
import { ChannelTagsControl } from "../../features/channel/ChannelTags";
|
||||
import { useChannelTagActions } from "../../lib/useChannelTagActions";
|
||||
@@ -64,21 +65,10 @@ export default function ChannelPage({
|
||||
channelId,
|
||||
initialName,
|
||||
me,
|
||||
view,
|
||||
setView,
|
||||
onShowInFeed,
|
||||
}: {
|
||||
channelId: string;
|
||||
initialName?: string;
|
||||
me: Me;
|
||||
// The feed's view pref, passed straight through to the Feed this page renders — so its
|
||||
// toolbar carries the same switcher and a change here is the same account-wide choice.
|
||||
view: FeedView;
|
||||
setView: (v: FeedView) => void;
|
||||
// Narrow the real feed to this channel and go there. Offered only for a channel you're
|
||||
// subscribed to: the feed defaults to scope "my", so filtering it to a channel you don't
|
||||
// follow would just land you on an empty page.
|
||||
onShowInFeed: () => void;
|
||||
}) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const lang = i18n.language;
|
||||
@@ -90,6 +80,14 @@ export default function ChannelPage({
|
||||
// shorts / live-upcoming) is a genuine preference, not part of "this channel's catalog" — so we
|
||||
// inherit it instead of forcing Live/Upcoming on. The channel toolbar can still toggle these.
|
||||
const globalFilters = useFeedFilters();
|
||||
// The feed's view pref, handed straight to the Feed this page renders — so its toolbar carries the
|
||||
// same switcher and a change here is the same account-wide choice.
|
||||
const view = useFeedView();
|
||||
const { setView } = useFeedViewActions();
|
||||
// Narrow the real feed to this channel and go there. Offered only for a channel you're subscribed
|
||||
// to: the feed defaults to scope "my", so filtering it to a channel you don't follow would just
|
||||
// land you on an empty page.
|
||||
const onShowInFeed = useShowChannelInFeed(channelId);
|
||||
const [tab, setTab] = useState<"videos" | "about">("videos");
|
||||
const panel = useTabPanel();
|
||||
// The whole channel chrome (banner, identity, tabs, toolbar) is fixed: it portals into the shell's
|
||||
|
||||
@@ -18,6 +18,7 @@ vi.mock("../../features/navigation/navigationContext", () => ({
|
||||
vi.mock("../../components/ui/confirmContext", () => ({ useConfirm: () => async () => true }));
|
||||
vi.mock("./feedFiltersContext", () => ({
|
||||
useFeedFilters: () => ({ includeNormal: true, includeShorts: false, includeLive: false }),
|
||||
useFeedFiltersActions: () => ({ setFilters: () => {} }),
|
||||
}));
|
||||
const feed = vi.hoisted(() => ({
|
||||
last: null as null | { filters: FeedFilters; setFilters: (f: FeedFilters) => void },
|
||||
@@ -75,9 +76,6 @@ function mount(preferences: Record<string, unknown> = {}) {
|
||||
channelId={CH}
|
||||
initialName="StarTalk"
|
||||
me={{ id: 1, can_write: true, can_read: true } as unknown as Me}
|
||||
view="cards"
|
||||
setView={() => {}}
|
||||
onShowInFeed={() => {}}
|
||||
/>
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
|
||||
@@ -41,7 +41,7 @@ const DATE_PRESETS: { days: number; key: string }[] = [
|
||||
|
||||
export default function Sidebar() {
|
||||
// Its layout and collapse flag are its own (lib/useRail), so the shell only places it.
|
||||
const { layout, setLayout, collapsed, onToggleCollapse } = useRail("feed");
|
||||
const { layout, setLayout, onResetLayout, collapsed, onToggleCollapse } = useRail("feed");
|
||||
const { t, i18n } = useTranslation();
|
||||
const lang = i18n.language;
|
||||
// The feed's saved views, handed to the shared views UI in this rail through the scope below.
|
||||
@@ -83,8 +83,8 @@ export default function Sidebar() {
|
||||
const topics = tags.filter((t) => t.category === "topic");
|
||||
const userTags = tags.filter((t) => !t.system);
|
||||
const [tagManagerOpen, setTagManagerOpen] = useState(false);
|
||||
// This rail's media source (the feed). Names the abstraction the feed and Plex rails share; drives
|
||||
// the panel layout key. `FilterRail` owns the customize `editing` state now.
|
||||
// This rail's media source (the feed). Names the abstraction the feed and Plex rails share; picks
|
||||
// the default group order below. `FilterRail` owns the customize `editing` state.
|
||||
const source: MediaSourceKind = "feed";
|
||||
|
||||
// The language/topic/tags sections all write into the one `filters.tags` list, so each combobox owns
|
||||
@@ -227,7 +227,7 @@ export default function Sidebar() {
|
||||
return (
|
||||
<SavedViewsScope value={savedViews}>
|
||||
<FilterRail
|
||||
kind={source}
|
||||
onResetLayout={onResetLayout}
|
||||
title={t("sidebar.filters")}
|
||||
icon={<SlidersHorizontal className="w-4 h-4" />}
|
||||
count={activeCount}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// The feed's public face: its stores (filters, view, saved views), mounted at the root and read app-wide.
|
||||
// The feed's public face: its stores (filters, saved views), mounted at the root and read app-wide,
|
||||
// and the feed page's scroll key, which the shell's scroller restores by.
|
||||
export { useFeedSavedViews } from "./feedSavedViews";
|
||||
export { useFeedFilters, useFeedFiltersActions } from "./feedFiltersContext";
|
||||
export { useFeedView, useFeedViewActions } from "./feedViewContext";
|
||||
export { useFeedScrollKey } from "./useFeedPageHooks";
|
||||
export { ChannelPage, feedModule } from "./module";
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// The feed's two page hooks the shell used to hand-build: the channel page's "Show in feed" (adds the
|
||||
// channel to the picked set and opens the feed) and the feed page's scroll key.
|
||||
import { describe, expect, it, vi, beforeEach } from "vitest";
|
||||
import { renderHook } from "@testing-library/react";
|
||||
|
||||
const h = vi.hoisted(() => ({
|
||||
filters: { channelIds: [] as string[], q: "", show: "unwatched" } as Record<string, unknown>,
|
||||
ytSearch: null as string | null,
|
||||
setFilters: vi.fn(),
|
||||
setPage: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../features/navigation/navigationContext", () => ({
|
||||
useNavigation: () => ({ ytSearch: h.ytSearch }),
|
||||
useNavigationActions: () => ({ setPage: h.setPage }),
|
||||
}));
|
||||
vi.mock("./feedFiltersContext", () => ({
|
||||
useFeedFilters: () => h.filters,
|
||||
useFeedFiltersActions: () => ({ setFilters: h.setFilters }),
|
||||
}));
|
||||
|
||||
import { useFeedScrollKey, useShowChannelInFeed } from "./useFeedPageHooks";
|
||||
|
||||
beforeEach(() => {
|
||||
h.filters = { channelIds: ["a"], q: "", show: "unwatched" };
|
||||
h.ytSearch = null;
|
||||
h.setFilters.mockClear();
|
||||
h.setPage.mockClear();
|
||||
});
|
||||
|
||||
describe("Show in feed", () => {
|
||||
it("adds the channel to the picked ones, keeps the other filters, and opens the feed", () => {
|
||||
renderHook(() => useShowChannelInFeed("b")).result.current();
|
||||
expect(h.setFilters).toHaveBeenCalledWith({
|
||||
channelIds: ["a", "b"],
|
||||
q: "",
|
||||
show: "unwatched",
|
||||
});
|
||||
expect(h.setPage).toHaveBeenCalledWith("feed");
|
||||
});
|
||||
|
||||
it("changes nothing in the set for a channel already picked", () => {
|
||||
renderHook(() => useShowChannelInFeed("a")).result.current();
|
||||
expect(h.setFilters.mock.calls[0]![0]).toMatchObject({ channelIds: ["a"] });
|
||||
});
|
||||
});
|
||||
|
||||
describe("the feed's scroll key", () => {
|
||||
it("follows the filters but not the search text", () => {
|
||||
const first = renderHook(() => useFeedScrollKey()).result.current;
|
||||
h.filters = { ...h.filters, q: "typing" };
|
||||
expect(renderHook(() => useFeedScrollKey()).result.current).toBe(first);
|
||||
h.filters = { ...h.filters, show: "all" };
|
||||
expect(renderHook(() => useFeedScrollKey()).result.current).not.toBe(first);
|
||||
});
|
||||
|
||||
it("is the live search's own while it is open", () => {
|
||||
h.ytSearch = "cats";
|
||||
expect(renderHook(() => useFeedScrollKey()).result.current).toBe("yt:cats");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,30 @@
|
||||
import { useNavigation, useNavigationActions } from "../../features/navigation/navigationContext";
|
||||
import { useFeedFilters, useFeedFiltersActions } from "./feedFiltersContext";
|
||||
|
||||
// "Show in feed" on a channel page: narrow the real feed to this channel and go there. The rest of
|
||||
// the feed's filters stay — the point is "this channel, the way I normally read": unwatched, my tags,
|
||||
// my date window. The channel is ADDED to the picked set, not swapped in: channels OR together, so
|
||||
// arriving from a second channel page widens the feed instead of silently dropping the first; picking
|
||||
// one already in the set changes nothing.
|
||||
export function useShowChannelInFeed(channelId: string): () => void {
|
||||
const filters = useFeedFilters();
|
||||
const { setFilters } = useFeedFiltersActions();
|
||||
const { setPage } = useNavigationActions();
|
||||
return () => {
|
||||
setFilters({ ...filters, channelIds: [...new Set([...filters.channelIds, channelId])] });
|
||||
// setPage already leaves an open channel page and pushes a clean history entry. Calling
|
||||
// closeChannel() first ran history.back(), whose popstate landed AFTER this and restored whatever
|
||||
// page the channel had been opened from.
|
||||
setPage("feed");
|
||||
};
|
||||
}
|
||||
|
||||
// The feed page's scroll restore/reset key (see PageScroller): the same feed filters return you to
|
||||
// where you were; a changed filter is a new key, so the fresh result set starts at the top. `q` is
|
||||
// normalised out so typing in the search box doesn't reset the scroll on every keystroke; the
|
||||
// live-YouTube sub-view gets its own key.
|
||||
export function useFeedScrollKey(): string {
|
||||
const filters = useFeedFilters();
|
||||
const { ytSearch } = useNavigation();
|
||||
return ytSearch != null ? `yt:${ytSearch}` : `feed:${JSON.stringify({ ...filters, q: "" })}`;
|
||||
}
|
||||
@@ -125,7 +125,7 @@ beforeAll(async () => {
|
||||
import("./FilePreview"),
|
||||
import("./ShareDialog"),
|
||||
import("./UploadDialog"),
|
||||
import("../../features/player/VideoEditor"),
|
||||
import("./VideoEditor"),
|
||||
]);
|
||||
}, 60_000);
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ import { MetaEditModal } from "../../features/downloads/downloadModals";
|
||||
import { jobTitle } from "../../features/downloads/downloadTitle";
|
||||
import { FileCard } from "./FileCard";
|
||||
import { PhoneBadge, RetentionBadge, SourceBadge } from "./fileBadges";
|
||||
import { folderLabel, takesUploads } from "./fileFolders";
|
||||
import { folderLabel, takesUploads } from "../../features/folders/fileFolders";
|
||||
import { ALL_FILES, useFiles, useFilesActions, viewParam, type FilesView } from "./filesContext";
|
||||
import { useFileActions } from "./useFileActions";
|
||||
import { useFolderActions } from "./useFolderActions";
|
||||
@@ -55,7 +55,7 @@ const UploadDialog = lazy(() => import("./UploadDialog"));
|
||||
const ShareDialog = lazy(() => import("./ShareDialog"));
|
||||
const LibraryPlayer = lazy(() => import("./LibraryPlayer"));
|
||||
const FilePreview = lazy(() => import("./FilePreview"));
|
||||
const VideoEditor = lazy(() => import("../../features/player/VideoEditor"));
|
||||
const VideoEditor = lazy(() => import("./VideoEditor"));
|
||||
|
||||
// The Files library: every done file of mine, the ones shared with me, or one folder's (the rail
|
||||
// picks — FilesProvider holds it), searched by the header's box. One aligned table: every kind of
|
||||
|
||||
@@ -10,7 +10,7 @@ import { formatBytes } from "../../lib/format";
|
||||
import { qk } from "../../lib/queryKeys";
|
||||
import { useLiveQuery } from "../../lib/useLiveQuery";
|
||||
import FilterRail from "../../components/ui/FilterRail";
|
||||
import { folderIcon, folderLabel } from "./fileFolders";
|
||||
import { folderIcon, folderLabel } from "../../features/folders/fileFolders";
|
||||
import { sameView, useFiles, useFilesActions, type FilesView } from "./filesContext";
|
||||
import { RenameField } from "../../components/ui/RenameField";
|
||||
import { useFolderActions } from "./useFolderActions";
|
||||
@@ -113,7 +113,7 @@ function StorageMeter() {
|
||||
|
||||
export default function FilesRail() {
|
||||
// Its layout and collapse flag are its own (lib/useRail), so the shell only places it.
|
||||
const { layout, setLayout, collapsed, onToggleCollapse } = useRail("files");
|
||||
const { layout, setLayout, onResetLayout, collapsed, onToggleCollapse } = useRail("files");
|
||||
const { t, i18n } = useTranslation();
|
||||
const { view } = useFiles();
|
||||
const foldersQ = useQuery({ queryKey: qk.fileFolders(), queryFn: api.fileFolders });
|
||||
@@ -255,7 +255,7 @@ export default function FilesRail() {
|
||||
|
||||
return (
|
||||
<FilterRail
|
||||
kind="files"
|
||||
onResetLayout={onResetLayout}
|
||||
title={t("files.navLabel")}
|
||||
icon={<FolderOpen className="w-4 h-4" />}
|
||||
collapsed={collapsed}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useTranslation } from "react-i18next";
|
||||
import { Clock, Pin, Smartphone } from "lucide-react";
|
||||
import type { LibraryFile } from "../../lib/api";
|
||||
import { formatDateTime } from "../../lib/format";
|
||||
import { sourceIcon, sourceLabel } from "./fileFolders";
|
||||
import { sourceIcon, sourceLabel } from "../../features/folders/fileFolders";
|
||||
import { Badge } from "../../components/ui/Badge";
|
||||
|
||||
// What a library file says about itself — where it came from, whether a phone plays it, how long it
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
// The Files page's public face: its store, mounted at the root, and how a folder is named and pictured.
|
||||
// The Files page's public face: its store, mounted at the root and read app-wide.
|
||||
export { useFilesActions } from "./filesContext";
|
||||
export { folderIcon, folderLabel, sourceLabel, takesUploads } from "./fileFolders";
|
||||
export { filesModule } from "./module";
|
||||
|
||||
@@ -25,7 +25,7 @@ import { libraryRowActions, sharedRowActions } from "../../lib/downloadActions";
|
||||
import { useConfirm } from "../../components/ui/confirmContext";
|
||||
import { useCopyItems } from "../../components/ui/copyItems";
|
||||
import { jobTitle } from "../../features/downloads/downloadTitle";
|
||||
import { folderLabel } from "./fileFolders";
|
||||
import { folderLabel } from "../../features/folders/fileFolders";
|
||||
import { type ActionMenuItem } from "../../components/ui/ActionMenu";
|
||||
import { menuGroups } from "../../components/ui/menuGroups";
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import { api, type FileFolder, type FolderPatch } from "../../lib/api";
|
||||
import { notifyActionError } from "../../lib/actionErrors";
|
||||
import { invalidateDownloads, quickShareLink } from "../../lib/downloads";
|
||||
import { useConfirm } from "../../components/ui/confirmContext";
|
||||
import { folderLabel } from "./fileFolders";
|
||||
import { folderLabel } from "../../features/folders/fileFolders";
|
||||
import { type ActionMenuItem } from "../../components/ui/ActionMenu";
|
||||
import { menuGroups } from "../../components/ui/menuGroups";
|
||||
|
||||
|
||||
@@ -49,7 +49,7 @@ const ROW_BASE = "w-full flex items-center gap-2.5 p-2 rounded-lg border transit
|
||||
// (Playlists.tsx) reacts to it. Grouped into two islands: "manage" and the playlist list.
|
||||
export default function PlaylistsRail() {
|
||||
// Its layout and collapse flag are its own (lib/useRail), so the shell only places it.
|
||||
const { layout, setLayout, collapsed, onToggleCollapse } = useRail("playlists");
|
||||
const { layout, setLayout, onResetLayout, collapsed, onToggleCollapse } = useRail("playlists");
|
||||
const canWrite = useMe().can_write;
|
||||
// The name filter + the selected playlist live in PlaylistsProvider (shared with the header search
|
||||
// box and the Playlists detail pane); this rail owns the list query + its own controls.
|
||||
@@ -391,7 +391,7 @@ export default function PlaylistsRail() {
|
||||
|
||||
return (
|
||||
<FilterRail
|
||||
kind="playlists"
|
||||
onResetLayout={onResetLayout}
|
||||
title={t("playlists.title")}
|
||||
icon={<ListVideo className="w-4 h-4" />}
|
||||
collapsed={collapsed}
|
||||
|
||||
@@ -1,14 +1,20 @@
|
||||
// @vitest-environment jsdom
|
||||
//
|
||||
// Plex's saved views: the same default-view seed as the feed's (parity — Plex once never applied its
|
||||
// default at all), asked for only while the Plex page is on screen. Plex's filters are mocked so we can
|
||||
// assert exactly which setFilters is called; api.savedViews is stubbed and driven through a real
|
||||
// default at all), asked for only while the Plex page is on screen, and an applied view kept across a
|
||||
// visit to another page (the store is Plex's own, mounted at the root). Plex's filters are mocked so we
|
||||
// can assert exactly which setFilters is called; api.savedViews is stubbed and driven through a real
|
||||
// QueryClient.
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
import { render, screen, waitFor, cleanup, act } from "@testing-library/react";
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import { useEffect } from "react";
|
||||
|
||||
const h = vi.hoisted(() => ({ page: "plex" as string, setPlexFilters: vi.fn() }));
|
||||
const h = vi.hoisted(() => ({
|
||||
page: "plex" as string,
|
||||
filters: {} as Record<string, unknown>,
|
||||
setPlexFilters: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("react-i18next", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||
@@ -18,7 +24,7 @@ vi.mock("../../features/navigation/navigationContext", () => ({
|
||||
useNavigation: () => ({ page: h.page }),
|
||||
}));
|
||||
vi.mock("./plexContext", () => ({
|
||||
usePlex: () => ({ filters: {} }),
|
||||
usePlex: () => ({ filters: h.filters }),
|
||||
usePlexActions: () => ({ setFilters: h.setPlexFilters }),
|
||||
}));
|
||||
vi.mock("../../components/ui/confirmContext", () => ({ useConfirm: () => async () => true }));
|
||||
@@ -28,6 +34,7 @@ import { usePlexSavedViews } from "./plexSavedViews";
|
||||
import { api, type SavedView } from "../../lib/api";
|
||||
import { claimLocalData, LS } from "../../lib/storage";
|
||||
import { qk } from "../../lib/queryKeys";
|
||||
import type { SavedViewsContext } from "../../features/filters/savedViewsContext";
|
||||
|
||||
const ID = 7;
|
||||
const view = (over: Partial<SavedView>): SavedView => ({
|
||||
@@ -39,9 +46,15 @@ const view = (over: Partial<SavedView>): SavedView => ({
|
||||
...over,
|
||||
});
|
||||
|
||||
// The store as the last commit left it, for the case that drives it directly.
|
||||
let latest: SavedViewsContext | null = null;
|
||||
const keep = (store: SavedViewsContext) => {
|
||||
latest = store;
|
||||
};
|
||||
function Probe() {
|
||||
const { loading } = usePlexSavedViews();
|
||||
return <div data-testid="loading">{String(loading)}</div>;
|
||||
const store = usePlexSavedViews();
|
||||
useEffect(() => keep(store));
|
||||
return <div data-testid="loading">{String(store.loading)}</div>;
|
||||
}
|
||||
|
||||
function mount(views: SavedView[]) {
|
||||
@@ -65,7 +78,9 @@ beforeEach(() => {
|
||||
localStorage.clear();
|
||||
claimLocalData(ID);
|
||||
h.page = "plex";
|
||||
h.filters = {};
|
||||
h.setPlexFilters.mockClear();
|
||||
latest = null;
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
@@ -98,4 +113,22 @@ describe("Plex's saved views", () => {
|
||||
rerender();
|
||||
await waitFor(() => expect(spy).toHaveBeenCalledWith("plex"));
|
||||
});
|
||||
|
||||
it("keeps the view you applied, edited since, when you come back from another page", async () => {
|
||||
const { rerender } = mount([
|
||||
view({ id: 5, name: "comedies", filters: { genres: ["Comedy"] } }),
|
||||
]);
|
||||
await waitFor(() => expect(screen.getByTestId("loading").textContent).toBe("false"));
|
||||
act(() => latest!.apply(latest!.views[0]!));
|
||||
h.filters = { genres: ["Comedy", "Drama"] }; // an edit since: the view is now shown as changed
|
||||
rerender();
|
||||
expect(latest!.displayView?.id).toBe(5);
|
||||
expect(latest!.isDirty).toBe(true);
|
||||
h.page = "home";
|
||||
rerender();
|
||||
h.page = "plex";
|
||||
rerender();
|
||||
expect(latest!.displayView?.id).toBe(5);
|
||||
expect(latest!.isDirty).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,7 +8,6 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { ListMusic, Plus, SlidersHorizontal } from "lucide-react";
|
||||
import { api, plexFilterCount, type PlexFilters } from "../../lib/api";
|
||||
import { notifyActionError } from "../../lib/actionErrors";
|
||||
import { type MediaSourceKind } from "../../lib/mediaSource";
|
||||
import { useDebounced } from "../../lib/useDebounced";
|
||||
import FilterRail from "../../components/ui/FilterRail";
|
||||
import { type PanelItem } from "../../components/ui/PanelGroups";
|
||||
@@ -39,7 +38,7 @@ const DURATION_BUCKETS: { key: string; min: number | null; max: number | null }[
|
||||
|
||||
export default function PlexSidebar() {
|
||||
// Its layout and collapse flag are its own (lib/useRail), so the shell only places it.
|
||||
const { layout, setLayout, collapsed, onToggleCollapse } = useRail("plex");
|
||||
const { layout, setLayout, onResetLayout, collapsed, onToggleCollapse } = useRail("plex");
|
||||
const { t, i18n } = useTranslation();
|
||||
// Scope / watch-state / sort / expanded filters live in PlexProvider (shared with PlexBrowse and
|
||||
// the header search box); this rail is the primary editor of them. Opening a playlist is a
|
||||
@@ -51,9 +50,6 @@ export default function PlexSidebar() {
|
||||
// They reach the shared views UI in this rail through the scope below.
|
||||
const savedViews = usePlexSavedViews();
|
||||
const { openModal, modalOpen } = savedViews;
|
||||
// This rail's media source (the Plex library) — names the abstraction the feed and Plex rails
|
||||
// share; drives the panel layout key. `FilterRail` owns the customize `editing` state now.
|
||||
const source: MediaSourceKind = "plex";
|
||||
// Titles are display-only (chip labels) — keep them OUT of the facet query key so renaming/relabelling
|
||||
// never refetches. collectionMode stays IN the key: any/all changes the result set.
|
||||
const { collectionTitles: _cts, ...facetKey } = filters;
|
||||
@@ -298,7 +294,7 @@ export default function PlexSidebar() {
|
||||
return (
|
||||
<SavedViewsScope value={savedViews}>
|
||||
<FilterRail
|
||||
kind={source}
|
||||
onResetLayout={onResetLayout}
|
||||
title={t("sidebar.filters")}
|
||||
icon={<SlidersHorizontal className="w-4 h-4" />}
|
||||
count={activeCount}
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
// The Plex module's public face: its store, mounted at the root and read app-wide, and how its filters read as chips.
|
||||
// The Plex module's public face: its store, mounted at the root and read app-wide.
|
||||
export { usePlexSavedViews } from "./plexSavedViews";
|
||||
export { usePlex, usePlexActions } from "./plexContext";
|
||||
export { buildPlexActiveFilters } from "./usePlexActiveFilters";
|
||||
export { plexModule } from "./module";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { vocabI18nKey } from "../../lib/vocab";
|
||||
import { type ActiveFilter } from "../../lib/useActiveFilters";
|
||||
import { buildPlexActiveFilters } from "../../lib/plexActiveFilters";
|
||||
import { EMPTY_PLEX_FILTERS, type PlexFilters } from "../../lib/api";
|
||||
import { PLEX_SORT_FALLBACK } from "../../lib/plexSort";
|
||||
import { usePlex, usePlexActions } from "./plexContext";
|
||||
@@ -12,10 +12,8 @@ import { usePlexCollectionTitles } from "../../lib/usePlexCollectionTitles";
|
||||
// the feed has, instead of the narrowing being visible only inside the collapsed sidebar. Labels reuse
|
||||
// the existing plex.* i18n keys; multi-value dimensions (genres, people, ratings) emit one chip each so
|
||||
// removing one keeps the rest (same shape as the feed's tag/channel chips). Sort is NOT a chip here —
|
||||
// it's an always-visible control above the grid, like the feed. Lives here, not in lib, because
|
||||
// it reads the Plex provider (lib stays provider-free, like useActiveFilters).
|
||||
type TFn = (key: string, options?: Record<string, unknown>) => string;
|
||||
|
||||
// it's an always-visible control above the grid, like the feed. The hook lives here because it reads
|
||||
// the Plex store; the pure builder it calls is lib/plexActiveFilters (lib stays store-free).
|
||||
export function usePlexActiveFilters(): ActiveFilter[] {
|
||||
const { show, filters } = usePlex();
|
||||
const { setShow, setFilters } = usePlexActions();
|
||||
@@ -35,126 +33,6 @@ export function usePlexActiveFilters(): ActiveFilter[] {
|
||||
);
|
||||
}
|
||||
|
||||
// The pure builder behind the hook: turns a Plex filter set + watch-state into the source-agnostic
|
||||
// ActiveFilter[]. Extracted so the saved-views manager can DESCRIBE an arbitrary view's captured
|
||||
// filters (with no-op removes) — the labels stay in one place, no duplication with the live chip row.
|
||||
export function buildPlexActiveFilters(
|
||||
filters: PlexFilters,
|
||||
show: string,
|
||||
patch: (p: Partial<PlexFilters>) => void,
|
||||
setShow: (s: string) => void,
|
||||
t: TFn,
|
||||
/** Collection titles for the chip labels, resolved beyond what the filter state carries. Omitted by
|
||||
* the saved-views manager, which describes a stored view and has only the captured titles. */
|
||||
collectionTitles?: Record<string, string>,
|
||||
/** The missing titles are still loading: their chips show a placeholder, not the generic word. */
|
||||
collectionsPending = false,
|
||||
): ActiveFilter[] {
|
||||
const out: ActiveFilter[] = [];
|
||||
|
||||
const has = (x: number | null | undefined) => x != null;
|
||||
|
||||
for (const g of filters.genres) {
|
||||
out.push({
|
||||
key: `genre:${g}`,
|
||||
label: g,
|
||||
remove: () => patch({ genres: filters.genres.filter((x) => x !== g) }),
|
||||
});
|
||||
}
|
||||
for (const r of filters.contentRatings) {
|
||||
out.push({
|
||||
key: `contentRating:${r}`,
|
||||
label: vocabI18nKey("certificate", r)
|
||||
? t(vocabI18nKey("certificate", r), { defaultValue: r })
|
||||
: r,
|
||||
remove: () => patch({ contentRatings: filters.contentRatings.filter((x) => x !== r) }),
|
||||
});
|
||||
}
|
||||
if (has(filters.yearMin) || has(filters.yearMax)) {
|
||||
const range =
|
||||
has(filters.yearMin) && has(filters.yearMax)
|
||||
? `${filters.yearMin}–${filters.yearMax}`
|
||||
: has(filters.yearMin)
|
||||
? `≥${filters.yearMin}`
|
||||
: `≤${filters.yearMax}`;
|
||||
out.push({
|
||||
key: "year",
|
||||
label: `${t("plex.filter.year")}: ${range}`,
|
||||
remove: () => patch({ yearMin: null, yearMax: null }),
|
||||
});
|
||||
}
|
||||
if (has(filters.ratingMin)) {
|
||||
out.push({
|
||||
key: "ratingMin",
|
||||
label: `${t("plex.filter.rating")}: ≥${filters.ratingMin}`,
|
||||
remove: () => patch({ ratingMin: null }),
|
||||
});
|
||||
}
|
||||
if (has(filters.durationMin) || has(filters.durationMax)) {
|
||||
const lo = has(filters.durationMin) ? Math.round(filters.durationMin! / 60) : null;
|
||||
const hi = has(filters.durationMax) ? Math.round(filters.durationMax! / 60) : null;
|
||||
const u = t("plex.filter.durationUnit");
|
||||
const range =
|
||||
lo != null && hi != null ? `${lo}–${hi} ${u}` : lo != null ? `≥${lo} ${u}` : `≤${hi} ${u}`;
|
||||
out.push({
|
||||
key: "duration",
|
||||
label: `${t("plex.filter.duration")}: ${range}`,
|
||||
remove: () => patch({ durationMin: null, durationMax: null }),
|
||||
});
|
||||
}
|
||||
if (filters.addedWithin) {
|
||||
out.push({
|
||||
key: "added",
|
||||
label: `${t("plex.filter.added")}: ${t("plex.filter.addedOpt." + filters.addedWithin)}`,
|
||||
remove: () => patch({ addedWithin: "" }),
|
||||
});
|
||||
}
|
||||
for (const d of filters.directors) {
|
||||
out.push({
|
||||
key: `director:${d}`,
|
||||
label: d,
|
||||
remove: () => patch({ directors: filters.directors.filter((x) => x !== d) }),
|
||||
});
|
||||
}
|
||||
for (const a of filters.actors) {
|
||||
out.push({
|
||||
key: `actor:${a}`,
|
||||
label: a,
|
||||
remove: () => patch({ actors: filters.actors.filter((x) => x !== a) }),
|
||||
});
|
||||
}
|
||||
for (const s of filters.studios) {
|
||||
out.push({
|
||||
key: `studio:${s}`,
|
||||
label: s,
|
||||
remove: () => patch({ studios: filters.studios.filter((x) => x !== s) }),
|
||||
});
|
||||
}
|
||||
for (const p of filters.playable) {
|
||||
out.push({
|
||||
key: `playable:${p}`,
|
||||
label: t(`plex.filter.playableOpt.${p}`),
|
||||
remove: () => patch({ playable: filters.playable.filter((x) => x !== p) }),
|
||||
});
|
||||
}
|
||||
for (const k of filters.keywords) {
|
||||
out.push({
|
||||
key: `keyword:${k}`,
|
||||
label: k,
|
||||
remove: () => patch({ keywords: filters.keywords.filter((x) => x !== k) }),
|
||||
});
|
||||
}
|
||||
out.push(...collectionChips(filters, patch, t, collectionTitles, collectionsPending));
|
||||
if (show !== "all") {
|
||||
out.push({
|
||||
key: "show",
|
||||
label: t("plex.filter.showOpt." + show),
|
||||
remove: () => setShow("all"),
|
||||
});
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
// The one "reset every Plex narrowing" callback, shared by the sidebar's Clear-filters and the chip
|
||||
// row's Clear-all so the two can't drift: expanded filters + watch-state + sort all go back to default.
|
||||
export function usePlexClearFilters(): () => void {
|
||||
@@ -165,30 +43,3 @@ export function usePlexClearFilters(): () => void {
|
||||
setSort(PLEX_SORT_FALLBACK);
|
||||
};
|
||||
}
|
||||
|
||||
// One chip per selected collection, named by the resolved titles (a placeholder while a link's titles
|
||||
// load), else by the ones the state carries, else the generic word.
|
||||
function collectionChips(
|
||||
filters: PlexFilters,
|
||||
patch: (p: Partial<PlexFilters>) => void,
|
||||
t: TFn,
|
||||
collectionTitles: Record<string, string> | undefined,
|
||||
pending: boolean,
|
||||
): ActiveFilter[] {
|
||||
return filters.collections.map((c) => ({
|
||||
key: `collection:${c}`,
|
||||
label: collectionTitles?.[c] ?? filters.collectionTitles?.[c] ?? t("plex.filter.collection"),
|
||||
pending: pending && collectionTitles?.[c] === undefined,
|
||||
// Prune the title too — collectionTitles is hashed into the plex query keys, so a stale entry
|
||||
// splits the cache from the logically-identical no-collection state (mirrors CollectionPicker).
|
||||
// Collapse an empty map back to undefined so the last-removed state key-matches the pristine
|
||||
// one (an empty `{}` still hashes differently from an absent key).
|
||||
remove: () => {
|
||||
const { [c]: _drop, ...restTitles } = filters.collectionTitles ?? {};
|
||||
patch({
|
||||
collections: filters.collections.filter((x) => x !== c),
|
||||
collectionTitles: Object.keys(restTitles).length ? restTitles : undefined,
|
||||
});
|
||||
},
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
import { folderIcon, folderLabel, takesUploads } from "../../features/folders/fileFolders";
|
||||
import { type ActionMenuItem } from "../../components/ui/ActionMenu";
|
||||
import { Badge } from "../../components/ui/Badge";
|
||||
import { Button } from "../../components/ui/Button";
|
||||
|
||||
@@ -11,13 +11,7 @@ import { useEntryParams } from "./useEntryParams";
|
||||
import { useAccessRequestsNudge } from "./useAccessRequestsNudge";
|
||||
import { useVerifyEntry } from "../lib/useVerifyEntry";
|
||||
import { useNavigation, useNavigationActions } from "../features/navigation/navigationContext";
|
||||
import {
|
||||
useFeedFilters,
|
||||
useFeedFiltersActions,
|
||||
useFeedView,
|
||||
useFeedViewActions,
|
||||
ChannelPage,
|
||||
} from "../modules/feed";
|
||||
import { useFeedScrollKey, ChannelPage } from "../modules/feed";
|
||||
import { moduleDef, pageTitleKey } from "../features/navigation/moduleRegistry";
|
||||
import { LoadingState } from "../components/ui/QueryState";
|
||||
// Persistent shell — always mounted, so eagerly imported.
|
||||
@@ -51,22 +45,16 @@ const ReleaseNotes = lazy(() => import("./ReleaseNotes"));
|
||||
|
||||
export default function App() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const { page, channelView, ytSearch } = useNavigation();
|
||||
const { page, channelView } = useNavigation();
|
||||
const { setPage } = useNavigationActions();
|
||||
// An emailed verification link is confirmed HERE, above the signed-in/signed-out branch below, so
|
||||
// it works in a browser that already holds a session. It used to be handled inside Welcome, which
|
||||
// that branch never renders when signed in — the token was silently dropped. Signed out, the
|
||||
// outcome goes to the auth card's banners; signed in, to the top-of-app bar.
|
||||
const verifyEntry = useVerifyEntry();
|
||||
// The global feed filters live in FeedFiltersProvider (per-account persistence + the account-load
|
||||
// effect + share-link capture all moved there). App reads them to hand to <Feed> and to the
|
||||
// handlers that mutate the feed view (onShowInFeed, tag-click, the feed scrollKey).
|
||||
const filters = useFeedFilters();
|
||||
const { setFilters } = useFeedFiltersActions();
|
||||
// The feed's view mode (card/row/tile density) lives in FeedViewProvider — server-synced, shared
|
||||
// by the feed page and the channel page. App reads it only to hand to <Feed>/<ChannelPage>.
|
||||
const view = useFeedView();
|
||||
const { setView } = useFeedViewActions();
|
||||
// The feed page restores its scroll by its own key (its filters, the live search); read always,
|
||||
// used only while the feed is the page.
|
||||
const feedScrollKey = useFeedScrollKey();
|
||||
const queryClient = useQueryClient();
|
||||
const [aboutOpen, setAboutOpen] = useState(false);
|
||||
const [notesOpen, setNotesOpen] = useState(false);
|
||||
@@ -119,16 +107,9 @@ export default function App() {
|
||||
// preload failed), and like every loading state it stays invisible for a load that is quick anyway.
|
||||
const pageFallback = <LoadingState className="flex-1 justify-center" />;
|
||||
|
||||
// Scroll restore/reset key for the shared normal-page scroller (see PageScroller): the same
|
||||
// page/feed-filters returns you to where you were (bug 1); a changed feed filter is a new key, so
|
||||
// the fresh result set starts at the top (bug 3). `q` is normalised out so typing in the search
|
||||
// box doesn't reset the scroll on every keystroke; the live-YouTube sub-view gets its own key.
|
||||
const normalScrollKey =
|
||||
page !== "feed"
|
||||
? `page:${page}`
|
||||
: ytSearch != null
|
||||
? `yt:${ytSearch}`
|
||||
: `feed:${JSON.stringify({ ...filters, q: "" })}`;
|
||||
// Scroll restore/reset key for the shared normal-page scroller (see PageScroller): the same page
|
||||
// returns you to where you were; the feed keys it by its filters (useFeedScrollKey).
|
||||
const normalScrollKey = page === "feed" ? feedScrollKey : `page:${page}`;
|
||||
|
||||
// The module registry drives the render from `page` alone — no per-page chrome conditionals. A page
|
||||
// the account can't reach never gets here: the navigation state already moved it to the feed
|
||||
@@ -168,23 +149,6 @@ export default function App() {
|
||||
channelId={channelView.id}
|
||||
initialName={channelView.name}
|
||||
me={me!}
|
||||
view={view}
|
||||
setView={setView}
|
||||
onShowInFeed={() => {
|
||||
// Keep the rest of the feed's filters — the point is "this channel, the way I
|
||||
// normally read": unwatched, my tags, my date window.
|
||||
// ADD to the picked set, don't replace it: channels OR together now, so arriving
|
||||
// from a second channel page should widen the feed, not silently drop the first.
|
||||
// Deduped — clicking it again for a channel already picked is a no-op.
|
||||
setFilters({
|
||||
...filters,
|
||||
channelIds: [...new Set([...filters.channelIds, channelView.id])],
|
||||
});
|
||||
// setPage already leaves an open channel page and pushes a clean history entry.
|
||||
// Calling closeChannel() first ran history.back(), whose popstate landed AFTER this
|
||||
// and restored whatever page the channel had been opened from.
|
||||
setPage("feed");
|
||||
}}
|
||||
/>
|
||||
</Suspense>
|
||||
</PageShell>
|
||||
|
||||
@@ -18,13 +18,19 @@ const FORBIDDEN: Record<string, string> = {
|
||||
i18next: "the app's i18n runtime",
|
||||
"react-i18next": "the app's i18n runtime",
|
||||
"@tanstack/react-query": "the app's data layer",
|
||||
"src/App.tsx": "the app shell",
|
||||
"src/main.tsx": "the app's entry",
|
||||
"src/lib/notifications.ts": "the app's notification center",
|
||||
"src/lib/logHooks.ts": "the app's client-log wiring",
|
||||
"flag-icons": "the app's flag sprites",
|
||||
[DYNAMIC]: "an import() of a computed path — no walk can say what it loads",
|
||||
};
|
||||
// …and the app's layers above the platform, by folder, so a file moved or added inside one is still
|
||||
// forbidden (a single path to the shell's App went stale when App moved).
|
||||
const FORBIDDEN_DIRS: Record<string, string> = {
|
||||
"src/shell/": "the app shell",
|
||||
"src/modules/": "an app page",
|
||||
"src/features/": "the app's shared features",
|
||||
};
|
||||
|
||||
function resolveSpec(from: string, spec: string): string | null {
|
||||
if (spec === DYNAMIC) return DYNAMIC;
|
||||
@@ -100,6 +106,9 @@ describe("the /watch entry", () => {
|
||||
const leaks = Object.keys(FORBIDDEN)
|
||||
.filter((mod) => seen.has(mod))
|
||||
.map((mod) => `${FORBIDDEN[mod]}: ${chain(seen, mod)}`);
|
||||
for (const mod of seen.keys())
|
||||
for (const [dir, what] of Object.entries(FORBIDDEN_DIRS))
|
||||
if (mod.startsWith(dir)) leaks.push(`${what}: ${chain(seen, mod)}`);
|
||||
expect(leaks).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -109,6 +118,7 @@ describe("the /watch entry", () => {
|
||||
const app = reach(join(SRC, "main.tsx"));
|
||||
expect(app.has("src/i18n/index.ts")).toBe(true);
|
||||
expect(app.has("src/components/legal/Terms.tsx")).toBe(true);
|
||||
expect([...app.keys()].some((m) => m.startsWith("src/shell/"))).toBe(true);
|
||||
});
|
||||
|
||||
it("serves the same head as the app's page, so the server's OG block lands the same way", () => {
|
||||
|
||||
Reference in New Issue
Block a user