Further improvements

This commit is contained in:
2026-02-22 15:32:45 +02:00
parent c5ef4406f6
commit ef31852fd8
6 changed files with 179 additions and 28 deletions

View File

@@ -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 016.`;
if (n < 0 || n > 16) return `Step value "${trimmed}" is out of range (must be 016).`;
}
}
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>

View File

@@ -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 016.`;
if (n < 0 || n > 16) return `Step value "${trimmed}" is out of range (must be 016).`;
}
}
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>