diff --git a/frontend/e2e/settings.spec.ts b/frontend/e2e/settings.spec.ts index 98797b40..39b7de3c 100644 --- a/frontend/e2e/settings.spec.ts +++ b/frontend/e2e/settings.spec.ts @@ -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()]); }); }); diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 88b7af97..c1051ef0 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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("feed"); const [theme, setThemeState] = useState(() => loadLocalTheme()); const [filters, setFiltersState] = useState(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(() => 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(() => getNotifSettings()); - const [savedPrefs, setSavedPrefs] = useState(() => ({ + // 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>(() => ({ @@ -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( + () => ({ 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; // 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) diff --git a/frontend/src/components/SettingsPanel.tsx b/frontend/src/components/SettingsPanel.tsx index 69263d15..dc44ac50 100644 --- a/frontend/src/components/SettingsPanel.tsx +++ b/frontend/src/components/SettingsPanel.tsx @@ -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({ - {/* 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. */} - + {/* Auto-save indicator — a change on any tab persists immediately; this just flashes the + outcome (the Account tab's actions have their own feedback). */} + ); } -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 ( - +
+ {state === "saving" ? ( + + + {t("settings.save.saving")} + + ) : state === "saved" ? ( + + + {t("settings.save.saved")} + + ) : ( + {t("settings.save.failed")} + )} +
); } diff --git a/frontend/src/i18n/locales/en/settings.json b/frontend/src/i18n/locales/en/settings.json index 2f6fcf39..0ce4a0ea 100644 --- a/frontend/src/i18n/locales/en/settings.json +++ b/frontend/src/i18n/locales/en/settings.json @@ -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", diff --git a/frontend/src/i18n/locales/hu/settings.json b/frontend/src/i18n/locales/hu/settings.json index 61c467cc..582a787f 100644 --- a/frontend/src/i18n/locales/hu/settings.json +++ b/frontend/src/i18n/locales/hu/settings.json @@ -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",