diff --git a/frontend/src/components/PersonalViewsModal.tsx b/frontend/src/components/PersonalViewsModal.tsx index e4106c67..cc027bc4 100644 --- a/frontend/src/components/PersonalViewsModal.tsx +++ b/frontend/src/components/PersonalViewsModal.tsx @@ -16,10 +16,11 @@ import { verticalListSortingStrategy, } from "@dnd-kit/sortable"; import { CSS } from "@dnd-kit/utilities"; -import type { SavedView } from "../lib/api"; +import { coercePlexFilters, type FeedFilters, type SavedView } from "../lib/api"; import Modal from "./Modal"; import { useSavedViews, asFilters } from "./SavedViewsProvider"; import { useActiveFilters } from "../lib/useActiveFilters"; +import { buildPlexActiveFilters } from "./usePlexActiveFilters"; // Master–detail manager for saved ("personal") views: the left column lists the views (drag to // reorder) plus a "save the current filters" input; the right column shows the selected view's name, @@ -72,19 +73,38 @@ function ViewRow({ export default function PersonalViewsModal() { const { t } = useTranslation(); - const { views, activeViewId, apply, create, rename, toggleDefault, remove, reorder, closeModal } = - useSavedViews(); + const { + source, + views, + activeViewId, + apply, + create, + rename, + toggleDefault, + remove, + reorder, + closeModal, + } = useSavedViews(); const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 4 } })); const [selectedId, setSelectedId] = useState(activeViewId ?? views[0]?.id ?? null); const selected = views.find((v) => v.id === selectedId) ?? null; const [draftName, setDraftName] = useState(""); - // The selected view's captured filters, as the same human labels the toolbar chip row shows. - const captured = useActiveFilters( - selected ? asFilters(selected.filters) : ({ tags: [], channelIds: [] } as never), - () => {}, + // The selected view's captured filters, as the same human labels its module's chip row shows. The + // feed hook runs unconditionally (fed empty filters off-feed); Plex uses the pure builder (no-op + // removes — this is display-only). A plex view captures its rail filters, not the watch-state. + const noop = () => {}; + const feedCaptured = useActiveFilters( + source === "feed" && selected + ? asFilters(selected.filters as unknown as FeedFilters) + : ({ tags: [], channelIds: [] } as never), + noop, ); + const captured = + source === "plex" && selected + ? buildPlexActiveFilters(coercePlexFilters(selected.filters), "all", noop, noop, t) + : feedCaptured; async function onDragEnd(e: DragEndEvent) { const { active, over } = e; diff --git a/frontend/src/components/PlexBrowse.tsx b/frontend/src/components/PlexBrowse.tsx index f3e2235f..deef4f58 100644 --- a/frontend/src/components/PlexBrowse.tsx +++ b/frontend/src/components/PlexBrowse.tsx @@ -30,6 +30,7 @@ import PlexDiscover from "./PlexDiscover"; import { PlexCollectionsBrowse, PlexPlaylistsBrowse } from "./plexBrowseLists"; import { PageToolbar } from "./PageShell"; import ActiveFilterChips from "./ActiveFilterChips"; +import SelectedViewChip from "./SelectedViewChip"; import ItemCount from "./ItemCount"; import { StateMessage } from "./QueryState"; import { usePlexActiveFilters, usePlexClearFilters } from "./usePlexActiveFilters"; @@ -461,6 +462,8 @@ export default function PlexBrowse() { left (overflowing to a "+N"/count pill so they never crowd the controls), while Sort + the Discover/Browse toggle stay pinned to the right — the same layout as the Feed. */}
+ {/* The active personal view (if any) leads the applied-filter row, like the feed. */} + (readPlexFiltersRaw); const filters = useMemo(() => { try { - // Drop the legacy `sortDir` — direction now lives in the sort SortState. Left here it would leak - // into the facets query key and be re-persisted on every filter change. Also migrate the legacy - // single `collection`/`collectionTitle` into the multi-select `collections`/`collectionTitles`. - const { - sortDir: _drop, - collection: legacyCollection, - collectionTitle: legacyTitle, - ...rest - } = JSON.parse(filtersRaw); - const merged: PlexFilters = { ...EMPTY_PLEX_FILTERS, ...rest }; - if (legacyCollection && !merged.collections.length) { - merged.collections = [legacyCollection]; - if (legacyTitle) merged.collectionTitles = { [legacyCollection]: legacyTitle }; - } - return merged; + // coercePlexFilters fills defaults, drops the legacy `sortDir` (direction now lives in the sort + // SortState — left here it would leak into the facets key and be re-persisted), and migrates the + // legacy single `collection`/`collectionTitle` into `collections`/`collectionTitles`. Shared with + // the saved-views provider so the localStorage seed and a DB view blob coerce identically. + return coercePlexFilters(JSON.parse(filtersRaw)); } catch { return EMPTY_PLEX_FILTERS; } diff --git a/frontend/src/components/PlexSidebar.tsx b/frontend/src/components/PlexSidebar.tsx index 79f8d389..a12cf31c 100644 --- a/frontend/src/components/PlexSidebar.tsx +++ b/frontend/src/components/PlexSidebar.tsx @@ -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 ( - } - 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} - /> + <> + } + 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 : ( + } /> + ) + } + onTitleClick={isDemo ? undefined : openModal} + titleActionLabel={isDemo ? undefined : t("views.manageTitle")} + /> + {/* The manager modal lives here (rail is always mounted on plex); Modal portals to . */} + {modalOpen && } + ); } diff --git a/frontend/src/components/SavedViewsProvider.tsx b/frontend/src/components/SavedViewsProvider.tsx index 496256af..58a1f3ae 100644 --- a/frontend/src/components/SavedViewsProvider.tsx +++ b/frontend/src/components/SavedViewsProvider.tsx @@ -4,9 +4,12 @@ import { useQuery, useQueryClient } from "@tanstack/react-query"; import { qk } from "../lib/queryKeys"; import { api, - getActiveAccount, + coercePlexFilters, coerceTagModes, + getActiveAccount, + plexFiltersSignature, type FeedFilters, + type PlexFilters, type SavedView, } from "../lib/api"; import { filtersToParams } from "../lib/urlState"; @@ -14,12 +17,17 @@ import { preferSticky } from "../lib/savedViews"; import { notifyActionError } from "../lib/actionErrors"; import { accountKey, LS, writeJSON } from "../lib/storage"; import { useConfirm } from "./ConfirmProvider"; +import { useNavigation } from "./NavigationProvider"; import { useFeedFilters, useFeedFiltersActions } from "./FeedFiltersProvider"; +import { usePlex, usePlexActions } from "./PlexProvider"; -// A saved view's `filters` is an opaque JSON blob from the DB — the FeedFilters type says nothing about -// what an older build wrote there, so coerce the array/shape fields (and migrate the legacy global -// tagMode) before the blob reaches app state or a serializer. NOT a migration: a view saved against a -// dropped field just loses that filter. +// Saved "smart views" now serve TWO modules — the feed and Plex — keyed by `source`. This one provider +// switches which module it operates on by the active page (both filter providers are global, so it can +// read either), so the shared UI (popover / modal / chip) works on the feed page against feed views and +// on the plex page against plex views without a second provider or an App-layout change. + +// 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). export const asFilters = (raw: FeedFilters): FeedFilters => ({ ...raw, tags: raw.tags ?? [], @@ -27,13 +35,27 @@ export const asFilters = (raw: FeedFilters): FeedFilters => ({ tagModes: coerceTagModes(raw), }); -// A stable signature of a filter set (only non-default keys, shuffle seed ignored) so a view can be -// matched against the live filters regardless of key order or transient fields. -const keyOf = (f: FeedFilters) => filtersToParams(asFilters(f)).toString(); +type ViewSource = "feed" | "plex"; -// Mirror the default view's filters to localStorage so the app can seed them on next load before the -// network returns (read by FeedFiltersProvider). -function syncDefaultMirror(views: SavedView[]): void { +// The per-source 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). +interface ViewConfig { + source: ViewSource; + filters: Record; + setFilters: (f: Record) => void; + keyOf: (f: Record) => string; + asFilters: (raw: Record) => Record; + stripForSave: (f: Record) => Record; + /** Feed only: mirror the default view's filters to localStorage so it seeds before the network returns. */ + syncDefaultMirror?: (views: SavedView[]) => void; +} + +const feedKeyOf = (f: Record) => + filtersToParams(asFilters(f as unknown as FeedFilters)).toString(); +const plexKeyOf = (f: Record) => plexFiltersSignature(coercePlexFilters(f)); + +// Mirror the feed default view's filters to localStorage (read by FeedFiltersProvider on load). +function syncFeedDefaultMirror(views: SavedView[]): void { const key = accountKey(LS.defaultViewFilters, getActiveAccount()); if (!key) return; const def = views.find((v) => v.is_default); @@ -41,26 +63,21 @@ function syncDefaultMirror(views: SavedView[]): void { } export interface SavedViewsContext { + /** Which module these views belong to — the shared UI branches its filter-describer on it. */ + source: ViewSource; views: SavedView[]; loading: boolean; - /** The view whose saved filters EXACTLY match the live filters (null if none) — highlighted as active. */ activeViewId: number | null; - /** The view to name in the chip: the exact match, else the last-applied ("sticky") one it diverged from. */ displayView: SavedView | null; - /** True when a sticky view is shown but the live filters no longer match it (the chip's "modified" dot). */ isDirty: boolean; modalOpen: boolean; openModal: () => void; closeModal: () => void; - /** Apply a view's filters and remember it as the active/sticky one. */ apply: (v: SavedView) => void; - /** Forget the active/sticky view (the chip disappears) without touching the filters. */ clearActive: () => void; - /** Save the CURRENT filters as a new view; returns success so a caller can keep its editor open on fail. */ create: (name: string) => Promise; rename: (id: number, name: string) => Promise; toggleDefault: (v: SavedView) => Promise; - /** Delete a view after confirming; resolves true only when it was actually deleted (false on cancel/fail). */ remove: (v: SavedView) => Promise; reorder: (nextIds: number[]) => Promise; } @@ -71,33 +88,72 @@ export function SavedViewsProvider({ children }: { children: ReactNode }) { const { t } = useTranslation(); const qc = useQueryClient(); const confirm = useConfirm(); - const filters = useFeedFilters(); - const { setFilters } = useFeedFiltersActions(); + const { page } = useNavigation(); - const viewsQuery = useQuery({ queryKey: qk.savedViews(), queryFn: api.savedViews }); + // 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, + setFilters: (f) => setPlexFilters(f as unknown as PlexFilters), + keyOf: plexKeyOf, + asFilters: (raw) => coercePlexFilters(raw) as unknown as Record, + stripForSave: (f) => f, // Plex has no transient field to drop + }; + } + return { + source: "feed", + filters: feedFilters as unknown as Record, + setFilters: (f) => setFeedFilters(f as unknown as FeedFilters), + keyOf: feedKeyOf, + asFilters: (raw) => + asFilters(raw as unknown as FeedFilters) as unknown as Record, + // Strip the transient shuffle seed so a saved "shuffle" view re-rolls fresh on apply. + stripForSave: (f) => { + const { seed: _seed, ...rest } = f; + return rest; + }, + syncDefaultMirror: syncFeedDefaultMirror, + }; + }, [page, feedFilters, plexFilters, setFeedFilters, setPlexFilters]); + + const source = cfg.source; + const viewsQuery = useQuery({ + queryKey: qk.savedViews(source), + queryFn: () => api.savedViews(source), + }); const views = useMemo(() => viewsQuery.data ?? [], [viewsQuery.data]); const [stickyId, setStickyId] = useState(null); const [modalOpen, setModalOpen] = useState(false); - // Keep the default-view mirror (read on load) in sync with the server state. + // Switching modules (feed ⟷ plex) starts fresh: a feed view can't be the sticky/active one on plex, + // and a feed modal shouldn't linger open on plex. useEffect(() => { - if (viewsQuery.data) syncDefaultMirror(viewsQuery.data); - }, [viewsQuery.data]); + setStickyId(null); + setModalOpen(false); + }, [source]); - const currentKey = keyOf(filters); + // Feed only: keep the default-view mirror (read on load) in sync with the server state. + useEffect(() => { + if (viewsQuery.data) cfg.syncDefaultMirror?.(viewsQuery.data); + }, [viewsQuery.data, cfg]); + + const currentKey = cfg.keyOf(cfg.filters); const activeView = useMemo( - // When several views capture identical filters, prefer the one the user explicitly applied - // (sticky) so apply(B) isn't silently re-attributed to an earlier duplicate A; else the first. () => preferSticky( - views.filter((v) => keyOf(v.filters) === currentKey), + views.filter((v) => cfg.keyOf(v.filters) === currentKey), stickyId, ), - [views, currentKey, stickyId], + [views, currentKey, stickyId, cfg], ); - // An exact match becomes the sticky selection, so filters that happen to equal a view (deep-link, - // manual, or an applied view) name that view in the chip and keep naming it after a later tweak. useEffect(() => { if (activeView) setStickyId(activeView.id); }, [activeView]); @@ -109,7 +165,7 @@ export function SavedViewsProvider({ children }: { children: ReactNode }) { const displayView = activeView ?? stickyView; const isDirty = displayView != null && activeView == null; - const refresh = () => qc.invalidateQueries({ queryKey: qk.savedViews() }); + const refresh = () => qc.invalidateQueries({ queryKey: qk.savedViews(source) }); async function runViewMutation(fn: () => Promise, failMsg: string): Promise { try { await fn(); @@ -123,6 +179,7 @@ export function SavedViewsProvider({ children }: { children: ReactNode }) { } const value: SavedViewsContext = { + source, views, loading: viewsQuery.isLoading, activeViewId: activeView?.id ?? null, @@ -132,16 +189,17 @@ export function SavedViewsProvider({ children }: { children: ReactNode }) { openModal: () => setModalOpen(true), closeModal: () => setModalOpen(false), apply: (v) => { - setFilters(asFilters(v.filters)); + cfg.setFilters(cfg.asFilters(v.filters)); setStickyId(v.id); }, clearActive: () => setStickyId(null), create: async (name) => { const clean = name.trim(); if (!clean) return false; - // Strip the transient shuffle seed so a saved "shuffle" view re-rolls fresh on apply. - const { seed: _seed, ...rest } = filters; - return runViewMutation(() => api.createView(clean, rest), t("views.actionFailed")); + return runViewMutation( + () => api.createView(clean, cfg.stripForSave(cfg.filters), source), + t("views.actionFailed"), + ); }, rename: async (id, name) => { const next = name.trim(); @@ -164,10 +222,8 @@ export function SavedViewsProvider({ children }: { children: ReactNode }) { return runViewMutation(() => api.deleteView(v.id), t("views.actionFailed")); }, reorder: async (nextIds) => { - // Optimistic reorder so the drop sticks visually before the round-trip; the refetch reverts to - // the server's truth on failure (with a toast, so it doesn't silently snap back). qc.setQueryData( - qk.savedViews(), + qk.savedViews(source), nextIds.map((id) => views.find((v) => v.id === id)!).filter(Boolean), ); await runViewMutation(() => api.reorderViews(nextIds), t("views.reorderFailed")); diff --git a/frontend/src/components/usePlexActiveFilters.ts b/frontend/src/components/usePlexActiveFilters.ts index fdc34c9b..a0d5e026 100644 --- a/frontend/src/components/usePlexActiveFilters.ts +++ b/frontend/src/components/usePlexActiveFilters.ts @@ -12,11 +12,26 @@ import { usePlex, usePlexActions } from "./PlexProvider"; // removing one keeps the rest (same shape as the feed's tag/channel chips). Sort is NOT a chip here — // it's an always-visible control above the grid (S3), like the feed. Lives here, not in lib, because // it reads the Plex provider (lib stays provider-free, like useActiveFilters). +type TFn = (key: string, options?: Record) => string; + export function usePlexActiveFilters(): ActiveFilter[] { const { show, filters } = usePlex(); const { setShow, setFilters } = usePlexActions(); const { t } = useTranslation(); const patch = (p: Partial) => setFilters({ ...filters, ...p }); + return buildPlexActiveFilters(filters, show, patch, setShow, t); +} + +// The pure builder behind the hook: turns a Plex filter set + watch-state into the source-agnostic +// ActiveFilter[]. Extracted so the saved-views manager can DESCRIBE an arbitrary view's captured +// filters (with no-op removes) — the labels stay in one place, no duplication with the live chip row. +export function buildPlexActiveFilters( + filters: PlexFilters, + show: string, + patch: (p: Partial) => void, + setShow: (s: string) => void, + t: TFn, +): ActiveFilter[] { const out: ActiveFilter[] = []; const has = (x: number | null | undefined) => x != null; diff --git a/frontend/src/i18n/locales/en/views.json b/frontend/src/i18n/locales/en/views.json index 4497730f..b9354ad4 100644 --- a/frontend/src/i18n/locales/en/views.json +++ b/frontend/src/i18n/locales/en/views.json @@ -3,7 +3,7 @@ "namePlaceholder": "View name", "saveAction": "Save", "cancel": "Cancel", - "empty": "No saved views yet. Filter the feed, then save it.", + "empty": "No saved views yet. Apply some filters, then save them.", "apply": "Apply this view", "makeDefault": "Set as default (loads on start)", "isDefault": "Default view — loads on start", diff --git a/frontend/src/i18n/locales/hu/views.json b/frontend/src/i18n/locales/hu/views.json index 8ea38330..2328496d 100644 --- a/frontend/src/i18n/locales/hu/views.json +++ b/frontend/src/i18n/locales/hu/views.json @@ -3,7 +3,7 @@ "namePlaceholder": "Nézet neve", "saveAction": "Mentés", "cancel": "Mégse", - "empty": "Még nincs mentett nézet. Szűrd a feedet, majd mentsd el.", + "empty": "Még nincs mentett nézet. Állíts be szűrőket, majd mentsd el.", "apply": "Nézet alkalmazása", "makeDefault": "Beállítás alapértelmezettként (indításkor betölt)", "isDefault": "Alapértelmezett nézet — indításkor betölt", diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index a050b06f..aaa7d98f 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -51,7 +51,12 @@ import { adminApi } from "./api/admin"; export type * from "./api/admin"; import { plexApi } from "./api/plex"; export type * from "./api/plex"; -export { EMPTY_PLEX_FILTERS, plexFilterCount } from "./api/plex"; +export { + EMPTY_PLEX_FILTERS, + plexFilterCount, + coercePlexFilters, + plexFiltersSignature, +} from "./api/plex"; import { messagingApi } from "./api/messaging"; export type * from "./api/messaging"; import { downloadsApi } from "./api/downloads"; diff --git a/frontend/src/lib/api/plex.ts b/frontend/src/lib/api/plex.ts index 920e3846..262ffe74 100644 --- a/frontend/src/lib/api/plex.ts +++ b/frontend/src/lib/api/plex.ts @@ -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): PlexFilters { + const { + sortDir: _drop, + collection, + collectionTitle, + ...rest + } = raw as Record & { + collection?: string | null; + collectionTitle?: string | null; + }; + const merged: PlexFilters = { ...EMPTY_PLEX_FILTERS, ...(rest as Partial) }; + 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 + diff --git a/frontend/src/lib/api/savedViews.ts b/frontend/src/lib/api/savedViews.ts index cb57ff50..d50b1eb2 100644 --- a/frontend/src/lib/api/savedViews.ts +++ b/frontend/src/lib/api/savedViews.ts @@ -1,24 +1,36 @@ -// Saved "smart views": per-user named snapshots of FeedFilters (see SavedViewsWidget). -// Only touches core's req; the FeedFilters shape is owned by feed.ts. +// Saved "smart views": per-user named snapshots of a module's filters, scoped by `source` +// ("feed" | "plex"). Only touches core's req; `filters` is an opaque JSON blob (each module casts it +// to its own filter shape), so this stays independent of feed.ts / plex.ts. import { req } from "./core"; -import type { FeedFilters } from "./feed"; -// A saved "smart view": a per-user named snapshot of FeedFilters (see SavedViewsWidget). +// A module's saved filter snapshot. `filters` is opaque here — the feed reads it as FeedFilters, Plex +// as PlexFilters; `source` is implicit (the caller queries per source). export interface SavedView { id: number; name: string; - filters: FeedFilters; + filters: Record; position: number; is_default: boolean; } +// The module a view belongs to. "feed" is the default so existing callers keep working. +type ViewSource = "feed" | "plex"; + export const savedViewsApi = { - savedViews: (): Promise => req("/api/saved-views"), - createView: (name: string, filters: FeedFilters): Promise => - req("/api/saved-views", { method: "POST", body: JSON.stringify({ name, filters }) }), + savedViews: (source: ViewSource = "feed"): Promise => + req(`/api/saved-views?source=${source}`), + createView: ( + name: string, + filters: Record, + source: ViewSource = "feed", + ): Promise => + req("/api/saved-views", { + method: "POST", + body: JSON.stringify({ name, filters, source }), + }), updateView: ( id: number, - patch: Partial<{ name: string; filters: FeedFilters; is_default: boolean }>, + patch: Partial<{ name: string; filters: Record; is_default: boolean }>, ): Promise => req(`/api/saved-views/${id}`, { method: "PATCH", body: JSON.stringify(patch) }), deleteView: (id: number): Promise<{ deleted: number }> => diff --git a/frontend/src/lib/queryKeys.ts b/frontend/src/lib/queryKeys.ts index 69561050..5c4065d4 100644 --- a/frontend/src/lib/queryKeys.ts +++ b/frontend/src/lib/queryKeys.ts @@ -44,7 +44,9 @@ export const qk = { playlist: (id?: number | null) => id !== undefined ? (["playlist", id] as const) : (["playlist"] as const), playlistMembership: (videoId: string) => ["playlist-membership", videoId] as const, - savedViews: () => ["saved-views"] as const, + // Keyed by source ("feed" | "plex") so each module's views cache independently; `savedViews()` (no + // arg) stays a prefix for a cross-source invalidation. + savedViews: (...rest: unknown[]) => ["saved-views", ...rest], // --- account / misc (R7 S2b) --- (me/myStatus/tags already declared as shared roots above) accounts: () => ["accounts"] as const, diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index 00e3acd7..f0717f1c 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -59,10 +59,12 @@ const root = - - - - + + + + {/* SavedViewsProvider sits below BOTH filter providers so it can switch its + source (feed ⟷ plex) by the active page and read the right live filters. */} + @@ -72,10 +74,10 @@ const root = - - - - + + + +