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
+35 -14
View File
@@ -13,7 +13,11 @@ import { type PanelItem } from "./PanelGroups";
import SegmentedToggle from "./SegmentedToggle";
import FilterChipGroup from "./FilterChipGroup";
import Combobox, { type ComboboxOption } from "./Combobox";
import SavedViewsPopover from "./SavedViewsPopover";
import PersonalViewsModal from "./PersonalViewsModal";
import { useSavedViews } from "./SavedViewsProvider";
import { usePlex, usePlexActions } from "./PlexProvider";
import { useMe } from "../lib/useMe";
// The Plex module's left filter column — now the shared floating SidePanel with its filter groups
// as reorderable "islands" (same system as the feed rail). Movie libraries get the full metadata
@@ -66,6 +70,10 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl
// hand-off to PlexBrowse via the shared `playlistOpen` slot.
const { scope, show, filters } = usePlex();
const { setFilters } = usePlexActions();
const isDemo = useMe().is_demo;
// 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). Hidden for the demo account.
const { openModal, modalOpen } = useSavedViews();
// This rail's media source (the Plex library) — names the abstraction the feed and Plex rails
// share; drives the panel layout key. `FilterRail` owns the customize `editing` state now.
const source: MediaSourceKind = "plex";
@@ -291,20 +299,33 @@ export default function PlexSidebar({ layout, setLayout, collapsed, onToggleColl
];
return (
<FilterRail
kind={source}
title={t("sidebar.filters")}
icon={<SlidersHorizontal className="w-4 h-4" />}
count={activeCount}
collapsed={collapsed}
onToggleCollapse={onToggleCollapse}
layout={layout}
setLayout={setLayout}
items={items}
// The Plex widgets are simple label + control rows now, not collapsible islands (matches the
// feed rail). The rail's own "Clear all" is dropped too — the chip row above the grid owns it.
collapsible={false}
/>
<>
<FilterRail
kind={source}
title={t("sidebar.filters")}
icon={<SlidersHorizontal className="w-4 h-4" />}
count={activeCount}
collapsed={collapsed}
onToggleCollapse={onToggleCollapse}
layout={layout}
setLayout={setLayout}
items={items}
// The Plex widgets are simple label + control rows now, not collapsible islands (matches the
// feed rail). The rail's own "Clear all" is dropped too — the chip row above the grid owns it.
collapsible={false}
// Saved ("personal") views: header icon → quick-pick popover, title → the manager modal. Hidden
// for the demo account. Same shared components as the feed rail (SavedViewsProvider switches source).
iconExpanded={
isDemo ? undefined : (
<SavedViewsPopover trigger={<SlidersHorizontal className="w-4 h-4" />} />
)
}
onTitleClick={isDemo ? undefined : openModal}
titleActionLabel={isDemo ? undefined : t("views.manageTitle")}
/>
{/* The manager modal lives here (rail is always mounted on plex); Modal portals to <body>. */}
{modalOpen && <PersonalViewsModal />}
</>
);
}