import { createContext, useContext, useEffect, useMemo, useRef, useState, type ReactNode, } from "react"; import { useTranslation } from "react-i18next"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { qk } from "../lib/queryKeys"; import { api, coercePlexFilters, coerceTagModes, coerceUntagged, getActiveAccount, plexFiltersSignature, type FeedFilters, type PlexFilters, type SavedView, } from "../lib/api"; import { filtersToParams, hasFilterParams } from "../lib/urlState"; import { pickDefaultToSeed, preferSticky } from "../lib/savedViews"; import { notifyActionError } from "../lib/actionErrors"; import { accountKey, LS } from "../lib/storage"; import { useActiveAccountId } from "../lib/activeAccount"; import { useConfirm } from "./ConfirmProvider"; import { useNavigation } from "./NavigationProvider"; import { useFeedFilters, useFeedFiltersActions } from "./FeedFiltersProvider"; import { usePlex, usePlexActions } from "./PlexProvider"; // 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 ?? [], channelIds: raw.channelIds ?? [], tagModes: coerceTagModes(raw), untagged: coerceUntagged(raw), }); type ViewSource = "feed" | "plex"; // 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; /** 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. */ canSeedDefault: () => boolean; } const feedKeyOf = (f: Record) => filtersToParams(asFilters(f as unknown as FeedFilters)).toString(); const plexKeyOf = (f: Record) => plexFiltersSignature(coercePlexFilters(f)); // 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 → // report "stored" so a default never seeds blind over an indeterminate state. function storedFiltersPresent(lsKey: string): boolean { const key = accountKey(lsKey, getActiveAccount()); if (!key) return true; try { return localStorage.getItem(key) != null; } catch { 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 interface SavedViewsContext { /** Which module these views belong to — the shared UI branches its filter-describer on it. */ source: ViewSource; views: SavedView[]; loading: boolean; activeViewId: number | null; displayView: SavedView | null; isDirty: boolean; modalOpen: boolean; openModal: () => void; closeModal: () => void; apply: (v: SavedView) => void; clearActive: () => void; create: (name: string) => Promise; rename: (id: number, name: string) => Promise; /** Overwrite a view's captured filters (used by the modal's removable chips). */ updateFilters: (id: number, filters: Record) => Promise; /** Save the module's CURRENTLY-applied filters into a view (the live→stored inverse of `apply`); * resolves to the saved snapshot on success (so the caller can resync its editor) or null. */ saveCurrent: (id: number) => Promise | null>; toggleDefault: (v: SavedView) => Promise; remove: (v: SavedView) => Promise; reorder: (nextIds: number[]) => Promise; } const Ctx = createContext(null); export function SavedViewsProvider({ children }: { children: ReactNode }) { const { t } = useTranslation(); const qc = useQueryClient(); 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, 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 canSeedDefault: plexCanSeedDefault, }; } 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; }, canSeedDefault: feedCanSeedDefault, }; }, [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); // 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(() => { setStickyId(null); setModalOpen(false); }, [source]); // The ONE default-application path for both modules. On the views query's FIRST resolve for a // source, seed the starred default view's filters — but only into a genuinely fresh module (no // stored filters, no URL override; see cfg.canSeedDefault). Applying via cfg.setFilters persists // 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. // // WAIT FOR THE ACCOUNT PIN. This provider mounts above , which pins the tab-account only after // /api/me — but viewsQuery is ungated, so on a fresh tab it resolves FIRST, while the account is null. // canSeedDefault() reads the per-account stored-filters key, which is indeterminate (→ "assume stored") // with no account, so evaluating it too early would decline to seed AND burn the one-shot guard, never // re-running once the account pins. Gating on the reactive account id makes the effect re-fire on the // pin (useActiveAccountId subscribes), and defers consuming the guard until canSeedDefault is real. const accountId = useActiveAccountId(); const seededRef = useRef>(new Set()); useEffect(() => { const data = viewsQuery.data; if (!data || accountId == null || seededRef.current.has(source)) return; seededRef.current.add(source); const def = pickDefaultToSeed(data, cfg.canSeedDefault()); if (!def) return; cfg.setFilters(cfg.asFilters(def.filters)); setStickyId(def.id); // cfg is read fresh on this resolve and the per-source guard makes the body run once, so cfg // (which re-memoizes on every filter edit) is deliberately kept out of the deps. // eslint-disable-next-line react-hooks/exhaustive-deps }, [viewsQuery.data, source, accountId]); const currentKey = cfg.keyOf(cfg.filters); const activeView = useMemo( () => preferSticky( views.filter((v) => cfg.keyOf(v.filters) === currentKey), stickyId, ), [views, currentKey, stickyId, cfg], ); useEffect(() => { if (activeView) setStickyId(activeView.id); }, [activeView]); const stickyView = useMemo( () => (stickyId == null ? null : (views.find((v) => v.id === stickyId) ?? null)), [views, stickyId], ); const displayView = activeView ?? stickyView; const isDirty = displayView != null && activeView == null; const refresh = () => qc.invalidateQueries({ queryKey: qk.savedViews(source) }); async function runViewMutation(fn: () => Promise, failMsg: string): Promise { try { await fn(); return true; } catch (e) { notifyActionError(e, failMsg); return false; } finally { refresh(); } } const value: SavedViewsContext = { source, views, loading: viewsQuery.isLoading, activeViewId: activeView?.id ?? null, displayView, isDirty, modalOpen, openModal: () => setModalOpen(true), closeModal: () => setModalOpen(false), apply: (v) => { cfg.setFilters(cfg.asFilters(v.filters)); setStickyId(v.id); }, clearActive: () => setStickyId(null), create: async (name) => { const clean = name.trim(); if (!clean) return false; return runViewMutation( () => api.createView(clean, cfg.stripForSave(cfg.filters), source), t("views.actionFailed"), ); }, rename: async (id, name) => { const next = name.trim(); if (!next) return false; return runViewMutation(() => api.updateView(id, { name: next }), t("views.actionFailed")); }, updateFilters: (id, filters) => runViewMutation(() => api.updateView(id, { filters }), t("views.actionFailed")), saveCurrent: async (id) => { const filters = cfg.stripForSave(cfg.filters); const ok = await runViewMutation( () => api.updateView(id, { filters }), t("views.actionFailed"), ); return ok ? filters : null; }, toggleDefault: (v) => runViewMutation( () => api.updateView(v.id, { is_default: !v.is_default }), t("views.actionFailed"), ).then(() => undefined), remove: async (v) => { const ok = await confirm({ message: t("views.deleteConfirm", { name: v.name }), confirmLabel: t("views.delete"), danger: true, }); if (!ok) return false; if (stickyId === v.id) setStickyId(null); return runViewMutation(() => api.deleteView(v.id), t("views.actionFailed")); }, reorder: async (nextIds) => { qc.setQueryData( qk.savedViews(source), nextIds.map((id) => views.find((v) => v.id === id)!).filter(Boolean), ); await runViewMutation(() => api.reorderViews(nextIds), t("views.reorderFailed")); }, }; return {children}; } export function useSavedViews(): SavedViewsContext { const ctx = useContext(Ctx); if (!ctx) throw new Error("useSavedViews must be used within a SavedViewsProvider"); return ctx; }