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:
@@ -1,62 +1,51 @@
|
|||||||
import { test, expect } from "@playwright/test";
|
import { test, expect, type Page } from "@playwright/test";
|
||||||
import { openApp, gotoPage } from "./helpers";
|
import { openApp, gotoPage } from "./helpers";
|
||||||
|
|
||||||
// Locks the Settings draft/dirty-save machine: an edit raises the save bar, Save persists via
|
// Locks the Settings AUTO-SAVE contract (the draft + Save button + leave-guard were removed): an
|
||||||
// PUT /me/preferences, and leaving with unsaved changes is guarded. The dirty-guard is one of
|
// Appearance change persists on its own via PUT /me/preferences and a transient indicator confirms
|
||||||
// the behaviors the god-component refactor must preserve exactly.
|
// it; leaving Settings after a change is no longer guarded.
|
||||||
test.describe("settings dirty-save", () => {
|
test.describe("settings auto-save", () => {
|
||||||
const TOGGLE = "Performance mode"; // an Appearance switch (locale pinned to en)
|
const TOGGLE = "Performance mode"; // an Appearance switch (locale pinned to en)
|
||||||
|
|
||||||
async function putPrefs(page: import("@playwright/test").Page) {
|
// The save is debounced (~500ms), so wait for the PUT rather than assuming it's synchronous.
|
||||||
return page.waitForResponse(
|
const putPrefs = (page: Page) =>
|
||||||
|
page.waitForResponse(
|
||||||
(r) => r.url().includes("/me/preferences") && r.request().method() === "PUT",
|
(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 openApp(page);
|
||||||
await gotoPage(page, "settings");
|
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 toggle = page.getByRole("switch", { name: TOGGLE });
|
||||||
|
const indicator = page.getByTestId("settings-save-indicator");
|
||||||
|
|
||||||
// Clean: the bar renders nothing.
|
// Idle: nothing to save, so no indicator (and no draft Save bar).
|
||||||
await expect(bar).toHaveCount(0);
|
await expect(indicator).toHaveCount(0);
|
||||||
|
|
||||||
// Edit → the bar appears.
|
// Change it → the PUT fires on its own (no Save click) and the indicator confirms it.
|
||||||
await toggle.click();
|
const [saved] = await Promise.all([putPrefs(page), 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()]);
|
|
||||||
expect(saved.ok()).toBeTruthy();
|
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 the baseline so the shared account stays clean.
|
||||||
// restore's own PUT (not the lingering "saved" from the first save, which shows for 2.5s).
|
await Promise.all([putPrefs(page), toggle.click()]);
|
||||||
await toggle.click();
|
|
||||||
const [restored] = await Promise.all([putPrefs(page), page.getByTestId("draft-save").click()]);
|
|
||||||
expect(restored.ok()).toBeTruthy();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
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 openApp(page);
|
||||||
await gotoPage(page, "settings");
|
await gotoPage(page, "settings");
|
||||||
|
|
||||||
await page.getByRole("switch", { name: TOGGLE }).click();
|
const toggle = page.getByRole("switch", { name: TOGGLE });
|
||||||
await expect(page.getByTestId("draft-save-bar")).toBeVisible();
|
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 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.
|
// Restore the baseline (back on Settings).
|
||||||
await page.getByRole("button", { name: "Cancel" }).click();
|
await gotoPage(page, "settings");
|
||||||
await expect(page.getByTestId("draft-save-bar")).toBeVisible();
|
await Promise.all([putPrefs(page), page.getByRole("switch", { name: TOGGLE }).click()]);
|
||||||
|
|
||||||
// Discard → clean, so the account is left at baseline.
|
|
||||||
await page.getByTestId("draft-discard").click();
|
|
||||||
await expect(page.getByTestId("draft-save-bar")).toHaveCount(0);
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
+67
-106
@@ -43,7 +43,6 @@ import {
|
|||||||
writeAccount,
|
writeAccount,
|
||||||
writeJSON,
|
writeJSON,
|
||||||
} from "./lib/storage";
|
} from "./lib/storage";
|
||||||
import { useConfirm } from "./components/ConfirmProvider";
|
|
||||||
import type { PrefsController } from "./components/SettingsPanel";
|
import type { PrefsController } from "./components/SettingsPanel";
|
||||||
import type { ChannelStatusFilter, ChannelsView } from "./components/Channels";
|
import type { ChannelStatusFilter, ChannelsView } from "./components/Channels";
|
||||||
// Persistent shell — always mounted, so eagerly imported.
|
// Persistent shell — always mounted, so eagerly imported.
|
||||||
@@ -157,13 +156,6 @@ function loadInitialFilters(): FeedFilters {
|
|||||||
|
|
||||||
export default function App() {
|
export default function App() {
|
||||||
const { t, i18n } = useTranslation();
|
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 [theme, setThemeState] = useState<ThemePrefs>(() => loadLocalTheme());
|
||||||
const [filters, setFiltersState] = useState<FeedFilters>(loadInitialFilters);
|
const [filters, setFiltersState] = useState<FeedFilters>(loadInitialFilters);
|
||||||
// Whether this load arrived via a "Share view" link (captured once, before the URL is stripped)
|
// 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(() =>
|
const [initialHadUrlFilters] = useState(() =>
|
||||||
hasFilterParams(new URLSearchParams(window.location.search))
|
hasFilterParams(new URLSearchParams(window.location.search))
|
||||||
);
|
);
|
||||||
// The feed's view mode is picked from the feed's own toolbar, so — unlike the Settings prefs
|
// The feed's view mode is picked from the feed's own toolbar; like every Settings pref it
|
||||||
// below — it persists the moment you choose it. It is NOT part of the EditablePrefs draft:
|
// persists the moment you choose it.
|
||||||
// that would mark the Settings page dirty from a toolbar click and trip its leave-page guard.
|
|
||||||
const [view, setViewState] = useState<FeedView>(() => parseFeedView(readAccount(LS.feedView, null)));
|
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 [perf, setPerf] = useState(() => getAccountRaw(PERF_KEY) === "1");
|
||||||
const [hints, setHints] = useState(() => hintsEnabled());
|
const [hints, setHints] = useState(() => hintsEnabled());
|
||||||
const [notif, setNotif] = useState<NotifSettings>(() => getNotifSettings());
|
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(),
|
theme: loadLocalTheme(),
|
||||||
performanceMode: getAccountRaw(PERF_KEY) === "1",
|
performanceMode: getAccountRaw(PERF_KEY) === "1",
|
||||||
hints: hintsEnabled(),
|
hints: hintsEnabled(),
|
||||||
notifications: getNotifSettings(),
|
notifications: getNotifSettings(),
|
||||||
}));
|
});
|
||||||
const [prefsSaveState, setPrefsSaveState] = useState<PrefsController["saveState"]>("idle");
|
const [prefsSaveState, setPrefsSaveState] = useState<PrefsController["saveState"]>("idle");
|
||||||
const saveMsgTimer = useRef<number | undefined>(undefined);
|
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),
|
// 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.
|
// persisted per-account. One record so feed/plex/playlists share the same mechanism.
|
||||||
const [panelLayouts, setPanelLayouts] = useState<Record<PanelId, PanelLayout>>(() => ({
|
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
|
// 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).
|
// even when the underlying page is unchanged (e.g. "Feed" while a channel opened from the feed).
|
||||||
if (next === page && !channelView) return;
|
if (next === page && !channelView) return;
|
||||||
const go = () => {
|
setChannelView(null); // leave any open channel page when navigating via the rail
|
||||||
setChannelView(null); // leave any open channel page when navigating via the rail
|
setPageState(next);
|
||||||
setPageState(next);
|
setAccountRaw(PAGE_KEY, next);
|
||||||
setAccountRaw(PAGE_KEY, next);
|
// Push an in-app history entry so the browser/mouse Back button steps through pages instead of
|
||||||
// Push an in-app history entry so the browser/mouse Back button steps through pages
|
// leaving the app (e.g. back to the OAuth redirect). The URL stays clean — the page rides in
|
||||||
// instead of leaving the app (e.g. back to the OAuth redirect). The URL stays clean —
|
// history.state, not the query string. A fresh { sfPage } (no carried-over _yt/_chan markers)
|
||||||
// the page rides in history.state, not the query string (filters never go in the URL).
|
// so each page starts at its root.
|
||||||
// A fresh { sfPage } (no carried-over _sub/_ov/_chan markers) so each page starts at its root.
|
window.history.pushState({ sfPage: next }, "");
|
||||||
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();
|
|
||||||
}
|
}
|
||||||
// Expose the current setPage to decoupled callers (download toast "View", etc.).
|
// Expose the current setPage to decoupled callers (download toast "View", etc.).
|
||||||
useEffect(() => setNavigator(setPage));
|
useEffect(() => setNavigator(setPage));
|
||||||
@@ -435,6 +414,51 @@ export default function App() {
|
|||||||
useEffect(() => setHintsEnabled(hints), [hints]);
|
useEffect(() => setHintsEnabled(hints), [hints]);
|
||||||
useEffect(() => configureNotifications(notif), [notif]);
|
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"
|
// 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
|
// link, its params have already hydrated the initial state — persist them and strip the
|
||||||
// query so the URL stays clean from here on.
|
// query so the URL stays clean from here on.
|
||||||
@@ -464,23 +488,6 @@ export default function App() {
|
|||||||
}
|
}
|
||||||
function onPop(e: PopStateEvent) {
|
function onPop(e: PopStateEvent) {
|
||||||
const p = (e.state?.sfPage as Page) ?? "feed";
|
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);
|
setPageState(p);
|
||||||
setAccountRaw(PAGE_KEY, p);
|
setAccountRaw(PAGE_KEY, p);
|
||||||
// The YouTube-search sub-view rides in history.state._yt, so Back/Forward restores or
|
// The YouTube-search sub-view rides in history.state._yt, so Back/Forward restores or
|
||||||
@@ -607,8 +614,8 @@ export default function App() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const prefs = meQuery.data?.preferences;
|
const prefs = meQuery.data?.preferences;
|
||||||
if (!prefs) return;
|
if (!prefs) return;
|
||||||
// Adopt the server prefs as both the saved baseline and the live draft. The runtime
|
// Adopt the server prefs into the live states (the runtime effects above apply them for
|
||||||
// effects above apply the draft (theme/perf/hints/notifications) for display.
|
// display) AND into the auto-save baseline, so adopting them doesn't echo a save back.
|
||||||
const adopted: EditablePrefs = {
|
const adopted: EditablePrefs = {
|
||||||
theme: prefs.theme ? { ...DEFAULT_THEME, ...prefs.theme } : loadLocalTheme(),
|
theme: prefs.theme ? { ...DEFAULT_THEME, ...prefs.theme } : loadLocalTheme(),
|
||||||
performanceMode:
|
performanceMode:
|
||||||
@@ -625,7 +632,7 @@ export default function App() {
|
|||||||
setPerf(adopted.performanceMode);
|
setPerf(adopted.performanceMode);
|
||||||
setHints(adopted.hints);
|
setHints(adopted.hints);
|
||||||
setNotif(adopted.notifications);
|
setNotif(adopted.notifications);
|
||||||
setSavedPrefs(adopted);
|
baselineRef.current = adopted;
|
||||||
// Out-of-scope prefs stay instant (edited outside the Settings page).
|
// Out-of-scope prefs stay instant (edited outside the Settings page).
|
||||||
const prefsRec = prefs as Record<string, unknown>;
|
const prefsRec = prefs as Record<string, unknown>;
|
||||||
// parseFeedView also maps the pre-0.45 "grid" / "list" values forward.
|
// 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
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [meQuery.data?.role, meQuery.data?.pending_invites]);
|
}, [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) {
|
function setTheme(next: ThemePrefs) {
|
||||||
setThemeState(next);
|
setThemeState(next);
|
||||||
saveLocalTheme(next);
|
saveLocalTheme(next);
|
||||||
@@ -698,39 +705,6 @@ export default function App() {
|
|||||||
api.savePrefs({ language: code }).catch(() => {});
|
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 = {
|
const prefsCtl: PrefsController = {
|
||||||
theme,
|
theme,
|
||||||
setTheme,
|
setTheme,
|
||||||
@@ -740,22 +714,9 @@ export default function App() {
|
|||||||
setHints,
|
setHints,
|
||||||
notif,
|
notif,
|
||||||
setNotif,
|
setNotif,
|
||||||
dirty: prefsDirty,
|
|
||||||
save: savePrefs,
|
|
||||||
discard: discardPrefs,
|
|
||||||
saveState: prefsSaveState,
|
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.
|
// First-run: a fresh instance is in setup mode (all other routes are locked) → show the wizard.
|
||||||
if (setupQuery.isLoading)
|
if (setupQuery.isLoading)
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
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 { SCHEMES, type Scheme, type ThemePrefs } from "../lib/theme";
|
||||||
import { api, type Me } from "../lib/api";
|
import { api, type Me } from "../lib/api";
|
||||||
import { LS, useAccountPersistedState } from "../lib/storage";
|
import { LS, useAccountPersistedState } from "../lib/storage";
|
||||||
@@ -9,12 +9,12 @@ import Avatar from "./Avatar";
|
|||||||
import { notify, type NotifSettings } from "../lib/notifications";
|
import { notify, type NotifSettings } from "../lib/notifications";
|
||||||
import Tooltip from "./Tooltip";
|
import Tooltip from "./Tooltip";
|
||||||
import { Section, SettingRow, Switch } from "./ui/form";
|
import { Section, SettingRow, Switch } from "./ui/form";
|
||||||
import { DraftSaveBar, type SaveState } from "./ui/DraftSaveBar";
|
import { type SaveState } from "./ui/DraftSaveBar";
|
||||||
import { useConfirm } from "./ConfirmProvider";
|
import { useConfirm } from "./ConfirmProvider";
|
||||||
|
|
||||||
// The Settings page edits server-persisted preferences as a draft: changes apply locally
|
// The Settings page edits server-persisted preferences. Each change applies locally for instant
|
||||||
// for instant preview but reach the server only on an explicit Save (or revert on Discard).
|
// preview AND auto-saves (debounced) — no draft, no Save button; a small indicator flashes the
|
||||||
// App owns the draft + baseline (so the leave-the-page guard can see it); this controller is
|
// outcome. App owns the state + the save; this controller is
|
||||||
// how the panel reads/writes it. See App.tsx.
|
// how the panel reads/writes it. See App.tsx.
|
||||||
export type PrefsSaveState = SaveState;
|
export type PrefsSaveState = SaveState;
|
||||||
export interface PrefsController {
|
export interface PrefsController {
|
||||||
@@ -26,9 +26,6 @@ export interface PrefsController {
|
|||||||
setHints: (v: boolean) => void;
|
setHints: (v: boolean) => void;
|
||||||
notif: NotifSettings;
|
notif: NotifSettings;
|
||||||
setNotif: (n: NotifSettings) => void;
|
setNotif: (n: NotifSettings) => void;
|
||||||
dirty: boolean;
|
|
||||||
save: () => void;
|
|
||||||
discard: () => void;
|
|
||||||
saveState: PrefsSaveState;
|
saveState: PrefsSaveState;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -94,32 +91,39 @@ export default function SettingsPanel({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Save/Discard bar — spans the whole card (a change can come from any tab). Only the
|
{/* Auto-save indicator — a change on any tab persists immediately; this just flashes the
|
||||||
Appearance/Notifications prefs are drafted; the Sync/Account tabs act immediately. */}
|
outcome (the Account tab's actions have their own feedback). */}
|
||||||
<PrefsSaveBar prefs={prefs} />
|
<SaveIndicator state={prefs.saveState} />
|
||||||
</div>
|
</div>
|
||||||
</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();
|
const { t } = useTranslation();
|
||||||
|
if (state === "idle") return null;
|
||||||
return (
|
return (
|
||||||
<DraftSaveBar
|
<div
|
||||||
variant="inline"
|
data-testid="settings-save-indicator"
|
||||||
dirty={prefs.dirty}
|
data-state={state}
|
||||||
state={prefs.saveState}
|
className="flex items-center justify-end gap-1.5 border-t border-border/60 px-4 py-2.5 text-sm"
|
||||||
onSave={prefs.save}
|
>
|
||||||
onDiscard={prefs.discard}
|
{state === "saving" ? (
|
||||||
labels={{
|
<span className="inline-flex items-center gap-1.5 text-muted">
|
||||||
saved: t("settings.save.saved"),
|
<Loader2 className="w-4 h-4 animate-spin" />
|
||||||
failed: t("settings.save.failed"),
|
{t("settings.save.saving")}
|
||||||
unsaved: t("settings.save.unsaved"),
|
</span>
|
||||||
discard: t("settings.save.discard"),
|
) : state === "saved" ? (
|
||||||
save: t("settings.save.save"),
|
<span className="inline-flex items-center gap-1.5 text-emerald-500">
|
||||||
saving: t("settings.save.saving"),
|
<Check className="w-4 h-4" />
|
||||||
}}
|
{t("settings.save.saved")}
|
||||||
/>
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-red-500">{t("settings.save.failed")}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -17,17 +17,9 @@
|
|||||||
"importing": "Importing your Plex watch history…"
|
"importing": "Importing your Plex watch history…"
|
||||||
},
|
},
|
||||||
"save": {
|
"save": {
|
||||||
"unsaved": "Unsaved changes",
|
|
||||||
"saving": "Saving…",
|
"saving": "Saving…",
|
||||||
"saved": "Settings saved",
|
"saved": "Settings saved",
|
||||||
"failed": "Couldn't save",
|
"failed": "Couldn't save"
|
||||||
"save": "Save",
|
|
||||||
"discard": "Discard"
|
|
||||||
},
|
|
||||||
"unsaved": {
|
|
||||||
"title": "Unsaved changes",
|
|
||||||
"message": "You have unsaved settings. Discard them and leave?",
|
|
||||||
"discard": "Discard changes"
|
|
||||||
},
|
},
|
||||||
"appearance": {
|
"appearance": {
|
||||||
"colorScheme": "Color scheme",
|
"colorScheme": "Color scheme",
|
||||||
|
|||||||
@@ -17,17 +17,9 @@
|
|||||||
"importing": "Plex-előzmény importálása…"
|
"importing": "Plex-előzmény importálása…"
|
||||||
},
|
},
|
||||||
"save": {
|
"save": {
|
||||||
"unsaved": "Nem mentett változások",
|
|
||||||
"saving": "Mentés…",
|
"saving": "Mentés…",
|
||||||
"saved": "Beállítások elmentve",
|
"saved": "Beállítások elmentve",
|
||||||
"failed": "Nem sikerült menteni",
|
"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"
|
|
||||||
},
|
},
|
||||||
"appearance": {
|
"appearance": {
|
||||||
"colorScheme": "Színséma",
|
"colorScheme": "Színséma",
|
||||||
|
|||||||
Reference in New Issue
Block a user