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:
2026-10-23 04:59:10 +02:00
parent cde5a98fd2
commit 6ae7825a14
43 changed files with 653 additions and 289 deletions
+7
View File
@@ -1,3 +1,4 @@
import type { ComponentType, ReactNode } from "react";
import type { LucideIcon } from "lucide-react";
import type { Me } from "./api";
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.
// MUST match the max-w the page's own content wrapper uses. Omit for a full-width page (feed).
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 }>;
};
+3 -3
View File
@@ -1,5 +1,5 @@
// Pure selection helpers for saved ("personal") views, split out of SavedViewsProvider so the
// tie-break logic is unit-testable without mounting the provider (its React Query / context deps).
// Pure selection helpers for saved ("personal") views, split out of useSavedViewsStore so the
// 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
* 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
* 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
* (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. */
export function pickDefaultToSeed<T extends { is_default: boolean }>(
views: T[],