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({ 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(() => loadLocalTheme()); const [perf, setPerf] = useState(() => getAccountRaw(PERF_KEY) === "1"); const [hints, setHints] = useState(() => hintsEnabled()); const [notif, setNotif] = useState(() => 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({ theme: loadLocalTheme(), performanceMode: getAccountRaw(PERF_KEY) === "1", hints: hintsEnabled(), notifications: getNotifSettings(), }); const [saveState, setSaveState] = useState("idle"); const saveMsgTimer = useRef(undefined); const saveTimer = useRef(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 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( () => ({ 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( () => ({ theme, setTheme, perf, setPerf, hints, setHints, notif, setNotif, saveState }), [theme, setTheme, perf, hints, notif, saveState], ); return {children}; }