Further improvements
This commit is contained in:
@@ -95,7 +95,7 @@ async def download_binary(
|
|||||||
raise HTTPException(status_code=404, detail="Binary not built yet for this melody")
|
raise HTTPException(status_code=404, detail="Binary not built yet for this melody")
|
||||||
|
|
||||||
melody = await service.get_built_melody(melody_id)
|
melody = await service.get_built_melody(melody_id)
|
||||||
filename = f"{melody.name}.bsm"
|
filename = f"{melody.pid or melody.name}.bsm"
|
||||||
|
|
||||||
return FileResponse(
|
return FileResponse(
|
||||||
path=str(path),
|
path=str(path),
|
||||||
|
|||||||
@@ -130,7 +130,20 @@ async def get_built_melody(melody_id: str) -> BuiltMelodyInDB:
|
|||||||
return _row_to_built_melody(row)
|
return _row_to_built_melody(row)
|
||||||
|
|
||||||
|
|
||||||
|
async def _check_unique(name: str, pid: str, exclude_id: Optional[str] = None) -> None:
|
||||||
|
"""Raise 409 if name or PID is already taken by another archetype."""
|
||||||
|
rows = await db.list_built_melodies()
|
||||||
|
for row in rows:
|
||||||
|
if exclude_id and row["id"] == exclude_id:
|
||||||
|
continue
|
||||||
|
if row["name"].lower() == name.lower():
|
||||||
|
raise HTTPException(status_code=409, detail=f"An archetype with the name '{name}' already exists.")
|
||||||
|
if pid and row.get("pid") and row["pid"].lower() == pid.lower():
|
||||||
|
raise HTTPException(status_code=409, detail=f"An archetype with the PID '{pid}' already exists.")
|
||||||
|
|
||||||
|
|
||||||
async def create_built_melody(data: BuiltMelodyCreate) -> BuiltMelodyInDB:
|
async def create_built_melody(data: BuiltMelodyCreate) -> BuiltMelodyInDB:
|
||||||
|
await _check_unique(data.name, data.pid or "")
|
||||||
melody_id = str(uuid.uuid4())
|
melody_id = str(uuid.uuid4())
|
||||||
await db.insert_built_melody(
|
await db.insert_built_melody(
|
||||||
melody_id=melody_id,
|
melody_id=melody_id,
|
||||||
@@ -150,6 +163,8 @@ async def update_built_melody(melody_id: str, data: BuiltMelodyUpdate) -> BuiltM
|
|||||||
new_pid = data.pid if data.pid is not None else row["pid"]
|
new_pid = data.pid if data.pid is not None else row["pid"]
|
||||||
new_steps = data.steps if data.steps is not None else row["steps"]
|
new_steps = data.steps if data.steps is not None else row["steps"]
|
||||||
|
|
||||||
|
await _check_unique(new_name, new_pid or "", exclude_id=melody_id)
|
||||||
|
|
||||||
await db.update_built_melody(melody_id, name=new_name, pid=new_pid, steps=new_steps)
|
await db.update_built_melody(melody_id, name=new_name, pid=new_pid, steps=new_steps)
|
||||||
return await get_built_melody(melody_id)
|
return await get_built_melody(melody_id)
|
||||||
|
|
||||||
|
|||||||
@@ -93,13 +93,17 @@ select {
|
|||||||
background-color: var(--bg-input) !important;
|
background-color: var(--bg-input) !important;
|
||||||
border-color: var(--border-input) !important;
|
border-color: var(--border-input) !important;
|
||||||
color: var(--text-primary) !important;
|
color: var(--text-primary) !important;
|
||||||
|
-webkit-appearance: none !important;
|
||||||
|
appearance: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Only select elements get the dropdown arrow */
|
||||||
|
select {
|
||||||
padding-right: 2rem !important;
|
padding-right: 2rem !important;
|
||||||
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%239ca3af' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E") !important;
|
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%239ca3af' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M6 9l6 6 6-6'/%3E%3C/svg%3E") !important;
|
||||||
background-repeat: no-repeat !important;
|
background-repeat: no-repeat !important;
|
||||||
background-position: right 0.6rem center !important;
|
background-position: right 0.6rem center !important;
|
||||||
background-size: 12px !important;
|
background-size: 12px !important;
|
||||||
-webkit-appearance: none !important;
|
|
||||||
appearance: none !important;
|
|
||||||
}
|
}
|
||||||
input::placeholder,
|
input::placeholder,
|
||||||
textarea::placeholder {
|
textarea::placeholder {
|
||||||
|
|||||||
@@ -383,17 +383,40 @@ export default function MelodyDetail() {
|
|||||||
<h2 className="text-lg font-semibold mb-4" style={{ color: "var(--text-heading)" }}>Files</h2>
|
<h2 className="text-lg font-semibold mb-4" style={{ color: "var(--text-heading)" }}>Files</h2>
|
||||||
<dl className="space-y-4">
|
<dl className="space-y-4">
|
||||||
<Field label="Binary File">
|
<Field label="Binary File">
|
||||||
{files.binary_url ? (
|
{files.binary_url ? (() => {
|
||||||
|
const binaryPid = builtMelody?.pid || melody.pid || "binary";
|
||||||
|
const binaryFilename = `${binaryPid}.bsm`;
|
||||||
|
const handleDownload = async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
try {
|
||||||
|
const token = localStorage.getItem("access_token");
|
||||||
|
const res = await fetch(files.binary_url, {
|
||||||
|
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error(`Download failed: ${res.statusText}`);
|
||||||
|
const blob = await res.blob();
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement("a");
|
||||||
|
a.href = url;
|
||||||
|
a.download = binaryFilename;
|
||||||
|
a.click();
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
} catch (err) {
|
||||||
|
// surface error in page error state if possible
|
||||||
|
console.error(err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
return (
|
||||||
<a
|
<a
|
||||||
href={files.binary_url}
|
href={files.binary_url}
|
||||||
target="_blank"
|
onClick={handleDownload}
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="underline"
|
className="underline"
|
||||||
style={{ color: "var(--accent)" }}
|
style={{ color: "var(--accent)" }}
|
||||||
>
|
>
|
||||||
Download binary
|
{binaryFilename}
|
||||||
</a>
|
</a>
|
||||||
) : (
|
);
|
||||||
|
})() : (
|
||||||
<span style={{ color: "var(--text-muted)" }}>Not uploaded</span>
|
<span style={{ color: "var(--text-muted)" }}>Not uploaded</span>
|
||||||
)}
|
)}
|
||||||
</Field>
|
</Field>
|
||||||
|
|||||||
@@ -23,6 +23,29 @@ function computeStepsAndNotes(stepsStr) {
|
|||||||
return { steps: tokens.length, totalNotes: bellSet.size || 1 };
|
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 }) {
|
export default function BuildOnTheFlyModal({ open, melodyId, currentMelody, defaultName, defaultPid, onClose, onSuccess }) {
|
||||||
const [name, setName] = useState(defaultName || "");
|
const [name, setName] = useState(defaultName || "");
|
||||||
const [pid, setPid] = useState(defaultPid || "");
|
const [pid, setPid] = useState(defaultPid || "");
|
||||||
@@ -34,14 +57,39 @@ export default function BuildOnTheFlyModal({ open, melodyId, currentMelody, defa
|
|||||||
if (!open) return null;
|
if (!open) return null;
|
||||||
|
|
||||||
const handleBuildAndUpload = async () => {
|
const handleBuildAndUpload = async () => {
|
||||||
|
// Mandatory field checks
|
||||||
if (!name.trim()) { setError("Name is required."); return; }
|
if (!name.trim()) { setError("Name is required."); return; }
|
||||||
|
if (!pid.trim()) { setError("PID is required."); return; }
|
||||||
if (!steps.trim()) { setError("Steps are required."); return; }
|
if (!steps.trim()) { setError("Steps are required."); return; }
|
||||||
|
|
||||||
|
// Steps format validation
|
||||||
|
const stepsError = validateSteps(steps);
|
||||||
|
if (stepsError) { setError(stepsError); return; }
|
||||||
|
|
||||||
setBuilding(true);
|
setBuilding(true);
|
||||||
setError("");
|
setError("");
|
||||||
setStatusMsg("");
|
setStatusMsg("");
|
||||||
|
|
||||||
let builtId = null;
|
|
||||||
try {
|
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
|
// Step 1: Create the built melody record
|
||||||
setStatusMsg("Creating melody record...");
|
setStatusMsg("Creating melody record...");
|
||||||
const created = await api.post("/builder/melodies", {
|
const created = await api.post("/builder/melodies", {
|
||||||
@@ -49,7 +97,7 @@ export default function BuildOnTheFlyModal({ open, melodyId, currentMelody, defa
|
|||||||
pid: pid.trim(),
|
pid: pid.trim(),
|
||||||
steps: steps.trim(),
|
steps: steps.trim(),
|
||||||
});
|
});
|
||||||
builtId = created.id;
|
const builtId = created.id;
|
||||||
|
|
||||||
// Step 2: Build the binary
|
// Step 2: Build the binary
|
||||||
setStatusMsg("Building 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}`);
|
if (!res.ok) throw new Error(`Failed to fetch binary: ${res.statusText}`);
|
||||||
const blob = await res.blob();
|
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);
|
await api.upload(`/melodies/${melodyId}/upload/binary`, file);
|
||||||
|
|
||||||
// Step 4: Assign to this melody
|
// 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} />
|
<input type="text" value={name} onChange={(e) => setName(e.target.value)} placeholder="e.g. Doksologia_3k" className={inputClass} disabled={building} />
|
||||||
</div>
|
</div>
|
||||||
<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} />
|
<input type="text" value={pid} onChange={(e) => setPid(e.target.value)} placeholder="e.g. builtin_doksologia_3k" className={inputClass} disabled={building} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -30,6 +30,29 @@ function countSteps(stepsStr) {
|
|||||||
return stepsStr.trim().split(",").length;
|
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) {
|
async function downloadBinary(binaryUrl, filename) {
|
||||||
const token = localStorage.getItem("access_token");
|
const token = localStorage.getItem("access_token");
|
||||||
const res = await fetch(`/api${binaryUrl}`, {
|
const res = await fetch(`/api${binaryUrl}`, {
|
||||||
@@ -50,10 +73,14 @@ export default function BuilderForm() {
|
|||||||
const isEdit = Boolean(id);
|
const isEdit = Boolean(id);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
// Form state (what the user is editing)
|
||||||
const [name, setName] = useState("");
|
const [name, setName] = useState("");
|
||||||
const [pid, setPid] = useState("");
|
const [pid, setPid] = useState("");
|
||||||
const [steps, setSteps] = 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 [loading, setLoading] = useState(false);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
const [buildingBinary, setBuildingBinary] = useState(false);
|
const [buildingBinary, setBuildingBinary] = useState(false);
|
||||||
@@ -66,6 +93,9 @@ export default function BuilderForm() {
|
|||||||
const [progmemCode, setProgmemCode] = useState("");
|
const [progmemCode, setProgmemCode] = useState("");
|
||||||
const [copied, setCopied] = useState(false);
|
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);
|
const codeRef = useRef(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -79,9 +109,11 @@ export default function BuilderForm() {
|
|||||||
setName(data.name || "");
|
setName(data.name || "");
|
||||||
setPid(data.pid || "");
|
setPid(data.pid || "");
|
||||||
setSteps(data.steps || "");
|
setSteps(data.steps || "");
|
||||||
|
setSavedPid(data.pid || "");
|
||||||
setBinaryBuilt(Boolean(data.binary_path));
|
setBinaryBuilt(Boolean(data.binary_path));
|
||||||
setBinaryUrl(data.binary_url || null);
|
setBinaryUrl(data.binary_url || null);
|
||||||
setProgmemCode(data.progmem_code || "");
|
setProgmemCode(data.progmem_code || "");
|
||||||
|
setHasUnsavedChanges(false);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
setError(err.message);
|
setError(err.message);
|
||||||
} finally {
|
} 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 () => {
|
const handleSave = async () => {
|
||||||
if (!name.trim()) { setError("Name is required."); return; }
|
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);
|
setSaving(true);
|
||||||
setError("");
|
setError("");
|
||||||
setSuccessMsg("");
|
setSuccessMsg("");
|
||||||
try {
|
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() };
|
const body = { name: name.trim(), pid: pid.trim(), steps: steps.trim() };
|
||||||
if (isEdit) {
|
if (isEdit) {
|
||||||
await api.put(`/builder/melodies/${id}`, body);
|
await api.put(`/builder/melodies/${id}`, body);
|
||||||
|
setSavedPid(pid.trim());
|
||||||
|
setHasUnsavedChanges(false);
|
||||||
setSuccessMsg("Saved.");
|
setSuccessMsg("Saved.");
|
||||||
} else {
|
} else {
|
||||||
const created = await api.post("/builder/melodies", body);
|
const created = await api.post("/builder/melodies", body);
|
||||||
@@ -113,6 +166,7 @@ export default function BuilderForm() {
|
|||||||
|
|
||||||
const handleBuildBinary = async () => {
|
const handleBuildBinary = async () => {
|
||||||
if (!isEdit) { setError("Save the melody first before building."); return; }
|
if (!isEdit) { setError("Save the melody first before building."); return; }
|
||||||
|
if (hasUnsavedChanges) { setError("You have unsaved changes. Save before rebuilding."); return; }
|
||||||
setBuildingBinary(true);
|
setBuildingBinary(true);
|
||||||
setError("");
|
setError("");
|
||||||
setSuccessMsg("");
|
setSuccessMsg("");
|
||||||
@@ -130,6 +184,7 @@ export default function BuilderForm() {
|
|||||||
|
|
||||||
const handleBuildBuiltin = async () => {
|
const handleBuildBuiltin = async () => {
|
||||||
if (!isEdit) { setError("Save the melody first before building."); return; }
|
if (!isEdit) { setError("Save the melody first before building."); return; }
|
||||||
|
if (hasUnsavedChanges) { setError("You have unsaved changes. Save before regenerating."); return; }
|
||||||
setBuildingBuiltin(true);
|
setBuildingBuiltin(true);
|
||||||
setError("");
|
setError("");
|
||||||
setSuccessMsg("");
|
setSuccessMsg("");
|
||||||
@@ -201,12 +256,12 @@ export default function BuilderForm() {
|
|||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1" style={labelStyle}>Name *</label>
|
<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>
|
||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1" style={labelStyle}>PID (Playback ID)</label>
|
<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} />
|
<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</p>
|
<p className="text-xs mt-1" style={mutedStyle}>Used as the built-in firmware identifier. Must be unique.</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="md:col-span-2">
|
<div className="md:col-span-2">
|
||||||
<div className="flex items-center justify-between mb-1">
|
<div className="flex items-center justify-between mb-1">
|
||||||
@@ -215,7 +270,7 @@ export default function BuilderForm() {
|
|||||||
</div>
|
</div>
|
||||||
<textarea
|
<textarea
|
||||||
value={steps}
|
value={steps}
|
||||||
onChange={(e) => setSteps(e.target.value)}
|
onChange={(e) => handleStepsChange(e.target.value)}
|
||||||
rows={5}
|
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"}
|
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}
|
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>
|
<h2 className="text-lg font-semibold mb-1" style={{ color: "var(--text-heading)" }}>Build</h2>
|
||||||
<p className="text-sm mb-4" style={mutedStyle}>
|
<p className="text-sm mb-4" style={mutedStyle}>
|
||||||
Save any changes above before building. Rebuilding will overwrite previous output.
|
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>
|
</p>
|
||||||
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
@@ -252,20 +310,21 @@ export default function BuilderForm() {
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={handleBuildBinary}
|
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"
|
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)" }}
|
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
|
||||||
|
title={hasUnsavedChanges ? "Save changes first" : undefined}
|
||||||
>
|
>
|
||||||
{buildingBinary ? "Building..." : binaryBuilt ? "Rebuild Binary" : "Build Binary"}
|
{buildingBinary ? "Building..." : binaryBuilt ? "Rebuild Binary" : "Build Binary"}
|
||||||
</button>
|
</button>
|
||||||
{binaryUrl && (
|
{binaryUrl && (
|
||||||
<button
|
<button
|
||||||
type="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"
|
className="block w-full text-center text-xs underline cursor-pointer"
|
||||||
style={{ color: "var(--accent)", background: "none", border: "none", padding: 0 }}
|
style={{ color: "var(--accent)", background: "none", border: "none", padding: 0 }}
|
||||||
>
|
>
|
||||||
Download {name}.bsm
|
Download {savedPid}.bsm
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -285,9 +344,10 @@ export default function BuilderForm() {
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
onClick={handleBuildBuiltin}
|
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"
|
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)" }}
|
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"}
|
{buildingBuiltin ? "Generating..." : progmemCode ? "Regenerate Code" : "Build Builtin Code"}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
Reference in New Issue
Block a user