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
+26 -37
View File
@@ -1,62 +1,51 @@
import { test, expect } from "@playwright/test";
import { test, expect, type Page } from "@playwright/test";
import { openApp, gotoPage } from "./helpers";
// Locks the Settings draft/dirty-save machine: an edit raises the save bar, Save persists via
// PUT /me/preferences, and leaving with unsaved changes is guarded. The dirty-guard is one of
// the behaviors the god-component refactor must preserve exactly.
test.describe("settings dirty-save", () => {
// Locks the Settings AUTO-SAVE contract (the draft + Save button + leave-guard were removed): an
// Appearance change persists on its own via PUT /me/preferences and a transient indicator confirms
// it; leaving Settings after a change is no longer guarded.
test.describe("settings auto-save", () => {
const TOGGLE = "Performance mode"; // an Appearance switch (locale pinned to en)
async function putPrefs(page: import("@playwright/test").Page) {
return page.waitForResponse(
// The save is debounced (~500ms), so wait for the PUT rather than assuming it's synchronous.
const putPrefs = (page: Page) =>
page.waitForResponse(
(r) => r.url().includes("/me/preferences") && r.request().method() === "PUT",
);
}
test("edit raises the bar and Save persists via PUT /me/preferences", async ({ page }) => {
test("changing a setting auto-saves and shows the saved indicator", async ({ page }) => {
await openApp(page);
await gotoPage(page, "settings");
const bar = page.getByTestId("draft-save-bar");
const status = page.getByTestId("draft-save-status");
const toggle = page.getByRole("switch", { name: TOGGLE });
const indicator = page.getByTestId("settings-save-indicator");
// Clean: the bar renders nothing.
await expect(bar).toHaveCount(0);
// Idle: nothing to save, so no indicator (and no draft Save bar).
await expect(indicator).toHaveCount(0);
// Edit → the bar appears.
await toggle.click();
await expect(bar).toBeVisible();
// Save → the PUT fires and the status flips to "saved".
const [saved] = await Promise.all([putPrefs(page), page.getByTestId("draft-save").click()]);
// Change it → the PUT fires on its own (no Save click) and the indicator confirms it.
const [saved] = await Promise.all([putPrefs(page), toggle.click()]);
expect(saved.ok()).toBeTruthy();
await expect(status).toHaveAttribute("data-state", "saved");
await expect(indicator).toHaveAttribute("data-state", "saved");
// Restore the baseline (toggle back and save) so the shared account stays clean. Assert the
// restore's own PUT (not the lingering "saved" from the first save, which shows for 2.5s).
await toggle.click();
const [restored] = await Promise.all([putPrefs(page), page.getByTestId("draft-save").click()]);
expect(restored.ok()).toBeTruthy();
// Restore the baseline so the shared account stays clean.
await Promise.all([putPrefs(page), toggle.click()]);
});
test("leaving with unsaved changes is guarded", async ({ page }) => {
test("a changed setting no longer blocks leaving the page", async ({ page }) => {
await openApp(page);
await gotoPage(page, "settings");
await page.getByRole("switch", { name: TOGGLE }).click();
await expect(page.getByTestId("draft-save-bar")).toBeVisible();
const toggle = page.getByRole("switch", { name: TOGGLE });
await Promise.all([putPrefs(page), toggle.click()]);
// Navigating away raises the confirm guard instead of leaving.
// No unsaved-changes prompt — navigating away just lands on the feed.
await page.getByTestId("nav-feed").click();
await expect(page.getByText("You have unsaved settings")).toBeVisible();
await expect(page.getByTestId("nav-feed")).toHaveAttribute("aria-current", "page");
await expect(page.getByTestId("video-card").first()).toBeVisible();
// Cancel → stay on Settings, still dirty.
await page.getByRole("button", { name: "Cancel" }).click();
await expect(page.getByTestId("draft-save-bar")).toBeVisible();
// Discard → clean, so the account is left at baseline.
await page.getByTestId("draft-discard").click();
await expect(page.getByTestId("draft-save-bar")).toHaveCount(0);
// Restore the baseline (back on Settings).
await gotoPage(page, "settings");
await Promise.all([putPrefs(page), page.getByRole("switch", { name: TOGGLE }).click()]);
});
});
+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)
+31 -27
View File
@@ -1,7 +1,7 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Bell, Monitor, RefreshCw, Trash2, User } from "lucide-react";
import { Bell, Check, Loader2, Monitor, RefreshCw, Trash2, User } from "lucide-react";
import { SCHEMES, type Scheme, type ThemePrefs } from "../lib/theme";
import { api, type Me } from "../lib/api";
import { LS, useAccountPersistedState } from "../lib/storage";
@@ -9,12 +9,12 @@ import Avatar from "./Avatar";
import { notify, type NotifSettings } from "../lib/notifications";
import Tooltip from "./Tooltip";
import { Section, SettingRow, Switch } from "./ui/form";
import { DraftSaveBar, type SaveState } from "./ui/DraftSaveBar";
import { type SaveState } from "./ui/DraftSaveBar";
import { useConfirm } from "./ConfirmProvider";
// The Settings page edits server-persisted preferences as a draft: changes apply locally
// for instant preview but reach the server only on an explicit Save (or revert on Discard).
// App owns the draft + baseline (so the leave-the-page guard can see it); this controller is
// 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
// outcome. App owns the state + the save; this controller is
// how the panel reads/writes it. See App.tsx.
export type PrefsSaveState = SaveState;
export interface PrefsController {
@@ -26,9 +26,6 @@ export interface PrefsController {
setHints: (v: boolean) => void;
notif: NotifSettings;
setNotif: (n: NotifSettings) => void;
dirty: boolean;
save: () => void;
discard: () => void;
saveState: PrefsSaveState;
}
@@ -94,32 +91,39 @@ export default function SettingsPanel({
</div>
</div>
{/* Save/Discard bar — spans the whole card (a change can come from any tab). Only the
Appearance/Notifications prefs are drafted; the Sync/Account tabs act immediately. */}
<PrefsSaveBar prefs={prefs} />
{/* Auto-save indicator — a change on any tab persists immediately; this just flashes the
outcome (the Account tab's actions have their own feedback). */}
<SaveIndicator state={prefs.saveState} />
</div>
</div>
);
}
function PrefsSaveBar({ prefs }: { prefs: PrefsController }) {
// A small transient indicator of the auto-save outcome. Renders nothing while idle; it sits at the
// bottom of the settings card and fades after a success (the state resets to "idle" in App).
function SaveIndicator({ state }: { state: PrefsSaveState }) {
const { t } = useTranslation();
if (state === "idle") return null;
return (
<DraftSaveBar
variant="inline"
dirty={prefs.dirty}
state={prefs.saveState}
onSave={prefs.save}
onDiscard={prefs.discard}
labels={{
saved: t("settings.save.saved"),
failed: t("settings.save.failed"),
unsaved: t("settings.save.unsaved"),
discard: t("settings.save.discard"),
save: t("settings.save.save"),
saving: t("settings.save.saving"),
}}
/>
<div
data-testid="settings-save-indicator"
data-state={state}
className="flex items-center justify-end gap-1.5 border-t border-border/60 px-4 py-2.5 text-sm"
>
{state === "saving" ? (
<span className="inline-flex items-center gap-1.5 text-muted">
<Loader2 className="w-4 h-4 animate-spin" />
{t("settings.save.saving")}
</span>
) : state === "saved" ? (
<span className="inline-flex items-center gap-1.5 text-emerald-500">
<Check className="w-4 h-4" />
{t("settings.save.saved")}
</span>
) : (
<span className="text-red-500">{t("settings.save.failed")}</span>
)}
</div>
);
}
+1 -9
View File
@@ -17,17 +17,9 @@
"importing": "Importing your Plex watch history…"
},
"save": {
"unsaved": "Unsaved changes",
"saving": "Saving…",
"saved": "Settings saved",
"failed": "Couldn't save",
"save": "Save",
"discard": "Discard"
},
"unsaved": {
"title": "Unsaved changes",
"message": "You have unsaved settings. Discard them and leave?",
"discard": "Discard changes"
"failed": "Couldn't save"
},
"appearance": {
"colorScheme": "Color scheme",
+1 -9
View File
@@ -17,17 +17,9 @@
"importing": "Plex-előzmény importálása…"
},
"save": {
"unsaved": "Nem mentett változások",
"saving": "Mentés…",
"saved": "Beállítások elmentve",
"failed": "Nem sikerült menteni",
"save": "Mentés",
"discard": "Elvetés"
},
"unsaved": {
"title": "Nem mentett változások",
"message": "Vannak nem mentett beállításaid. Elveted őket és továbblépsz?",
"discard": "Változások elvetése"
"failed": "Nem sikerült menteni"
},
"appearance": {
"colorScheme": "Színséma",