feat(settings): auto-save preferences instead of draft + Save

Every Settings change now persists on its own (debounced ~500ms so a slider drag
is one PUT), with a small transient indicator; the draft/dirty-save UI and the
navigation leave-guard are gone. Also unblocks the navigation extraction — there
is no longer a cross-page "unsaved settings" guard to thread through routing.
This commit is contained in:
2026-07-18 01:44:25 +02:00
parent 49739da925
commit c855924fdc
5 changed files with 126 additions and 188 deletions
+67 -106
View File
@@ -43,7 +43,6 @@ import {
writeAccount,
writeJSON,
} from "./lib/storage";
import { useConfirm } from "./components/ConfirmProvider";
import type { PrefsController } from "./components/SettingsPanel";
import type { ChannelStatusFilter, ChannelsView } from "./components/Channels";
// Persistent shell — always mounted, so eagerly imported.
@@ -157,13 +156,6 @@ function loadInitialFilters(): FeedFilters {
export default function App() {
const { t, i18n } = useTranslation();
const confirm = useConfirm();
// Refs the (stable) navigation handlers read so they always see the latest unsaved-prefs
// state without being recreated; populated by an effect each render (below).
const prefsDirtyRef = useRef(false);
const discardRef = useRef<() => void>(() => {});
const confirmRef = useRef(confirm);
const pageRef = useRef<Page>("feed");
const [theme, setThemeState] = useState<ThemePrefs>(() => loadLocalTheme());
const [filters, setFiltersState] = useState<FeedFilters>(loadInitialFilters);
// Whether this load arrived via a "Share view" link (captured once, before the URL is stripped)
@@ -171,22 +163,26 @@ export default function App() {
const [initialHadUrlFilters] = useState(() =>
hasFilterParams(new URLSearchParams(window.location.search))
);
// The feed's view mode is picked from the feed's own toolbar, so — unlike the Settings prefs
// below — it persists the moment you choose it. It is NOT part of the EditablePrefs draft:
// that would mark the Settings page dirty from a toolbar click and trip its leave-page guard.
// The feed's view mode is picked from the feed's own toolbar; like every Settings pref it
// persists the moment you choose it.
const [view, setViewState] = useState<FeedView>(() => parseFeedView(readAccount(LS.feedView, null)));
// Settings-page prefs draft (apply live, persist on Save — see EditablePrefs).
// 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());
const [savedPrefs, setSavedPrefs] = useState<EditablePrefs>(() => ({
// 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>>(() => ({
@@ -323,31 +319,14 @@ export default function App() {
// While a channel page is open it overlays the content column, so a nav click must close it
// even when the underlying page is unchanged (e.g. "Feed" while a channel opened from the feed).
if (next === page && !channelView) return;
const go = () => {
setChannelView(null); // leave any open channel page when navigating via the rail
setPageState(next);
setAccountRaw(PAGE_KEY, next);
// Push an in-app history entry so the browser/mouse Back button steps through pages
// instead of leaving the app (e.g. back to the OAuth redirect). The URL stays clean —
// the page rides in history.state, not the query string (filters never go in the URL).
// A fresh { sfPage } (no carried-over _sub/_ov/_chan markers) so each page starts at its root.
window.history.pushState({ sfPage: next }, "");
};
// Guard leaving the Settings page with unsaved preference changes.
if (page === "settings" && prefsDirtyRef.current) {
void confirmRef.current({
title: t("settings.unsaved.title"),
message: t("settings.unsaved.message"),
confirmLabel: t("settings.unsaved.discard"),
danger: true,
}).then((ok) => {
if (!ok) return;
discardRef.current();
go();
});
return;
}
go();
setChannelView(null); // leave any open channel page when navigating via the rail
setPageState(next);
setAccountRaw(PAGE_KEY, next);
// Push an in-app history entry so the browser/mouse Back button steps through pages instead of
// leaving the app (e.g. back to the OAuth redirect). The URL stays clean — the page rides in
// history.state, not the query string. A fresh { sfPage } (no carried-over _yt/_chan markers)
// so each page starts at its root.
window.history.pushState({ sfPage: next }, "");
}
// Expose the current setPage to decoupled callers (download toast "View", etc.).
useEffect(() => setNavigator(setPage));
@@ -435,6 +414,51 @@ export default function App() {
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.
@@ -464,23 +488,6 @@ export default function App() {
}
function onPop(e: PopStateEvent) {
const p = (e.state?.sfPage as Page) ?? "feed";
// Guard a Back step that leaves Settings with unsaved changes: re-assert the Settings
// entry so nothing is silently stranded, then ask; on discard, go where Back headed.
if (pageRef.current === "settings" && p !== "settings" && prefsDirtyRef.current) {
window.history.pushState({ ...window.history.state, sfPage: "settings" }, "");
void confirmRef.current({
title: t("settings.unsaved.title"),
message: t("settings.unsaved.message"),
confirmLabel: t("settings.unsaved.discard"),
danger: true,
}).then((ok) => {
if (!ok) return;
discardRef.current();
setPageState(p);
setAccountRaw(PAGE_KEY, p);
});
return;
}
setPageState(p);
setAccountRaw(PAGE_KEY, p);
// The YouTube-search sub-view rides in history.state._yt, so Back/Forward restores or
@@ -607,8 +614,8 @@ export default function App() {
useEffect(() => {
const prefs = meQuery.data?.preferences;
if (!prefs) return;
// Adopt the server prefs as both the saved baseline and the live draft. The runtime
// effects above apply the draft (theme/perf/hints/notifications) for display.
// 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:
@@ -625,7 +632,7 @@ export default function App() {
setPerf(adopted.performanceMode);
setHints(adopted.hints);
setNotif(adopted.notifications);
setSavedPrefs(adopted);
baselineRef.current = adopted;
// Out-of-scope prefs stay instant (edited outside the Settings page).
const prefsRec = prefs as Record<string, unknown>;
// parseFeedView also maps the pre-0.45 "grid" / "list" values forward.
@@ -687,7 +694,7 @@ export default function App() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [meQuery.data?.role, meQuery.data?.pending_invites]);
// Theme is part of the Settings draft: apply locally for preview, persist on Save.
// Theme applies live (and caches to localStorage); the auto-save effect persists it to the server.
function setTheme(next: ThemePrefs) {
setThemeState(next);
saveLocalTheme(next);
@@ -698,39 +705,6 @@ export default function App() {
api.savePrefs({ language: code }).catch(() => {});
}
const prefsDirty =
JSON.stringify({ theme, performanceMode: perf, hints, notifications: notif }) !==
JSON.stringify(savedPrefs);
const savePrefs = useCallback(() => {
const payload: EditablePrefs = { theme, performanceMode: perf, hints, notifications: notif };
window.clearTimeout(saveMsgTimer.current);
setPrefsSaveState("saving");
api
.savePrefs(payload)
.then(() => {
setSavedPrefs(payload);
setPrefsSaveState("saved");
saveMsgTimer.current = window.setTimeout(() => setPrefsSaveState("idle"), 2500);
})
.catch(() => {
// api.req() already surfaces the failure (connection-lost status / error dialog);
// just flag the bar so the user sees the save didn't take, then clear it.
setPrefsSaveState("error");
saveMsgTimer.current = window.setTimeout(() => setPrefsSaveState("idle"), 4000);
});
}, [theme, perf, hints, notif]);
const discardPrefs = useCallback(() => {
setThemeState(savedPrefs.theme);
saveLocalTheme(savedPrefs.theme);
setPerf(savedPrefs.performanceMode);
setHints(savedPrefs.hints);
setNotif(savedPrefs.notifications);
window.clearTimeout(saveMsgTimer.current);
setPrefsSaveState("idle");
}, [savedPrefs]);
const prefsCtl: PrefsController = {
theme,
setTheme,
@@ -740,22 +714,9 @@ export default function App() {
setHints,
notif,
setNotif,
dirty: prefsDirty,
save: savePrefs,
discard: discardPrefs,
saveState: prefsSaveState,
};
// Keep the navigation guards (setPage / popstate) reading the latest values.
useEffect(() => {
prefsDirtyRef.current = prefsDirty;
discardRef.current = discardPrefs;
confirmRef.current = confirm;
pageRef.current = page;
});
// No beforeunload guard: a reload/close can only raise the browser's own native prompt
// (we can't show our in-app confirm there), and unsaved prefs are local-only — they
// revert to the saved server baseline on the next load — so there's nothing to lose.
// First-run: a fresh instance is in setup mode (all other routes are locked) → show the wizard.
if (setupQuery.isLoading)