Added SpeedCalc and MelodyBuilder. Evaluation Pending
This commit is contained in:
152
frontend/src/melodies/builder/BuildOnTheFlyModal.jsx
Normal file
152
frontend/src/melodies/builder/BuildOnTheFlyModal.jsx
Normal file
@@ -0,0 +1,152 @@
|
||||
import { useState } from "react";
|
||||
import api from "../../api/client";
|
||||
|
||||
const labelStyle = { color: "var(--text-secondary)" };
|
||||
const mutedStyle = { color: "var(--text-muted)" };
|
||||
const inputClass = "w-full px-3 py-2 rounded-md text-sm border";
|
||||
|
||||
function countSteps(stepsStr) {
|
||||
if (!stepsStr || !stepsStr.trim()) return 0;
|
||||
return stepsStr.trim().split(",").length;
|
||||
}
|
||||
|
||||
export default function BuildOnTheFlyModal({ open, melodyId, defaultName, defaultPid, onClose, onSuccess }) {
|
||||
const [name, setName] = useState(defaultName || "");
|
||||
const [pid, setPid] = useState(defaultPid || "");
|
||||
const [steps, setSteps] = useState("");
|
||||
const [building, setBuilding] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [statusMsg, setStatusMsg] = useState("");
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const handleBuildAndUpload = async () => {
|
||||
if (!name.trim()) { setError("Name is required."); return; }
|
||||
if (!steps.trim()) { setError("Steps are required."); return; }
|
||||
setBuilding(true);
|
||||
setError("");
|
||||
setStatusMsg("");
|
||||
|
||||
let builtId = null;
|
||||
try {
|
||||
// Step 1: Create the built melody record
|
||||
setStatusMsg("Creating melody record...");
|
||||
const created = await api.post("/builder/melodies", {
|
||||
name: name.trim(),
|
||||
pid: pid.trim(),
|
||||
steps: steps.trim(),
|
||||
});
|
||||
builtId = created.id;
|
||||
|
||||
// Step 2: Build the binary
|
||||
setStatusMsg("Building binary...");
|
||||
const built = await api.post(`/builder/melodies/${builtId}/build-binary`);
|
||||
|
||||
// Step 3: Fetch the .bsm file and upload to Firebase Storage
|
||||
setStatusMsg("Uploading to cloud storage...");
|
||||
const token = localStorage.getItem("access_token");
|
||||
const res = await fetch(`/api${built.binary_url}`, {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
});
|
||||
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" });
|
||||
await api.upload(`/melodies/${melodyId}/upload/binary`, file);
|
||||
|
||||
// Step 4: Assign to this melody
|
||||
setStatusMsg("Linking to melody...");
|
||||
await api.post(`/builder/melodies/${builtId}/assign?firestore_melody_id=${melodyId}`);
|
||||
|
||||
setStatusMsg("Done!");
|
||||
onSuccess();
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
setStatusMsg("");
|
||||
} finally {
|
||||
setBuilding(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 flex items-center justify-center z-50"
|
||||
style={{ backgroundColor: "rgba(0,0,0,0.6)" }}
|
||||
onClick={(e) => e.target === e.currentTarget && !building && onClose()}
|
||||
>
|
||||
<div
|
||||
className="w-full max-w-xl rounded-lg border shadow-xl"
|
||||
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
|
||||
>
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b" style={{ borderColor: "var(--border-primary)" }}>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold" style={{ color: "var(--text-heading)" }}>Build on the Fly</h2>
|
||||
<p className="text-xs mt-0.5" style={mutedStyle}>Enter steps, build binary, and upload — all in one step.</p>
|
||||
</div>
|
||||
{!building && (
|
||||
<button onClick={onClose} className="text-xl leading-none" style={mutedStyle}>×</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="p-6 space-y-4">
|
||||
{error && (
|
||||
<div className="text-sm rounded-md p-3 border" style={{ backgroundColor: "var(--danger-bg)", borderColor: "var(--danger)", color: "var(--danger-text)" }}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid 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} disabled={building} />
|
||||
</div>
|
||||
<div>
|
||||
<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>
|
||||
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<label className="block text-sm font-medium" style={labelStyle}>Steps *</label>
|
||||
<span className="text-xs" style={mutedStyle}>{countSteps(steps)} steps</span>
|
||||
</div>
|
||||
<textarea
|
||||
value={steps}
|
||||
onChange={(e) => setSteps(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: 2+3+1\n• Silence: 0"}
|
||||
className={inputClass}
|
||||
style={{ fontFamily: "monospace", resize: "vertical" }}
|
||||
disabled={building}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{statusMsg && !error && (
|
||||
<div className="text-sm rounded-md p-2 text-center" style={{ color: "var(--text-muted)", backgroundColor: "var(--bg-primary)" }}>
|
||||
{statusMsg}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-end gap-3 px-6 py-4 border-t" style={{ borderColor: "var(--border-primary)" }}>
|
||||
<button
|
||||
onClick={onClose}
|
||||
disabled={building}
|
||||
className="px-4 py-2 text-sm rounded-md disabled:opacity-50 transition-colors"
|
||||
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-primary)" }}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleBuildAndUpload}
|
||||
disabled={building}
|
||||
className="px-4 py-2 text-sm rounded-md disabled:opacity-50 transition-colors font-medium"
|
||||
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
|
||||
>
|
||||
{building ? "Building & Uploading..." : "Build & Upload"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
312
frontend/src/melodies/builder/BuilderForm.jsx
Normal file
312
frontend/src/melodies/builder/BuilderForm.jsx
Normal file
@@ -0,0 +1,312 @@
|
||||
import { useState, useEffect, useRef } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import api from "../../api/client";
|
||||
|
||||
const sectionStyle = { backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" };
|
||||
const labelStyle = { color: "var(--text-secondary)" };
|
||||
const mutedStyle = { color: "var(--text-muted)" };
|
||||
const inputClass = "w-full px-3 py-2 rounded-md text-sm border";
|
||||
|
||||
function countSteps(stepsStr) {
|
||||
if (!stepsStr || !stepsStr.trim()) return 0;
|
||||
return stepsStr.trim().split(",").length;
|
||||
}
|
||||
|
||||
export default function BuilderForm() {
|
||||
const { id } = useParams();
|
||||
const isEdit = Boolean(id);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const [name, setName] = useState("");
|
||||
const [pid, setPid] = useState("");
|
||||
const [steps, setSteps] = useState("");
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [buildingBinary, setBuildingBinary] = useState(false);
|
||||
const [buildingBuiltin, setBuildingBuiltin] = useState(false);
|
||||
const [error, setError] = useState("");
|
||||
const [successMsg, setSuccessMsg] = useState("");
|
||||
|
||||
const [binaryBuilt, setBinaryBuilt] = useState(false);
|
||||
const [binaryUrl, setBinaryUrl] = useState(null);
|
||||
const [progmemCode, setProgmemCode] = useState("");
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const codeRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (isEdit) loadMelody();
|
||||
}, [id]);
|
||||
|
||||
const loadMelody = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const data = await api.get(`/builder/melodies/${id}`);
|
||||
setName(data.name || "");
|
||||
setPid(data.pid || "");
|
||||
setSteps(data.steps || "");
|
||||
setBinaryBuilt(Boolean(data.binary_path));
|
||||
setBinaryUrl(data.binary_url || null);
|
||||
setProgmemCode(data.progmem_code || "");
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!name.trim()) { setError("Name is required."); return; }
|
||||
if (!steps.trim()) { setError("Steps are required."); return; }
|
||||
setSaving(true);
|
||||
setError("");
|
||||
setSuccessMsg("");
|
||||
try {
|
||||
const body = { name: name.trim(), pid: pid.trim(), steps: steps.trim() };
|
||||
if (isEdit) {
|
||||
await api.put(`/builder/melodies/${id}`, body);
|
||||
setSuccessMsg("Saved.");
|
||||
} else {
|
||||
const created = await api.post("/builder/melodies", body);
|
||||
navigate(`/melodies/builder/${created.id}`, { replace: true });
|
||||
}
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBuildBinary = async () => {
|
||||
if (!isEdit) { setError("Save the melody first before building."); return; }
|
||||
setBuildingBinary(true);
|
||||
setError("");
|
||||
setSuccessMsg("");
|
||||
try {
|
||||
const data = await api.post(`/builder/melodies/${id}/build-binary`);
|
||||
setBinaryBuilt(Boolean(data.binary_path));
|
||||
setBinaryUrl(data.binary_url || null);
|
||||
setSuccessMsg("Binary built successfully.");
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setBuildingBinary(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBuildBuiltin = async () => {
|
||||
if (!isEdit) { setError("Save the melody first before building."); return; }
|
||||
setBuildingBuiltin(true);
|
||||
setError("");
|
||||
setSuccessMsg("");
|
||||
try {
|
||||
const data = await api.post(`/builder/melodies/${id}/build-builtin`);
|
||||
setProgmemCode(data.progmem_code || "");
|
||||
setSuccessMsg("PROGMEM code generated.");
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setBuildingBuiltin(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCopy = () => {
|
||||
if (!progmemCode) return;
|
||||
navigator.clipboard.writeText(progmemCode).then(() => {
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
});
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return <div className="text-center py-8" style={mutedStyle}>Loading...</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<button onClick={() => navigate("/melodies/builder")} className="text-sm mb-2 inline-block" style={{ color: "var(--accent)" }}>
|
||||
← Back to Builder
|
||||
</button>
|
||||
<h1 className="text-2xl font-bold" style={{ color: "var(--text-heading)" }}>
|
||||
{isEdit ? "Edit Built Melody" : "New Built Melody"}
|
||||
</h1>
|
||||
</div>
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() => navigate("/melodies/builder")}
|
||||
className="px-4 py-2 text-sm rounded-md transition-colors"
|
||||
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-primary)" }}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="px-4 py-2 text-sm rounded-md disabled:opacity-50 transition-colors"
|
||||
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
|
||||
>
|
||||
{saving ? "Saving..." : isEdit ? "Save Changes" : "Create"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="text-sm rounded-md p-3 mb-4 border" style={{ backgroundColor: "var(--danger-bg)", borderColor: "var(--danger)", color: "var(--danger-text)" }}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{successMsg && (
|
||||
<div className="text-sm rounded-md p-3 mb-4 border" style={{ backgroundColor: "var(--success-bg)", borderColor: "var(--success)", color: "var(--success-text)" }}>
|
||||
{successMsg}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* --- Info Section --- */}
|
||||
<section className="rounded-lg p-6 border" style={sectionStyle}>
|
||||
<h2 className="text-lg font-semibold mb-4" style={{ color: "var(--text-heading)" }}>Melody Info</h2>
|
||||
<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} />
|
||||
</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>
|
||||
</div>
|
||||
<div className="md:col-span-2">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<label className="block text-sm font-medium" style={labelStyle}>Steps *</label>
|
||||
<span className="text-xs" style={mutedStyle}>{countSteps(steps)} steps</span>
|
||||
</div>
|
||||
<textarea
|
||||
value={steps}
|
||||
onChange={(e) => setSteps(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}
|
||||
style={{ fontFamily: "monospace", resize: "vertical" }}
|
||||
/>
|
||||
<p className="text-xs mt-1" style={mutedStyle}>
|
||||
Each value = one step. Bell numbers 1–16 (1 = highest). Combine with +. Silence = 0.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* --- Build Actions Section --- */}
|
||||
{isEdit && (
|
||||
<section className="rounded-lg p-6 border" style={sectionStyle}>
|
||||
<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.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* Binary */}
|
||||
<div className="rounded-lg p-4 border space-y-3" style={{ borderColor: "var(--border-primary)", backgroundColor: "var(--bg-primary)" }}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold" style={{ color: "var(--text-heading)" }}>Binary (.bsm)</h3>
|
||||
<p className="text-xs mt-0.5" style={mutedStyle}>For SD card playback on the controller</p>
|
||||
</div>
|
||||
{binaryBuilt && (
|
||||
<span className="px-2 py-0.5 text-xs rounded-full" style={{ backgroundColor: "var(--success-bg)", color: "var(--success-text)" }}>
|
||||
Built
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={handleBuildBinary}
|
||||
disabled={buildingBinary}
|
||||
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)" }}
|
||||
>
|
||||
{buildingBinary ? "Building..." : binaryBuilt ? "Rebuild Binary" : "Build Binary"}
|
||||
</button>
|
||||
{binaryUrl && (
|
||||
<a
|
||||
href={`/api${binaryUrl}`}
|
||||
className="block text-center text-xs underline"
|
||||
style={{ color: "var(--accent)" }}
|
||||
>
|
||||
Download {name}.bsm
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Builtin Code */}
|
||||
<div className="rounded-lg p-4 border space-y-3" style={{ borderColor: "var(--border-primary)", backgroundColor: "var(--bg-primary)" }}>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold" style={{ color: "var(--text-heading)" }}>PROGMEM Code</h3>
|
||||
<p className="text-xs mt-0.5" style={mutedStyle}>For built-in firmware (ESP32 PROGMEM)</p>
|
||||
</div>
|
||||
{progmemCode && (
|
||||
<span className="px-2 py-0.5 text-xs rounded-full" style={{ backgroundColor: "var(--success-bg)", color: "var(--success-text)" }}>
|
||||
Generated
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={handleBuildBuiltin}
|
||||
disabled={buildingBuiltin}
|
||||
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)" }}
|
||||
>
|
||||
{buildingBuiltin ? "Generating..." : progmemCode ? "Regenerate Code" : "Build Builtin Code"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* PROGMEM Code Block */}
|
||||
{progmemCode && (
|
||||
<div className="mt-4 rounded-lg border overflow-hidden" style={{ borderColor: "var(--border-primary)" }}>
|
||||
<div className="flex items-center justify-between px-4 py-2 border-b" style={{ backgroundColor: "var(--bg-card-hover)", borderColor: "var(--border-primary)" }}>
|
||||
<span className="text-xs font-medium font-mono" style={{ color: "var(--text-muted)" }}>
|
||||
PROGMEM C Code — copy into your firmware
|
||||
</span>
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="text-xs px-3 py-1 rounded transition-colors"
|
||||
style={{
|
||||
backgroundColor: copied ? "var(--success-bg)" : "var(--bg-primary)",
|
||||
color: copied ? "var(--success-text)" : "var(--text-secondary)",
|
||||
border: "1px solid var(--border-primary)",
|
||||
}}
|
||||
>
|
||||
{copied ? "Copied!" : "Copy"}
|
||||
</button>
|
||||
</div>
|
||||
<pre
|
||||
ref={codeRef}
|
||||
className="p-4 text-xs overflow-x-auto"
|
||||
style={{
|
||||
backgroundColor: "var(--bg-primary)",
|
||||
color: "var(--text-primary)",
|
||||
fontFamily: "monospace",
|
||||
whiteSpace: "pre",
|
||||
maxHeight: "400px",
|
||||
overflowY: "auto",
|
||||
}}
|
||||
>
|
||||
{progmemCode}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
{!isEdit && (
|
||||
<div className="rounded-lg p-4 border text-sm" style={{ borderColor: "var(--border-primary)", ...sectionStyle, color: "var(--text-muted)" }}>
|
||||
Build actions (Binary + PROGMEM Code) will be available after saving.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
181
frontend/src/melodies/builder/BuilderList.jsx
Normal file
181
frontend/src/melodies/builder/BuilderList.jsx
Normal file
@@ -0,0 +1,181 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import api from "../../api/client";
|
||||
import ConfirmDialog from "../../components/ConfirmDialog";
|
||||
|
||||
const sectionStyle = { backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" };
|
||||
|
||||
export default function BuilderList() {
|
||||
const navigate = useNavigate();
|
||||
const [melodies, setMelodies] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState("");
|
||||
const [deleteTarget, setDeleteTarget] = useState(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadMelodies();
|
||||
}, []);
|
||||
|
||||
const loadMelodies = async () => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const data = await api.get("/builder/melodies");
|
||||
setMelodies(data.melodies || []);
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
if (!deleteTarget) return;
|
||||
try {
|
||||
await api.delete(`/builder/melodies/${deleteTarget.id}`);
|
||||
setDeleteTarget(null);
|
||||
loadMelodies();
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
setDeleteTarget(null);
|
||||
}
|
||||
};
|
||||
|
||||
const countSteps = (stepsStr) => {
|
||||
if (!stepsStr) return 0;
|
||||
return stepsStr.split(",").length;
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold" style={{ color: "var(--text-heading)" }}>
|
||||
Melody Builder
|
||||
</h1>
|
||||
<p className="text-sm mt-1" style={{ color: "var(--text-muted)" }}>
|
||||
Build binary (.bsm) files and firmware PROGMEM code from melody step notation.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => navigate("/melodies/builder/new")}
|
||||
className="px-4 py-2 text-sm rounded-md transition-colors"
|
||||
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
|
||||
>
|
||||
+ Add Melody
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="text-sm rounded-md p-3 mb-4 border" style={{ backgroundColor: "var(--danger-bg)", borderColor: "var(--danger)", color: "var(--danger-text)" }}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="text-center py-8" style={{ color: "var(--text-muted)" }}>Loading...</div>
|
||||
) : melodies.length === 0 ? (
|
||||
<div className="rounded-lg border p-12 text-center" style={sectionStyle}>
|
||||
<p className="text-sm mb-4" style={{ color: "var(--text-muted)" }}>No built melodies yet.</p>
|
||||
<button
|
||||
onClick={() => navigate("/melodies/builder/new")}
|
||||
className="px-4 py-2 text-sm rounded-md transition-colors"
|
||||
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
|
||||
>
|
||||
Add Your First Melody
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-lg border overflow-hidden" style={sectionStyle}>
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b text-left" style={{ borderColor: "var(--border-primary)", backgroundColor: "var(--bg-card-hover)" }}>
|
||||
<th className="px-4 py-3 font-medium" style={{ color: "var(--text-secondary)" }}>Name</th>
|
||||
<th className="px-4 py-3 font-medium" style={{ color: "var(--text-secondary)" }}>PID</th>
|
||||
<th className="px-4 py-3 font-medium text-center" style={{ color: "var(--text-secondary)" }}>Steps</th>
|
||||
<th className="px-4 py-3 font-medium text-center" style={{ color: "var(--text-secondary)" }}>Binary</th>
|
||||
<th className="px-4 py-3 font-medium text-center" style={{ color: "var(--text-secondary)" }}>Builtin Code</th>
|
||||
<th className="px-4 py-3 font-medium text-center" style={{ color: "var(--text-secondary)" }}>Assigned</th>
|
||||
<th className="px-4 py-3 font-medium" style={{ color: "var(--text-secondary)" }}>Updated</th>
|
||||
<th className="px-4 py-3" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{melodies.map((m, idx) => (
|
||||
<tr
|
||||
key={m.id}
|
||||
onClick={() => navigate(`/melodies/builder/${m.id}`)}
|
||||
className="border-b cursor-pointer transition-colors hover:bg-[var(--bg-card-hover)]"
|
||||
style={{ borderColor: "var(--border-primary)" }}
|
||||
>
|
||||
<td className="px-4 py-3 font-medium" style={{ color: "var(--text-heading)" }}>
|
||||
{m.name}
|
||||
</td>
|
||||
<td className="px-4 py-3" style={{ color: "var(--text-secondary)" }}>
|
||||
<span className="font-mono text-xs px-2 py-0.5 rounded" style={{ backgroundColor: "var(--bg-card-hover)" }}>
|
||||
{m.pid || "-"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center" style={{ color: "var(--text-secondary)" }}>
|
||||
{countSteps(m.steps)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
{m.binary_path ? (
|
||||
<span className="px-2 py-0.5 text-xs rounded-full" style={{ backgroundColor: "var(--success-bg)", color: "var(--success-text)" }}>
|
||||
Built
|
||||
</span>
|
||||
) : (
|
||||
<span className="px-2 py-0.5 text-xs rounded-full" style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-muted)" }}>
|
||||
—
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center">
|
||||
{m.progmem_code ? (
|
||||
<span className="px-2 py-0.5 text-xs rounded-full" style={{ backgroundColor: "var(--success-bg)", color: "var(--success-text)" }}>
|
||||
Generated
|
||||
</span>
|
||||
) : (
|
||||
<span className="px-2 py-0.5 text-xs rounded-full" style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-muted)" }}>
|
||||
—
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-center" style={{ color: "var(--text-secondary)" }}>
|
||||
{m.assigned_melody_ids?.length > 0 ? (
|
||||
<span className="px-2 py-0.5 text-xs rounded-full" style={{ backgroundColor: "rgba(59,130,246,0.15)", color: "#60a5fa" }}>
|
||||
{m.assigned_melody_ids.length} melody{m.assigned_melody_ids.length !== 1 ? "s" : ""}
|
||||
</span>
|
||||
) : (
|
||||
<span style={{ color: "var(--text-muted)" }}>—</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-xs" style={{ color: "var(--text-muted)" }}>
|
||||
{new Date(m.updated_at).toLocaleDateString()}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); setDeleteTarget(m); }}
|
||||
className="px-2 py-1 text-xs rounded transition-colors"
|
||||
style={{ color: "var(--danger)", backgroundColor: "var(--danger-bg)" }}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ConfirmDialog
|
||||
open={Boolean(deleteTarget)}
|
||||
title="Delete Built Melody"
|
||||
message={`Are you sure you want to delete "${deleteTarget?.name}"? This will also delete the .bsm binary file if it exists. This action cannot be undone.`}
|
||||
onConfirm={handleDelete}
|
||||
onCancel={() => setDeleteTarget(null)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
121
frontend/src/melodies/builder/SelectBuiltMelodyModal.jsx
Normal file
121
frontend/src/melodies/builder/SelectBuiltMelodyModal.jsx
Normal file
@@ -0,0 +1,121 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import api from "../../api/client";
|
||||
|
||||
export default function SelectBuiltMelodyModal({ open, melodyId, onClose, onSuccess }) {
|
||||
const [melodies, setMelodies] = useState([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [assigning, setAssigning] = useState(null); // id of the one being assigned
|
||||
const [error, setError] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (open) loadMelodies();
|
||||
}, [open]);
|
||||
|
||||
const loadMelodies = async () => {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
try {
|
||||
const data = await api.get("/builder/melodies");
|
||||
// Only show those with a built binary
|
||||
setMelodies((data.melodies || []).filter((m) => m.binary_path));
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelect = async (builtMelody) => {
|
||||
setAssigning(builtMelody.id);
|
||||
setError("");
|
||||
try {
|
||||
// 1. Fetch the .bsm file from the builder endpoint
|
||||
const token = localStorage.getItem("access_token");
|
||||
const res = await fetch(`/api${builtMelody.binary_url}`, {
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
});
|
||||
if (!res.ok) throw new Error(`Failed to download binary: ${res.statusText}`);
|
||||
const blob = await res.blob();
|
||||
const file = new File([blob], `${builtMelody.name}.bsm`, { type: "application/octet-stream" });
|
||||
|
||||
// 2. Upload to Firebase Storage via the existing melody upload endpoint
|
||||
await api.upload(`/melodies/${melodyId}/upload/binary`, file);
|
||||
|
||||
// 3. Mark this built melody as assigned to this Firestore melody
|
||||
await api.post(`/builder/melodies/${builtMelody.id}/assign?firestore_melody_id=${melodyId}`);
|
||||
|
||||
onSuccess();
|
||||
} catch (err) {
|
||||
setError(err.message);
|
||||
} finally {
|
||||
setAssigning(null);
|
||||
}
|
||||
};
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 flex items-center justify-center z-50"
|
||||
style={{ backgroundColor: "rgba(0,0,0,0.6)" }}
|
||||
onClick={(e) => e.target === e.currentTarget && onClose()}
|
||||
>
|
||||
<div
|
||||
className="w-full max-w-2xl rounded-lg border shadow-xl"
|
||||
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
|
||||
>
|
||||
<div className="flex items-center justify-between px-6 py-4 border-b" style={{ borderColor: "var(--border-primary)" }}>
|
||||
<h2 className="text-lg font-semibold" style={{ color: "var(--text-heading)" }}>Select Built Melody</h2>
|
||||
<button onClick={onClose} className="text-xl leading-none" style={{ color: "var(--text-muted)" }}>×</button>
|
||||
</div>
|
||||
|
||||
<div className="p-6">
|
||||
{error && (
|
||||
<div className="text-sm rounded-md p-3 mb-4 border" style={{ backgroundColor: "var(--danger-bg)", borderColor: "var(--danger)", color: "var(--danger-text)" }}>
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="text-center py-8" style={{ color: "var(--text-muted)" }}>Loading...</div>
|
||||
) : melodies.length === 0 ? (
|
||||
<div className="text-center py-8 text-sm" style={{ color: "var(--text-muted)" }}>
|
||||
No built binaries found. Go to <strong>Melody Builder</strong> to create one first.
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2 max-h-96 overflow-y-auto">
|
||||
{melodies.map((m) => (
|
||||
<div
|
||||
key={m.id}
|
||||
className="flex items-center justify-between rounded-lg px-4 py-3 border transition-colors"
|
||||
style={{ borderColor: "var(--border-primary)", backgroundColor: "var(--bg-primary)" }}
|
||||
>
|
||||
<div>
|
||||
<p className="text-sm font-medium" style={{ color: "var(--text-heading)" }}>{m.name}</p>
|
||||
<p className="text-xs mt-0.5 font-mono" style={{ color: "var(--text-muted)" }}>
|
||||
PID: {m.pid || "—"} · {m.steps?.split(",").length || 0} steps
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => handleSelect(m)}
|
||||
disabled={Boolean(assigning)}
|
||||
className="px-3 py-1.5 text-xs rounded-md disabled:opacity-50 transition-colors font-medium"
|
||||
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
|
||||
>
|
||||
{assigning === m.id ? "Uploading..." : "Select & Upload"}
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end px-6 py-4 border-t" style={{ borderColor: "var(--border-primary)" }}>
|
||||
<button onClick={onClose} className="px-4 py-2 text-sm rounded-md transition-colors" style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-primary)" }}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user