feat(plex): port the saved-views (personal views) UI to Plex (frontend)

Plex now has the same personal-views system as the feed — the rail header icon
opens the quick-pick popover, the rail title opens the manager modal (list /
create / rename / delete / reorder / set-default / apply), and the active view
leads the chip row with a "modified" dot.

One SavedViewsProvider serves both modules: it switches `source` (feed ⟷ plex)
by the active page (both filter providers are global, so it reads whichever the
page owns) and runs the same logic on per-source wiring — the live filters,
apply, a stable signature (feed filtersToParams / plex plexFiltersSignature),
coercion (asFilters / coercePlexFilters), and what to strip on save. Moved below
PlexProvider in the tree so it can read plex state; resets sticky/modal on a
source switch. The shared popover/modal/chip are unchanged; the modal describes
a plex view's captured filters via a pure buildPlexActiveFilters (extracted from
usePlexActiveFilters — no duplication). api.savedViews/createView take a source;
`SavedView.filters` is now an opaque blob. New coercePlexFilters/plexFilters-
Signature (also dedups PlexProvider's collection migration). Empty-state copy
genericised (no longer says "feed"). A Plex view captures the rail filters (not
watch-state/sort/scope).

Verified end-to-end on localdev: create/apply/preview/default/delete on plex,
feed views stay separate + unregressed (402 vitest), no console errors.
This commit is contained in:
2026-08-22 19:16:30 +02:00
parent fa7a09a420
commit a20e27800c
13 changed files with 254 additions and 97 deletions
+31
View File
@@ -252,6 +252,37 @@ export const EMPTY_PLEX_FILTERS: PlexFilters = {
keywords: [],
collections: [],
};
// Coerce an opaque persisted/saved filter blob to a full PlexFilters: fill defaults for any missing
// array/scalar, and migrate the legacy single `collection`/`collectionTitle` into the multi-select
// `collections`/`collectionTitles`. Shared by PlexProvider (localStorage seed) and the saved-views
// provider (a DB view blob). Not a migration — an unknown field a newer build dropped just falls away.
export function coercePlexFilters(raw: Record<string, unknown>): PlexFilters {
const {
sortDir: _drop,
collection,
collectionTitle,
...rest
} = raw as Record<string, unknown> & {
collection?: string | null;
collectionTitle?: string | null;
};
const merged: PlexFilters = { ...EMPTY_PLEX_FILTERS, ...(rest as Partial<PlexFilters>) };
if (collection && !merged.collections.length) {
merged.collections = [collection];
if (collectionTitle) merged.collectionTitles = { [collection]: collectionTitle };
}
return merged;
}
// A stable signature of a PlexFilters set (only non-default keys; display-only titles excluded), so a
// saved view can be matched against the live filters regardless of key order — the Plex analogue of the
// feed's filtersToParams. Reuses appendPlexFilters so the signature can't drift from the wire params.
export function plexFiltersSignature(f: PlexFilters): string {
const u = new URLSearchParams();
appendPlexFilters(u, f);
return u.toString();
}
export function plexFilterCount(f: PlexFilters): number {
return (
f.genres.length +