refactor(ui): shared DraftSaveBar (Settings + Config save bars)
One Save/Discard bar with an inline (Settings card) and floating (Config page) variant, replacing the two near-identical hand-rolled bars + their state machine.
This commit is contained in:
parent
53c75f69e2
commit
f6b9ac2dd1
3 changed files with 109 additions and 66 deletions
|
|
@ -1,12 +1,13 @@
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, 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 { Check, RotateCcw, Save, Send } from "lucide-react";
|
import { RotateCcw, Send } from "lucide-react";
|
||||||
import { api, type ConfigItem } from "../lib/api";
|
import { api, type ConfigItem } from "../lib/api";
|
||||||
import { notify } from "../lib/notifications";
|
import { notify } from "../lib/notifications";
|
||||||
import Tooltip from "./Tooltip";
|
import Tooltip from "./Tooltip";
|
||||||
import Tabs, { usePersistedTab } from "./Tabs";
|
import Tabs, { usePersistedTab } from "./Tabs";
|
||||||
import { Switch } from "./ui/form";
|
import { Switch } from "./ui/form";
|
||||||
|
import { DraftSaveBar } from "./ui/DraftSaveBar";
|
||||||
|
|
||||||
// Admin Configuration page: edit DB-overridable operational settings (registry-driven from the
|
// 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
|
// backend — adding a key there makes it appear here automatically). Edits are drafted and
|
||||||
|
|
@ -151,40 +152,21 @@ export default function ConfigPanel() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{(dirty || saveState !== "idle") && (
|
<DraftSaveBar
|
||||||
<div className="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))]">
|
variant="floating"
|
||||||
<span className="text-sm text-muted">
|
dirty={dirty}
|
||||||
{saveState === "saved" ? (
|
state={saveState}
|
||||||
<span className="text-emerald-500 inline-flex items-center gap-1.5">
|
onSave={save}
|
||||||
<Check className="w-4 h-4" />
|
onDiscard={discard}
|
||||||
{t("config.saved")}
|
labels={{
|
||||||
</span>
|
saved: t("config.saved"),
|
||||||
) : saveState === "error" ? (
|
failed: t("config.saveFailed"),
|
||||||
<span className="text-red-500">{t("config.saveFailed")}</span>
|
unsaved: t("config.unsaved"),
|
||||||
) : (
|
discard: t("config.discard"),
|
||||||
t("config.unsaved")
|
save: t("config.save"),
|
||||||
)}
|
saving: t("config.saving"),
|
||||||
</span>
|
}}
|
||||||
<div className="flex items-center gap-2">
|
/>
|
||||||
<button
|
|
||||||
onClick={discard}
|
|
||||||
disabled={saveState === "saving" || !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("config.discard")}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={save}
|
|
||||||
disabled={saveState === "saving" || !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" />
|
|
||||||
{saveState === "saving" ? t("config.saving") : t("config.save")}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useQueryClient } from "@tanstack/react-query";
|
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 { SCHEMES, type Scheme, type ThemePrefs } from "../lib/theme";
|
||||||
import { api, type Me } from "../lib/api";
|
import { api, type Me } from "../lib/api";
|
||||||
import { LS, usePersistedState } from "../lib/storage";
|
import { LS, usePersistedState } from "../lib/storage";
|
||||||
|
|
@ -9,6 +9,7 @@ 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 } 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 as a draft: changes apply locally
|
||||||
|
|
@ -104,37 +105,22 @@ export default function SettingsPanel({
|
||||||
|
|
||||||
function PrefsSaveBar({ prefs }: { prefs: PrefsController }) {
|
function PrefsSaveBar({ prefs }: { prefs: PrefsController }) {
|
||||||
const { t } = useTranslation();
|
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 (
|
return (
|
||||||
<div className="flex items-center justify-between gap-3 border-t border-border/60 px-4 py-3 bg-card/40">
|
<DraftSaveBar
|
||||||
<span className="text-sm text-muted">
|
variant="inline"
|
||||||
{prefs.saveState === "saved"
|
dirty={prefs.dirty}
|
||||||
? <span className="text-emerald-500 inline-flex items-center gap-1.5"><Check className="w-4 h-4" />{t("settings.save.saved")}</span>
|
state={prefs.saveState}
|
||||||
: prefs.saveState === "error"
|
onSave={prefs.save}
|
||||||
? <span className="text-red-500">{t("settings.save.failed")}</span>
|
onDiscard={prefs.discard}
|
||||||
: t("settings.save.unsaved")}
|
labels={{
|
||||||
</span>
|
saved: t("settings.save.saved"),
|
||||||
<div className="flex items-center gap-2">
|
failed: t("settings.save.failed"),
|
||||||
<button
|
unsaved: t("settings.save.unsaved"),
|
||||||
onClick={prefs.discard}
|
discard: t("settings.save.discard"),
|
||||||
disabled={saving || !prefs.dirty}
|
save: t("settings.save.save"),
|
||||||
className="glass-card glass-hover flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm disabled:opacity-40 transition"
|
saving: t("settings.save.saving"),
|
||||||
>
|
}}
|
||||||
<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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
75
frontend/src/components/ui/DraftSaveBar.tsx
Normal file
75
frontend/src/components/ui/DraftSaveBar.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<div className={wrap}>
|
||||||
|
<span className="text-sm text-muted">
|
||||||
|
{state === "saved" ? (
|
||||||
|
<span className="text-emerald-500 inline-flex items-center gap-1.5">
|
||||||
|
<Check className="w-4 h-4" />
|
||||||
|
{labels.saved}
|
||||||
|
</span>
|
||||||
|
) : state === "error" ? (
|
||||||
|
<span className="text-red-500">{labels.failed}</span>
|
||||||
|
) : (
|
||||||
|
labels.unsaved
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={onDiscard}
|
||||||
|
disabled={saving || !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" />
|
||||||
|
{labels.discard}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={onSave}
|
||||||
|
disabled={saving || !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 ? labels.saving : labels.save}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue