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.
244 lines
9.0 KiB
TypeScript
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;
|
|
}
|