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)
This commit is contained in:
+7
-148
@@ -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<ThemePrefs>(() => 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 <Feed> 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 <Feed>/<ChannelPage>.
|
||||
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<NotifSettings>(() => 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<EditablePrefs>({
|
||||
theme: loadLocalTheme(),
|
||||
performanceMode: getAccountRaw(PERF_KEY) === "1",
|
||||
hints: hintsEnabled(),
|
||||
notifications: getNotifSettings(),
|
||||
});
|
||||
const [prefsSaveState, setPrefsSaveState] = useState<PrefsController["saveState"]>("idle");
|
||||
const saveMsgTimer = useRef<number | undefined>(undefined);
|
||||
const saveTimer = useRef<number | undefined>(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<Record<PanelId, PanelLayout>>(() => ({
|
||||
@@ -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 <body> (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<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.
|
||||
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<string, unknown>;
|
||||
(["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 ? (
|
||||
<DownloadCenter me={meQuery.data!} />
|
||||
) : page === "settings" ? (
|
||||
<SettingsPanel me={meQuery.data!} prefs={prefsCtl} />
|
||||
<SettingsPanel me={meQuery.data!} />
|
||||
) : page === "plex" && meQuery.data!.plex_enabled ? (
|
||||
<PlexBrowse />
|
||||
) : (
|
||||
@@ -624,7 +483,7 @@ export default function App() {
|
||||
{/* Re-binds to the active page's scroll container on navigation (page or channel change). */}
|
||||
<BackToTop dep={channelView ? `chan:${channelView.id}` : page} />
|
||||
{/* Opt-in live appearance tuner (Settings → Appearance; off by default). */}
|
||||
<GlassTuner enabled={theme.showTuner} />
|
||||
<GlassTuner />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user