Files
siftlode/frontend/src/components/SavedViewsProvider.tsx
T
peter b7a4eaff8d fix(plex): resolve deep-review findings on the filter-bar overhaul
Self-review of the branch diff (feature/plex-filter-bar-overhaul) against main:

- Surprise rail query key omitted `lang` while the backend /surprise now filters
  against the language-specific genre column → a HU/EN switch under an active genre
  filter left the rail stale. Key it by scope+lang+filters, matching plexDiscover.
- Chip-removing a collection left its entry in `collectionTitles` (hashed into the
  plex query keys), splitting the cache from the pristine no-collection state and
  growing the titles map unboundedly. Prune the title on remove, like CollectionPicker.
- The saved-views default-mirror effect gained `cfg` in its deps (re-memoized on every
  live filter edit) → a synchronous localStorage write per keystroke. Depend on the
  module-stable mirror fn instead.
- Collections-browse search input had no accessible name (placeholder only). Add
  aria-label + type="search".
- Extract the tile hue-hash + gradient duplicated between PlexDiscover and
  plexBrowseLists into a shared lib/hueTile so the two surfaces can't drift.
- Document migration 0073 downgrade() as lossy and de-dup non-feed rows before
  recreating the narrow (user_id, name) constraint (verified by the drift test).
- Add unit tests for coercePlexFilters (legacy single-collection migration) and
  plexFiltersSignature (order-independence, title-ignoring).

Gate: tsc/lint/knip/prettier clean, 410 vitest; backend ruff + migration drift +
saved-views tests green.
2026-08-23 01:39:29 +02:00

244 lines
9.0 KiB
TypeScript

import { createContext, useContext, useEffect, useMemo, 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,
getActiveAccount,
plexFiltersSignature,
type FeedFilters,
type PlexFilters,
type SavedView,
} from "../lib/api";
import { filtersToParams } from "../lib/urlState";
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";
// 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),
});
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<string, unknown>;
setFilters: (f: Record<string, unknown>) => void;
keyOf: (f: Record<string, unknown>) => string;
asFilters: (raw: Record<string, unknown>) => Record<string, unknown>;
stripForSave: (f: Record<string, unknown>) => Record<string, unknown>;
/** 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<string, unknown>) =>
filtersToParams(asFilters(f as unknown as FeedFilters)).toString();
const plexKeyOf = (f: Record<string, unknown>) => 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);
writeJSON(key, def ? def.filters : null);
}
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<boolean>;
rename: (id: number, name: string) => Promise<boolean>;
toggleDefault: (v: SavedView) => Promise<void>;
remove: (v: SavedView) => Promise<boolean>;
reorder: (nextIds: number[]) => Promise<void>;
}
const Ctx = createContext<SavedViewsContext | null>(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<string, unknown>,
setFilters: (f) => setPlexFilters(f as unknown as PlexFilters),
keyOf: plexKeyOf,
asFilters: (raw) => coercePlexFilters(raw) as unknown as Record<string, unknown>,
stripForSave: (f) => f, // Plex has no transient field to drop
};
}
return {
source: "feed",
filters: feedFilters as unknown as Record<string, unknown>,
setFilters: (f) => setFeedFilters(f as unknown as FeedFilters),
keyOf: feedKeyOf,
asFilters: (raw) =>
asFilters(raw as unknown as FeedFilters) as unknown as Record<string, unknown>,
// 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<number | null>(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]);
// Feed only: keep the default-view mirror (read on load) in sync with the server state. Depend on the
// mirror fn (a module-stable ref, or undefined for plex) NOT the whole `cfg` object — cfg re-memoizes
// on every live filter edit, which would fire this synchronous localStorage write far beyond its intent.
const syncMirror = cfg.syncDefaultMirror;
useEffect(() => {
if (viewsQuery.data) syncMirror?.(viewsQuery.data);
}, [viewsQuery.data, syncMirror]);
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<unknown>, failMsg: string): Promise<boolean> {
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"));
},
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<SavedView[]>(
qk.savedViews(source),
nextIds.map((id) => views.find((v) => v.id === id)!).filter(Boolean),
);
await runViewMutation(() => api.reorderViews(nextIds), t("views.reorderFailed"));
},
};
return <Ctx.Provider value={value}>{children}</Ctx.Provider>;
}
export function useSavedViews(): SavedViewsContext {
const ctx = useContext(Ctx);
if (!ctx) throw new Error("useSavedViews must be used within a SavedViewsProvider");
return ctx;
}