Extend lib/queryKeys.ts to the remaining ~34 key roots and migrate every remaining literal (queryKey: [...] AND the positional useLiveQuery keys) in 36 files. All ~200 React Query keys across the app now route through the factory; zero raw key literals remain outside queryKeys.ts. The heterogeneous composite plex keys (plex-library/facets/collections/ playlists carry a filters object, a ratingKeys array, or a union/group discriminator) take a variadic pass-through — the factory centralizes the ROOT string; single-id keys stay precisely typed. Nullable keys (thread, playlist, ytSearch) accept null so a null segment is preserved. Pure substitution — byte-identical key arrays, so TanStack prefix matching is unchanged. Gate green: typecheck (app+node+e2e), eslint (0 err), prettier, 68 vitest. Runtime-verified on a fresh dev server: Feed + Plex modules load, feed/facets/tags/my-status/plex-library/plex-facets/ plex-collections/plex-playlists all 200, full UI renders.
173 lines
6.9 KiB
TypeScript
173 lines
6.9 KiB
TypeScript
import {
|
|
createContext,
|
|
useCallback,
|
|
useContext,
|
|
useEffect,
|
|
useMemo,
|
|
useRef,
|
|
useState,
|
|
type ReactNode,
|
|
} from "react";
|
|
import { qk } from "../lib/queryKeys";
|
|
import { useQuery } from "@tanstack/react-query";
|
|
import { api } from "../lib/api";
|
|
import {
|
|
applyTheme,
|
|
DEFAULT_THEME,
|
|
loadLocalTheme,
|
|
saveLocalTheme,
|
|
type ThemePrefs,
|
|
} from "../lib/theme";
|
|
import { configureNotifications, getNotifSettings, type NotifSettings } from "../lib/notifications";
|
|
import { hintsEnabled, setHintsEnabled } from "../lib/hints";
|
|
import { getAccountRaw, LS, setAccountRaw } from "../lib/storage";
|
|
import type { PrefsController } from "./SettingsPanel";
|
|
|
|
const PERF_KEY = LS.perfMode;
|
|
|
|
// The four Settings-page preferences. They apply locally for instant preview and auto-save (debounced)
|
|
// to the server — there is no draft or Save button. This provider owns them (lifted out of the App god
|
|
// component) plus the last-saved "baseline" used to suppress the echo when a change came from adopting
|
|
// server prefs on login. Consumers (SettingsPanel, GlassTuner) read the PrefsController via usePrefs().
|
|
type EditablePrefs = {
|
|
theme: ThemePrefs;
|
|
performanceMode: boolean;
|
|
hints: boolean;
|
|
notifications: NotifSettings;
|
|
};
|
|
|
|
const PrefsContext = createContext<PrefsController>({
|
|
theme: DEFAULT_THEME,
|
|
setTheme: () => {},
|
|
perf: false,
|
|
setPerf: () => {},
|
|
hints: true,
|
|
setHints: () => {},
|
|
notif: getNotifSettings(),
|
|
setNotif: () => {},
|
|
saveState: "idle",
|
|
});
|
|
|
|
export function usePrefs(): PrefsController {
|
|
return useContext(PrefsContext);
|
|
}
|
|
|
|
export function PrefsProvider({ children }: { children: ReactNode }) {
|
|
const [theme, setThemeState] = useState<ThemePrefs>(() => loadLocalTheme());
|
|
const [perf, setPerf] = useState(() => getAccountRaw(PERF_KEY) === "1");
|
|
const [hints, setHints] = useState(() => hintsEnabled());
|
|
const [notif, setNotif] = useState<NotifSettings>(() => getNotifSettings());
|
|
// The last server-known prefs. Written on adopt + after each save so the auto-save effect can
|
|
// compare against it and avoid echoing a server-adopted value straight back. A ref (drives no UI).
|
|
const baselineRef = useRef<EditablePrefs>({
|
|
theme: loadLocalTheme(),
|
|
performanceMode: getAccountRaw(PERF_KEY) === "1",
|
|
hints: hintsEnabled(),
|
|
notifications: getNotifSettings(),
|
|
});
|
|
const [saveState, setSaveState] = useState<PrefsController["saveState"]>("idle");
|
|
const saveMsgTimer = useRef<number | undefined>(undefined);
|
|
const saveTimer = useRef<number | undefined>(undefined);
|
|
// Passive observer of the account App fetches (enabled:false → reads cache, re-renders on update).
|
|
const { data: me } = useQuery({ queryKey: qk.me(), queryFn: api.me, enabled: false });
|
|
|
|
// Live-apply the prefs locally for instant preview (their localStorage mirrors update via the
|
|
// stores too); persistence to the server is the auto-save effect below.
|
|
useEffect(() => applyTheme(theme), [theme]);
|
|
useEffect(() => {
|
|
document.documentElement.dataset.perf = perf ? "1" : "";
|
|
setAccountRaw(PERF_KEY, perf ? "1" : "0");
|
|
}, [perf]);
|
|
// The per-scheme background image (the "glass over image" look) is on when the user hasn't opted
|
|
// out and we're not in perf mode. Drives `html[data-backdrop]` (see index.css), which paints the
|
|
// theme-appropriate image on <body> and switches the glass to translucent.
|
|
useEffect(() => {
|
|
const on = theme.bgImage && !perf;
|
|
document.documentElement.dataset.backdrop = on ? "on" : "off";
|
|
}, [theme.bgImage, perf]);
|
|
useEffect(() => setHintsEnabled(hints), [hints]);
|
|
useEffect(() => configureNotifications(notif), [notif]);
|
|
|
|
// Auto-save on change (debounced ~500ms so a slider drag coalesces into one PUT). Live-apply is the
|
|
// effects above; this is the only persistence path. `baselineRef` suppresses the echo when the
|
|
// change came from adopting server prefs on login (`/me` merges, so a full payload is fine).
|
|
const editablePrefs = useMemo<EditablePrefs>(
|
|
() => ({ theme, performanceMode: perf, hints, notifications: notif }),
|
|
[theme, perf, hints, notif],
|
|
);
|
|
useEffect(() => {
|
|
window.clearTimeout(saveTimer.current);
|
|
if (JSON.stringify(editablePrefs) === JSON.stringify(baselineRef.current)) {
|
|
// Back at the last-saved value (initial load, server adopt, or an edit undone) — nothing to
|
|
// save; clear any "Saving…" left from a change that was reverted before it flushed.
|
|
setSaveState((s) => (s === "saving" ? "idle" : s));
|
|
return;
|
|
}
|
|
setSaveState("saving");
|
|
window.clearTimeout(saveMsgTimer.current);
|
|
const payload = editablePrefs;
|
|
saveTimer.current = window.setTimeout(() => {
|
|
api
|
|
.savePrefs(payload)
|
|
.then(() => {
|
|
baselineRef.current = payload;
|
|
setSaveState("saved");
|
|
saveMsgTimer.current = window.setTimeout(() => setSaveState("idle"), 2500);
|
|
})
|
|
.catch(() => {
|
|
// api.req() already surfaces the failure; flag the indicator so the user sees it didn't take.
|
|
setSaveState("error");
|
|
saveMsgTimer.current = window.setTimeout(() => setSaveState("idle"), 4000);
|
|
});
|
|
}, 500);
|
|
return () => window.clearTimeout(saveTimer.current);
|
|
}, [editablePrefs]);
|
|
// Clear pending timers on unmount so a debounced PUT or a fade timer never fires setState on a dead
|
|
// component.
|
|
useEffect(
|
|
() => () => {
|
|
window.clearTimeout(saveTimer.current);
|
|
window.clearTimeout(saveMsgTimer.current);
|
|
},
|
|
[],
|
|
);
|
|
|
|
// On login / account switch, adopt the server-stored prefs into the live states AND the auto-save
|
|
// baseline (so adopting them doesn't echo a save back).
|
|
useEffect(() => {
|
|
const prefs = me?.preferences;
|
|
if (!prefs) return;
|
|
const adopted: EditablePrefs = {
|
|
theme: prefs.theme ? { ...DEFAULT_THEME, ...prefs.theme } : loadLocalTheme(),
|
|
performanceMode:
|
|
typeof prefs.performanceMode === "boolean"
|
|
? prefs.performanceMode
|
|
: getAccountRaw(PERF_KEY) === "1",
|
|
hints: typeof prefs.hints === "boolean" ? prefs.hints : hintsEnabled(),
|
|
notifications: prefs.notifications
|
|
? { ...getNotifSettings(), ...prefs.notifications }
|
|
: getNotifSettings(),
|
|
};
|
|
setThemeState(adopted.theme);
|
|
saveLocalTheme(adopted.theme);
|
|
setPerf(adopted.performanceMode);
|
|
setHints(adopted.hints);
|
|
setNotif(adopted.notifications);
|
|
baselineRef.current = adopted;
|
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
}, [me?.id]);
|
|
|
|
// Theme applies live (and caches to localStorage); the auto-save effect persists it to the server.
|
|
const setTheme = useCallback((next: ThemePrefs) => {
|
|
setThemeState(next);
|
|
saveLocalTheme(next);
|
|
}, []);
|
|
|
|
const value = useMemo<PrefsController>(
|
|
() => ({ theme, setTheme, perf, setPerf, hints, setHints, notif, setNotif, saveState }),
|
|
[theme, setTheme, perf, hints, notif, saveState],
|
|
);
|
|
|
|
return <PrefsContext.Provider value={value}>{children}</PrefsContext.Provider>;
|
|
}
|