Files
siftlode/frontend/src/components/PrefsProvider.tsx
T

160 lines
6.7 KiB
TypeScript

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<EditablePrefs>(() => {
const prefs = qc.getQueryData<Me | null>(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<ThemePrefs>(seed.theme);
const [perf, setPerf] = useState(seed.performanceMode);
const [hints, setHints] = useState(seed.hints);
const [railLabels, setRailLabels] = useState(seed.railLabels);
const [notif, setNotif] = useState<NotifSettings>(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<EditablePrefs>(seed);
const [saveState, setSaveState] = useState<PrefsController["saveState"]>("idle");
const saveMsgTimer = useRef<number | undefined>(undefined);
const saveTimer = useRef<number | undefined>(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 <body> 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<EditablePrefs>(
() => ({ 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<PrefsController>(
() => ({
theme,
setTheme,
perf,
setPerf,
hints,
setHints,
railLabels,
setRailLabels,
notif,
setNotif,
saveState,
}),
[theme, setTheme, perf, hints, railLabels, notif, saveState],
);
return <PrefsContext.Provider value={value}>{children}</PrefsContext.Provider>;
}