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
+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>
);
}