Files
siftlode/frontend/src/components/SavedViewsProvider.tsx
T
peter c3d88d9b20 refactor: component files export only components; lint fails on a warning
The 43 react-refresh/only-export-components warnings the gate let through meant an edit to a
provider (or AnchoredPopover, Toaster, plexDetailUi, …) could not be hot-swapped: Fast Refresh
reloads a module that also exports hooks or helpers.

- Each provider's contexts, the types its hooks return and the hooks themselves live in a sibling
  context module (navigationContext.ts, channelsContext.ts, …); the provider file keeps the
  component. loadAccountFilters moved next to the feed filters' defaults (feedFiltersInit.ts),
  following navigationInit/playlistsInit.
- Helpers that sat in component files have their own modules: welcomeMode, downloadTitle,
  plexPlayable, channelSync, ui/anchoredPopoverHooks, ui/pageToolbarSlot, ui/pagerContent,
  ui/toastLevels, ui/overlayOrder, ui/tableAlign, lib/plexDetail; the cells of the channel columns
  and the registry's page wrappers are components files of their own (channelCells, modulePages).
- usePersistedTab, an alias of useAccountPersistedState, is gone; its callers use the original.

npm run lint is now eslint --max-warnings 0, so a warning fails the gate like an error does.
2026-09-17 02:59:09 +02:00

259 lines
10 KiB
TypeScript

import { 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,
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 { useConfirm } from "./confirmContext";
import { useNavigation } from "./navigationContext";
import { useFeedFilters, useFeedFiltersActions } from "./feedFiltersContext";
import { usePlex, usePlexActions } from "./plexContext";
import {
asFilters,
SavedViewsCtx,
type SavedViewsContext,
type ViewSource,
} from "./savedViewsContext";
// 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.
//
// The context, its value type and the hook live in savedViewsContext.ts.
// 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>;
/** 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<string, unknown>) =>
filtersToParams(asFilters(f as unknown as FeedFilters)).toString();
const plexKeyOf = (f: Record<string, unknown>) => 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 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
canSeedDefault: plexCanSeedDefault,
};
}
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;
},
canSeedDefault: feedCanSeedDefault,
};
}, [page, feedFilters, plexFilters, setFeedFilters, setPlexFilters]);
const source = cfg.source;
// Passive observer of the account BootGate fetches: the providers mount for a signed-out tab too
// (the public landing), and saved views are per-account, so asking for "nobody's" views only logs
// a 401 on the landing page. Enabled once a signed-in account is cached.
const { data: me } = useQuery({ queryKey: qk.me(), queryFn: api.me, enabled: false });
const viewsQuery = useQuery({
queryKey: qk.savedViews(source),
queryFn: () => api.savedViews(source),
enabled: !!me,
});
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]);
// 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. canSeedDefault() reads the
// per-account stored-filters key, which is always readable here: BootGate pins the tab's account
// before this provider mounts.
const seededRef = useRef<Set<ViewSource>>(new Set());
useEffect(() => {
const data = viewsQuery.data;
if (!data || 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]);
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"));
},
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<SavedView[]>(
qk.savedViews(source),
nextIds.map((id) => views.find((v) => v.id === id)!).filter(Boolean),
);
await runViewMutation(() => api.reorderViews(nextIds), t("views.reorderFailed"));
},
};
return <SavedViewsCtx.Provider value={value}>{children}</SavedViewsCtx.Provider>;
}