feat(settings): explicit Save/Discard for preferences with dirty tracking
Settings-page prefs (theme/scheme/dark-mode/list-view/perf/hints/font + the
notification settings) were each auto-saved to the server on every toggle via
fire-and-forget savePrefs().catch(() => {}) — silent on failure, and no
positive confirmation on success, so the user had zero feedback either way.
Make them a draft instead: changes apply locally for instant preview but
persist only on an explicit Save (or revert on Discard). App owns the live
draft + the last-saved baseline, computes dirty, and exposes a controller to
the panel. The panel shows a Save/Discard bar with 'Saving…' → 'Settings
saved' (auto-clearing) / 'Couldn't save' feedback. Leaving the page with
unsaved changes prompts a confirm (in-app nav + browser Back), and a
beforeunload guards reload/close. savePrefs is now idempotent so the Save
survives a transient gateway blip; failures surface via the connection-lost
status. Language & sidebar layout stay instant (edited outside this page).
New i18n keys settings.save.* / settings.unsaved.* in EN/HU/DE.
This commit is contained in:
parent
d61e844f6b
commit
4a1a025353
6 changed files with 336 additions and 120 deletions
|
|
@ -1,4 +1,4 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { api, HttpError, type FeedFilters } from "./lib/api";
|
||||
|
|
@ -17,8 +17,10 @@ import {
|
|||
saveLayoutLocal,
|
||||
type SidebarLayout,
|
||||
} from "./lib/sidebarLayout";
|
||||
import { configureNotifications, notify } from "./lib/notifications";
|
||||
import { setHintsEnabled } from "./lib/hints";
|
||||
import { configureNotifications, getNotifSettings, notify, type NotifSettings } from "./lib/notifications";
|
||||
import { hintsEnabled, setHintsEnabled } from "./lib/hints";
|
||||
import { useConfirm } from "./components/ConfirmProvider";
|
||||
import type { PrefsController } from "./components/SettingsPanel";
|
||||
import Login from "./components/Login";
|
||||
import Header from "./components/Header";
|
||||
import NavSidebar from "./components/NavSidebar";
|
||||
|
|
@ -54,6 +56,18 @@ const DEFAULT_FILTERS: FeedFilters = {
|
|||
|
||||
const FILTERS_KEY = "subfeed.filters";
|
||||
const PAGE_KEY = "siftlode.page";
|
||||
const PERF_KEY = "subfeed.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;
|
||||
view: "grid" | "list";
|
||||
performanceMode: boolean;
|
||||
hints: boolean;
|
||||
notifications: NotifSettings;
|
||||
};
|
||||
|
||||
// Page is navigation state, not a filter, but it's also kept out of the address bar — so
|
||||
// persist it to localStorage to survive a reload. A share link's ?page= still wins.
|
||||
|
|
@ -81,9 +95,29 @@ 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);
|
||||
const [view, setView] = useState<"grid" | "list">("grid");
|
||||
// Settings-page prefs draft (apply live, persist on Save — see EditablePrefs).
|
||||
const [perf, setPerf] = useState(() => localStorage.getItem(PERF_KEY) === "1");
|
||||
const [hints, setHints] = useState(() => hintsEnabled());
|
||||
const [notif, setNotif] = useState<NotifSettings>(() => getNotifSettings());
|
||||
const [savedPrefs, setSavedPrefs] = useState<EditablePrefs>(() => ({
|
||||
theme: loadLocalTheme(),
|
||||
view: "grid",
|
||||
performanceMode: localStorage.getItem(PERF_KEY) === "1",
|
||||
hints: hintsEnabled(),
|
||||
notifications: getNotifSettings(),
|
||||
}));
|
||||
const [prefsSaveState, setPrefsSaveState] = useState<PrefsController["saveState"]>("idle");
|
||||
const saveMsgTimer = useRef<number | undefined>(undefined);
|
||||
const [sidebarLayout, setSidebarLayoutState] = useState<SidebarLayout>(loadLayout);
|
||||
const [page, setPageState] = useState<Page>(loadInitialPage);
|
||||
const [wizardOpen, setWizardOpen] = useState(false);
|
||||
|
|
@ -125,12 +159,29 @@ export default function App() {
|
|||
|
||||
function setPage(next: Page) {
|
||||
if (next === page) return;
|
||||
setPageState(next);
|
||||
localStorage.setItem(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).
|
||||
window.history.pushState({ ...window.history.state, sfPage: next }, "");
|
||||
const go = () => {
|
||||
setPageState(next);
|
||||
localStorage.setItem(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).
|
||||
window.history.pushState({ ...window.history.state, 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();
|
||||
}
|
||||
|
||||
function setSidebarLayout(next: SidebarLayout) {
|
||||
|
|
@ -141,6 +192,15 @@ export default function App() {
|
|||
|
||||
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" : "";
|
||||
localStorage.setItem(PERF_KEY, perf ? "1" : "0");
|
||||
}, [perf]);
|
||||
useEffect(() => setHintsEnabled(hints), [hints]);
|
||||
useEffect(() => configureNotifications(notif), [notif]);
|
||||
|
||||
// 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.
|
||||
|
|
@ -162,6 +222,23 @@ 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);
|
||||
localStorage.setItem(PAGE_KEY, p);
|
||||
});
|
||||
return;
|
||||
}
|
||||
setPageState(p);
|
||||
localStorage.setItem(PAGE_KEY, p);
|
||||
}
|
||||
|
|
@ -182,22 +259,34 @@ export default function App() {
|
|||
useEffect(() => {
|
||||
const prefs = meQuery.data?.preferences;
|
||||
if (!prefs) return;
|
||||
if (prefs.theme) {
|
||||
const merged = { ...DEFAULT_THEME, ...prefs.theme };
|
||||
setThemeState(merged);
|
||||
saveLocalTheme(merged);
|
||||
}
|
||||
if (prefs.view === "grid" || prefs.view === "list") setView(prefs.view);
|
||||
// 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.
|
||||
const adopted: EditablePrefs = {
|
||||
theme: prefs.theme ? { ...DEFAULT_THEME, ...prefs.theme } : loadLocalTheme(),
|
||||
view: prefs.view === "grid" || prefs.view === "list" ? prefs.view : "grid",
|
||||
performanceMode:
|
||||
typeof prefs.performanceMode === "boolean"
|
||||
? prefs.performanceMode
|
||||
: localStorage.getItem(PERF_KEY) === "1",
|
||||
hints: typeof prefs.hints === "boolean" ? prefs.hints : hintsEnabled(),
|
||||
notifications: prefs.notifications
|
||||
? { ...getNotifSettings(), ...prefs.notifications }
|
||||
: getNotifSettings(),
|
||||
};
|
||||
setThemeState(adopted.theme);
|
||||
saveLocalTheme(adopted.theme);
|
||||
setView(adopted.view);
|
||||
setPerf(adopted.performanceMode);
|
||||
setHints(adopted.hints);
|
||||
setNotif(adopted.notifications);
|
||||
setSavedPrefs(adopted);
|
||||
// Out-of-scope prefs stay instant (edited outside the Settings page).
|
||||
if (prefs.sidebarLayout) {
|
||||
const l = normalizeLayout(prefs.sidebarLayout);
|
||||
setSidebarLayoutState(l);
|
||||
saveLayoutLocal(l);
|
||||
}
|
||||
if (isSupported(prefs.language)) setLanguage(prefs.language);
|
||||
if (prefs.notifications) configureNotifications(prefs.notifications);
|
||||
if (typeof prefs.hints === "boolean") setHintsEnabled(prefs.hints);
|
||||
if (typeof prefs.performanceMode === "boolean")
|
||||
document.documentElement.dataset.perf = prefs.performanceMode ? "1" : "";
|
||||
// Nudge admins when access requests are waiting (in lieu of a server-push bell).
|
||||
const pending = meQuery.data?.pending_invites ?? 0;
|
||||
if (meQuery.data?.role === "admin" && pending > 0) {
|
||||
|
|
@ -216,20 +305,87 @@ export default function App() {
|
|||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [meQuery.data?.id]);
|
||||
|
||||
// Theme is part of the Settings draft: apply locally for preview, persist on Save.
|
||||
function setTheme(next: ThemePrefs) {
|
||||
setThemeState(next);
|
||||
saveLocalTheme(next);
|
||||
api.savePrefs({ theme: next }).catch(() => {});
|
||||
}
|
||||
function changeView(v: "grid" | "list") {
|
||||
setView(v);
|
||||
api.savePrefs({ view: v }).catch(() => {});
|
||||
}
|
||||
// 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 prefsDirty =
|
||||
JSON.stringify({ theme, view, performanceMode: perf, hints, notifications: notif }) !==
|
||||
JSON.stringify(savedPrefs);
|
||||
|
||||
const savePrefs = useCallback(() => {
|
||||
const payload: EditablePrefs = { theme, view, 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, view, perf, hints, notif]);
|
||||
|
||||
const discardPrefs = useCallback(() => {
|
||||
setThemeState(savedPrefs.theme);
|
||||
saveLocalTheme(savedPrefs.theme);
|
||||
setView(savedPrefs.view);
|
||||
setPerf(savedPrefs.performanceMode);
|
||||
setHints(savedPrefs.hints);
|
||||
setNotif(savedPrefs.notifications);
|
||||
window.clearTimeout(saveMsgTimer.current);
|
||||
setPrefsSaveState("idle");
|
||||
}, [savedPrefs]);
|
||||
|
||||
const prefsCtl: PrefsController = {
|
||||
theme,
|
||||
setTheme,
|
||||
view,
|
||||
setView,
|
||||
perf,
|
||||
setPerf,
|
||||
hints,
|
||||
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;
|
||||
});
|
||||
|
||||
// Warn on a hard navigation (reload / tab close) while preference changes are unsaved.
|
||||
useEffect(() => {
|
||||
if (!prefsDirty) return;
|
||||
const handler = (e: BeforeUnloadEvent) => {
|
||||
e.preventDefault();
|
||||
e.returnValue = "";
|
||||
};
|
||||
window.addEventListener("beforeunload", handler);
|
||||
return () => window.removeEventListener("beforeunload", handler);
|
||||
}, [prefsDirty]);
|
||||
|
||||
if (meQuery.isLoading)
|
||||
return (
|
||||
<div className="min-h-screen grid place-items-center text-muted">{t("common.loading")}</div>
|
||||
|
|
@ -307,10 +463,7 @@ export default function App() {
|
|||
) : page === "settings" ? (
|
||||
<SettingsPanel
|
||||
me={meQuery.data!}
|
||||
theme={theme}
|
||||
setTheme={setTheme}
|
||||
view={view}
|
||||
setView={changeView}
|
||||
prefs={prefsCtl}
|
||||
onOpenWizard={() => setWizardOpen(true)}
|
||||
/>
|
||||
) : (
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue