refactor(frontend): the module stores mount from the registry, and the feed and Plex each own their saved views
A module's definition names its store (feed: filters, view, saved views; Plex: browse state, saved views; channels, files, playlists their provider), and ModuleStores mounts them at the root in registry order instead of a hand-nested tree in main.tsx. A contract test holds that every store's read side is in reach and that no store's static imports leave its own module, so the order cannot matter. Saved views were one provider that switched between the feed and Plex by the active page and reset on each switch. The logic is now one hook (useSavedViewsStore) with an instance per module; the shared chip, popover and modal read the views their page or rail hands down. A feed view applied and edited now survives a visit to Plex. Plex's views are still asked for only on its page, and a module's manage modal still closes when its page is left. The feed's URL-override check applies only on the feed page, where its filter params live. The Channels store's tab type moves from the page into the store's context.
This commit is contained in:
@@ -76,8 +76,9 @@ cannot (an ORM write on another unit's loaded row, SQL in `text()`, a commit by
|
|||||||
Frontend, by folder under `src/`: `main.tsx` provider tree → `shell/` (`App`, header, nav rail, palette,
|
Frontend, by folder under `src/`: `main.tsx` provider tree → `shell/` (`App`, header, nav rail, palette,
|
||||||
boot; a shared `/watch` link boots from its own entry, `watch.html` → `watchMain.tsx` → `watch/`, held
|
boot; a shared `/watch` link boots from its own entry, `watch.html` → `watchMain.tsx` → `watch/`, held
|
||||||
app-free by a contract test) → **`modules/<page>/`** (one folder per page: the page, its rail, its store, its tests,
|
app-free by a contract test) → **`modules/<page>/`** (one folder per page: the page, its rail, its store, its tests,
|
||||||
and its `module.ts` definition — label, icon, gate, the lazy page and rail — collected in nav order by
|
and its `module.ts` definition — label, icon, gate, the lazy page and rail, its store — collected in nav order by
|
||||||
`features/navigation/moduleRegistry`; a new page is its folder, its id in `lib/urlState` and one line there) →
|
`features/navigation/moduleRegistry`, which also mounts every store at the root; a new page is its folder, its id in
|
||||||
|
`lib/urlState` and one line there) →
|
||||||
**`features/<name>/`** (what several pages share: video cards, filters, downloads, the player, navigation) → the
|
**`features/<name>/`** (what several pages share: video cards, filters, downloads, the player, navigation) → the
|
||||||
**platform layer `components/ui/`** (domain-free primitives: the popover engine and its menus, `Modal`/`Overlay`,
|
**platform layer `components/ui/`** (domain-free primitives: the popover engine and its menus, `Modal`/`Overlay`,
|
||||||
`Button`/`form`, `DataTable`/`VirtualGrid`, `QueryState`,
|
`Button`/`form`, `DataTable`/`VirtualGrid`, `QueryState`,
|
||||||
|
|||||||
@@ -63,7 +63,8 @@ const CTX = {
|
|||||||
toggleDefault: vi.fn(async () => {}),
|
toggleDefault: vi.fn(async () => {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
vi.mock("./savedViewsContext", () => ({
|
vi.mock("./savedViewsContext", async (importOriginal) => ({
|
||||||
|
...(await importOriginal<typeof import("./savedViewsContext")>()),
|
||||||
useSavedViews: () => ({
|
useSavedViews: () => ({
|
||||||
...CTX,
|
...CTX,
|
||||||
activeViewId: h.activeViewId,
|
activeViewId: h.activeViewId,
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ import { Input } from "../../components/ui/Input";
|
|||||||
// the right column shows the selected view's name (renamable), its captured filters as REMOVABLE
|
// the right column shows the selected view's name (renamable), its captured filters as REMOVABLE
|
||||||
// chips (pruning writes back to the stored view, undoable), its default flag, its place in the order,
|
// chips (pruning writes back to the stored view, undoable), its default flag, its place in the order,
|
||||||
// apply, save-current, and delete. Opened from the filter panel's title or the saved-views popover;
|
// apply, save-current, and delete. Opened from the filter panel's title or the saved-views popover;
|
||||||
// all mutations flow through SavedViewsProvider.
|
// all mutations flow through the module's saved-views store (useSavedViewsStore).
|
||||||
function ViewRow({
|
function ViewRow({
|
||||||
view,
|
view,
|
||||||
selected,
|
selected,
|
||||||
|
|||||||
@@ -1,8 +1,11 @@
|
|||||||
import { createContext, useContext } from "react";
|
import { createContext, useContext } from "react";
|
||||||
import { coerceTagModes, coerceUntagged, type FeedFilters, type SavedView } from "../../lib/api";
|
import { coerceTagModes, coerceUntagged, type FeedFilters, type SavedView } from "../../lib/api";
|
||||||
|
|
||||||
// The saved-views context + hook (and the feed blob coercion its consumers share), split out of
|
// The saved-views contexts + hooks (and the feed blob coercion their consumers share). Each module that
|
||||||
// SavedViewsProvider so that file exports only components (Fast Refresh).
|
// has saved views (the feed, Plex) owns a store of them (useSavedViewsStore) under its own context, made
|
||||||
|
// by `createSavedViewsStore`. The shared UI here (popover, chip, modal) reads `useSavedViews()`: the
|
||||||
|
// views of the module whose page or rail it sits in, which that page or rail hands down with
|
||||||
|
// `SavedViewsScope`.
|
||||||
|
|
||||||
// A saved view's `filters` is an opaque JSON blob from the DB; coerce it to the module's shape before it
|
// A saved view's `filters` is an opaque JSON blob from the DB; coerce it to the module's shape before it
|
||||||
// reaches app state or a serializer (an older build's dropped field just falls away — NOT a migration).
|
// reaches app state or a serializer (an older build's dropped field just falls away — NOT a migration).
|
||||||
@@ -41,10 +44,25 @@ export interface SavedViewsContext {
|
|||||||
reorder: (nextIds: number[]) => Promise<void>;
|
reorder: (nextIds: number[]) => Promise<void>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const SavedViewsCtx = createContext<SavedViewsContext | null>(null);
|
// One module's store: the context its provider fills and the hook its own code (and the shell) reads.
|
||||||
|
export function createSavedViewsStore(owner: string) {
|
||||||
|
const Ctx = createContext<SavedViewsContext | null>(null);
|
||||||
|
Ctx.displayName = `${owner}SavedViews`;
|
||||||
|
function useModuleSavedViews(): SavedViewsContext {
|
||||||
|
const ctx = useContext(Ctx);
|
||||||
|
if (!ctx) throw new Error(`${owner}'s saved views are read outside its store`);
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
|
return { Provider: Ctx.Provider, useViews: useModuleSavedViews };
|
||||||
|
}
|
||||||
|
|
||||||
|
// The scope the shared UI reads: the views of the module it is rendered for
|
||||||
|
// (`<SavedViewsScope value={useFeedSavedViews()}>`).
|
||||||
|
const SavedViewsScopeCtx = createContext<SavedViewsContext | null>(null);
|
||||||
|
export const SavedViewsScope = SavedViewsScopeCtx.Provider;
|
||||||
|
|
||||||
export function useSavedViews(): SavedViewsContext {
|
export function useSavedViews(): SavedViewsContext {
|
||||||
const ctx = useContext(SavedViewsCtx);
|
const ctx = useContext(SavedViewsScopeCtx);
|
||||||
if (!ctx) throw new Error("useSavedViews must be used within a SavedViewsProvider");
|
if (!ctx) throw new Error("useSavedViews must be used within a SavedViewsScope");
|
||||||
return ctx;
|
return ctx;
|
||||||
}
|
}
|
||||||
|
|||||||
+37
-111
@@ -1,40 +1,21 @@
|
|||||||
import { useEffect, useEffectEvent, useMemo, useRef, useState, type ReactNode } from "react";
|
import { useEffect, useEffectEvent, useMemo, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { qk } from "../../lib/queryKeys";
|
import { qk } from "../../lib/queryKeys";
|
||||||
import {
|
import { api, type SavedView } from "../../lib/api";
|
||||||
api,
|
|
||||||
coercePlexFilters,
|
|
||||||
plexFiltersSignature,
|
|
||||||
type FeedFilters,
|
|
||||||
type PlexFilters,
|
|
||||||
type SavedView,
|
|
||||||
} from "../../lib/api";
|
|
||||||
import { filtersToParams, hasFilterParams } from "../../lib/urlState";
|
|
||||||
import { pickDefaultToSeed, preferSticky } from "../../lib/savedViews";
|
import { pickDefaultToSeed, preferSticky } from "../../lib/savedViews";
|
||||||
import { notifyActionError } from "../../lib/actionErrors";
|
import { notifyActionError } from "../../lib/actionErrors";
|
||||||
import { LS, ownKey } from "../../lib/storage";
|
import { ownKey } from "../../lib/storage";
|
||||||
import { useConfirm } from "../../components/ui/confirmContext";
|
import { useConfirm } from "../../components/ui/confirmContext";
|
||||||
import { useNavigation } from "../../features/navigation/navigationContext";
|
import type { SavedViewsContext, ViewSource } from "./savedViewsContext";
|
||||||
import { useFeedFilters, useFeedFiltersActions } from "./feedFiltersContext";
|
|
||||||
import { usePlex, usePlexActions } from "../plex";
|
|
||||||
import {
|
|
||||||
asFilters,
|
|
||||||
SavedViewsCtx,
|
|
||||||
type SavedViewsContext,
|
|
||||||
type ViewSource,
|
|
||||||
} from "../../features/filters/savedViewsContext";
|
|
||||||
|
|
||||||
// Saved "smart views" now serve TWO modules — the feed and Plex — keyed by `source`. This one provider
|
// Saved "smart views" serve two modules — the feed and Plex — and each owns one store of them in its
|
||||||
// switches which module it operates on by the active page (both filter providers are global, so it can
|
// own folder, built on this hook. The two never switch into each other: a visit to Plex leaves the
|
||||||
// read either), so the shared UI (popover / modal / chip) works on the feed page against feed views and
|
// feed's active view where it was.
|
||||||
// on the plex page against plex views without a second provider or an App-layout change.
|
|
||||||
//
|
|
||||||
// The context, its value type and the hook live in savedViewsContext.ts.
|
|
||||||
|
|
||||||
// The per-source wiring the generic logic runs on: the live filters, how to apply a view's, a stable
|
// The per-module wiring the generic logic runs on: the live filters, how to apply a view's, a stable
|
||||||
// signature for matching, coercion, and what to strip before saving (the feed's transient shuffle seed).
|
// signature for matching, coercion, and what to strip before saving (the feed's transient shuffle seed).
|
||||||
interface ViewConfig {
|
export interface SavedViewsConfig {
|
||||||
source: ViewSource;
|
source: ViewSource;
|
||||||
filters: Record<string, unknown>;
|
filters: Record<string, unknown>;
|
||||||
setFilters: (f: Record<string, unknown>) => void;
|
setFilters: (f: Record<string, unknown>) => void;
|
||||||
@@ -44,16 +25,17 @@ interface ViewConfig {
|
|||||||
/** Whether this module is a genuinely fresh start the starred default may seed on first load: no
|
/** Whether this module is a genuinely fresh start the starred default may seed on first load: no
|
||||||
* stored filters for the active account, and — feed only — no URL filter override. */
|
* stored filters for the active account, and — feed only — no URL filter override. */
|
||||||
canSeedDefault: () => boolean;
|
canSeedDefault: () => boolean;
|
||||||
|
/** Whether to ask for the views now (a signed-in account is required on top of this). */
|
||||||
|
enabled: boolean;
|
||||||
|
/** Whether the module's page is the one on screen. Its manage modal belongs to that visit: leaving
|
||||||
|
* the page closes it, so it does not reopen by itself on the way back. */
|
||||||
|
onScreen: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const feedKeyOf = (f: Record<string, unknown>) =>
|
|
||||||
filtersToParams(asFilters(f as unknown as FeedFilters)).toString();
|
|
||||||
const plexKeyOf = (f: Record<string, unknown>) => plexFiltersSignature(coercePlexFilters(f));
|
|
||||||
|
|
||||||
// True once this account has its own persisted filters for `lsKey` — the signal that the last
|
// True once this account has its own persisted filters for `lsKey` — the signal that the last
|
||||||
// view / manual tweaks (not the default) should win. Account unknown or localStorage unavailable →
|
// view / manual tweaks (not the default) should win. Account unknown or localStorage unavailable →
|
||||||
// report "stored" so a default never seeds blind over an indeterminate state.
|
// report "stored" so a default never seeds blind over an indeterminate state.
|
||||||
function storedFiltersPresent(lsKey: string): boolean {
|
export function storedFiltersPresent(lsKey: string): boolean {
|
||||||
const key = ownKey(lsKey);
|
const key = ownKey(lsKey);
|
||||||
if (!key) return true;
|
if (!key) return true;
|
||||||
try {
|
try {
|
||||||
@@ -62,55 +44,11 @@ function storedFiltersPresent(lsKey: string): boolean {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Feed also yields to a URL that carries filter params (a shared/bookmarked view).
|
|
||||||
function feedCanSeedDefault(): boolean {
|
|
||||||
if (hasFilterParams(new URLSearchParams(window.location.search))) return false;
|
|
||||||
return !storedFiltersPresent(LS.filters);
|
|
||||||
}
|
|
||||||
// Plex views are facets-only, so LS.plexFilters is the "has the user set this module's filters" key.
|
|
||||||
function plexCanSeedDefault(): boolean {
|
|
||||||
return !storedFiltersPresent(LS.plexFilters);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function SavedViewsProvider({ children }: { children: ReactNode }) {
|
export function useSavedViewsStore(cfg: SavedViewsConfig): SavedViewsContext {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const qc = useQueryClient();
|
const qc = useQueryClient();
|
||||||
const confirm = useConfirm();
|
const confirm = useConfirm();
|
||||||
const { page } = useNavigation();
|
|
||||||
|
|
||||||
// Both filter providers are global — read both, then use the one the active page owns.
|
|
||||||
const feedFilters = useFeedFilters();
|
|
||||||
const { setFilters: setFeedFilters } = useFeedFiltersActions();
|
|
||||||
const { filters: plexFilters } = usePlex();
|
|
||||||
const { setFilters: setPlexFilters } = usePlexActions();
|
|
||||||
|
|
||||||
const cfg: ViewConfig = useMemo(() => {
|
|
||||||
if (page === "plex") {
|
|
||||||
return {
|
|
||||||
source: "plex",
|
|
||||||
filters: plexFilters as unknown as Record<string, unknown>,
|
|
||||||
setFilters: (f) => setPlexFilters(f as unknown as PlexFilters),
|
|
||||||
keyOf: plexKeyOf,
|
|
||||||
asFilters: (raw) => coercePlexFilters(raw) as unknown as Record<string, unknown>,
|
|
||||||
stripForSave: (f) => f, // Plex has no transient field to drop
|
|
||||||
canSeedDefault: plexCanSeedDefault,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
source: "feed",
|
|
||||||
filters: feedFilters as unknown as Record<string, unknown>,
|
|
||||||
setFilters: (f) => setFeedFilters(f as unknown as FeedFilters),
|
|
||||||
keyOf: feedKeyOf,
|
|
||||||
asFilters: (raw) =>
|
|
||||||
asFilters(raw as unknown as FeedFilters) as unknown as Record<string, unknown>,
|
|
||||||
// Strip the transient shuffle seed so a saved "shuffle" view re-rolls fresh on apply.
|
|
||||||
stripForSave: (f) => {
|
|
||||||
const { seed: _seed, ...rest } = f;
|
|
||||||
return rest;
|
|
||||||
},
|
|
||||||
canSeedDefault: feedCanSeedDefault,
|
|
||||||
};
|
|
||||||
}, [page, feedFilters, plexFilters, setFeedFilters, setPlexFilters]);
|
|
||||||
|
|
||||||
const source = cfg.source;
|
const source = cfg.source;
|
||||||
// Passive observer of the account BootGate fetches: the providers mount for a signed-out tab too
|
// Passive observer of the account BootGate fetches: the providers mount for a signed-out tab too
|
||||||
@@ -120,49 +58,39 @@ export function SavedViewsProvider({ children }: { children: ReactNode }) {
|
|||||||
const viewsQuery = useQuery({
|
const viewsQuery = useQuery({
|
||||||
queryKey: qk.savedViews(source),
|
queryKey: qk.savedViews(source),
|
||||||
queryFn: () => api.savedViews(source),
|
queryFn: () => api.savedViews(source),
|
||||||
enabled: !!me,
|
enabled: !!me && cfg.enabled,
|
||||||
});
|
});
|
||||||
const views = useMemo(() => viewsQuery.data ?? [], [viewsQuery.data]);
|
const views = useMemo(() => viewsQuery.data ?? [], [viewsQuery.data]);
|
||||||
|
|
||||||
const [stickyId, setStickyId] = useState<number | null>(null);
|
const [stickyId, setStickyId] = useState<number | null>(null);
|
||||||
const [modalOpen, setModalOpen] = useState(false);
|
const [modalOpen, setModalOpen] = useState(false);
|
||||||
|
// Adjusted during render, not from an effect, so the frame after leaving never commits it open.
|
||||||
|
if (modalOpen && !cfg.onScreen) setModalOpen(false);
|
||||||
|
|
||||||
// Switching modules (feed ⟷ plex) starts fresh: a feed view can't be the sticky/active one on plex,
|
// The ONE default-application path for both modules. On the views query's FIRST resolve, seed the
|
||||||
// and a feed modal shouldn't linger open on plex. Adjusted DURING RENDER against the previous
|
// starred default view's filters — but only into a genuinely fresh module (no stored filters, no URL
|
||||||
// source rather than from an effect, so the first plex render never commits carrying the feed's
|
// override; see cfg.canSeedDefault). Applying via cfg.setFilters persists it, so it happens once and
|
||||||
// sticky id — which is exactly long enough for a stale view chip to paint.
|
// the normal last-view-wins path takes over, exactly as the storage semantics document. This replaced
|
||||||
const [prevSource, setPrevSource] = useState(source);
|
// the feed-only localStorage mirror that raced FeedFiltersProvider's login effect (on a fresh browser
|
||||||
if (prevSource !== source) {
|
// the feed could seed DEFAULT_FILTERS and never re-apply until a reload) — and it gives Plex the
|
||||||
setPrevSource(source);
|
// default it previously never applied at all. One-shot, so a background refetch never re-seeds over
|
||||||
setStickyId(null);
|
// the filters you're editing. canSeedDefault() reads the account's stored-filters key, which is always
|
||||||
setModalOpen(false);
|
// readable here: BootGate hands the browser's local state to the account (claimLocalData) before
|
||||||
}
|
// this store mounts.
|
||||||
|
const seededRef = useRef(false);
|
||||||
// The ONE default-application path for both modules. On the views query's FIRST resolve for a
|
// `cfg` changes on every filter edit, so it is read through an effect event: the seed must fire on
|
||||||
// source, seed the starred default view's filters — but only into a genuinely fresh module (no
|
// the views query's first resolve and never again, which `seededRef` enforces. Listing cfg would
|
||||||
// stored filters, no URL override; see cfg.canSeedDefault). Applying via cfg.setFilters persists
|
// re-arm nothing (the guard still holds) but would re-run the effect on every keystroke.
|
||||||
// it, so it happens once and the normal last-view-wins path takes over, exactly as the storage
|
|
||||||
// semantics document. This replaced the feed-only localStorage mirror that raced FeedFiltersProvider's
|
|
||||||
// login effect (on a fresh browser the feed could seed DEFAULT_FILTERS and never re-apply until a
|
|
||||||
// reload) — and it gives Plex the default it previously never applied at all. Per-source guard so a
|
|
||||||
// 60s background refetch never re-seeds over the filters you're editing. canSeedDefault() reads the
|
|
||||||
// account's stored-filters key, which is always readable here: BootGate hands the browser's local
|
|
||||||
// state to the account (claimLocalData) before this provider mounts.
|
|
||||||
const seededRef = useRef<Set<ViewSource>>(new Set());
|
|
||||||
// `cfg` re-memoizes on every filter edit, so it is read through an effect event: the seed must
|
|
||||||
// fire on the views query's first resolve for a source and never again, which `seededRef`
|
|
||||||
// enforces. Listing cfg would re-arm nothing (the guard still holds) but would re-run the effect
|
|
||||||
// on every keystroke in the filter bar.
|
|
||||||
const latestCfg = useEffectEvent(() => cfg);
|
const latestCfg = useEffectEvent(() => cfg);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const data = viewsQuery.data;
|
const data = viewsQuery.data;
|
||||||
if (!data || seededRef.current.has(source)) return;
|
if (!data || seededRef.current) return;
|
||||||
seededRef.current.add(source);
|
seededRef.current = true;
|
||||||
const c = latestCfg();
|
const c = latestCfg();
|
||||||
const def = pickDefaultToSeed(data, c.canSeedDefault());
|
const def = pickDefaultToSeed(data, c.canSeedDefault());
|
||||||
// The name follows by itself: `preferSticky` picks the starred default among same-filter views.
|
// The name follows by itself: `preferSticky` picks the starred default among same-filter views.
|
||||||
if (def) c.setFilters(c.asFilters(def.filters));
|
if (def) c.setFilters(c.asFilters(def.filters));
|
||||||
}, [viewsQuery.data, source]);
|
}, [viewsQuery.data]);
|
||||||
|
|
||||||
const currentKey = cfg.keyOf(cfg.filters);
|
const currentKey = cfg.keyOf(cfg.filters);
|
||||||
const activeView = useMemo(
|
const activeView = useMemo(
|
||||||
@@ -198,7 +126,7 @@ export function SavedViewsProvider({ children }: { children: ReactNode }) {
|
|||||||
return ok;
|
return ok;
|
||||||
}
|
}
|
||||||
|
|
||||||
const value: SavedViewsContext = {
|
return {
|
||||||
source,
|
source,
|
||||||
views,
|
views,
|
||||||
loading: viewsQuery.isLoading,
|
loading: viewsQuery.isLoading,
|
||||||
@@ -259,6 +187,4 @@ export function SavedViewsProvider({ children }: { children: ReactNode }) {
|
|||||||
await runViewMutation(() => api.reorderViews(nextIds), t("views.reorderFailed"));
|
await runViewMutation(() => api.reorderViews(nextIds), t("views.reorderFailed"));
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
return <SavedViewsCtx.Provider value={value}>{children}</SavedViewsCtx.Provider>;
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { MODULE_STORES } from "./moduleRegistry";
|
||||||
|
|
||||||
|
// Every module's store, mounted once at the root for the whole session, composed from the registry
|
||||||
|
// instead of nested by hand: a new module's store is its definition's `Store` and nothing here. They sit
|
||||||
|
// at the root and not inside their pages because they are read from outside them (the palette, the
|
||||||
|
// notifications, the download tray steering Files) and hold in-memory state — search terms, the Plex
|
||||||
|
// jump target, the Files focus — that has to outlive a visit to another page.
|
||||||
|
export function ModuleStores({ children }: { children: ReactNode }) {
|
||||||
|
return MODULE_STORES.reduceRight<ReactNode>((inner, Store) => <Store>{inner}</Store>, children);
|
||||||
|
}
|
||||||
@@ -29,8 +29,10 @@ describe("the module registry", () => {
|
|||||||
|
|
||||||
it.each([...PAGES])("loads %s's page (and rail) lazily", (page) => {
|
it.each([...PAGES])("loads %s's page (and rail) lazily", (page) => {
|
||||||
// A static import of the page in its definition would put it on the boot path for every
|
// A static import of the page in its definition would put it on the boot path for every
|
||||||
// account; the chunk guard fails the build on that too, this says which module did it.
|
// account; the chunk guard fails the build on that too, this says which module did it. The one
|
||||||
|
// static import a definition may make of its own folder is its store (a `…Provider` / `…Store`
|
||||||
|
// file): that is root-mounted, on the boot path by design.
|
||||||
const source = readFileSync(resolve(SRC, `modules/${page}/module.ts`), "utf8");
|
const source = readFileSync(resolve(SRC, `modules/${page}/module.ts`), "utf8");
|
||||||
expect(source).not.toMatch(/^import [^;]* from "\.\//m);
|
expect(source).not.toMatch(/^import [^;]* from "\.\/(?!\w+(?:Provider|Store)")/m);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -97,3 +97,6 @@ export function preloadPages(pages: Page[]): void {
|
|||||||
def.Rail?.preload().catch(() => {});
|
def.Rail?.preload().catch(() => {});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The modules' stores (lib/moduleDef `Store`), in registry order: ModuleStores mounts them at the root.
|
||||||
|
export const MODULE_STORES = ALL.flatMap((m) => (m.Store ? [m.Store] : []));
|
||||||
|
|||||||
@@ -0,0 +1,91 @@
|
|||||||
|
// @vitest-environment jsdom
|
||||||
|
//
|
||||||
|
// The modules' stores are mounted at the root by ModuleStores, composed from the registry in its order
|
||||||
|
// (lib/moduleDef `Store`). Two things make that safe, and this pins both: every store a reader needs is
|
||||||
|
// in the composition (a missing one throws in its first reader — the palette, the notifications, a
|
||||||
|
// rail), and no store reads another module's store, so the order they happen to nest in can never
|
||||||
|
// leave one outside a provider it depends on.
|
||||||
|
import { readFileSync, existsSync } from "node:fs";
|
||||||
|
import { dirname, resolve } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { render } from "@testing-library/react";
|
||||||
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { ModuleStores } from "./ModuleStores";
|
||||||
|
import { REGISTERED_PAGES, moduleDef } from "./moduleRegistry";
|
||||||
|
import { useFeedFilters, useFeedSavedViews, useFeedView } from "../../modules/feed";
|
||||||
|
import { usePlex, usePlexSavedViews } from "../../modules/plex";
|
||||||
|
import { useChannelsActions } from "../../modules/channels";
|
||||||
|
import { useFilesActions } from "../../modules/files";
|
||||||
|
import { usePlaylistsActions } from "../../modules/playlists";
|
||||||
|
|
||||||
|
vi.mock("react-i18next", async (importOriginal) => ({
|
||||||
|
...(await importOriginal<typeof import("react-i18next")>()),
|
||||||
|
useTranslation: () => ({ t: (k: string) => k, i18n: { language: "en" } }),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const SRC = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
|
||||||
|
|
||||||
|
// Each store's read side, as the app's readers reach it (the module faces).
|
||||||
|
function Readers() {
|
||||||
|
useFeedFilters();
|
||||||
|
useFeedView();
|
||||||
|
useFeedSavedViews();
|
||||||
|
usePlex();
|
||||||
|
usePlexSavedViews();
|
||||||
|
useChannelsActions();
|
||||||
|
useFilesActions();
|
||||||
|
usePlaylistsActions();
|
||||||
|
return <p>all stores in reach</p>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// The static (value) imports of a source file, resolved to files under src/. Type-only imports and
|
||||||
|
// lazy `import()` are not edges a store's render depends on.
|
||||||
|
function staticImports(file: string): string[] {
|
||||||
|
const text = readFileSync(file, "utf8");
|
||||||
|
const out: string[] = [];
|
||||||
|
for (const m of text.matchAll(/^import (?!type )[^;]*? from "(\.[^"]+)";/gms)) {
|
||||||
|
const base = resolve(dirname(file), m[1]!);
|
||||||
|
const path = [base + ".ts", base + ".tsx", base + "/index.ts", base + "/index.tsx", base].find(
|
||||||
|
(p) => /\.tsx?$/.test(p) && existsSync(p),
|
||||||
|
);
|
||||||
|
if (path) out.push(path);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Every file a module's definition statically pulls in — its store and what the store imports.
|
||||||
|
function closure(start: string): Set<string> {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const todo = [start];
|
||||||
|
while (todo.length) {
|
||||||
|
const f = todo.pop()!;
|
||||||
|
if (seen.has(f)) continue;
|
||||||
|
seen.add(f);
|
||||||
|
todo.push(...staticImports(f));
|
||||||
|
}
|
||||||
|
return seen;
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("the module stores", () => {
|
||||||
|
it("are all in reach under ModuleStores", () => {
|
||||||
|
const qc = new QueryClient();
|
||||||
|
const { getByText } = render(
|
||||||
|
<QueryClientProvider client={qc}>
|
||||||
|
<ModuleStores>
|
||||||
|
<Readers />
|
||||||
|
</ModuleStores>
|
||||||
|
</QueryClientProvider>,
|
||||||
|
);
|
||||||
|
expect(getByText("all stores in reach")).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
const withStore = REGISTERED_PAGES.filter((p) => moduleDef(p).Store);
|
||||||
|
it.each(withStore)("%s's store reads no other module", (page) => {
|
||||||
|
const own = resolve(SRC, "modules", page).split("\\").join("/") + "/";
|
||||||
|
const others = [...closure(resolve(SRC, `modules/${page}/module.ts`))]
|
||||||
|
.map((f) => f.split("\\").join("/"))
|
||||||
|
.filter((f) => f.includes("/src/modules/") && !f.startsWith(own));
|
||||||
|
expect(others).toEqual([]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import type { ComponentType, ReactNode } from "react";
|
||||||
import type { LucideIcon } from "lucide-react";
|
import type { LucideIcon } from "lucide-react";
|
||||||
import type { Me } from "./api";
|
import type { Me } from "./api";
|
||||||
import type { Page } from "./urlState";
|
import type { Page } from "./urlState";
|
||||||
@@ -37,4 +38,10 @@ export type ModuleDef = {
|
|||||||
// so the floating chrome lines up with the centred content instead of spanning the whole column.
|
// so the floating chrome lines up with the centred content instead of spanning the whole column.
|
||||||
// MUST match the max-w the page's own content wrapper uses. Omit for a full-width page (feed).
|
// MUST match the max-w the page's own content wrapper uses. Omit for a full-width page (feed).
|
||||||
maxWidth?: string;
|
maxWidth?: string;
|
||||||
|
// The module's store, if it has one: the state its page, its rail and its readers elsewhere (the
|
||||||
|
// palette, the notifications, the header search) share. Mounted at the root for the whole session
|
||||||
|
// (features/navigation/ModuleStores), so what it holds in memory survives a visit to another page.
|
||||||
|
// Static, unlike the page: it is on the boot path by design. It reads no other module's store, so
|
||||||
|
// the order the stores nest in does not matter (moduleStores.contract.test).
|
||||||
|
Store?: ComponentType<{ children: ReactNode }>;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
// Pure selection helpers for saved ("personal") views, split out of SavedViewsProvider so the
|
// Pure selection helpers for saved ("personal") views, split out of useSavedViewsStore so the
|
||||||
// tie-break logic is unit-testable without mounting the provider (its React Query / context deps).
|
// tie-break logic is unit-testable without mounting a store (its React Query / context deps).
|
||||||
|
|
||||||
/** Among the views whose filters match the live filters, pick the one to treat as active: prefer the
|
/** Among the views whose filters match the live filters, pick the one to treat as active: prefer the
|
||||||
* explicitly-applied ("sticky") view when several capture identical filters, so apply(B) isn't
|
* explicitly-applied ("sticky") view when several capture identical filters, so apply(B) isn't
|
||||||
@@ -23,7 +23,7 @@ export function preferSticky<T extends { id: number; is_default?: boolean }>(
|
|||||||
* account and — feed only — no URL filter override); otherwise the last-session/URL/manual filters
|
* account and — feed only — no URL filter override); otherwise the last-session/URL/manual filters
|
||||||
* win, exactly as they do after any edit. First `is_default` wins if somehow several are flagged, so
|
* win, exactly as they do after any edit. First `is_default` wins if somehow several are flagged, so
|
||||||
* the choice is deterministic. This is the single default-application path for BOTH the feed and Plex
|
* the choice is deterministic. This is the single default-application path for BOTH the feed and Plex
|
||||||
* (SavedViewsProvider drives it on the views query's first resolve) — it replaced the feed-only
|
* (useSavedViewsStore drives it on the views query's first resolve) — it replaced the feed-only
|
||||||
* localStorage mirror that raced the login effect and left Plex with no default at all. */
|
* localStorage mirror that raced the login effect and left Plex with no default at all. */
|
||||||
export function pickDefaultToSeed<T extends { is_default: boolean }>(
|
export function pickDefaultToSeed<T extends { is_default: boolean }>(
|
||||||
views: T[],
|
views: T[],
|
||||||
|
|||||||
+12
-30
@@ -7,11 +7,7 @@ import ErrorBoundary from "./shell/ErrorBoundary";
|
|||||||
import { ConfirmProvider } from "./components/ui/ConfirmProvider";
|
import { ConfirmProvider } from "./components/ui/ConfirmProvider";
|
||||||
import BootGate, { BootScreen } from "./shell/BootGate";
|
import BootGate, { BootScreen } from "./shell/BootGate";
|
||||||
import { NavigationProvider } from "./features/navigation/NavigationProvider";
|
import { NavigationProvider } from "./features/navigation/NavigationProvider";
|
||||||
import { FeedFiltersProvider, FeedViewProvider, SavedViewsProvider } from "./modules/feed";
|
import { ModuleStores } from "./features/navigation/ModuleStores";
|
||||||
import { PlaylistsProvider } from "./modules/playlists";
|
|
||||||
import { FilesProvider } from "./modules/files";
|
|
||||||
import { PlexProvider } from "./modules/plex";
|
|
||||||
import { ChannelsProvider } from "./modules/channels";
|
|
||||||
import { PrefsProvider } from "./features/prefs/PrefsProvider";
|
import { PrefsProvider } from "./features/prefs/PrefsProvider";
|
||||||
import { WizardProvider } from "./features/wizard/WizardProvider";
|
import { WizardProvider } from "./features/wizard/WizardProvider";
|
||||||
import "./i18n";
|
import "./i18n";
|
||||||
@@ -77,31 +73,17 @@ const root =
|
|||||||
seeds per-account state in its initializer (see BootGate). */}
|
seeds per-account state in its initializer (see BootGate). */}
|
||||||
<BootGate>
|
<BootGate>
|
||||||
<NavigationProvider>
|
<NavigationProvider>
|
||||||
<FeedFiltersProvider>
|
<ModuleStores>
|
||||||
<PlaylistsProvider>
|
<PrefsProvider>
|
||||||
<FilesProvider>
|
<WizardProvider>
|
||||||
<PlexProvider>
|
{/* The same quiet screen as the gate's, so a chunk that is still
|
||||||
{/* SavedViewsProvider sits below BOTH filter providers so it can switch its
|
loading when the gate opens continues it instead of blanking. */}
|
||||||
source (feed ⟷ plex) by the active page and read the right live filters. */}
|
<Suspense fallback={<BootScreen />}>
|
||||||
<SavedViewsProvider>
|
<App />
|
||||||
<ChannelsProvider>
|
</Suspense>
|
||||||
<FeedViewProvider>
|
</WizardProvider>
|
||||||
<PrefsProvider>
|
</PrefsProvider>
|
||||||
<WizardProvider>
|
</ModuleStores>
|
||||||
{/* The same quiet screen as the gate's, so a chunk that is still
|
|
||||||
loading when the gate opens continues it instead of blanking. */}
|
|
||||||
<Suspense fallback={<BootScreen />}>
|
|
||||||
<App />
|
|
||||||
</Suspense>
|
|
||||||
</WizardProvider>
|
|
||||||
</PrefsProvider>
|
|
||||||
</FeedViewProvider>
|
|
||||||
</ChannelsProvider>
|
|
||||||
</SavedViewsProvider>
|
|
||||||
</PlexProvider>
|
|
||||||
</FilesProvider>
|
|
||||||
</PlaylistsProvider>
|
|
||||||
</FeedFiltersProvider>
|
|
||||||
</NavigationProvider>
|
</NavigationProvider>
|
||||||
</BootGate>
|
</BootGate>
|
||||||
</ConfirmProvider>
|
</ConfirmProvider>
|
||||||
|
|||||||
@@ -58,7 +58,12 @@ import { choiceSubmenu, sortSubmenu } from "../../components/ui/pageMenuItems";
|
|||||||
import { optionsFromColumns } from "../../lib/sortOptions";
|
import { optionsFromColumns } from "../../lib/sortOptions";
|
||||||
import ChannelDiscovery from "./ChannelDiscovery";
|
import ChannelDiscovery from "./ChannelDiscovery";
|
||||||
import TagManager from "../../features/channel/TagManager";
|
import TagManager from "../../features/channel/TagManager";
|
||||||
import { useChannelLayout, useChannels, useChannelsActions } from "./channelsContext";
|
import {
|
||||||
|
useChannelLayout,
|
||||||
|
useChannels,
|
||||||
|
useChannelsActions,
|
||||||
|
type ChannelsView,
|
||||||
|
} from "./channelsContext";
|
||||||
import { useNavigationActions } from "../../features/navigation/navigationContext";
|
import { useNavigationActions } from "../../features/navigation/navigationContext";
|
||||||
import { useFeedFilters, useFeedFiltersActions } from "../feed";
|
import { useFeedFilters, useFeedFiltersActions } from "../feed";
|
||||||
import { useWizard } from "../../features/wizard/wizardContext";
|
import { useWizard } from "../../features/wizard/wizardContext";
|
||||||
@@ -66,8 +71,6 @@ import { useMe } from "../../lib/useMe";
|
|||||||
import { useTags } from "../../lib/useTags";
|
import { useTags } from "../../lib/useTags";
|
||||||
import ChannelsHeader from "./ChannelsHeader";
|
import ChannelsHeader from "./ChannelsHeader";
|
||||||
|
|
||||||
export type ChannelsView = "subscribed" | "discovery" | "hidden" | "blocked";
|
|
||||||
|
|
||||||
// The tag filter has its own chip row above the list, so the hidden-filter cue does not name it again.
|
// The tag filter has its own chip row above the list, so the hidden-filter cue does not name it again.
|
||||||
const SURFACED_FILTERS = ["tags"];
|
const SURFACED_FILTERS = ["tags"];
|
||||||
|
|
||||||
|
|||||||
@@ -24,13 +24,13 @@ import {
|
|||||||
} from "../../lib/channelsUrlState";
|
} from "../../lib/channelsUrlState";
|
||||||
import type { FilterMap } from "../../lib/tableColumns";
|
import type { FilterMap } from "../../lib/tableColumns";
|
||||||
import { useNavigation, useNavigationActions } from "../../features/navigation/navigationContext";
|
import { useNavigation, useNavigationActions } from "../../features/navigation/navigationContext";
|
||||||
import type { ChannelsView } from "./Channels";
|
|
||||||
import {
|
import {
|
||||||
ChannelLayoutContext,
|
ChannelLayoutContext,
|
||||||
ChannelsActionsContext,
|
ChannelsActionsContext,
|
||||||
ChannelsStateContext,
|
ChannelsStateContext,
|
||||||
type ChannelsActions,
|
type ChannelsActions,
|
||||||
type ChannelsState,
|
type ChannelsState,
|
||||||
|
type ChannelsView,
|
||||||
} from "./channelsContext";
|
} from "./channelsContext";
|
||||||
|
|
||||||
// Owns the Channels module's shared local state — the rail-less manager's name filter, column-filter
|
// Owns the Channels module's shared local state — the rail-less manager's name filter, column-filter
|
||||||
|
|||||||
@@ -2,11 +2,13 @@ import { createContext, useContext } from "react";
|
|||||||
import type { ChannelLayout } from "../../lib/channelLayout";
|
import type { ChannelLayout } from "../../lib/channelLayout";
|
||||||
import type { SortState } from "../../lib/columnSort";
|
import type { SortState } from "../../lib/columnSort";
|
||||||
import type { FilterMap } from "../../lib/tableColumns";
|
import type { FilterMap } from "../../lib/tableColumns";
|
||||||
import type { ChannelsView } from "./Channels";
|
|
||||||
|
|
||||||
// The Channels module's contexts + hooks, split out of ChannelsProvider so that file exports only
|
// The Channels module's contexts + hooks, split out of ChannelsProvider so that file exports only
|
||||||
// components (Fast Refresh). The provider owns the state; this is the read side every consumer imports.
|
// components (Fast Refresh). The provider owns the state; this is the read side every consumer imports.
|
||||||
|
|
||||||
|
// The manager's tabs.
|
||||||
|
export type ChannelsView = "subscribed" | "discovery" | "hidden" | "blocked";
|
||||||
|
|
||||||
export type ChannelsState = {
|
export type ChannelsState = {
|
||||||
search: string;
|
search: string;
|
||||||
// The subscribed table's column filters, lifted out of DataTable so the top status chips, the Sync
|
// The subscribed table's column filters, lifted out of DataTable so the top status chips, the Sync
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
// The channel manager's public face: its store, mounted at the root and read app-wide, and the sync
|
// The channel manager's public face: its store, mounted at the root and read app-wide, and the sync
|
||||||
// chip, which the feed's header shows too.
|
// chip, which the feed's header shows too.
|
||||||
export { ChannelsProvider } from "./ChannelsProvider";
|
|
||||||
export { useChannelsActions } from "./channelsContext";
|
export { useChannelsActions } from "./channelsContext";
|
||||||
export { default as SyncStatus } from "./SyncStatus";
|
export { default as SyncStatus } from "./SyncStatus";
|
||||||
export { channelsModule } from "./module";
|
export { channelsModule } from "./module";
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Tv } from "lucide-react";
|
import { Tv } from "lucide-react";
|
||||||
import { lazyWithPreload } from "../../lib/lazyWithPreload";
|
import { lazyWithPreload } from "../../lib/lazyWithPreload";
|
||||||
import type { ModuleDef } from "../../lib/moduleDef";
|
import type { ModuleDef } from "../../lib/moduleDef";
|
||||||
|
import { ChannelsProvider } from "./ChannelsProvider";
|
||||||
|
|
||||||
// This page as the shell and the navigation know it (lib/moduleDef). Its page load lazily.
|
// This page as the shell and the navigation know it (lib/moduleDef). Its page load lazily.
|
||||||
export const channelsModule: ModuleDef = {
|
export const channelsModule: ModuleDef = {
|
||||||
@@ -11,4 +12,5 @@ export const channelsModule: ModuleDef = {
|
|||||||
icon: Tv,
|
icon: Tv,
|
||||||
Page: lazyWithPreload(() => import("./Channels")),
|
Page: lazyWithPreload(() => import("./Channels")),
|
||||||
maxWidth: "96rem",
|
maxWidth: "96rem",
|
||||||
|
Store: ChannelsProvider,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ import { useFeedFilters, useFeedFiltersActions } from "./feedFiltersContext";
|
|||||||
import { useFeedView, useFeedViewActions } from "./feedViewContext";
|
import { useFeedView, useFeedViewActions } from "./feedViewContext";
|
||||||
import { useNavigation, useNavigationActions } from "../../features/navigation/navigationContext";
|
import { useNavigation, useNavigationActions } from "../../features/navigation/navigationContext";
|
||||||
import PersonalViewsModal from "../../features/filters/PersonalViewsModal";
|
import PersonalViewsModal from "../../features/filters/PersonalViewsModal";
|
||||||
import { useSavedViews } from "../../features/filters/savedViewsContext";
|
import { SavedViewsScope } from "../../features/filters/savedViewsContext";
|
||||||
|
import { useFeedSavedViews } from "./feedSavedViews";
|
||||||
import FeedHeader from "./FeedHeader";
|
import FeedHeader from "./FeedHeader";
|
||||||
import Feed from "./Feed";
|
import Feed from "./Feed";
|
||||||
|
|
||||||
@@ -21,9 +22,10 @@ export default function FeedPage() {
|
|||||||
// The Personal Views modal lives at the feed-page level (always mounted while on the feed, unmounts
|
// The Personal Views modal lives at the feed-page level (always mounted while on the feed, unmounts
|
||||||
// on navigation) rather than inside the collapsible filter rail — otherwise opening it from the
|
// on navigation) rather than inside the collapsible filter rail — otherwise opening it from the
|
||||||
// always-visible toolbar view chip while the rail is unpinned would render nothing and latch open.
|
// always-visible toolbar view chip while the rail is unpinned would render nothing and latch open.
|
||||||
const { modalOpen } = useSavedViews();
|
// The feed's saved views reach the shared views UI under this page (the chip, the modal) by the scope.
|
||||||
|
const savedViews = useFeedSavedViews();
|
||||||
return (
|
return (
|
||||||
<>
|
<SavedViewsScope value={savedViews}>
|
||||||
<FeedHeader />
|
<FeedHeader />
|
||||||
<Feed
|
<Feed
|
||||||
filters={filters}
|
filters={filters}
|
||||||
@@ -35,7 +37,7 @@ export default function FeedPage() {
|
|||||||
onYtSearch={enterYtSearch}
|
onYtSearch={enterYtSearch}
|
||||||
onExitYtSearch={exitYtSearch}
|
onExitYtSearch={exitYtSearch}
|
||||||
/>
|
/>
|
||||||
{modalOpen && <PersonalViewsModal />}
|
{savedViews.modalOpen && <PersonalViewsModal />}
|
||||||
</>
|
</SavedViewsScope>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+95
-72
@@ -1,19 +1,34 @@
|
|||||||
// @vitest-environment jsdom
|
// @vitest-environment jsdom
|
||||||
//
|
//
|
||||||
// The single default-application path. On the saved-views query's FIRST resolve, SavedViewsProvider
|
// The feed's saved views. The single default-application path: on the views query's FIRST resolve, the
|
||||||
// seeds the starred default view's filters into a genuinely fresh module (no stored filters), for BOTH
|
// starred default view's filters seed a genuinely fresh feed (no stored filters, no URL filter override)
|
||||||
// the feed and Plex — but leaves them alone once the account has its own stored filters. This is the
|
// — and are left alone once the account has its own stored filters. This is the fix for the old
|
||||||
// fix for the old feed-only LS mirror that raced FeedFiltersProvider's login effect and never applied a
|
// feed-only LS mirror that raced FeedFiltersProvider's login effect. And the store is the feed's own: a
|
||||||
// Plex default at all. The sub-providers are mocked so we can assert exactly which setFilters is called;
|
// visit to another page leaves its active view where it was. The feed filters are mocked so we can
|
||||||
// api.savedViews is stubbed and driven through a real QueryClient.
|
// 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 { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||||
import { render, screen, waitFor, cleanup, act } from "@testing-library/react";
|
import { render, screen, waitFor, cleanup, act } from "@testing-library/react";
|
||||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||||
|
import { useEffect } from "react";
|
||||||
|
|
||||||
|
const BASE = {
|
||||||
|
tags: [],
|
||||||
|
channelIds: [],
|
||||||
|
tagModes: { language: "or", topic: "or", other: "or" },
|
||||||
|
q: "",
|
||||||
|
sort: "newest",
|
||||||
|
scope: "my",
|
||||||
|
includeNormal: true,
|
||||||
|
includeShorts: false,
|
||||||
|
includeLive: false,
|
||||||
|
show: "unwatched",
|
||||||
|
};
|
||||||
|
|
||||||
const h = vi.hoisted(() => ({
|
const h = vi.hoisted(() => ({
|
||||||
page: "feed" as "feed" | "plex",
|
page: "feed" as string,
|
||||||
|
filters: {} as Record<string, unknown>,
|
||||||
setFeedFilters: vi.fn(),
|
setFeedFilters: vi.fn(),
|
||||||
setPlexFilters: vi.fn(),
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock("react-i18next", async (importOriginal) => {
|
vi.mock("react-i18next", async (importOriginal) => {
|
||||||
@@ -24,31 +39,17 @@ vi.mock("../../features/navigation/navigationContext", () => ({
|
|||||||
useNavigation: () => ({ page: h.page }),
|
useNavigation: () => ({ page: h.page }),
|
||||||
}));
|
}));
|
||||||
vi.mock("./feedFiltersContext", () => ({
|
vi.mock("./feedFiltersContext", () => ({
|
||||||
useFeedFilters: () => ({
|
useFeedFilters: () => h.filters,
|
||||||
tags: [],
|
|
||||||
channelIds: [],
|
|
||||||
tagModes: { language: "or", topic: "or", other: "or" },
|
|
||||||
q: "",
|
|
||||||
sort: "newest",
|
|
||||||
scope: "my",
|
|
||||||
includeNormal: true,
|
|
||||||
includeShorts: false,
|
|
||||||
includeLive: false,
|
|
||||||
show: "unwatched",
|
|
||||||
}),
|
|
||||||
useFeedFiltersActions: () => ({ setFilters: h.setFeedFilters }),
|
useFeedFiltersActions: () => ({ setFilters: h.setFeedFilters }),
|
||||||
}));
|
}));
|
||||||
vi.mock("../plex/plexContext", () => ({
|
|
||||||
usePlex: () => ({ filters: {} }),
|
|
||||||
usePlexActions: () => ({ setFilters: h.setPlexFilters }),
|
|
||||||
}));
|
|
||||||
vi.mock("../../components/ui/confirmContext", () => ({ useConfirm: () => async () => true }));
|
vi.mock("../../components/ui/confirmContext", () => ({ useConfirm: () => async () => true }));
|
||||||
|
|
||||||
import { SavedViewsProvider } from "./SavedViewsProvider";
|
import { FeedSavedViewsProvider } from "./FeedSavedViewsProvider";
|
||||||
import { useSavedViews } from "../../features/filters/savedViewsContext";
|
import { useFeedSavedViews } from "./feedSavedViews";
|
||||||
import { api, type SavedView } from "../../lib/api";
|
import { api, type SavedView } from "../../lib/api";
|
||||||
import { claimLocalData, LS } from "../../lib/storage";
|
import { claimLocalData, LS } from "../../lib/storage";
|
||||||
import { qk } from "../../lib/queryKeys";
|
import { qk } from "../../lib/queryKeys";
|
||||||
|
import type { SavedViewsContext } from "../../features/filters/savedViewsContext";
|
||||||
|
|
||||||
const ID = 7;
|
const ID = 7;
|
||||||
const view = (over: Partial<SavedView>): SavedView => ({
|
const view = (over: Partial<SavedView>): SavedView => ({
|
||||||
@@ -60,11 +61,17 @@ const view = (over: Partial<SavedView>): SavedView => ({
|
|||||||
...over,
|
...over,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The store as the last commit left it, for the cases that drive it directly.
|
||||||
|
let latest: SavedViewsContext | null = null;
|
||||||
|
const keep = (store: SavedViewsContext) => {
|
||||||
|
latest = store;
|
||||||
|
};
|
||||||
function Probe() {
|
function Probe() {
|
||||||
const { loading, views } = useSavedViews();
|
const store = useFeedSavedViews();
|
||||||
|
useEffect(() => keep(store));
|
||||||
return (
|
return (
|
||||||
<div data-testid="loading" data-count={views.length}>
|
<div data-testid="loading" data-count={store.views.length}>
|
||||||
{String(loading)}
|
{String(store.loading)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -76,41 +83,46 @@ function signedInClient() {
|
|||||||
return qc;
|
return qc;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function tree(qc: QueryClient) {
|
||||||
|
return (
|
||||||
|
<QueryClientProvider client={qc}>
|
||||||
|
<FeedSavedViewsProvider>
|
||||||
|
<Probe />
|
||||||
|
</FeedSavedViewsProvider>
|
||||||
|
</QueryClientProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
async function mountResolved(views: SavedView[]) {
|
async function mountResolved(views: SavedView[]) {
|
||||||
// Fresh array + object copies per call, like a real fetch — so a refetch changes viewsQuery.data's
|
// Fresh array + object copies per call, like a real fetch — so a refetch changes viewsQuery.data's
|
||||||
// identity and re-runs the seed effect (which is what makes the per-source guard actually load-bearing
|
// identity and re-runs the seed effect (which is what makes the one-shot guard actually load-bearing
|
||||||
// in the re-seed test; a shared reference would skip the effect and hide a missing guard).
|
// in the re-seed test; a shared reference would skip the effect and hide a missing guard).
|
||||||
vi.spyOn(api, "savedViews").mockImplementation(async () => views.map((v) => ({ ...v })));
|
vi.spyOn(api, "savedViews").mockImplementation(async () => views.map((v) => ({ ...v })));
|
||||||
const qc = signedInClient();
|
const qc = signedInClient();
|
||||||
render(
|
const r = render(tree(qc));
|
||||||
<QueryClientProvider client={qc}>
|
|
||||||
<SavedViewsProvider>
|
|
||||||
<Probe />
|
|
||||||
</SavedViewsProvider>
|
|
||||||
</QueryClientProvider>,
|
|
||||||
);
|
|
||||||
// Wait for the query to resolve so the first-resolve effect has run.
|
// Wait for the query to resolve so the first-resolve effect has run.
|
||||||
await waitFor(() => expect(screen.getByTestId("loading").textContent).toBe("false"));
|
await waitFor(() => expect(screen.getByTestId("loading").textContent).toBe("false"));
|
||||||
return qc;
|
return { qc, rerender: () => r.rerender(tree(qc)) };
|
||||||
}
|
}
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
localStorage.clear();
|
localStorage.clear();
|
||||||
claimLocalData(ID);
|
claimLocalData(ID);
|
||||||
// Reset the URL: feedCanSeedDefault reads window.location.search, and a stale ?sort= from a prior
|
// Reset the URL: the seed check reads window.location.search, and a stale ?sort= from a prior case
|
||||||
// case would suppress seeding in the others.
|
// would suppress seeding in the others.
|
||||||
window.history.replaceState({}, "", "/");
|
window.history.replaceState({}, "", "/");
|
||||||
h.page = "feed";
|
h.page = "feed";
|
||||||
|
h.filters = { ...BASE };
|
||||||
h.setFeedFilters.mockClear();
|
h.setFeedFilters.mockClear();
|
||||||
h.setPlexFilters.mockClear();
|
latest = null;
|
||||||
});
|
});
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
cleanup();
|
cleanup();
|
||||||
vi.restoreAllMocks();
|
vi.restoreAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("SavedViewsProvider — default-view seeding on first resolve", () => {
|
describe("the feed's saved views — default-view seeding on first resolve", () => {
|
||||||
it("feed: seeds the starred default into a fresh module (no stored filters)", async () => {
|
it("seeds the starred default into a fresh feed (no stored filters)", async () => {
|
||||||
await mountResolved([
|
await mountResolved([
|
||||||
view({ id: 2, is_default: true, filters: { scope: "all", show: "watched" } }),
|
view({ id: 2, is_default: true, filters: { scope: "all", show: "watched" } }),
|
||||||
]);
|
]);
|
||||||
@@ -118,20 +130,20 @@ describe("SavedViewsProvider — default-view seeding on first resolve", () => {
|
|||||||
expect(h.setFeedFilters.mock.calls[0]![0]).toMatchObject({ scope: "all", show: "watched" });
|
expect(h.setFeedFilters.mock.calls[0]![0]).toMatchObject({ scope: "all", show: "watched" });
|
||||||
});
|
});
|
||||||
|
|
||||||
it("feed: does NOT seed when the account already has stored filters", async () => {
|
it("does NOT seed when the account already has stored filters", async () => {
|
||||||
localStorage.setItem(LS.filters, JSON.stringify({ scope: "my" }));
|
localStorage.setItem(LS.filters, JSON.stringify({ scope: "my" }));
|
||||||
await mountResolved([view({ id: 2, is_default: true, filters: { scope: "all" } })]);
|
await mountResolved([view({ id: 2, is_default: true, filters: { scope: "all" } })]);
|
||||||
expect(h.setFeedFilters).not.toHaveBeenCalled();
|
expect(h.setFeedFilters).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("feed: does NOT seed when no view is starred as default", async () => {
|
it("does NOT seed when no view is starred as default", async () => {
|
||||||
await mountResolved([view({ id: 2, is_default: false, filters: { scope: "all" } })]);
|
await mountResolved([view({ id: 2, is_default: false, filters: { scope: "all" } })]);
|
||||||
expect(h.setFeedFilters).not.toHaveBeenCalled();
|
expect(h.setFeedFilters).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("feed: does NOT seed when the URL carries filter params (a shared/bookmarked view wins)", async () => {
|
it("does NOT seed when the feed's URL carries filter params (a shared/bookmarked view wins)", async () => {
|
||||||
// A link like ?sort=oldest is an explicit filter the user arrived on; the starred default must not
|
// A link like ?sort=oldest is an explicit filter the user arrived on; the starred default must not
|
||||||
// stomp it. feedCanSeedDefault() bails on hasFilterParams — plex ignores the URL, so this is feed-only.
|
// stomp it.
|
||||||
window.history.replaceState({}, "", "/?sort=oldest");
|
window.history.replaceState({}, "", "/?sort=oldest");
|
||||||
await mountResolved([
|
await mountResolved([
|
||||||
view({ id: 2, is_default: true, filters: { scope: "all", show: "watched" } }),
|
view({ id: 2, is_default: true, filters: { scope: "all", show: "watched" } }),
|
||||||
@@ -139,7 +151,16 @@ describe("SavedViewsProvider — default-view seeding on first resolve", () => {
|
|||||||
expect(h.setFeedFilters).not.toHaveBeenCalled();
|
expect(h.setFeedFilters).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("does NOT re-seed on a background refetch that returns a changed list (per-source one-shot guard)", async () => {
|
it("seeds when another page's URL carries those params: they are that page's, not a feed link", async () => {
|
||||||
|
// Booting onto Plex with its ?sort= used to hold the feed's default back: the feed's views were
|
||||||
|
// only asked for once you left Plex, by when the URL was the feed's own.
|
||||||
|
h.page = "plex";
|
||||||
|
window.history.replaceState({}, "", "/?page=plex&sort=oldest");
|
||||||
|
await mountResolved([view({ id: 2, is_default: true, filters: { scope: "all" } })]);
|
||||||
|
expect(h.setFeedFilters).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT re-seed on a background refetch that returns a changed list (one-shot guard)", async () => {
|
||||||
// The 60s refetch re-resolves the query; when it returns a CHANGED list (e.g. the user just added a
|
// The 60s refetch re-resolves the query; when it returns a CHANGED list (e.g. the user just added a
|
||||||
// view elsewhere) react-query hands down a new reference and the seed effect re-runs. seededRef must
|
// view elsewhere) react-query hands down a new reference and the seed effect re-runs. seededRef must
|
||||||
// stop it re-applying the default over filters the user has since edited. (An identical refetch is
|
// stop it re-applying the default over filters the user has since edited. (An identical refetch is
|
||||||
@@ -151,13 +172,7 @@ describe("SavedViewsProvider — default-view seeding on first resolve", () => {
|
|||||||
return calls === 1 ? [def] : [def, view({ id: 9, name: "added-later" })];
|
return calls === 1 ? [def] : [def, view({ id: 9, name: "added-later" })];
|
||||||
});
|
});
|
||||||
const qc = signedInClient();
|
const qc = signedInClient();
|
||||||
render(
|
render(tree(qc));
|
||||||
<QueryClientProvider client={qc}>
|
|
||||||
<SavedViewsProvider>
|
|
||||||
<Probe />
|
|
||||||
</SavedViewsProvider>
|
|
||||||
</QueryClientProvider>,
|
|
||||||
);
|
|
||||||
await waitFor(() => expect(screen.getByTestId("loading").textContent).toBe("false"));
|
await waitFor(() => expect(screen.getByTestId("loading").textContent).toBe("false"));
|
||||||
expect(h.setFeedFilters).toHaveBeenCalledTimes(1);
|
expect(h.setFeedFilters).toHaveBeenCalledTimes(1);
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
@@ -168,34 +183,42 @@ describe("SavedViewsProvider — default-view seeding on first resolve", () => {
|
|||||||
await waitFor(() => expect(screen.getByTestId("loading").dataset.count).toBe("2"));
|
await waitFor(() => expect(screen.getByTestId("loading").dataset.count).toBe("2"));
|
||||||
expect(h.setFeedFilters).toHaveBeenCalledTimes(1);
|
expect(h.setFeedFilters).toHaveBeenCalledTimes(1);
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("plex: seeds the starred default into a fresh module (parity with the feed)", async () => {
|
describe("the feed's saved views — a visit to another page", () => {
|
||||||
|
it("keeps the view you applied, edited since, when you come back from Plex", async () => {
|
||||||
|
const { rerender } = await mountResolved([view({ id: 4, name: "mine", filters: { ...BASE } })]);
|
||||||
|
act(() => latest!.apply(latest!.views[0]!));
|
||||||
|
h.filters = { ...BASE, show: "all" }; // an edit since: the view is now shown as changed
|
||||||
|
rerender();
|
||||||
|
expect(latest!.displayView?.id).toBe(4);
|
||||||
|
expect(latest!.isDirty).toBe(true);
|
||||||
h.page = "plex";
|
h.page = "plex";
|
||||||
await mountResolved([view({ id: 3, is_default: true, filters: { genres: ["Action"] } })]);
|
rerender();
|
||||||
expect(h.setPlexFilters).toHaveBeenCalledTimes(1);
|
h.page = "feed";
|
||||||
expect(h.setPlexFilters.mock.calls[0]![0]).toMatchObject({ genres: ["Action"] });
|
rerender();
|
||||||
|
expect(latest!.displayView?.id).toBe(4);
|
||||||
|
expect(latest!.isDirty).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("plex: does NOT seed when plex filters are already stored", async () => {
|
it("closes its manage modal when you leave the feed, so it does not reopen on the way back", async () => {
|
||||||
h.page = "plex";
|
const { rerender } = await mountResolved([]);
|
||||||
localStorage.setItem(LS.plexFilters, JSON.stringify({ genres: ["Comedy"] }));
|
act(() => latest!.openModal());
|
||||||
await mountResolved([view({ id: 3, is_default: true, filters: { genres: ["Action"] } })]);
|
expect(latest!.modalOpen).toBe(true);
|
||||||
expect(h.setPlexFilters).not.toHaveBeenCalled();
|
h.page = "home";
|
||||||
|
rerender();
|
||||||
|
h.page = "feed";
|
||||||
|
rerender();
|
||||||
|
expect(latest!.modalOpen).toBe(false);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("SavedViewsProvider — signed-out tab", () => {
|
describe("the feed's saved views — signed-out tab", () => {
|
||||||
it("does NOT ask for saved views while no account is signed in (the landing logged a 401)", async () => {
|
it("does NOT ask for saved views while no account is signed in (the landing logged a 401)", async () => {
|
||||||
const spy = vi.spyOn(api, "savedViews").mockResolvedValue([]);
|
const spy = vi.spyOn(api, "savedViews").mockResolvedValue([]);
|
||||||
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||||
qc.setQueryData(qk.me(), null); // BootGate's "nobody"
|
qc.setQueryData(qk.me(), null); // BootGate's "nobody"
|
||||||
render(
|
render(tree(qc));
|
||||||
<QueryClientProvider client={qc}>
|
|
||||||
<SavedViewsProvider>
|
|
||||||
<Probe />
|
|
||||||
</SavedViewsProvider>
|
|
||||||
</QueryClientProvider>,
|
|
||||||
);
|
|
||||||
// Let any fetch the mount would start get going before asserting it never happened.
|
// Let any fetch the mount would start get going before asserting it never happened.
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
await new Promise((r) => setTimeout(r, 20));
|
await new Promise((r) => setTimeout(r, 20));
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import { useMemo, type ReactNode } from "react";
|
||||||
|
import type { FeedFilters } from "../../lib/api";
|
||||||
|
import { filtersToParams, hasFilterParams } from "../../lib/urlState";
|
||||||
|
import { LS } from "../../lib/storage";
|
||||||
|
import { useNavigation } from "../../features/navigation/navigationContext";
|
||||||
|
import { asFilters } from "../../features/filters/savedViewsContext";
|
||||||
|
import {
|
||||||
|
storedFiltersPresent,
|
||||||
|
useSavedViewsStore,
|
||||||
|
type SavedViewsConfig,
|
||||||
|
} from "../../features/filters/useSavedViewsStore";
|
||||||
|
import { useFeedFilters, useFeedFiltersActions } from "./feedFiltersContext";
|
||||||
|
import { FeedSavedViewsContextProvider } from "./feedSavedViews";
|
||||||
|
|
||||||
|
const keyOf = (f: Record<string, unknown>) =>
|
||||||
|
filtersToParams(asFilters(f as unknown as FeedFilters)).toString();
|
||||||
|
|
||||||
|
// The feed's saved views, over the feed's live filters (useSavedViewsStore holds the logic).
|
||||||
|
export function FeedSavedViewsProvider({ children }: { children: ReactNode }) {
|
||||||
|
const { page } = useNavigation();
|
||||||
|
const filters = useFeedFilters();
|
||||||
|
const { setFilters } = useFeedFiltersActions();
|
||||||
|
const onScreen = page === "feed";
|
||||||
|
const cfg: SavedViewsConfig = useMemo(
|
||||||
|
() => ({
|
||||||
|
source: "feed",
|
||||||
|
filters: filters as unknown as Record<string, unknown>,
|
||||||
|
setFilters: (f) => setFilters(f as unknown as FeedFilters),
|
||||||
|
keyOf,
|
||||||
|
asFilters: (raw) =>
|
||||||
|
asFilters(raw as unknown as FeedFilters) as unknown as Record<string, unknown>,
|
||||||
|
// Strip the transient shuffle seed so a saved "shuffle" view re-rolls fresh on apply.
|
||||||
|
stripForSave: (f) => {
|
||||||
|
const { seed: _seed, ...rest } = f;
|
||||||
|
return rest;
|
||||||
|
},
|
||||||
|
// The feed also yields to a URL that carries filter params (a shared/bookmarked view) — which
|
||||||
|
// only the feed's own page can carry: on any other page those params are that page's.
|
||||||
|
canSeedDefault: () =>
|
||||||
|
!(onScreen && hasFilterParams(new URLSearchParams(window.location.search))) &&
|
||||||
|
!storedFiltersPresent(LS.filters),
|
||||||
|
enabled: true,
|
||||||
|
onScreen,
|
||||||
|
}),
|
||||||
|
[filters, setFilters, onScreen],
|
||||||
|
);
|
||||||
|
const value = useSavedViewsStore(cfg);
|
||||||
|
return <FeedSavedViewsContextProvider value={value}>{children}</FeedSavedViewsContextProvider>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { FeedFiltersProvider } from "./FeedFiltersProvider";
|
||||||
|
import { FeedViewProvider } from "./FeedViewProvider";
|
||||||
|
import { FeedSavedViewsProvider } from "./FeedSavedViewsProvider";
|
||||||
|
|
||||||
|
// The feed module's store, mounted at the root (features/navigation/ModuleStores): its filters, its view
|
||||||
|
// (the card or row layout) and its saved views over those filters.
|
||||||
|
export function FeedStore({ children }: { children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<FeedFiltersProvider>
|
||||||
|
<FeedViewProvider>
|
||||||
|
<FeedSavedViewsProvider>{children}</FeedSavedViewsProvider>
|
||||||
|
</FeedViewProvider>
|
||||||
|
</FeedFiltersProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -9,7 +9,8 @@ import { useDebounced } from "../../lib/useDebounced";
|
|||||||
import { useActiveFilters } from "../../lib/useActiveFilters";
|
import { useActiveFilters } from "../../lib/useActiveFilters";
|
||||||
import TagManager from "../../features/channel/TagManager";
|
import TagManager from "../../features/channel/TagManager";
|
||||||
import SavedViewsPopover from "../../features/filters/SavedViewsPopover";
|
import SavedViewsPopover from "../../features/filters/SavedViewsPopover";
|
||||||
import { useSavedViews } from "../../features/filters/savedViewsContext";
|
import { SavedViewsScope } from "../../features/filters/savedViewsContext";
|
||||||
|
import { useFeedSavedViews } from "./feedSavedViews";
|
||||||
import FilterRail from "../../components/ui/FilterRail";
|
import FilterRail from "../../components/ui/FilterRail";
|
||||||
import { type PanelItem } from "../../components/ui/PanelGroups";
|
import { type PanelItem } from "../../components/ui/PanelGroups";
|
||||||
import { type MediaSourceKind } from "../../lib/mediaSource";
|
import { type MediaSourceKind } from "../../lib/mediaSource";
|
||||||
@@ -43,7 +44,8 @@ export default function Sidebar() {
|
|||||||
const { layout, setLayout, collapsed, onToggleCollapse } = useRail("feed");
|
const { layout, setLayout, collapsed, onToggleCollapse } = useRail("feed");
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const lang = i18n.language;
|
const lang = i18n.language;
|
||||||
const { openModal } = useSavedViews();
|
// The feed's saved views, handed to the shared views UI in this rail through the scope below.
|
||||||
|
const savedViews = useFeedSavedViews();
|
||||||
const filters = useFeedFilters();
|
const filters = useFeedFilters();
|
||||||
const { setFilters } = useFeedFiltersActions();
|
const { setFilters } = useFeedFiltersActions();
|
||||||
// "Focus this channel in the manager" (from the tag manager here) lives in ChannelsProvider.
|
// "Focus this channel in the manager" (from the tag manager here) lives in ChannelsProvider.
|
||||||
@@ -223,31 +225,33 @@ export default function Sidebar() {
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FilterRail
|
<SavedViewsScope value={savedViews}>
|
||||||
kind={source}
|
<FilterRail
|
||||||
title={t("sidebar.filters")}
|
kind={source}
|
||||||
icon={<SlidersHorizontal className="w-4 h-4" />}
|
title={t("sidebar.filters")}
|
||||||
count={activeCount}
|
icon={<SlidersHorizontal className="w-4 h-4" />}
|
||||||
collapsed={collapsed}
|
count={activeCount}
|
||||||
onToggleCollapse={onToggleCollapse}
|
collapsed={collapsed}
|
||||||
layout={layout}
|
onToggleCollapse={onToggleCollapse}
|
||||||
setLayout={setLayout}
|
layout={layout}
|
||||||
items={items}
|
setLayout={setLayout}
|
||||||
// The feed widgets are simple label + control rows, not collapsible islands.
|
items={items}
|
||||||
collapsible={false}
|
// The feed widgets are simple label + control rows, not collapsible islands.
|
||||||
// Saved ("personal") views: the header icon opens a quick-pick popover, the title opens the
|
collapsible={false}
|
||||||
// full manager modal. Feed rail only.
|
// Saved ("personal") views: the header icon opens a quick-pick popover, the title opens the
|
||||||
iconExpanded={
|
// full manager modal. Feed rail only.
|
||||||
source === "feed" ? (
|
iconExpanded={
|
||||||
<SavedViewsPopover trigger={<SlidersHorizontal className="w-4 h-4" />} />
|
source === "feed" ? (
|
||||||
) : undefined
|
<SavedViewsPopover trigger={<SlidersHorizontal className="w-4 h-4" />} />
|
||||||
}
|
) : undefined
|
||||||
onTitleClick={source === "feed" ? openModal : undefined}
|
}
|
||||||
titleActionLabel={source === "feed" ? t("views.manageTitle") : undefined}
|
onTitleClick={source === "feed" ? savedViews.openModal : undefined}
|
||||||
>
|
titleActionLabel={source === "feed" ? t("views.manageTitle") : undefined}
|
||||||
{tagManagerOpen && (
|
>
|
||||||
<TagManager onClose={() => setTagManagerOpen(false)} onFocusChannel={onFocusChannel} />
|
{tagManagerOpen && (
|
||||||
)}
|
<TagManager onClose={() => setTagManagerOpen(false)} onFocusChannel={onFocusChannel} />
|
||||||
</FilterRail>
|
)}
|
||||||
|
</FilterRail>
|
||||||
|
</SavedViewsScope>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ export function loadAccountFilters(): FeedFilters {
|
|||||||
// Your last-applied filters win, so a reload keeps whatever view you're on (a saved view you
|
// Your last-applied filters win, so a reload keeps whatever view you're on (a saved view you
|
||||||
// picked, or manual tweaks) — setFilters persists them under LS.filters on every change. A starred
|
// picked, or manual tweaks) — setFilters persists them under LS.filters on every change. A starred
|
||||||
// default view seeds a fresh account that has never stored filters, but that is applied ONCE by
|
// default view seeds a fresh account that has never stored filters, but that is applied ONCE by
|
||||||
// SavedViewsProvider on its views query's first resolve (which then persists here like any edit) —
|
// FeedSavedViewsProvider on its views query's first resolve (which then persists here like any edit) —
|
||||||
// not read from a mirror on this synchronous path, which used to race that resolve. So here we only
|
// not read from a mirror on this synchronous path, which used to race that resolve. So here we only
|
||||||
// ever read the account's own stored filters, else fall back to DEFAULT_FILTERS.
|
// ever read the account's own stored filters, else fall back to DEFAULT_FILTERS.
|
||||||
const fKey = ownKey(FILTERS_KEY);
|
const fKey = ownKey(FILTERS_KEY);
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { createSavedViewsStore } from "../../features/filters/savedViewsContext";
|
||||||
|
|
||||||
|
// The feed's saved views: filled by FeedSavedViewsProvider, read by the feed's page and rail (which hand
|
||||||
|
// them to the shared views UI) and by the palette.
|
||||||
|
const store = createSavedViewsStore("Feed");
|
||||||
|
export const FeedSavedViewsContextProvider = store.Provider;
|
||||||
|
export const useFeedSavedViews = store.useViews;
|
||||||
@@ -1,7 +1,5 @@
|
|||||||
// 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, view, saved views), mounted at the root and read app-wide.
|
||||||
export { FeedFiltersProvider } from "./FeedFiltersProvider";
|
export { useFeedSavedViews } from "./feedSavedViews";
|
||||||
export { FeedViewProvider } from "./FeedViewProvider";
|
|
||||||
export { SavedViewsProvider } from "./SavedViewsProvider";
|
|
||||||
export { useFeedFilters, useFeedFiltersActions } from "./feedFiltersContext";
|
export { useFeedFilters, useFeedFiltersActions } from "./feedFiltersContext";
|
||||||
export { useFeedView, useFeedViewActions } from "./feedViewContext";
|
export { useFeedView, useFeedViewActions } from "./feedViewContext";
|
||||||
export { ChannelPage, feedModule } from "./module";
|
export { ChannelPage, feedModule } from "./module";
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { YouTubeIcon } from "../../lib/brandIcons";
|
import { YouTubeIcon } from "../../lib/brandIcons";
|
||||||
import { lazyWithPreload, lazyWithPreloadAlongside } from "../../lib/lazyWithPreload";
|
import { lazyWithPreload, lazyWithPreloadAlongside } from "../../lib/lazyWithPreload";
|
||||||
import type { ModuleDef } from "../../lib/moduleDef";
|
import type { ModuleDef } from "../../lib/moduleDef";
|
||||||
|
import { FeedStore } from "./FeedStore";
|
||||||
|
|
||||||
// The rail loads with the page (lazyWithPreloadAlongside), so it never arrives after it.
|
// The rail loads with the page (lazyWithPreloadAlongside), so it never arrives after it.
|
||||||
const Rail = lazyWithPreload(() => import("./Sidebar"));
|
const Rail = lazyWithPreload(() => import("./Sidebar"));
|
||||||
@@ -13,6 +14,7 @@ export const feedModule: ModuleDef = {
|
|||||||
icon: YouTubeIcon,
|
icon: YouTubeIcon,
|
||||||
Rail,
|
Rail,
|
||||||
Page: lazyWithPreloadAlongside(() => import("./FeedPage"), Rail),
|
Page: lazyWithPreloadAlongside(() => import("./FeedPage"), Rail),
|
||||||
|
Store: FeedStore,
|
||||||
};
|
};
|
||||||
|
|
||||||
// The channel page: a feed scoped to one channel, opened over any page (App renders it in place of the
|
// The channel page: a feed scoped to one channel, opened over any page (App renders it in place of the
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
// 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 how a folder is named and pictured.
|
||||||
export { FilesProvider } from "./FilesProvider";
|
|
||||||
export { useFilesActions } from "./filesContext";
|
export { useFilesActions } from "./filesContext";
|
||||||
export { folderIcon, folderLabel, sourceLabel, takesUploads } from "./fileFolders";
|
export { folderIcon, folderLabel, sourceLabel, takesUploads } from "./fileFolders";
|
||||||
export { filesModule } from "./module";
|
export { filesModule } from "./module";
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { FolderOpen } from "lucide-react";
|
import { FolderOpen } from "lucide-react";
|
||||||
import { lazyWithPreload, lazyWithPreloadAlongside } from "../../lib/lazyWithPreload";
|
import { lazyWithPreload, lazyWithPreloadAlongside } from "../../lib/lazyWithPreload";
|
||||||
import type { ModuleDef } from "../../lib/moduleDef";
|
import type { ModuleDef } from "../../lib/moduleDef";
|
||||||
|
import { FilesProvider } from "./FilesProvider";
|
||||||
|
|
||||||
// The rail loads with the page (lazyWithPreloadAlongside), so it never arrives after it.
|
// The rail loads with the page (lazyWithPreloadAlongside), so it never arrives after it.
|
||||||
const Rail = lazyWithPreload(() => import("./FilesRail"));
|
const Rail = lazyWithPreload(() => import("./FilesRail"));
|
||||||
@@ -14,4 +15,5 @@ export const filesModule: ModuleDef = {
|
|||||||
Rail,
|
Rail,
|
||||||
Page: lazyWithPreloadAlongside(() => import("./Files"), Rail),
|
Page: lazyWithPreloadAlongside(() => import("./Files"), Rail),
|
||||||
maxWidth: "80rem",
|
maxWidth: "80rem",
|
||||||
|
Store: FilesProvider,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
// The playlists page's public face: its store, mounted at the root and read app-wide.
|
// The playlists page's public face: its store, mounted at the root and read app-wide.
|
||||||
export { PlaylistsProvider } from "./PlaylistsProvider";
|
|
||||||
export { usePlaylistsActions } from "./playlistsContext";
|
export { usePlaylistsActions } from "./playlistsContext";
|
||||||
export { playlistsModule } from "./module";
|
export { playlistsModule } from "./module";
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { ListVideo } from "lucide-react";
|
import { ListVideo } from "lucide-react";
|
||||||
import { lazyWithPreload, lazyWithPreloadAlongside } from "../../lib/lazyWithPreload";
|
import { lazyWithPreload, lazyWithPreloadAlongside } from "../../lib/lazyWithPreload";
|
||||||
import type { ModuleDef } from "../../lib/moduleDef";
|
import type { ModuleDef } from "../../lib/moduleDef";
|
||||||
|
import { PlaylistsProvider } from "./PlaylistsProvider";
|
||||||
|
|
||||||
// The rail loads with the page (lazyWithPreloadAlongside), so it never arrives after it.
|
// The rail loads with the page (lazyWithPreloadAlongside), so it never arrives after it.
|
||||||
const Rail = lazyWithPreload(() => import("./PlaylistsRail"));
|
const Rail = lazyWithPreload(() => import("./PlaylistsRail"));
|
||||||
@@ -14,4 +15,5 @@ export const playlistsModule: ModuleDef = {
|
|||||||
Rail,
|
Rail,
|
||||||
Page: lazyWithPreloadAlongside(() => import("./Playlists"), Rail),
|
Page: lazyWithPreloadAlongside(() => import("./Playlists"), Rail),
|
||||||
maxWidth: "64rem",
|
maxWidth: "64rem",
|
||||||
|
Store: PlaylistsProvider,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -24,6 +24,12 @@ vi.mock("./plexViews", async (importOriginal) => ({
|
|||||||
PlexInfoView: ({ id }: { id: string }) => <div>info page {id}</div>,
|
PlexInfoView: ({ id }: { id: string }) => <div>info page {id}</div>,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// The page hands Plex's saved views to its chip row; none are saved here.
|
||||||
|
vi.mock("./plexSavedViews", async (importOriginal) => ({
|
||||||
|
...(await importOriginal<typeof import("./plexSavedViews")>()),
|
||||||
|
usePlexSavedViews: () => ({ views: [], displayView: null, isDirty: false, activeViewId: null }),
|
||||||
|
}));
|
||||||
|
|
||||||
import PlexBrowse from "./PlexBrowse";
|
import PlexBrowse from "./PlexBrowse";
|
||||||
import { api, type PlexCard } from "../../lib/api";
|
import { api, type PlexCard } from "../../lib/api";
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ import { menuGroups } from "../../components/ui/menuGroups";
|
|||||||
import { choiceSubmenu, sortSubmenu } from "../../components/ui/pageMenuItems";
|
import { choiceSubmenu, sortSubmenu } from "../../components/ui/pageMenuItems";
|
||||||
import ActiveFilterChips from "../../features/filters/ActiveFilterChips";
|
import ActiveFilterChips from "../../features/filters/ActiveFilterChips";
|
||||||
import SelectedViewChip from "../../features/filters/SelectedViewChip";
|
import SelectedViewChip from "../../features/filters/SelectedViewChip";
|
||||||
|
import { SavedViewsScope } from "../../features/filters/savedViewsContext";
|
||||||
|
import { usePlexSavedViews } from "./plexSavedViews";
|
||||||
import ItemCount from "../../components/ui/ItemCount";
|
import ItemCount from "../../components/ui/ItemCount";
|
||||||
import { FilterFoldButton } from "../../components/ui/FilterFold";
|
import { FilterFoldButton } from "../../components/ui/FilterFold";
|
||||||
import { useFilterFold } from "../../lib/useFilterFold";
|
import { useFilterFold } from "../../lib/useFilterFold";
|
||||||
@@ -101,11 +103,13 @@ export default function PlexBrowse() {
|
|||||||
// history, and entering the plex page mounts this anew, so each entry reseeds it (a page switch
|
// history, and entering the plex page mounts this anew, so each entry reseeds it (a page switch
|
||||||
// clears ?v and drops _sub: every plex entry starts at the Discover root).
|
// clears ?v and drops _sub: every plex entry starts at the Discover root).
|
||||||
const sub = useHistorySubview<Sub>({ kind: "discover" }, PLEX_SUB_URL);
|
const sub = useHistorySubview<Sub>({ kind: "discover" }, PLEX_SUB_URL);
|
||||||
|
// Plex's saved views reach the shared views UI under this page (the chip) by the scope.
|
||||||
|
const savedViews = usePlexSavedViews();
|
||||||
return (
|
return (
|
||||||
<>
|
<SavedViewsScope value={savedViews}>
|
||||||
<PlexHeader view={{ kind: sub.view.kind, open: sub.open }} />
|
<PlexHeader view={{ kind: sub.view.kind, open: sub.open }} />
|
||||||
<PlexBrowseView sub={sub} />
|
<PlexBrowseView sub={sub} />
|
||||||
</>
|
</SavedViewsScope>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
//
|
//
|
||||||
// A shared Plex link is persisted to the account, but only the dimensions it CARRIES. The bug it
|
// A shared Plex link is persisted to the account, but only the dimensions it CARRIES. The bug it
|
||||||
// replaced: a link with only a scope also wrote the (empty) default filters, and a stored Plex filters
|
// replaced: a link with only a scope also wrote the (empty) default filters, and a stored Plex filters
|
||||||
// value at all is what tells SavedViewsProvider the account has picked filters — so the account's
|
// value at all is what tells PlexSavedViewsProvider the account has picked filters — so the account's
|
||||||
// default saved view was never applied again.
|
// default saved view was never applied again.
|
||||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||||
import { render, cleanup } from "@testing-library/react";
|
import { render, cleanup } from "@testing-library/react";
|
||||||
|
|||||||
@@ -142,7 +142,7 @@ export function PlexProvider({ children }: { children: ReactNode }) {
|
|||||||
// Persist a shared link's view to the account once, so it wins over the stored view and survives a
|
// Persist a shared link's view to the account once, so it wins over the stored view and survives a
|
||||||
// later plain reload. Mirrors FeedFiltersProvider's initialHadUrlFilters branch. Only the dimensions
|
// later plain reload. Mirrors FeedFiltersProvider's initialHadUrlFilters branch. Only the dimensions
|
||||||
// the link CARRIED are written: an omitted one already reads from storage, and writing its default
|
// the link CARRIED are written: an omitted one already reads from storage, and writing its default
|
||||||
// would not be a no-op — a stored Plex filters value at all is what tells SavedViewsProvider the
|
// would not be a no-op — a stored Plex filters value at all is what tells PlexSavedViewsProvider the
|
||||||
// account has picked filters, so the account's default saved view would never be applied again.
|
// account has picked filters, so the account's default saved view would never be applied again.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!urlSeed) return;
|
if (!urlSeed) return;
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
// @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
|
||||||
|
// 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";
|
||||||
|
|
||||||
|
const h = vi.hoisted(() => ({ page: "plex" as string, setPlexFilters: vi.fn() }));
|
||||||
|
|
||||||
|
vi.mock("react-i18next", async (importOriginal) => {
|
||||||
|
const actual = await importOriginal<typeof import("react-i18next")>();
|
||||||
|
return { ...actual, useTranslation: () => ({ t: (key: string) => key }) };
|
||||||
|
});
|
||||||
|
vi.mock("../../features/navigation/navigationContext", () => ({
|
||||||
|
useNavigation: () => ({ page: h.page }),
|
||||||
|
}));
|
||||||
|
vi.mock("./plexContext", () => ({
|
||||||
|
usePlex: () => ({ filters: {} }),
|
||||||
|
usePlexActions: () => ({ setFilters: h.setPlexFilters }),
|
||||||
|
}));
|
||||||
|
vi.mock("../../components/ui/confirmContext", () => ({ useConfirm: () => async () => true }));
|
||||||
|
|
||||||
|
import { PlexSavedViewsProvider } from "./PlexSavedViewsProvider";
|
||||||
|
import { usePlexSavedViews } from "./plexSavedViews";
|
||||||
|
import { api, type SavedView } from "../../lib/api";
|
||||||
|
import { claimLocalData, LS } from "../../lib/storage";
|
||||||
|
import { qk } from "../../lib/queryKeys";
|
||||||
|
|
||||||
|
const ID = 7;
|
||||||
|
const view = (over: Partial<SavedView>): SavedView => ({
|
||||||
|
id: 1,
|
||||||
|
name: "v",
|
||||||
|
filters: {},
|
||||||
|
position: 0,
|
||||||
|
is_default: false,
|
||||||
|
...over,
|
||||||
|
});
|
||||||
|
|
||||||
|
function Probe() {
|
||||||
|
const { loading } = usePlexSavedViews();
|
||||||
|
return <div data-testid="loading">{String(loading)}</div>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mount(views: SavedView[]) {
|
||||||
|
const spy = vi
|
||||||
|
.spyOn(api, "savedViews")
|
||||||
|
.mockImplementation(async () => views.map((v) => ({ ...v })));
|
||||||
|
const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||||
|
qc.setQueryData(qk.me(), { id: ID, preferences: {} });
|
||||||
|
const tree = () => (
|
||||||
|
<QueryClientProvider client={qc}>
|
||||||
|
<PlexSavedViewsProvider>
|
||||||
|
<Probe />
|
||||||
|
</PlexSavedViewsProvider>
|
||||||
|
</QueryClientProvider>
|
||||||
|
);
|
||||||
|
const r = render(tree());
|
||||||
|
return { spy, rerender: () => r.rerender(tree()) };
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
localStorage.clear();
|
||||||
|
claimLocalData(ID);
|
||||||
|
h.page = "plex";
|
||||||
|
h.setPlexFilters.mockClear();
|
||||||
|
});
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.restoreAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("Plex's saved views", () => {
|
||||||
|
it("seeds the starred default into a fresh Plex (parity with the feed)", async () => {
|
||||||
|
mount([view({ id: 3, is_default: true, filters: { genres: ["Action"] } })]);
|
||||||
|
await waitFor(() => expect(screen.getByTestId("loading").textContent).toBe("false"));
|
||||||
|
expect(h.setPlexFilters).toHaveBeenCalledTimes(1);
|
||||||
|
expect(h.setPlexFilters.mock.calls[0]![0]).toMatchObject({ genres: ["Action"] });
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT seed when Plex filters are already stored", async () => {
|
||||||
|
localStorage.setItem(LS.plexFilters, JSON.stringify({ genres: ["Comedy"] }));
|
||||||
|
mount([view({ id: 3, is_default: true, filters: { genres: ["Action"] } })]);
|
||||||
|
await waitFor(() => expect(screen.getByTestId("loading").textContent).toBe("false"));
|
||||||
|
expect(h.setPlexFilters).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("asks for nothing until the Plex page is on screen", async () => {
|
||||||
|
h.page = "feed";
|
||||||
|
const { spy, rerender } = mount([]);
|
||||||
|
await act(async () => {
|
||||||
|
await new Promise((r) => setTimeout(r, 20));
|
||||||
|
});
|
||||||
|
expect(spy).not.toHaveBeenCalled();
|
||||||
|
h.page = "plex";
|
||||||
|
rerender();
|
||||||
|
await waitFor(() => expect(spy).toHaveBeenCalledWith("plex"));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
import { useMemo, type ReactNode } from "react";
|
||||||
|
import { coercePlexFilters, plexFiltersSignature, type PlexFilters } from "../../lib/api";
|
||||||
|
import { LS } from "../../lib/storage";
|
||||||
|
import { useNavigation } from "../../features/navigation/navigationContext";
|
||||||
|
import {
|
||||||
|
storedFiltersPresent,
|
||||||
|
useSavedViewsStore,
|
||||||
|
type SavedViewsConfig,
|
||||||
|
} from "../../features/filters/useSavedViewsStore";
|
||||||
|
import { usePlex, usePlexActions } from "./plexContext";
|
||||||
|
import { PlexSavedViewsContextProvider } from "./plexSavedViews";
|
||||||
|
|
||||||
|
const keyOf = (f: Record<string, unknown>) => plexFiltersSignature(coercePlexFilters(f));
|
||||||
|
const asFilters = (raw: Record<string, unknown>) =>
|
||||||
|
coercePlexFilters(raw) as unknown as Record<string, unknown>;
|
||||||
|
// Plex has no transient field to drop before saving.
|
||||||
|
const stripForSave = (f: Record<string, unknown>) => f;
|
||||||
|
// Plex views are facets-only, so LS.plexFilters is the "has the user set this module's filters" key.
|
||||||
|
const canSeedDefault = () => !storedFiltersPresent(LS.plexFilters);
|
||||||
|
|
||||||
|
// Plex's saved views, over Plex's live filters (useSavedViewsStore holds the logic). Asked for only while
|
||||||
|
// the Plex page is on screen, so an account that never opens Plex never fetches them, and the starred
|
||||||
|
// default seeds on the first visit.
|
||||||
|
export function PlexSavedViewsProvider({ children }: { children: ReactNode }) {
|
||||||
|
const { page } = useNavigation();
|
||||||
|
const { filters } = usePlex();
|
||||||
|
const { setFilters } = usePlexActions();
|
||||||
|
const onScreen = page === "plex";
|
||||||
|
const cfg: SavedViewsConfig = useMemo(
|
||||||
|
() => ({
|
||||||
|
source: "plex",
|
||||||
|
filters: filters as unknown as Record<string, unknown>,
|
||||||
|
setFilters: (f) => setFilters(f as unknown as PlexFilters),
|
||||||
|
keyOf,
|
||||||
|
asFilters,
|
||||||
|
stripForSave,
|
||||||
|
canSeedDefault,
|
||||||
|
enabled: onScreen,
|
||||||
|
onScreen,
|
||||||
|
}),
|
||||||
|
[filters, setFilters, onScreen],
|
||||||
|
);
|
||||||
|
const value = useSavedViewsStore(cfg);
|
||||||
|
return <PlexSavedViewsContextProvider value={value}>{children}</PlexSavedViewsContextProvider>;
|
||||||
|
}
|
||||||
@@ -17,7 +17,8 @@ import FilterChipGroup from "../../components/ui/FilterChipGroup";
|
|||||||
import Combobox, { type ComboboxOption } from "../../components/ui/Combobox";
|
import Combobox, { type ComboboxOption } from "../../components/ui/Combobox";
|
||||||
import SavedViewsPopover from "../../features/filters/SavedViewsPopover";
|
import SavedViewsPopover from "../../features/filters/SavedViewsPopover";
|
||||||
import PersonalViewsModal from "../../features/filters/PersonalViewsModal";
|
import PersonalViewsModal from "../../features/filters/PersonalViewsModal";
|
||||||
import { useSavedViews } from "../../features/filters/savedViewsContext";
|
import { SavedViewsScope } from "../../features/filters/savedViewsContext";
|
||||||
|
import { usePlexSavedViews } from "./plexSavedViews";
|
||||||
import { usePlex, usePlexActions } from "./plexContext";
|
import { usePlex, usePlexActions } from "./plexContext";
|
||||||
import { DismissibleChip } from "../../components/ui/Chip";
|
import { DismissibleChip } from "../../components/ui/Chip";
|
||||||
import { Input } from "../../components/ui/Input";
|
import { Input } from "../../components/ui/Input";
|
||||||
@@ -47,7 +48,9 @@ export default function PlexSidebar() {
|
|||||||
const { setFilters } = usePlexActions();
|
const { setFilters } = usePlexActions();
|
||||||
// Personal ("saved") views for the Plex module: the rail header icon opens a quick-pick popover, the
|
// Personal ("saved") views for the Plex module: the rail header icon opens a quick-pick popover, the
|
||||||
// title opens the full manager modal (same shared components as the feed).
|
// title opens the full manager modal (same shared components as the feed).
|
||||||
const { openModal, modalOpen } = useSavedViews();
|
// 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
|
// 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.
|
// share; drives the panel layout key. `FilterRail` owns the customize `editing` state now.
|
||||||
const source: MediaSourceKind = "plex";
|
const source: MediaSourceKind = "plex";
|
||||||
@@ -293,7 +296,7 @@ export default function PlexSidebar() {
|
|||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<SavedViewsScope value={savedViews}>
|
||||||
<FilterRail
|
<FilterRail
|
||||||
kind={source}
|
kind={source}
|
||||||
title={t("sidebar.filters")}
|
title={t("sidebar.filters")}
|
||||||
@@ -308,14 +311,14 @@ export default function PlexSidebar() {
|
|||||||
// feed rail). The rail's own "Clear all" is dropped too — the chip row above the grid owns it.
|
// feed rail). The rail's own "Clear all" is dropped too — the chip row above the grid owns it.
|
||||||
collapsible={false}
|
collapsible={false}
|
||||||
// Saved ("personal") views: header icon → quick-pick popover, title → the manager modal.
|
// Saved ("personal") views: header icon → quick-pick popover, title → the manager modal.
|
||||||
// Same shared components as the feed rail (SavedViewsProvider switches source).
|
// The same shared components as the feed rail, over Plex's own views.
|
||||||
iconExpanded={<SavedViewsPopover trigger={<SlidersHorizontal className="w-4 h-4" />} />}
|
iconExpanded={<SavedViewsPopover trigger={<SlidersHorizontal className="w-4 h-4" />} />}
|
||||||
onTitleClick={openModal}
|
onTitleClick={openModal}
|
||||||
titleActionLabel={t("views.manageTitle")}
|
titleActionLabel={t("views.manageTitle")}
|
||||||
/>
|
/>
|
||||||
{/* The manager modal lives here (rail is always mounted on plex); Modal portals to <body>. */}
|
{/* The manager modal lives here (rail is always mounted on plex); Modal portals to <body>. */}
|
||||||
{modalOpen && <PersonalViewsModal />}
|
{modalOpen && <PersonalViewsModal />}
|
||||||
</>
|
</SavedViewsScope>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
import type { ReactNode } from "react";
|
||||||
|
import { PlexProvider } from "./PlexProvider";
|
||||||
|
import { PlexSavedViewsProvider } from "./PlexSavedViewsProvider";
|
||||||
|
|
||||||
|
// The Plex module's store, mounted at the root (features/navigation/ModuleStores): its browse state, and
|
||||||
|
// its saved views over that state's filters.
|
||||||
|
export function PlexStore({ children }: { children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<PlexProvider>
|
||||||
|
<PlexSavedViewsProvider>{children}</PlexSavedViewsProvider>
|
||||||
|
</PlexProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,5 +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.
|
// 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 { usePlexSavedViews } from "./plexSavedViews";
|
||||||
export { usePlex, usePlexActions } from "./plexContext";
|
export { usePlex, usePlexActions } from "./plexContext";
|
||||||
export { buildPlexActiveFilters } from "./usePlexActiveFilters";
|
export { buildPlexActiveFilters } from "./usePlexActiveFilters";
|
||||||
export { plexModule } from "./module";
|
export { plexModule } from "./module";
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Clapperboard } from "lucide-react";
|
import { Clapperboard } from "lucide-react";
|
||||||
import { lazyWithPreload, lazyWithPreloadAlongside } from "../../lib/lazyWithPreload";
|
import { lazyWithPreload, lazyWithPreloadAlongside } from "../../lib/lazyWithPreload";
|
||||||
import type { ModuleDef } from "../../lib/moduleDef";
|
import type { ModuleDef } from "../../lib/moduleDef";
|
||||||
|
import { PlexStore } from "./PlexStore";
|
||||||
|
|
||||||
// The rail loads with the page (lazyWithPreloadAlongside), so it never arrives after it.
|
// The rail loads with the page (lazyWithPreloadAlongside), so it never arrives after it.
|
||||||
const Rail = lazyWithPreload(() => import("./PlexSidebar"));
|
const Rail = lazyWithPreload(() => import("./PlexSidebar"));
|
||||||
@@ -15,4 +16,5 @@ export const plexModule: ModuleDef = {
|
|||||||
Rail,
|
Rail,
|
||||||
Page: lazyWithPreloadAlongside(() => import("./PlexBrowse"), Rail),
|
Page: lazyWithPreloadAlongside(() => import("./PlexBrowse"), Rail),
|
||||||
maxWidth: "100rem",
|
maxWidth: "100rem",
|
||||||
|
Store: PlexStore,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
import { createSavedViewsStore } from "../../features/filters/savedViewsContext";
|
||||||
|
|
||||||
|
// Plex's saved views: filled by PlexSavedViewsProvider, read by the Plex page and rail (which hand them
|
||||||
|
// to the shared views UI) and by the palette.
|
||||||
|
const store = createSavedViewsStore("Plex");
|
||||||
|
export const PlexSavedViewsContextProvider = store.Provider;
|
||||||
|
export const usePlexSavedViews = store.useViews;
|
||||||
@@ -43,12 +43,20 @@ vi.mock("../lib/keymap", () => ({
|
|||||||
toggleKeyboardHelp: vi.fn(),
|
toggleKeyboardHelp: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
// Each module's saved views, and the page the palette opens over.
|
||||||
|
const views = {
|
||||||
|
feed: [] as { id: number; name: string }[],
|
||||||
|
plex: [] as { id: number; name: string }[],
|
||||||
|
};
|
||||||
|
const nav = { page: "feed" };
|
||||||
|
|
||||||
const calls: string[] = [];
|
const calls: string[] = [];
|
||||||
const setPage = vi.fn((page: string, then?: () => void) => {
|
const setPage = vi.fn((page: string, then?: () => void) => {
|
||||||
calls.push(`setPage:${page}`);
|
calls.push(`setPage:${page}`);
|
||||||
then?.();
|
then?.();
|
||||||
});
|
});
|
||||||
vi.mock("../features/navigation/navigationContext", () => ({
|
vi.mock("../features/navigation/navigationContext", () => ({
|
||||||
|
useNavigation: () => ({ page: nav.page }),
|
||||||
useNavigationActions: () => ({ setPage, openChannel: vi.fn() }),
|
useNavigationActions: () => ({ setPage, openChannel: vi.fn() }),
|
||||||
}));
|
}));
|
||||||
vi.mock("../modules/playlists/playlistsContext", () => ({
|
vi.mock("../modules/playlists/playlistsContext", () => ({
|
||||||
@@ -59,8 +67,13 @@ vi.mock("../modules/playlists/playlistsContext", () => ({
|
|||||||
vi.mock("../features/prefs/prefsContext", () => ({
|
vi.mock("../features/prefs/prefsContext", () => ({
|
||||||
usePrefs: () => ({ theme: { scheme: "default" }, setTheme: vi.fn() }),
|
usePrefs: () => ({ theme: { scheme: "default" }, setTheme: vi.fn() }),
|
||||||
}));
|
}));
|
||||||
vi.mock("../features/filters/savedViewsContext", () => ({
|
vi.mock("../modules/feed/feedSavedViews", async (importOriginal) => ({
|
||||||
useSavedViews: () => ({ views: [], apply: vi.fn() }),
|
...(await importOriginal<Record<string, unknown>>()),
|
||||||
|
useFeedSavedViews: () => ({ views: views.feed, apply: vi.fn() }),
|
||||||
|
}));
|
||||||
|
vi.mock("../modules/plex/plexSavedViews", async (importOriginal) => ({
|
||||||
|
...(await importOriginal<Record<string, unknown>>()),
|
||||||
|
usePlexSavedViews: () => ({ views: views.plex, apply: vi.fn() }),
|
||||||
}));
|
}));
|
||||||
const requestSurprise = vi.fn();
|
const requestSurprise = vi.fn();
|
||||||
vi.mock("../lib/homeIntent", () => ({ requestSurprise: () => requestSurprise() }));
|
vi.mock("../lib/homeIntent", () => ({ requestSurprise: () => requestSurprise() }));
|
||||||
@@ -96,6 +109,9 @@ beforeEach(() => {
|
|||||||
requestSurprise.mockClear();
|
requestSurprise.mockClear();
|
||||||
channels.mockReset().mockResolvedValue([]);
|
channels.mockReset().mockResolvedValue([]);
|
||||||
playlists.mockReset().mockResolvedValue([]);
|
playlists.mockReset().mockResolvedValue([]);
|
||||||
|
views.feed = [];
|
||||||
|
views.plex = [];
|
||||||
|
nav.page = "feed";
|
||||||
});
|
});
|
||||||
afterEach(cleanup);
|
afterEach(cleanup);
|
||||||
|
|
||||||
@@ -114,6 +130,18 @@ describe("AppCommandPalette", () => {
|
|||||||
expect(ids("modules")).toContain("page:config");
|
expect(ids("modules")).toContain("page:config");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("offers Plex's saved views over the Plex page, and the feed's over any other", () => {
|
||||||
|
views.feed = [{ id: 1, name: "feed view" }];
|
||||||
|
views.plex = [{ id: 2, name: "plex view" }];
|
||||||
|
nav.page = "plex";
|
||||||
|
mount();
|
||||||
|
expect(ids("views")).toEqual(["view:2"]);
|
||||||
|
cleanup();
|
||||||
|
nav.page = "home";
|
||||||
|
mount();
|
||||||
|
expect(ids("views")).toEqual(["view:1"]);
|
||||||
|
});
|
||||||
|
|
||||||
it("leaves out the channels the user has hidden", async () => {
|
it("leaves out the channels the user has hidden", async () => {
|
||||||
channels.mockResolvedValue([
|
channels.mockResolvedValue([
|
||||||
{ id: "UC1", title: "Shown", handle: "@shown", hidden: false },
|
{ id: "UC1", title: "Shown", handle: "@shown", hidden: false },
|
||||||
|
|||||||
@@ -23,10 +23,11 @@ import { requestSurprise } from "../lib/homeIntent";
|
|||||||
import { requestLinkBox } from "../lib/downloadIntake";
|
import { requestLinkBox } from "../lib/downloadIntake";
|
||||||
import { openRecorderStart } from "../lib/debugRecorder";
|
import { openRecorderStart } from "../lib/debugRecorder";
|
||||||
import { useDebugRecording } from "../features/diagnostics/useDebugRecording";
|
import { useDebugRecording } from "../features/diagnostics/useDebugRecording";
|
||||||
import { useNavigationActions } from "../features/navigation/navigationContext";
|
import { useNavigation, useNavigationActions } from "../features/navigation/navigationContext";
|
||||||
|
import { useFeedSavedViews } from "../modules/feed";
|
||||||
import { usePlaylistsActions } from "../modules/playlists";
|
import { usePlaylistsActions } from "../modules/playlists";
|
||||||
|
import { usePlexSavedViews } from "../modules/plex";
|
||||||
import { usePrefs } from "../features/prefs/prefsContext";
|
import { usePrefs } from "../features/prefs/prefsContext";
|
||||||
import { useSavedViews } from "../features/filters/savedViewsContext";
|
|
||||||
|
|
||||||
// What Ctrl/Cmd+K can reach. The generic list/filter/keyboard shell is `ui/CommandPalette`; this is
|
// What Ctrl/Cmd+K can reach. The generic list/filter/keyboard shell is `ui/CommandPalette`; this is
|
||||||
// the domain half — the layer boundary says a primitive may not import `lib/api` or a module.
|
// the domain half — the layer boundary says a primitive may not import `lib/api` or a module.
|
||||||
@@ -37,8 +38,8 @@ import { useSavedViews } from "../features/filters/savedViewsContext";
|
|||||||
// the Channels page already gives it, and one mistyped Enter is a worse failure than the walk it
|
// the Channels page already gives it, and one mistyped Enter is a worse failure than the walk it
|
||||||
// would have saved.
|
// would have saved.
|
||||||
//
|
//
|
||||||
// Saved views come from whichever module owns the current SavedViewsProvider source (feed on the
|
// Saved views are Plex's on the Plex page and the feed's everywhere else, i.e. the views that are
|
||||||
// feed, Plex on Plex), i.e. exactly the views that are applicable where you stand.
|
// applicable where you stand.
|
||||||
export default function AppCommandPalette() {
|
export default function AppCommandPalette() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const open = usePaletteOpen();
|
const open = usePaletteOpen();
|
||||||
@@ -46,7 +47,10 @@ export default function AppCommandPalette() {
|
|||||||
const { setPage, openChannel } = useNavigationActions();
|
const { setPage, openChannel } = useNavigationActions();
|
||||||
const { setSelectedId } = usePlaylistsActions();
|
const { setSelectedId } = usePlaylistsActions();
|
||||||
const { theme, setTheme } = usePrefs();
|
const { theme, setTheme } = usePrefs();
|
||||||
const savedViews = useSavedViews();
|
const { page } = useNavigation();
|
||||||
|
const feedViews = useFeedSavedViews();
|
||||||
|
const plexViews = usePlexSavedViews();
|
||||||
|
const savedViews = page === "plex" ? plexViews : feedViews;
|
||||||
const { session: recordingSession, stop: stopRecording, markStep } = useDebugRecording();
|
const { session: recordingSession, stop: stopRecording, markStep } = useDebugRecording();
|
||||||
const stopMutate = stopRecording.mutate;
|
const stopMutate = stopRecording.mutate;
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user