313 lines
13 KiB
JavaScript
313 lines
13 KiB
JavaScript
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>
|
||
);
|
||
}
|