Merge improvement/config-unified-savebar
Replace the Configuration page's per-field Save buttons with one drafted Save/Discard bar, consistent with the Settings page.
This commit is contained in:
commit
e127ecf490
4 changed files with 171 additions and 81 deletions
|
|
@ -1,15 +1,18 @@
|
|||
import { useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { RotateCcw, Save, Send } from "lucide-react";
|
||||
import { api, type ConfigItem, type SystemConfigData } from "../lib/api";
|
||||
import { Check, RotateCcw, Save, Send } from "lucide-react";
|
||||
import { api, type ConfigItem } from "../lib/api";
|
||||
import { notify } from "../lib/notifications";
|
||||
import Tooltip from "./Tooltip";
|
||||
|
||||
// Admin Configuration page: edit DB-overridable operational settings (registry-driven from
|
||||
// the backend — adding a key there makes it appear here automatically). Group order is fixed
|
||||
// where known so logically related settings (e.g. Email/SMTP) stay together.
|
||||
// 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
|
||||
// applied together via one Save/Discard bar (consistent with the Settings page); nothing hits
|
||||
// the server until Save. Group order is fixed where known so logically related settings (e.g.
|
||||
// Email/SMTP) stay together.
|
||||
const GROUP_ORDER = ["email"];
|
||||
type SaveState = "idle" | "saving" | "saved" | "error";
|
||||
|
||||
export default function ConfigPanel() {
|
||||
const { t } = useTranslation();
|
||||
|
|
@ -17,32 +20,71 @@ export default function ConfigPanel() {
|
|||
const q = useQuery({ queryKey: ["admin-config"], queryFn: api.adminConfig });
|
||||
const data = q.data;
|
||||
|
||||
const apply = (fresh: SystemConfigData) => qc.setQueryData(["admin-config"], fresh);
|
||||
const save = useMutation({
|
||||
mutationFn: ({ key, value }: { key: string; value: string | number | boolean }) =>
|
||||
api.setConfig(key, value),
|
||||
onSuccess: (fresh) => {
|
||||
apply(fresh);
|
||||
notify({ level: "success", message: t("config.saved") });
|
||||
},
|
||||
onError: () => notify({ level: "error", message: t("config.saveFailed") }),
|
||||
});
|
||||
const reset = useMutation({
|
||||
mutationFn: (key: string) => api.resetConfig(key),
|
||||
onSuccess: (fresh) => {
|
||||
apply(fresh);
|
||||
notify({ level: "success", message: t("config.resetDone") });
|
||||
},
|
||||
onError: () => notify({ level: "error", message: t("config.saveFailed") }),
|
||||
});
|
||||
const items = useMemo(() => (data ? Object.values(data.groups).flat() : []), [data]);
|
||||
const byKey = useMemo(() => Object.fromEntries(items.map((i) => [i.key, i])), [items]);
|
||||
// Baseline draft from the server: non-secrets show their effective value; secrets start empty
|
||||
// (write-only — we never load them back).
|
||||
const baseline = useMemo(() => {
|
||||
const m: Record<string, string> = {};
|
||||
for (const it of items) m[it.key] = it.secret ? "" : String(it.value ?? "");
|
||||
return m;
|
||||
}, [items]);
|
||||
|
||||
const [draft, setDraft] = useState<Record<string, string>>({});
|
||||
// Secrets the admin marked to reset-to-default on the next Save (can't be expressed by
|
||||
// clearing the field, since an empty secret field means "leave unchanged").
|
||||
const [secretReset, setSecretReset] = useState<Record<string, boolean>>({});
|
||||
const [saveState, setSaveState] = useState<SaveState>("idle");
|
||||
|
||||
// Re-seed the draft whenever the server data changes (initial load + after a save/reset).
|
||||
useEffect(() => {
|
||||
setDraft(baseline);
|
||||
setSecretReset({});
|
||||
}, [baseline]);
|
||||
|
||||
const isDirty = (it: ConfigItem): boolean => {
|
||||
if (it.secret) return (draft[it.key] ?? "").trim() !== "" || !!secretReset[it.key];
|
||||
return (draft[it.key] ?? "") !== (baseline[it.key] ?? "");
|
||||
};
|
||||
const dirtyKeys = items.filter(isDirty).map((i) => i.key);
|
||||
const dirty = dirtyKeys.length > 0;
|
||||
|
||||
async function save() {
|
||||
setSaveState("saving");
|
||||
try {
|
||||
for (const key of dirtyKeys) {
|
||||
const it = byKey[key];
|
||||
const raw = draft[key] ?? "";
|
||||
if (it.secret) {
|
||||
if (secretReset[key] && !raw.trim()) await api.resetConfig(key);
|
||||
else if (raw.trim()) await api.setConfig(key, raw);
|
||||
} else if (raw === "") {
|
||||
if (it.is_set) await api.resetConfig(key); // cleared → fall back to default
|
||||
} else {
|
||||
await api.setConfig(key, it.type === "int" ? Number(raw) : raw);
|
||||
}
|
||||
}
|
||||
await qc.invalidateQueries({ queryKey: ["admin-config"] });
|
||||
setSaveState("saved");
|
||||
setTimeout(() => setSaveState((s) => (s === "saved" ? "idle" : s)), 2500);
|
||||
} catch {
|
||||
setSaveState("error");
|
||||
notify({ level: "error", message: t("config.saveFailed") });
|
||||
}
|
||||
}
|
||||
|
||||
function discard() {
|
||||
setDraft(baseline);
|
||||
setSecretReset({});
|
||||
setSaveState("idle");
|
||||
}
|
||||
|
||||
const testEmail = useMutation({
|
||||
mutationFn: () => api.testEmail(),
|
||||
onSuccess: (r) => notify({ level: "success", message: t("config.testSent", { to: r.to }) }),
|
||||
onError: () => notify({ level: "error", message: t("config.testFailed") }),
|
||||
});
|
||||
|
||||
const busy = save.isPending || reset.isPending;
|
||||
|
||||
if (q.isLoading || !data) {
|
||||
return <div className="p-4 max-w-3xl mx-auto text-muted">{t("config.loading")}</div>;
|
||||
}
|
||||
|
|
@ -52,7 +94,7 @@ export default function ConfigPanel() {
|
|||
);
|
||||
|
||||
return (
|
||||
<div className="p-4 max-w-3xl w-full mx-auto">
|
||||
<div className="p-4 max-w-3xl w-full mx-auto pb-24">
|
||||
<p className="text-xs text-muted mb-4 leading-relaxed">{t("config.intro")}</p>
|
||||
|
||||
{groupKeys.map((g) => (
|
||||
|
|
@ -63,12 +105,16 @@ export default function ConfigPanel() {
|
|||
<div className="divide-y divide-border/60">
|
||||
{data.groups[g].map((item) => (
|
||||
<Field
|
||||
key={`${item.key}:${item.is_set}:${String(item.value ?? "")}`}
|
||||
key={item.key}
|
||||
item={item}
|
||||
value={draft[item.key] ?? ""}
|
||||
secretsManageable={data.secrets_manageable}
|
||||
busy={busy}
|
||||
onSave={(value) => save.mutate({ key: item.key, value })}
|
||||
onReset={() => reset.mutate(item.key)}
|
||||
pendingSecretReset={!!secretReset[item.key]}
|
||||
onChange={(v) => setDraft((d) => ({ ...d, [item.key]: v }))}
|
||||
onResetField={() => {
|
||||
if (item.secret) setSecretReset((s) => ({ ...s, [item.key]: !s[item.key] }));
|
||||
else setDraft((d) => ({ ...d, [item.key]: "" }));
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
|
@ -78,7 +124,7 @@ export default function ConfigPanel() {
|
|||
<Tooltip hint={t("config.testEmailHint")}>
|
||||
<button
|
||||
onClick={() => testEmail.mutate()}
|
||||
disabled={testEmail.isPending}
|
||||
disabled={testEmail.isPending || dirty}
|
||||
className="glass-card glass-hover inline-flex items-center gap-2 px-3 py-2 rounded-xl text-sm disabled:opacity-50 transition"
|
||||
>
|
||||
<Send className={`w-4 h-4 ${testEmail.isPending ? "animate-pulse" : ""}`} />
|
||||
|
|
@ -89,36 +135,83 @@ export default function ConfigPanel() {
|
|||
)}
|
||||
</div>
|
||||
))}
|
||||
|
||||
{(dirty || saveState !== "idle") && (
|
||||
<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))]">
|
||||
<span className="text-sm text-muted">
|
||||
{saveState === "saved" ? (
|
||||
<span className="text-emerald-500 inline-flex items-center gap-1.5">
|
||||
<Check className="w-4 h-4" />
|
||||
{t("config.saved")}
|
||||
</span>
|
||||
) : saveState === "error" ? (
|
||||
<span className="text-red-500">{t("config.saveFailed")}</span>
|
||||
) : (
|
||||
t("config.unsaved")
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
||||
function Field({
|
||||
item,
|
||||
value,
|
||||
secretsManageable,
|
||||
busy,
|
||||
onSave,
|
||||
onReset,
|
||||
pendingSecretReset,
|
||||
onChange,
|
||||
onResetField,
|
||||
}: {
|
||||
item: ConfigItem;
|
||||
value: string;
|
||||
secretsManageable: boolean;
|
||||
busy: boolean;
|
||||
onSave: (value: string | number) => void;
|
||||
onReset: () => void;
|
||||
pendingSecretReset: boolean;
|
||||
onChange: (v: string) => void;
|
||||
onResetField: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const isNum = item.type === "int";
|
||||
const [draft, setDraft] = useState<string>(item.secret ? "" : String(item.value ?? ""));
|
||||
|
||||
const secretDisabled = item.secret && !secretsManageable;
|
||||
// Status line for secrets (never reveal the value): stored override / env value / unset.
|
||||
const secretStatus = item.is_set
|
||||
? t("config.secretSet")
|
||||
: item.default_is_set
|
||||
? t("config.secretEnv")
|
||||
: t("config.secretUnset");
|
||||
|
||||
const canSave = item.secret ? draft.trim().length > 0 && !secretDisabled : true;
|
||||
// Status line: never reveal a secret's value.
|
||||
let status: string;
|
||||
if (item.secret) {
|
||||
status = secretDisabled
|
||||
? t("config.secretsDisabled")
|
||||
: pendingSecretReset
|
||||
? t("config.willReset")
|
||||
: item.is_set
|
||||
? t("config.secretSet")
|
||||
: item.default_is_set
|
||||
? t("config.secretEnv")
|
||||
: t("config.secretUnset");
|
||||
} else {
|
||||
status = item.is_set ? t("config.overridden") : t("config.usingDefault");
|
||||
}
|
||||
|
||||
// Reset affordance shows when a DB override exists: clears a non-secret to its default, or
|
||||
// toggles a secret's reset-on-save (applied on Save, not immediately).
|
||||
const showReset = item.is_set;
|
||||
|
||||
return (
|
||||
<div className="flex items-start justify-between gap-3 py-3">
|
||||
|
|
@ -127,50 +220,35 @@ function Field({
|
|||
<p className="text-xs text-muted leading-relaxed mt-0.5">
|
||||
{t(`config.fields.${item.key}.hint`, "")}
|
||||
</p>
|
||||
{item.secret ? (
|
||||
<p className="text-[11px] text-muted mt-1">
|
||||
{secretDisabled ? t("config.secretsDisabled") : secretStatus}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-[11px] text-muted mt-1">
|
||||
{item.is_set ? t("config.overridden") : t("config.usingDefault")}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-[11px] text-muted mt-1">{status}</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col items-end gap-1.5 shrink-0">
|
||||
<input
|
||||
type={item.secret ? "password" : isNum ? "number" : "text"}
|
||||
value={draft}
|
||||
disabled={secretDisabled}
|
||||
onChange={(e) => setDraft(e.target.value)}
|
||||
value={value}
|
||||
disabled={secretDisabled || (item.secret && pendingSecretReset)}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
min={isNum && item.min != null ? item.min : undefined}
|
||||
max={isNum && item.max != null ? item.max : undefined}
|
||||
placeholder={item.secret ? "••••••••" : String(item.default ?? "")}
|
||||
className="w-52 bg-card border border-border rounded-xl px-3 py-1.5 text-sm outline-none focus:border-accent disabled:opacity-50"
|
||||
/>
|
||||
<div className="flex items-center gap-1.5">
|
||||
{item.is_set && (
|
||||
<Tooltip hint={t("config.resetHint")}>
|
||||
<button
|
||||
onClick={onReset}
|
||||
disabled={busy}
|
||||
className="glass-card glass-hover inline-flex items-center gap-1 px-2.5 py-1.5 rounded-lg text-xs disabled:opacity-50 transition"
|
||||
>
|
||||
<RotateCcw className="w-3.5 h-3.5" />
|
||||
{t("config.reset")}
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
<button
|
||||
onClick={() => onSave(isNum ? Number(draft) : draft)}
|
||||
disabled={busy || !canSave}
|
||||
className="inline-flex items-center gap-1 px-2.5 py-1.5 rounded-lg text-xs bg-accent text-accent-fg font-medium disabled:opacity-40 transition"
|
||||
>
|
||||
<Save className="w-3.5 h-3.5" />
|
||||
{t("config.save")}
|
||||
</button>
|
||||
</div>
|
||||
{showReset && !secretDisabled && (
|
||||
<Tooltip hint={t("config.resetHint")}>
|
||||
<button
|
||||
onClick={onResetField}
|
||||
className={`inline-flex items-center gap-1 text-xs transition ${
|
||||
item.secret && pendingSecretReset
|
||||
? "text-accent"
|
||||
: "text-muted hover:text-fg"
|
||||
}`}
|
||||
>
|
||||
<RotateCcw className="w-3.5 h-3.5" />
|
||||
{t("config.reset")}
|
||||
</button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -12,8 +12,12 @@
|
|||
"smtp_password": { "label": "SMTP-Passwort", "hint": "App-Passwort. Verschlüsselt gespeichert; nur schreibbar — wird nie wieder angezeigt." }
|
||||
},
|
||||
"save": "Speichern",
|
||||
"saving": "Speichern…",
|
||||
"saved": "Gespeichert",
|
||||
"saveFailed": "Speichern fehlgeschlagen",
|
||||
"unsaved": "Nicht gespeicherte Änderungen",
|
||||
"discard": "Verwerfen",
|
||||
"willReset": "wird beim Speichern auf den Standard zurückgesetzt",
|
||||
"reset": "Zurücksetzen",
|
||||
"resetHint": "Diese Überschreibung entfernen und auf den Datei-/Env-Standard zurückfallen.",
|
||||
"resetDone": "Auf Standard zurückgesetzt",
|
||||
|
|
|
|||
|
|
@ -12,8 +12,12 @@
|
|||
"smtp_password": { "label": "SMTP password", "hint": "App password. Stored encrypted; write-only — it's never shown back." }
|
||||
},
|
||||
"save": "Save",
|
||||
"saving": "Saving…",
|
||||
"saved": "Saved",
|
||||
"saveFailed": "Couldn't save",
|
||||
"unsaved": "Unsaved changes",
|
||||
"discard": "Discard",
|
||||
"willReset": "will reset to default on save",
|
||||
"reset": "Reset",
|
||||
"resetHint": "Remove this override and fall back to the file/env default.",
|
||||
"resetDone": "Reset to default",
|
||||
|
|
|
|||
|
|
@ -12,8 +12,12 @@
|
|||
"smtp_password": { "label": "SMTP-jelszó", "hint": "Alkalmazásjelszó. Titkosítva tárolva; csak írható — soha nem jelenik meg újra." }
|
||||
},
|
||||
"save": "Mentés",
|
||||
"saving": "Mentés…",
|
||||
"saved": "Elmentve",
|
||||
"saveFailed": "Nem sikerült menteni",
|
||||
"unsaved": "Nem mentett változások",
|
||||
"discard": "Elvetés",
|
||||
"willReset": "mentéskor visszaáll alapértékre",
|
||||
"reset": "Visszaállítás",
|
||||
"resetHint": "Eltávolítja ezt a felülírást, és visszatér a fájl/env alapértékhez.",
|
||||
"resetDone": "Visszaállítva az alapértékre",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue