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:
+90
-23
@@ -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" }}>
|
||||
|
||||
Reference in New Issue
Block a user