diff --git a/frontend/src/components/ConfigPanel.tsx b/frontend/src/components/ConfigPanel.tsx
index 8fa14b2..fad57be 100644
--- a/frontend/src/components/ConfigPanel.tsx
+++ b/frontend/src/components/ConfigPanel.tsx
@@ -1,12 +1,13 @@
import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
-import { Check, RotateCcw, Save, Send } from "lucide-react";
+import { RotateCcw, Send } from "lucide-react";
import { api, type ConfigItem } from "../lib/api";
import { notify } from "../lib/notifications";
import Tooltip from "./Tooltip";
import Tabs, { usePersistedTab } from "./Tabs";
import { Switch } from "./ui/form";
+import { DraftSaveBar } from "./ui/DraftSaveBar";
// Admin Configuration page: edit DB-overridable operational settings (registry-driven from the
// backend — adding a key there makes it appear here automatically). Edits are drafted and
@@ -151,40 +152,21 @@ export default function ConfigPanel() {
)}
- {(dirty || saveState !== "idle") && (
-
-
- {saveState === "saved" ? (
-
-
- {t("config.saved")}
-
- ) : saveState === "error" ? (
- {t("config.saveFailed")}
- ) : (
- t("config.unsaved")
- )}
-
-
-
-
-
-
- )}
+
);
}
diff --git a/frontend/src/components/SettingsPanel.tsx b/frontend/src/components/SettingsPanel.tsx
index 8227a37..4d13622 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 { useQueryClient } from "@tanstack/react-query";
-import { Bell, Check, Monitor, RotateCcw, Save, Trash2, User } from "lucide-react";
+import { Bell, Monitor, Trash2, User } from "lucide-react";
import { SCHEMES, type Scheme, type ThemePrefs } from "../lib/theme";
import { api, type Me } from "../lib/api";
import { LS, usePersistedState } from "../lib/storage";
@@ -9,6 +9,7 @@ import Avatar from "./Avatar";
import { notify, type NotifSettings } from "../lib/notifications";
import Tooltip from "./Tooltip";
import { Section, SettingRow, Switch } from "./ui/form";
+import { DraftSaveBar } from "./ui/DraftSaveBar";
import { useConfirm } from "./ConfirmProvider";
// The Settings page edits server-persisted preferences as a draft: changes apply locally
@@ -104,37 +105,22 @@ export default function SettingsPanel({
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 (
-
-
- {prefs.saveState === "saved"
- ? {t("settings.save.saved")}
- : prefs.saveState === "error"
- ? {t("settings.save.failed")}
- : t("settings.save.unsaved")}
-
-
-
-
-
-
+
);
}
diff --git a/frontend/src/components/ui/DraftSaveBar.tsx b/frontend/src/components/ui/DraftSaveBar.tsx
new file mode 100644
index 0000000..5e629af
--- /dev/null
+++ b/frontend/src/components/ui/DraftSaveBar.tsx
@@ -0,0 +1,75 @@
+import { Check, RotateCcw, Save } from "lucide-react";
+
+// The Save / Discard bar shown while an edited draft has unsaved changes (Settings prefs,
+// admin Configuration). Two visual variants share one state machine + markup:
+// - "inline": a bar pinned to the bottom of a card (Settings panel)
+// - "floating": a centered floating pill over the page (Configuration page)
+// Renders nothing while clean and idle; stays mounted through the fading saved/error message.
+
+export type SaveState = "idle" | "saving" | "saved" | "error";
+
+export interface DraftSaveBarLabels {
+ saved: string;
+ failed: string;
+ unsaved: string;
+ discard: string;
+ save: string;
+ saving: string;
+}
+
+export function DraftSaveBar({
+ dirty,
+ state,
+ onSave,
+ onDiscard,
+ labels,
+ variant = "inline",
+}: {
+ dirty: boolean;
+ state: SaveState;
+ onSave: () => void;
+ onDiscard: () => void;
+ labels: DraftSaveBarLabels;
+ variant?: "inline" | "floating";
+}) {
+ if (!dirty && state === "idle") return null;
+ const saving = state === "saving";
+ const wrap =
+ variant === "floating"
+ ? "fixed bottom-4 left-1/2 -translate-x-1/2 z-40 glass rounded-2xl shadow-lg flex items-center justify-between gap-4 px-4 py-3 w-[min(48rem,calc(100%-2rem))]"
+ : "flex items-center justify-between gap-3 border-t border-border/60 px-4 py-3 bg-card/40";
+ return (
+
+
+ {state === "saved" ? (
+
+
+ {labels.saved}
+
+ ) : state === "error" ? (
+ {labels.failed}
+ ) : (
+ labels.unsaved
+ )}
+
+
+
+
+
+
+ );
+}