Further improvements
This commit is contained in:
@@ -23,6 +23,29 @@ function computeStepsAndNotes(stepsStr) {
|
||||
return { steps: tokens.length, totalNotes: bellSet.size || 1 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate steps string. Each comma-separated token must be:
|
||||
* - "0" (silence)
|
||||
* - a number 1-16
|
||||
* - multiple of the above joined by "+"
|
||||
* Returns null if valid, or an error string if invalid.
|
||||
*/
|
||||
function validateSteps(stepsStr) {
|
||||
if (!stepsStr || !stepsStr.trim()) return "Steps are required.";
|
||||
const tokens = stepsStr.trim().split(",");
|
||||
for (const token of tokens) {
|
||||
const parts = token.split("+");
|
||||
for (const part of parts) {
|
||||
const trimmed = part.trim();
|
||||
if (trimmed === "") return `Invalid token near: "${token.trim()}" — empty part found.`;
|
||||
const n = parseInt(trimmed, 10);
|
||||
if (isNaN(n)) return `Invalid step value: "${trimmed}" — must be a number 0–16.`;
|
||||
if (n < 0 || n > 16) return `Step value "${trimmed}" is out of range (must be 0–16).`;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export default function BuildOnTheFlyModal({ open, melodyId, currentMelody, defaultName, defaultPid, onClose, onSuccess }) {
|
||||
const [name, setName] = useState(defaultName || "");
|
||||
const [pid, setPid] = useState(defaultPid || "");
|
||||
@@ -34,14 +57,39 @@ export default function BuildOnTheFlyModal({ open, melodyId, currentMelody, defa
|
||||
if (!open) return null;
|
||||
|
||||
const handleBuildAndUpload = async () => {
|
||||
// Mandatory field checks
|
||||
if (!name.trim()) { setError("Name is required."); return; }
|
||||
if (!pid.trim()) { setError("PID is required."); return; }
|
||||
if (!steps.trim()) { setError("Steps are required."); return; }
|
||||
|
||||
// Steps format validation
|
||||
const stepsError = validateSteps(steps);
|
||||
if (stepsError) { setError(stepsError); return; }
|
||||
|
||||
setBuilding(true);
|
||||
setError("");
|
||||
setStatusMsg("");
|
||||
|
||||
let builtId = null;
|
||||
try {
|
||||
// Uniqueness check: fetch all existing archetypes
|
||||
setStatusMsg("Checking for conflicts...");
|
||||
const existing = await api.get("/builder/melodies");
|
||||
const list = existing.melodies || [];
|
||||
const dupName = list.find((m) => m.name.toLowerCase() === name.trim().toLowerCase());
|
||||
if (dupName) {
|
||||
setError(`An archetype with the name "${name.trim()}" already exists.`);
|
||||
setBuilding(false);
|
||||
setStatusMsg("");
|
||||
return;
|
||||
}
|
||||
const dupPid = list.find((m) => m.pid && m.pid.toLowerCase() === pid.trim().toLowerCase());
|
||||
if (dupPid) {
|
||||
setError(`An archetype with the PID "${pid.trim()}" already exists.`);
|
||||
setBuilding(false);
|
||||
setStatusMsg("");
|
||||
return;
|
||||
}
|
||||
|
||||
// Step 1: Create the built melody record
|
||||
setStatusMsg("Creating melody record...");
|
||||
const created = await api.post("/builder/melodies", {
|
||||
@@ -49,7 +97,7 @@ export default function BuildOnTheFlyModal({ open, melodyId, currentMelody, defa
|
||||
pid: pid.trim(),
|
||||
steps: steps.trim(),
|
||||
});
|
||||
builtId = created.id;
|
||||
const builtId = created.id;
|
||||
|
||||
// Step 2: Build the binary
|
||||
setStatusMsg("Building binary...");
|
||||
@@ -63,7 +111,8 @@ export default function BuildOnTheFlyModal({ open, melodyId, currentMelody, defa
|
||||
});
|
||||
if (!res.ok) throw new Error(`Failed to fetch binary: ${res.statusText}`);
|
||||
const blob = await res.blob();
|
||||
const file = new File([blob], `${name.trim()}.bsm`, { type: "application/octet-stream" });
|
||||
// File is named by PID, not friendly name
|
||||
const file = new File([blob], `${pid.trim()}.bsm`, { type: "application/octet-stream" });
|
||||
await api.upload(`/melodies/${melodyId}/upload/binary`, file);
|
||||
|
||||
// Step 4: Assign to this melody
|
||||
@@ -134,7 +183,7 @@ export default function BuildOnTheFlyModal({ open, melodyId, currentMelody, defa
|
||||
<input type="text" value={name} onChange={(e) => setName(e.target.value)} placeholder="e.g. Doksologia_3k" className={inputClass} disabled={building} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1" style={labelStyle}>PID</label>
|
||||
<label className="block text-sm font-medium mb-1" style={labelStyle}>PID *</label>
|
||||
<input type="text" value={pid} onChange={(e) => setPid(e.target.value)} placeholder="e.g. builtin_doksologia_3k" className={inputClass} disabled={building} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -30,6 +30,29 @@ function countSteps(stepsStr) {
|
||||
return stepsStr.trim().split(",").length;
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate steps string. Each comma-separated token must be:
|
||||
* - "0" (silence)
|
||||
* - a number 1-16
|
||||
* - multiple of the above joined by "+"
|
||||
* Returns null if valid, or an error string if invalid.
|
||||
*/
|
||||
function validateSteps(stepsStr) {
|
||||
if (!stepsStr || !stepsStr.trim()) return "Steps are required.";
|
||||
const tokens = stepsStr.trim().split(",");
|
||||
for (const token of tokens) {
|
||||
const parts = token.split("+");
|
||||
for (const part of parts) {
|
||||
const trimmed = part.trim();
|
||||
if (trimmed === "") return `Invalid token near: "${token.trim()}" — empty part found.`;
|
||||
const n = parseInt(trimmed, 10);
|
||||
if (isNaN(n)) return `Invalid step value: "${trimmed}" — must be a number 0–16.`;
|
||||
if (n < 0 || n > 16) return `Step value "${trimmed}" is out of range (must be 0–16).`;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async function downloadBinary(binaryUrl, filename) {
|
||||
const token = localStorage.getItem("access_token");
|
||||
const res = await fetch(`/api${binaryUrl}`, {
|
||||
@@ -50,10 +73,14 @@ export default function BuilderForm() {
|
||||
const isEdit = Boolean(id);
|
||||
const navigate = useNavigate();
|
||||
|
||||
// Form state (what the user is editing)
|
||||
const [name, setName] = useState("");
|
||||
const [pid, setPid] = useState("");
|
||||
const [steps, setSteps] = useState("");
|
||||
|
||||
// Saved state (what's actually stored — build actions use this)
|
||||
const [savedPid, setSavedPid] = useState("");
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [buildingBinary, setBuildingBinary] = useState(false);
|
||||
@@ -66,6 +93,9 @@ export default function BuilderForm() {
|
||||
const [progmemCode, setProgmemCode] = useState("");
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
// Track whether the form has unsaved changes relative to what's built
|
||||
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
|
||||
|
||||
const codeRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -79,9 +109,11 @@ export default function BuilderForm() {
|
||||
setName(data.name || "");
|
||||
setPid(data.pid || "");
|
||||
setSteps(data.steps || "");
|
||||
setSavedPid(data.pid || "");
|
||||
setBinaryBuilt(Boolean(data.binary_path));
|
||||
setBinaryUrl(data.binary_url || null);
|
||||
setProgmemCode(data.progmem_code || "");
|
||||
setHasUnsavedChanges(false);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
@@ -89,16 +121,37 @@ export default function BuilderForm() {
|
||||
}
|
||||
};
|
||||
|
||||
// Track form dirtiness whenever name/pid/steps change after initial load
|
||||
const handleNameChange = (v) => { setName(v); setHasUnsavedChanges(true); };
|
||||
const handlePidChange = (v) => { setPid(v); setHasUnsavedChanges(true); };
|
||||
const handleStepsChange = (v) => { setSteps(v); setHasUnsavedChanges(true); };
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!name.trim()) { setError("Name is required."); return; }
|
||||
if (!steps.trim()) { setError("Steps are required."); return; }
|
||||
if (!pid.trim()) { setError("PID is required."); return; }
|
||||
|
||||
const stepsError = validateSteps(steps);
|
||||
if (stepsError) { setError(stepsError); return; }
|
||||
|
||||
setSaving(true);
|
||||
setError("");
|
||||
setSuccessMsg("");
|
||||
try {
|
||||
// Uniqueness check
|
||||
const existing = await api.get("/builder/melodies");
|
||||
const list = existing.melodies || [];
|
||||
|
||||
const dupName = list.find((m) => m.id !== id && m.name.toLowerCase() === name.trim().toLowerCase());
|
||||
if (dupName) { setError(`An archetype with the name "${name.trim()}" already exists.`); return; }
|
||||
|
||||
const dupPid = list.find((m) => m.id !== id && m.pid && m.pid.toLowerCase() === pid.trim().toLowerCase());
|
||||
if (dupPid) { setError(`An archetype with the PID "${pid.trim()}" already exists.`); return; }
|
||||
|
||||
const body = { name: name.trim(), pid: pid.trim(), steps: steps.trim() };
|
||||
if (isEdit) {
|
||||
await api.put(`/builder/melodies/${id}`, body);
|
||||
setSavedPid(pid.trim());
|
||||
setHasUnsavedChanges(false);
|
||||
setSuccessMsg("Saved.");
|
||||
} else {
|
||||
const created = await api.post("/builder/melodies", body);
|
||||
@@ -113,6 +166,7 @@ export default function BuilderForm() {
|
||||
|
||||
const handleBuildBinary = async () => {
|
||||
if (!isEdit) { setError("Save the melody first before building."); return; }
|
||||
if (hasUnsavedChanges) { setError("You have unsaved changes. Save before rebuilding."); return; }
|
||||
setBuildingBinary(true);
|
||||
setError("");
|
||||
setSuccessMsg("");
|
||||
@@ -130,6 +184,7 @@ export default function BuilderForm() {
|
||||
|
||||
const handleBuildBuiltin = async () => {
|
||||
if (!isEdit) { setError("Save the melody first before building."); return; }
|
||||
if (hasUnsavedChanges) { setError("You have unsaved changes. Save before regenerating."); return; }
|
||||
setBuildingBuiltin(true);
|
||||
setError("");
|
||||
setSuccessMsg("");
|
||||
@@ -201,12 +256,12 @@ export default function BuilderForm() {
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1" style={labelStyle}>Name *</label>
|
||||
<input type="text" value={name} onChange={(e) => setName(e.target.value)} placeholder="e.g. Doksologia_3k" className={inputClass} />
|
||||
<input type="text" value={name} onChange={(e) => handleNameChange(e.target.value)} placeholder="e.g. Doksologia_3k" className={inputClass} />
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm font-medium mb-1" style={labelStyle}>PID (Playback ID)</label>
|
||||
<input type="text" value={pid} onChange={(e) => setPid(e.target.value)} placeholder="e.g. builtin_doksologia_3k" className={inputClass} />
|
||||
<p className="text-xs mt-1" style={mutedStyle}>Used as the built-in firmware identifier</p>
|
||||
<label className="block text-sm font-medium mb-1" style={labelStyle}>PID (Playback ID) *</label>
|
||||
<input type="text" value={pid} onChange={(e) => handlePidChange(e.target.value)} placeholder="e.g. builtin_doksologia_3k" className={inputClass} />
|
||||
<p className="text-xs mt-1" style={mutedStyle}>Used as the built-in firmware identifier. Must be unique.</p>
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
@@ -215,7 +270,7 @@ export default function BuilderForm() {
|
||||
</div>
|
||||
<textarea
|
||||
value={steps}
|
||||
onChange={(e) => setSteps(e.target.value)}
|
||||
onChange={(e) => handleStepsChange(e.target.value)}
|
||||
rows={5}
|
||||
placeholder={"e.g. 1,2,2+1,1,2,3+1,0,3\n\n• Comma-separated values\n• Single bell: 1, 2, 3...\n• Multiple bells: 2+3+1\n• Silence: 0"}
|
||||
className={inputClass}
|
||||
@@ -234,6 +289,9 @@ export default function BuilderForm() {
|
||||
<h2 className="text-lg font-semibold mb-1" style={{ color: "var(--text-heading)" }}>Build</h2>
|
||||
<p className="text-sm mb-4" style={mutedStyle}>
|
||||
Save any changes above before building. Rebuilding will overwrite previous output.
|
||||
{hasUnsavedChanges && (
|
||||
<span style={{ color: "var(--warning, #f59e0b)", fontWeight: 600 }}> — You have unsaved changes.</span>
|
||||
)}
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
@@ -252,20 +310,21 @@ export default function BuilderForm() {
|
||||
</div>
|
||||
<button
|
||||
onClick={handleBuildBinary}
|
||||
disabled={buildingBinary}
|
||||
disabled={buildingBinary || hasUnsavedChanges}
|
||||
className="w-full px-3 py-2 text-sm rounded-md disabled:opacity-50 transition-colors font-medium"
|
||||
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
|
||||
title={hasUnsavedChanges ? "Save changes first" : undefined}
|
||||
>
|
||||
{buildingBinary ? "Building..." : binaryBuilt ? "Rebuild Binary" : "Build Binary"}
|
||||
</button>
|
||||
{binaryUrl && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => downloadBinary(binaryUrl, `${name}.bsm`).catch((e) => setError(e.message))}
|
||||
onClick={() => downloadBinary(binaryUrl, `${savedPid}.bsm`).catch((e) => setError(e.message))}
|
||||
className="block w-full text-center text-xs underline cursor-pointer"
|
||||
style={{ color: "var(--accent)", background: "none", border: "none", padding: 0 }}
|
||||
>
|
||||
Download {name}.bsm
|
||||
Download {savedPid}.bsm
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
@@ -285,9 +344,10 @@ export default function BuilderForm() {
|
||||
</div>
|
||||
<button
|
||||
onClick={handleBuildBuiltin}
|
||||
disabled={buildingBuiltin}
|
||||
disabled={buildingBuiltin || hasUnsavedChanges}
|
||||
className="w-full px-3 py-2 text-sm rounded-md disabled:opacity-50 transition-colors font-medium"
|
||||
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-primary)", border: "1px solid var(--border-primary)" }}
|
||||
title={hasUnsavedChanges ? "Save changes first" : undefined}
|
||||
>
|
||||
{buildingBuiltin ? "Generating..." : progmemCode ? "Regenerate Code" : "Build Builtin Code"}
|
||||
</button>
|
||||
|
||||
Reference in New Issue
Block a user