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,21 +1,37 @@
|
|||
import { useCallback, useEffect, useState, useSyncExternalStore } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { Bell, Check, History, Monitor, Pause, Play, RefreshCw, RotateCcw, Trash2, User, UserPlus, X } from "lucide-react";
|
||||
import { Bell, Check, History, Monitor, Pause, Play, RefreshCw, RotateCcw, Save, Trash2, User, UserPlus, X } from "lucide-react";
|
||||
import { SCHEMES, type Scheme, type ThemePrefs } from "../lib/theme";
|
||||
import { api, type Invite, type Me } from "../lib/api";
|
||||
import { formatEta, quotaActionLabel } from "../lib/format";
|
||||
import Avatar from "./Avatar";
|
||||
import {
|
||||
configureNotifications,
|
||||
getNotifSettings,
|
||||
notify,
|
||||
type NotifSettings,
|
||||
} from "../lib/notifications";
|
||||
import { hintsEnabled, setHintsEnabled, subscribeHints } from "../lib/hints";
|
||||
import { notify, type NotifSettings } from "../lib/notifications";
|
||||
import Tooltip from "./Tooltip";
|
||||
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
|
||||
// how the panel reads/writes it. See App.tsx.
|
||||
export type PrefsSaveState = "idle" | "saving" | "saved" | "error";
|
||||
export interface PrefsController {
|
||||
theme: ThemePrefs;
|
||||
setTheme: (t: ThemePrefs) => void;
|
||||
view: "grid" | "list";
|
||||
setView: (v: "grid" | "list") => void;
|
||||
perf: boolean;
|
||||
setPerf: (v: boolean) => void;
|
||||
hints: boolean;
|
||||
setHints: (v: boolean) => void;
|
||||
notif: NotifSettings;
|
||||
setNotif: (n: NotifSettings) => void;
|
||||
dirty: boolean;
|
||||
save: () => void;
|
||||
discard: () => void;
|
||||
saveState: PrefsSaveState;
|
||||
}
|
||||
|
||||
type TabId = "appearance" | "notifications" | "sync" | "account";
|
||||
const TABS: { id: TabId; icon: typeof Monitor }[] = [
|
||||
{ id: "appearance", icon: Monitor },
|
||||
|
|
@ -37,17 +53,11 @@ function loadSettingsTab(): TabId {
|
|||
// shown by the Header; the tab rail stays as in-page sub-navigation.
|
||||
export default function SettingsPanel({
|
||||
me,
|
||||
theme,
|
||||
setTheme,
|
||||
view,
|
||||
setView,
|
||||
prefs,
|
||||
onOpenWizard,
|
||||
}: {
|
||||
me: Me;
|
||||
theme: ThemePrefs;
|
||||
setTheme: (t: ThemePrefs) => void;
|
||||
view: "grid" | "list";
|
||||
setView: (v: "grid" | "list") => void;
|
||||
prefs: PrefsController;
|
||||
onOpenWizard: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
|
|
@ -59,47 +69,87 @@ export default function SettingsPanel({
|
|||
|
||||
return (
|
||||
<div className="p-4 max-w-3xl w-full mx-auto">
|
||||
<div className="glass rounded-2xl overflow-hidden flex">
|
||||
{/* Vertical tab rail — never wraps; active is a clear accent pill. */}
|
||||
<nav className="w-32 sm:w-36 shrink-0 border-r border-border/60 p-2 flex flex-col gap-1">
|
||||
{TABS.map((tabItem) => {
|
||||
const active = tab === tabItem.id;
|
||||
return (
|
||||
<button
|
||||
<div className="glass rounded-2xl overflow-hidden">
|
||||
<div className="flex">
|
||||
{/* Vertical tab rail — never wraps; active is a clear accent pill. */}
|
||||
<nav className="w-32 sm:w-36 shrink-0 border-r border-border/60 p-2 flex flex-col gap-1">
|
||||
{TABS.map((tabItem) => {
|
||||
const active = tab === tabItem.id;
|
||||
return (
|
||||
<button
|
||||
key={tabItem.id}
|
||||
onClick={() => setTab(tabItem.id)}
|
||||
className={`flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-left transition ${
|
||||
active
|
||||
? "bg-accent text-accent-fg font-medium shadow-md shadow-accent/20"
|
||||
: "text-muted hover:text-fg hover:bg-card/60"
|
||||
}`}
|
||||
>
|
||||
<tabItem.icon className="w-4 h-4 shrink-0" />
|
||||
{t(`settings.tabs.${tabItem.id}`)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* All tabs stacked in one grid cell so the panel sizes to the tallest tab
|
||||
(stable height, no jump on switch); the active one is shown on top. */}
|
||||
<div className="grid flex-1 min-w-0">
|
||||
{TABS.map((tabItem) => (
|
||||
<div
|
||||
key={tabItem.id}
|
||||
onClick={() => setTab(tabItem.id)}
|
||||
className={`flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-left transition ${
|
||||
active
|
||||
? "bg-accent text-accent-fg font-medium shadow-md shadow-accent/20"
|
||||
: "text-muted hover:text-fg hover:bg-card/60"
|
||||
className={`[grid-area:1/1] p-4 ${
|
||||
tab === tabItem.id ? "" : "invisible pointer-events-none"
|
||||
}`}
|
||||
>
|
||||
<tabItem.icon className="w-4 h-4 shrink-0" />
|
||||
{t(`settings.tabs.${tabItem.id}`)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
|
||||
{/* All tabs stacked in one grid cell so the panel sizes to the tallest tab
|
||||
(stable height, no jump on switch); the active one is shown on top. */}
|
||||
<div className="grid flex-1 min-w-0">
|
||||
{TABS.map((tabItem) => (
|
||||
<div
|
||||
key={tabItem.id}
|
||||
className={`[grid-area:1/1] p-4 ${
|
||||
tab === tabItem.id ? "" : "invisible pointer-events-none"
|
||||
}`}
|
||||
>
|
||||
{tabItem.id === "appearance" && (
|
||||
<Appearance theme={theme} setTheme={setTheme} view={view} setView={setView} />
|
||||
)}
|
||||
{tabItem.id === "notifications" && <Notifications />}
|
||||
{tabItem.id === "sync" && <Sync me={me} />}
|
||||
{tabItem.id === "account" && <Account me={me} onOpenWizard={onOpenWizard} />}
|
||||
</div>
|
||||
))}
|
||||
{tabItem.id === "appearance" && <Appearance prefs={prefs} />}
|
||||
{tabItem.id === "notifications" && <Notifications prefs={prefs} />}
|
||||
{tabItem.id === "sync" && <Sync me={me} />}
|
||||
{tabItem.id === "account" && <Account me={me} onOpenWizard={onOpenWizard} />}
|
||||
</div>
|
||||
))}
|
||||
</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} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PrefsSaveBar({ prefs }: { prefs: PrefsController }) {
|
||||
const { t } = useTranslation();
|
||||
// Stay mounted while a transient "saved"/"error" message is fading, even after dirty clears.
|
||||
if (!prefs.dirty && prefs.saveState === "idle") return null;
|
||||
const saving = prefs.saveState === "saving";
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-3 border-t border-border/60 px-4 py-3 bg-card/40">
|
||||
<span className="text-sm text-muted">
|
||||
{prefs.saveState === "saved"
|
||||
? <span className="text-emerald-500 inline-flex items-center gap-1.5"><Check className="w-4 h-4" />{t("settings.save.saved")}</span>
|
||||
: prefs.saveState === "error"
|
||||
? <span className="text-red-500">{t("settings.save.failed")}</span>
|
||||
: t("settings.save.unsaved")}
|
||||
</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={prefs.discard}
|
||||
disabled={saving || !prefs.dirty}
|
||||
className="glass-card glass-hover flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm disabled:opacity-40 transition"
|
||||
>
|
||||
<RotateCcw className="w-4 h-4" />
|
||||
{t("settings.save.discard")}
|
||||
</button>
|
||||
<button
|
||||
onClick={prefs.save}
|
||||
disabled={saving || !prefs.dirty}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm bg-accent text-accent-fg font-medium shadow-md shadow-accent/20 disabled:opacity-40 transition"
|
||||
>
|
||||
<Save className="w-4 h-4" />
|
||||
{saving ? t("settings.save.saving") : t("settings.save.save")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
@ -155,35 +205,11 @@ function Switch({ checked, onChange }: { checked: boolean; onChange: (v: boolean
|
|||
);
|
||||
}
|
||||
|
||||
const PERF_KEY = "subfeed.perfMode";
|
||||
|
||||
function Appearance({
|
||||
theme,
|
||||
setTheme,
|
||||
view,
|
||||
setView,
|
||||
}: {
|
||||
theme: ThemePrefs;
|
||||
setTheme: (t: ThemePrefs) => void;
|
||||
view: "grid" | "list";
|
||||
setView: (v: "grid" | "list") => void;
|
||||
}) {
|
||||
function Appearance({ prefs }: { prefs: PrefsController }) {
|
||||
const { t } = useTranslation();
|
||||
const [perf, setPerf] = useState(() => localStorage.getItem(PERF_KEY) === "1");
|
||||
const hints = useSyncExternalStore(subscribeHints, hintsEnabled, hintsEnabled);
|
||||
|
||||
useEffect(() => {
|
||||
document.documentElement.dataset.perf = perf ? "1" : "";
|
||||
}, [perf]);
|
||||
function togglePerf(v: boolean) {
|
||||
setPerf(v);
|
||||
localStorage.setItem(PERF_KEY, v ? "1" : "0");
|
||||
api.savePrefs({ performanceMode: v }).catch(() => {});
|
||||
}
|
||||
function toggleHints(v: boolean) {
|
||||
setHintsEnabled(v);
|
||||
api.savePrefs({ hints: v }).catch(() => {});
|
||||
}
|
||||
// Controlled by App's prefs draft: each change applies locally for preview but is only
|
||||
// persisted on an explicit Save (handled by the panel's Save/Discard bar).
|
||||
const { theme, setTheme, view, setView, perf, setPerf, hints, setHints } = prefs;
|
||||
|
||||
return (
|
||||
<>
|
||||
|
|
@ -217,13 +243,13 @@ function Appearance({
|
|||
label={t("settings.appearance.performanceMode")}
|
||||
hint={t("settings.appearance.performanceModeHint")}
|
||||
>
|
||||
<Switch checked={perf} onChange={togglePerf} />
|
||||
<Switch checked={perf} onChange={setPerf} />
|
||||
</Row>
|
||||
<Row
|
||||
label={t("settings.appearance.showHints")}
|
||||
hint={t("settings.appearance.showHintsHint")}
|
||||
>
|
||||
<Switch checked={hints} onChange={toggleHints} />
|
||||
<Switch checked={hints} onChange={setHints} />
|
||||
</Row>
|
||||
</Section>
|
||||
|
||||
|
|
@ -243,15 +269,12 @@ function Appearance({
|
|||
);
|
||||
}
|
||||
|
||||
function Notifications() {
|
||||
function Notifications({ prefs }: { prefs: PrefsController }) {
|
||||
const { t } = useTranslation();
|
||||
const [cfg, setCfg] = useState<NotifSettings>(() => getNotifSettings());
|
||||
function update(p: Partial<NotifSettings>) {
|
||||
const next = { ...cfg, ...p };
|
||||
setCfg(next);
|
||||
configureNotifications(next);
|
||||
api.savePrefs({ notifications: next }).catch(() => {});
|
||||
}
|
||||
// Controlled by App's prefs draft (live preview via configureNotifications there); the
|
||||
// panel's Save bar persists it. The "send test" button below still fires immediately.
|
||||
const { notif: cfg, setNotif } = prefs;
|
||||
const update = (p: Partial<NotifSettings>) => setNotif({ ...cfg, ...p });
|
||||
const autoOn = cfg.durationMs > 0;
|
||||
|
||||
return (
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue