fix: GlobalSettings — add Save/Discard button pattern with visible feedback

- Removed auto-save-on-every-change (was silent, confusing)
- Added local draft state: changes accumulate until user clicks Save
- Added 'Unsaved' indicator when there are pending changes
- Added 'Save' button (amber, disabled when no changes)
- Added 'Discard' button to revert to last-saved state
- Shows '✓ Saved' green feedback for 2s after success
- Shows '✗ Save failed' on error
- Bulk-saves all changed settings in one POST /api/settings call
- Instantly routes master_volume/routing/audio_profile via dedicated endpoints
This commit is contained in:
2026-06-14 13:43:47 -04:00
parent 2402e649a2
commit cca4aed380
+90 -23
View File
@@ -1,4 +1,4 @@
import { useState, useCallback, useRef, useEffect } from "react";
import { useState, useCallback, useRef, useEffect, useMemo } from "react";
const T = {
bg: "#0A0A0C", panel: "#141418", surface: "#1C1C22", border: "#2A2A32",
@@ -292,7 +292,10 @@ function BpmWidget({ label, value, onChange }) {
export default function GlobalSettings({ onClose, onMasterVolume, onAudioProfile, onRoutingChange, onSaveSettings }) {
const [values, setValues] = useState(buildDefaults());
const [savedValues, setSavedValues] = useState(buildDefaults());
const [loaded, setLoaded] = useState(false);
const [saving, setSaving] = useState(false);
const [saveMsg, setSaveMsg] = useState(null); // "saved" | "error" | null
useEffect(() => {
let cancelled = false;
@@ -316,41 +319,79 @@ export default function GlobalSettings({ onClose, onMasterVolume, onAudioProfile
if (routing.routing_mode) overrides.routing_mode = routing.routing_mode;
if (routing.routing_breakpoint != null) overrides.routing_breakpoint = String(routing.routing_breakpoint);
}
setValues(prev => ({ ...prev, ...overrides }));
const merged = { ...buildDefaults(), ...overrides };
setValues(merged);
setSavedValues(merged);
setLoaded(true);
});
return () => { cancelled = true; };
}, []);
const hasChanges = useMemo(() => {
return Object.keys(values).some(k => values[k] !== savedValues[k]);
}, [values, savedValues]);
const groups = Object.entries(SETTINGS);
const update = useCallback((key, val) => {
setValues(prev => {
const next = { ...prev, [key]: val };
setValues(prev => ({ ...prev, [key]: val }));
setSaveMsg(null);
}, []);
// Route via dedicated endpoints
if (key === "master_volume") onMasterVolume?.(val);
else if (key === "sample_rate" || key === "buffer_size") {
const rate = key === "sample_rate" ? val : (next.sample_rate || "48000");
const period = key === "buffer_size" ? val : (next.buffer_size || "512");
onAudioProfile?.(parseInt(rate), parseInt(period));
const handleSave = useCallback(async () => {
setSaving(true);
setSaveMsg(null);
const changed = {};
for (const k of Object.keys(values)) {
if (values[k] !== savedValues[k]) {
changed[k] = values[k];
}
}
// Apply instant-effect settings via dedicated endpoints first
const promises = [];
if (changed.master_volume != null) {
onMasterVolume?.(changed.master_volume);
}
if (changed.sample_rate != null || changed.buffer_size != null) {
const rate = changed.sample_rate || values.sample_rate || "48000";
const period = changed.buffer_size || values.buffer_size || "512";
onAudioProfile?.(parseInt(rate), parseInt(period));
}
if (changed.routing_mode != null || changed.routing_breakpoint != null) {
const mode = changed.routing_mode || values.routing_mode || "mono";
const bp = changed.routing_breakpoint || values.routing_breakpoint || "7";
onRoutingChange?.(mode, parseInt(bp));
}
else if (key === "routing_mode" || key === "routing_breakpoint") {
const mode = key === "routing_mode" ? val : (next.routing_mode || "mono");
const bp = key === "routing_breakpoint" ? val : (next.routing_breakpoint || "7");
onRoutingChange?.(mode, parseInt(bp));
}
// Everything else → bulk POST /api/settings
const settingsChanged = Object.fromEntries(
Object.entries(changed).filter(([k]) =>
!["master_volume", "sample_rate", "buffer_size", "routing_mode", "routing_breakpoint"].includes(k)
)
);
if (Object.keys(settingsChanged).length > 0) {
promises.push(
api("POST", "/api/settings", settingsChanged)
.then(() => {})
.catch(() => { throw new Error("Save failed"); })
);
}
else {
// All other settings → POST /api/settings (generic persistence)
onSaveSettings?.({ [key]: val });
}
try {
await Promise.all(promises);
setSavedValues({ ...values });
setSaveMsg("saved");
setTimeout(() => { setSaveMsg(null); }, 2000);
} catch {
setSaveMsg("error");
}
setSaving(false);
}, [values, savedValues, onMasterVolume, onAudioProfile, onRoutingChange]);
return next;
});
}, [onMasterVolume, onAudioProfile, onRoutingChange, onSaveSettings]);
const handleDiscard = useCallback(() => {
setValues({ ...savedValues });
setSaveMsg(null);
}, [savedValues]);
return (
<div style={{ position: "fixed", inset: 0, zIndex: 998, display: "flex", flexDirection: "column", background: T.bg, color: T.textPrimary, fontFamily: "'Inter', sans-serif" }}>
@@ -362,6 +403,32 @@ export default function GlobalSettings({ onClose, onMasterVolume, onAudioProfile
{!loaded && <span style={{ fontSize: 10, color: T.textDim }}>Loading</span>}
</div>
</div>
<div style={{ display: "flex", alignItems: "center", gap: 8 }}>
{saveMsg === "saved" && (
<span style={{ fontSize: 10, fontWeight: 600, color: T.green }}> Saved</span>
)}
{saveMsg === "error" && (
<span style={{ fontSize: 10, fontWeight: 600, color: T.red }}> Save failed</span>
)}
{hasChanges && (
<span style={{ fontSize: 10, color: T.amber, fontWeight: 600 }}>Unsaved</span>
)}
{hasChanges && (
<button onClick={handleDiscard}
style={{ padding: "6px 12px", borderRadius: 6, border: `1px solid ${T.border}`, background: T.surface, color: T.textSec, fontSize: 11, fontWeight: 600, cursor: "pointer" }}>
Discard
</button>
)}
<button onClick={handleSave} disabled={!hasChanges || saving}
style={{
padding: "6px 14px", borderRadius: 6, border: "none",
background: hasChanges ? T.amber : T.border, color: hasChanges ? "#000" : T.textDim,
fontSize: 11, fontWeight: 700, cursor: hasChanges ? "pointer" : "default",
opacity: saving ? 0.6 : 1,
}}>
{saving ? "Saving…" : "Save"}
</button>
</div>
</div>
<div style={{ flex: 1, overflowY: "auto", overflowX: "hidden", padding: "6px 14px 20px" }}>