import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, type ReactNode, } from "react"; import { qk } from "../lib/queryKeys"; import { useQueryClient } from "@tanstack/react-query"; import { api, type Me } from "../lib/api"; import { applyTheme, loadLocalTheme, normalizeTheme, 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"; import { PrefsContext } from "./prefsContext"; 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" (the server values it was seeded with, then each successful // save) used to suppress saving what the server already holds. Consumers (SettingsPanel) read the PrefsController via usePrefs() (prefsContext.ts). type EditablePrefs = { theme: ThemePrefs; performanceMode: boolean; hints: boolean; railLabels: boolean; notifications: NotifSettings; }; export function PrefsProvider({ children }: { children: ReactNode }) { const qc = useQueryClient(); // The account's prefs, seeded straight from the `me` cache — BootGate mounts this only once /api/me // has answered, so a theme changed on another device is right on the first paint instead of // switching after it. This browser's copy fills any key the server has never stored. const [seed] = useState(() => { const prefs = qc.getQueryData(qk.me())?.preferences; return { theme: prefs?.theme ? normalizeTheme(prefs.theme) : loadLocalTheme(), performanceMode: typeof prefs?.performanceMode === "boolean" ? prefs.performanceMode : getAccountRaw(PERF_KEY) === "1", hints: typeof prefs?.hints === "boolean" ? prefs.hints : hintsEnabled(), // B7: off by default — the icon rail with name tooltips is the house look. railLabels: prefs?.railLabels === true, notifications: prefs?.notifications ? { ...getNotifSettings(), ...prefs.notifications } : getNotifSettings(), }; }); const [theme, setThemeState] = useState(seed.theme); const [perf, setPerf] = useState(seed.performanceMode); const [hints, setHints] = useState(seed.hints); const [railLabels, setRailLabels] = useState(seed.railLabels); const [notif, setNotif] = useState(seed.notifications); // The last server-known prefs: the seed, then each successful save. The auto-save effect compares // against it, so the seed itself is never echoed back as a save. A ref (drives no UI). const baselineRef = useRef(seed); const [saveState, setSaveState] = useState("idle"); const saveMsgTimer = useRef(undefined); const saveTimer = useRef(undefined); // 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. The look is applied in // LAYOUT effects, before the browser paints, so the app's first frame is already in the account's // theme rather than in the baseline one main.tsx sets for the boot screen. useLayoutEffect(() => applyTheme(theme), [theme]); useLayoutEffect(() => { 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. useLayoutEffect(() => { 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` keeps the mount seed from being // saved straight back (`/me` merges, so a full payload is fine). const editablePrefs = useMemo( () => ({ theme, performanceMode: perf, hints, railLabels, notifications: notif }), [theme, perf, hints, railLabels, notif], ); useEffect(() => { window.clearTimeout(saveTimer.current); if (JSON.stringify(editablePrefs) === JSON.stringify(baselineRef.current)) { // Back at the last-saved value (the mount seed, 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); }, [], ); // 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, railLabels, setRailLabels, notif, setNotif, saveState, }), [theme, setTheme, perf, hints, railLabels, notif, saveState], ); return {children}; }