From fd1742e7834ea2bae0316fb58de29ce288e91213 Mon Sep 17 00:00:00 2001 From: npeter83 Date: Sun, 19 Jul 2026 01:09:20 +0200 Subject: [PATCH] refactor(prefs): move Settings auto-save machinery into PrefsProvider Lift the four editable Settings prefs (theme/perf/hints/notifications), their live-apply effects, the debounced auto-save + baseline echo-guard, and the server-adopt-on-login into PrefsProvider, exposing the PrefsController via usePrefs(). SettingsPanel and GlassTuner read it from the hook instead of props; App's big adopt effect keeps only the shell prefs (panel layouts, rail collapse, language). App.tsx 655->489 lines. E2E 17/17 incl. settings auto-save. (Platform S6b.3) --- frontend/src/App.tsx | 155 +------------------- frontend/src/components/GlassTuner.tsx | 5 +- frontend/src/components/PrefsProvider.tsx | 171 ++++++++++++++++++++++ frontend/src/components/SettingsPanel.tsx | 5 +- frontend/src/main.tsx | 9 +- 5 files changed, 192 insertions(+), 153 deletions(-) create mode 100644 frontend/src/components/PrefsProvider.tsx diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 2fd08ec..96d391c 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,4 +1,4 @@ -import { lazy, Suspense, useEffect, useMemo, useRef, useState } from "react"; +import { lazy, Suspense, useEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { useQuery, useQueryClient } from "@tanstack/react-query"; import { @@ -8,13 +8,6 @@ import { setUnauthorizedHandler, } from "./lib/api"; import { setLanguage, isSupported, type LangCode } from "./i18n"; -import { - applyTheme, - DEFAULT_THEME, - loadLocalTheme, - saveLocalTheme, - type ThemePrefs, -} from "./lib/theme"; import { hasFilterParams, stripUrlParams } from "./lib/urlState"; import { loadLayout, @@ -24,16 +17,13 @@ import { type PanelId, type PanelLayout, } from "./lib/panelLayout"; -import { configureNotifications, getNotifSettings, notify, removeByMetaKind, type NotifSettings } from "./lib/notifications"; -import { hintsEnabled, setHintsEnabled } from "./lib/hints"; +import { notify, removeByMetaKind } from "./lib/notifications"; import { - getAccountRaw, LS, readAccount, setAccountRaw, writeAccount, } from "./lib/storage"; -import type { PrefsController } from "./components/SettingsPanel"; import { useNavigation, useNavigationActions } from "./components/NavigationProvider"; import { useFeedFilters, useFeedFiltersActions } from "./components/FeedFiltersProvider"; import { useFeedView, useFeedViewActions } from "./components/FeedViewProvider"; @@ -76,23 +66,10 @@ const DownloadCenter = lazy(() => import("./components/DownloadCenter")); const About = lazy(() => import("./components/About")); const ReleaseNotes = lazy(() => import("./components/ReleaseNotes")); -const PERF_KEY = LS.perfMode; - -// The preferences edited on the Settings page. They apply locally for instant preview but -// persist to the server only on an explicit Save — so App holds both the live "draft" (the -// individual states below) and the last-saved "baseline" to compute dirty / revert on discard. -type EditablePrefs = { - theme: ThemePrefs; - performanceMode: boolean; - hints: boolean; - notifications: NotifSettings; -}; - export default function App() { const { t, i18n } = useTranslation(); const { page, channelView, ytSearch } = useNavigation(); const { setPage, enterYtSearch, exitYtSearch } = useNavigationActions(); - const [theme, setThemeState] = useState(() => loadLocalTheme()); // The global feed filters live in FeedFiltersProvider (per-account persistence + the account-load // effect + share-link capture all moved there). App reads them to hand to and to the // handlers that mutate the feed view (onShowInFeed, tag-click, the feed scrollKey). @@ -102,23 +79,6 @@ export default function App() { // by the feed page and the channel page. App reads it only to hand to /. const view = useFeedView(); const { setView } = useFeedViewActions(); - // Settings-page prefs: applied live by the effects below, and auto-saved (debounced) on change - // — no draft, no Save button (see the auto-save effect). - const [perf, setPerf] = useState(() => getAccountRaw(PERF_KEY) === "1"); - const [hints, setHints] = useState(() => hintsEnabled()); - const [notif, setNotif] = useState(() => getNotifSettings()); - // The last server-known prefs. The same states are ALSO written when we adopt server prefs on - // login/account-switch, so the auto-save effect compares against this baseline to avoid echoing - // a server-adopted value straight back. A ref (drives no UI); updated on adopt + after each save. - const baselineRef = useRef({ - theme: loadLocalTheme(), - performanceMode: getAccountRaw(PERF_KEY) === "1", - hints: hintsEnabled(), - notifications: getNotifSettings(), - }); - const [prefsSaveState, setPrefsSaveState] = useState("idle"); - const saveMsgTimer = useRef(undefined); - const saveTimer = useRef(undefined); // Per-panel "customize" layouts (order/collapsed/hidden of each side panel's group cards), // persisted per-account. One record so feed/plex/playlists share the same mechanism. const [panelLayouts, setPanelLayouts] = useState>(() => ({ @@ -171,70 +131,6 @@ export default function App() { writeAccount(LS.playlistsCollapsed, next); api.savePrefs({ playlistsCollapsed: next }).catch(() => {}); } - useEffect(() => applyTheme(theme), [theme]); - - // Apply the draft prefs locally for instant preview (their localStorage mirrors update via - // the stores too); persistence to the server is deferred to an explicit Save. - 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 (dark or /light/ set) 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 the Settings prefs on change (debounced ~500ms so a slider drag coalesces into one - // PUT). Live-apply is handled by the effects above; this is the only persistence path — there is - // no draft or Save button. `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. - setPrefsSaveState((s) => (s === "saving" ? "idle" : s)); - return; - } - setPrefsSaveState("saving"); - window.clearTimeout(saveMsgTimer.current); - const payload = editablePrefs; - saveTimer.current = window.setTimeout(() => { - api - .savePrefs(payload) - .then(() => { - baselineRef.current = payload; - setPrefsSaveState("saved"); - saveMsgTimer.current = window.setTimeout(() => setPrefsSaveState("idle"), 2500); - }) - .catch(() => { - // api.req() already surfaces the failure; flag the indicator so the user sees it didn't take. - setPrefsSaveState("error"); - saveMsgTimer.current = window.setTimeout(() => setPrefsSaveState("idle"), 4000); - }); - }, 500); - return () => window.clearTimeout(saveTimer.current); - }, [editablePrefs]); - // Clear pending pref-save 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); - }, - [], - ); - // Filters live in localStorage, not the address bar. If we arrived via a "Share view" // link, its params have already hydrated the initial state — persist them and strip the // query so the URL stays clean from here on. @@ -330,31 +226,12 @@ export default function App() { stripUrlParams(); }, [adminDeepLink, meQuery.data?.id]); // eslint-disable-line react-hooks/exhaustive-deps - // On login, adopt server-stored preferences. + // On login, adopt the server-stored SHELL preferences (panel layouts, rail collapse flags, + // language). The Settings-page prefs (theme/perf/hints/notifications) are adopted by PrefsProvider + // and the feed view by FeedViewProvider — each via its own passive ["me"] observer. useEffect(() => { const prefs = meQuery.data?.preferences; if (!prefs) return; - // Adopt the server prefs into the live states (the runtime effects above apply them for - // display) AND into the auto-save baseline, so adopting them doesn't echo a save back. - 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; - // Out-of-scope prefs stay instant (edited outside the Settings page). The feed view is adopted - // by FeedViewProvider (its own passive ["me"] observer), not here. const prefsRec = prefs as Record; (["feed", "plex", "playlists"] as PanelId[]).forEach((p) => { const raw = prefsRec[PREF_KEY[p]]; @@ -411,30 +288,12 @@ export default function App() { // eslint-disable-next-line react-hooks/exhaustive-deps }, [meQuery.data?.role, meQuery.data?.pending_invites]); - // Theme applies live (and caches to localStorage); the auto-save effect persists it to the server. - function setTheme(next: ThemePrefs) { - setThemeState(next); - saveLocalTheme(next); - } // Language is edited outside the Settings page (sidebar/login), so it stays instant. function changeLanguage(code: LangCode) { setLanguage(code); api.savePrefs({ language: code }).catch(() => {}); } - const prefsCtl: PrefsController = { - theme, - setTheme, - perf, - setPerf, - hints, - setHints, - notif, - setNotif, - saveState: prefsSaveState, - }; - - // First-run: a fresh instance is in setup mode (all other routes are locked) → show the wizard. if (setupQuery.isLoading) return ( @@ -581,7 +440,7 @@ export default function App() { ) : page === "downloads" && !meQuery.data!.is_demo ? ( ) : page === "settings" ? ( - + ) : page === "plex" && meQuery.data!.plex_enabled ? ( ) : ( @@ -624,7 +483,7 @@ export default function App() { {/* Re-binds to the active page's scroll container on navigation (page or channel change). */} {/* Opt-in live appearance tuner (Settings → Appearance; off by default). */} - + ); } diff --git a/frontend/src/components/GlassTuner.tsx b/frontend/src/components/GlassTuner.tsx index 7620eb7..4b28d92 100644 --- a/frontend/src/components/GlassTuner.tsx +++ b/frontend/src/components/GlassTuner.tsx @@ -1,5 +1,6 @@ import { useEffect, useMemo, useState } from "react"; import Overlay from "./Overlay"; +import { usePrefs } from "./PrefsProvider"; /** * A floating live appearance tuner. Mounted at the app root so it's reachable on every page; it @@ -91,7 +92,9 @@ function loadSaved(): Saved { } } -export default function GlassTuner({ enabled }: { enabled: boolean }) { +export default function GlassTuner() { + // Opt-in via Settings → Appearance (theme.showTuner, off by default); the flag lives in PrefsProvider. + const enabled = usePrefs().theme.showTuner; const initial = useMemo(loadSaved, []); const [vars, setVars] = useState>(() => { const base: Record = {}; diff --git a/frontend/src/components/PrefsProvider.tsx b/frontend/src/components/PrefsProvider.tsx new file mode 100644 index 0000000..18f3c5a --- /dev/null +++ b/frontend/src/components/PrefsProvider.tsx @@ -0,0 +1,171 @@ +import { + createContext, + useCallback, + useContext, + useEffect, + useMemo, + useRef, + useState, + type ReactNode, +} from "react"; +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: ["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}; +} diff --git a/frontend/src/components/SettingsPanel.tsx b/frontend/src/components/SettingsPanel.tsx index 2e9fdbe..c2a5f97 100644 --- a/frontend/src/components/SettingsPanel.tsx +++ b/frontend/src/components/SettingsPanel.tsx @@ -12,6 +12,7 @@ import { Section, SettingRow, Switch } from "./ui/form"; import { type SaveState } from "./ui/DraftSaveBar"; import { useConfirm } from "./ConfirmProvider"; import { useWizard } from "./WizardProvider"; +import { usePrefs } from "./PrefsProvider"; // The Settings page edits server-persisted preferences. Each change applies locally for instant // preview AND auto-saves (debounced) — no draft, no Save button; a small indicator flashes the @@ -40,8 +41,10 @@ const TABS: { id: TabId; icon: typeof Monitor }[] = [ // Settings as a page (Design B): rendered in the main content area, not an overlay. It keeps // the frosted `.glass` surface so it still refracts the ambient backdrop. The page title is // shown by the Header; the tab rail stays as in-page sub-navigation. -export default function SettingsPanel({ me, prefs }: { me: Me; prefs: PrefsController }) { +export default function SettingsPanel({ me }: { me: Me }) { const { t } = useTranslation(); + // The editable prefs (theme/perf/hints/notifications + save state) live in PrefsProvider. + const prefs = usePrefs(); // "Connect YouTube" onboarding lives in WizardProvider (the Account tab's reconnect button). const { openWizard } = useWizard(); const [tabRaw, setTab] = useAccountPersistedState(LS.settingsTab, "appearance"); diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx index ad7ca70..553b436 100644 --- a/frontend/src/main.tsx +++ b/frontend/src/main.tsx @@ -9,6 +9,7 @@ import { PlaylistsProvider } from "./components/PlaylistsProvider"; import { PlexProvider } from "./components/PlexProvider"; import { ChannelsProvider } from "./components/ChannelsProvider"; import { FeedViewProvider } from "./components/FeedViewProvider"; +import { PrefsProvider } from "./components/PrefsProvider"; import { WizardProvider } from "./components/WizardProvider"; import "./i18n"; import "./index.css"; @@ -48,9 +49,11 @@ const root = - - - + + + + +