Various more changes to the Archetype Builder

This commit is contained in:
2026-02-22 14:44:27 +02:00
parent bdddc304ee
commit 99c7004ac2
7 changed files with 550 additions and 22 deletions

View File

@@ -58,7 +58,7 @@ export default function BuildOnTheFlyModal({ open, melodyId, defaultName, defaul
await api.post(`/builder/melodies/${builtId}/assign?firestore_melody_id=${melodyId}`);
setStatusMsg("Done!");
onSuccess();
onSuccess({ name: name.trim(), pid: pid.trim() });
} catch (err) {
setError(err.message);
setStatusMsg("");

View File

@@ -12,6 +12,21 @@ function countSteps(stepsStr) {
return stepsStr.trim().split(",").length;
}
async function downloadBinary(binaryUrl, filename) {
const token = localStorage.getItem("access_token");
const res = await fetch(`/api${binaryUrl}`, {
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 = filename;
a.click();
URL.revokeObjectURL(url);
}
export default function BuilderForm() {
const { id } = useParams();
const isEdit = Boolean(id);
@@ -229,13 +244,14 @@ export default function BuilderForm() {
{buildingBinary ? "Building..." : binaryBuilt ? "Rebuild Binary" : "Build Binary"}
</button>
{binaryUrl && (
<a
href={`/api${binaryUrl}`}
className="block text-center text-xs underline"
style={{ color: "var(--accent)" }}
<button
type="button"
onClick={() => downloadBinary(binaryUrl, `${name}.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
</a>
</button>
)}
</div>

View File

@@ -3,6 +3,55 @@ import { useNavigate } from "react-router-dom";
import api from "../../api/client";
import ConfirmDialog from "../../components/ConfirmDialog";
function CodeSnippetModal({ melody, onClose }) {
const [copied, setCopied] = useState(false);
if (!melody) return null;
return (
<div
className="fixed inset-0 flex items-center justify-center z-50"
style={{ backgroundColor: "rgba(0,0,0,0.7)" }}
onClick={(e) => e.target === e.currentTarget && onClose()}
>
<div
className="w-full rounded-lg border shadow-xl"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)", maxWidth: "720px", maxHeight: "80vh", display: "flex", flexDirection: "column" }}
>
<div className="flex items-center justify-between px-6 py-4 border-b flex-shrink-0" style={{ borderColor: "var(--border-primary)" }}>
<div>
<h2 className="text-base font-semibold" style={{ color: "var(--text-heading)" }}>Firmware Code Snippet</h2>
<p className="text-xs mt-0.5" style={{ color: "var(--text-muted)" }}>{melody.name} · PID: <span className="font-mono">{melody.pid || "—"}</span></p>
</div>
<div className="flex items-center gap-3">
<button
onClick={() => {
navigator.clipboard.writeText(melody.progmem_code).then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
});
}}
className="px-3 py-1.5 text-xs rounded transition-colors"
style={{
backgroundColor: copied ? "var(--success-bg)" : "var(--bg-card-hover)",
color: copied ? "var(--success-text)" : "var(--text-secondary)",
border: "1px solid var(--border-primary)",
}}
>
{copied ? "Copied!" : "Copy"}
</button>
<button onClick={onClose} className="text-xl leading-none" style={{ color: "var(--text-muted)" }}>&times;</button>
</div>
</div>
<pre
className="flex-1 overflow-auto p-5 text-xs"
style={{ fontFamily: "monospace", color: "var(--text-primary)", backgroundColor: "var(--bg-primary)", whiteSpace: "pre", margin: 0 }}
>
{melody.progmem_code}
</pre>
</div>
</div>
);
}
const sectionStyle = { backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" };
export default function BuilderList() {
@@ -11,6 +60,8 @@ export default function BuilderList() {
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [deleteTarget, setDeleteTarget] = useState(null);
const [deleteWarningConfirmed, setDeleteWarningConfirmed] = useState(false);
const [codeSnippetMelody, setCodeSnippetMelody] = useState(null);
useEffect(() => {
loadMelodies();
@@ -46,6 +97,22 @@ export default function BuilderList() {
return stepsStr.split(",").length;
};
const handleDownloadBinary = async (e, m) => {
e.stopPropagation();
const token = localStorage.getItem("access_token");
const res = await fetch(`/api/builder/melodies/${m.id}/download`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
});
if (!res.ok) return;
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${m.name || m.id}.bsm`;
a.click();
URL.revokeObjectURL(url);
};
return (
<div>
<div className="flex items-center justify-between mb-6">
@@ -54,7 +121,7 @@ export default function BuilderList() {
Archetype 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.
Add Archetypes, and Build binary (.bsm) files and firmware PROGMEM code.
</p>
</div>
<button
@@ -121,7 +188,12 @@ export default function BuilderList() {
</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)" }}>
<span
title="Click to Download"
onClick={(e) => handleDownloadBinary(e, m)}
className="px-2 py-0.5 text-xs rounded-full cursor-pointer transition-opacity hover:opacity-70"
style={{ backgroundColor: "var(--success-bg)", color: "var(--success-text)" }}
>
Built
</span>
) : (
@@ -132,7 +204,12 @@ export default function BuilderList() {
</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)" }}>
<span
title="Click to View Code Snippet"
onClick={(e) => { e.stopPropagation(); setCodeSnippetMelody(m); }}
className="px-2 py-0.5 text-xs rounded-full cursor-pointer transition-opacity hover:opacity-70"
style={{ backgroundColor: "var(--success-bg)", color: "var(--success-text)" }}
>
Generated
</span>
) : (
@@ -169,12 +246,48 @@ export default function BuilderList() {
</div>
)}
{/* Two-stage delete: warn if assigned, then confirm */}
{deleteTarget && !deleteWarningConfirmed && (deleteTarget.assigned_melody_ids?.length || 0) > 0 && (
<div className="fixed inset-0 flex items-center justify-center z-50" style={{ backgroundColor: "rgba(0,0,0,0.6)" }}>
<div className="w-full max-w-md rounded-lg border shadow-xl p-6 space-y-4" style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}>
<h3 className="text-base font-semibold" style={{ color: "var(--text-heading)" }}>Archetype In Use</h3>
<div className="rounded-md p-3 border text-sm" style={{ backgroundColor: "var(--warning-bg, rgba(245,158,11,0.1))", borderColor: "var(--warning, #f59e0b)", color: "var(--warning-text, #f59e0b)" }}>
<strong>"{deleteTarget.name}"</strong> is currently assigned to{" "}
<strong>{deleteTarget.assigned_melody_ids.length} melody{deleteTarget.assigned_melody_ids.length !== 1 ? "ies" : "y"}</strong>.
Deleting it will remove the archetype and its binary file, but existing melodies will keep their uploaded binary.
</div>
<p className="text-sm" style={{ color: "var(--text-muted)" }}>Do you still want to delete this archetype?</p>
<div className="flex justify-end gap-3">
<button
onClick={() => { setDeleteTarget(null); setDeleteWarningConfirmed(false); }}
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={() => setDeleteWarningConfirmed(true)}
className="px-4 py-2 text-sm rounded-md transition-colors font-medium"
style={{ backgroundColor: "var(--danger)", color: "#fff" }}
>
Yes, Delete Anyway
</button>
</div>
</div>
</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.`}
open={Boolean(deleteTarget) && (deleteWarningConfirmed || (deleteTarget?.assigned_melody_ids?.length || 0) === 0)}
title="Delete Archetype"
message={`Are you sure you want to permanently delete "${deleteTarget?.name}"? This will also delete the .bsm binary file. This action cannot be undone.`}
onConfirm={handleDelete}
onCancel={() => setDeleteTarget(null)}
onCancel={() => { setDeleteTarget(null); setDeleteWarningConfirmed(false); }}
/>
<CodeSnippetModal
melody={codeSnippetMelody}
onClose={() => setCodeSnippetMelody(null)}
/>
</div>
);

View File

@@ -44,7 +44,7 @@ export default function SelectBuiltMelodyModal({ open, melodyId, onClose, onSucc
// 3. Mark this built melody as assigned to this Firestore melody
await api.post(`/builder/melodies/${builtMelody.id}/assign?firestore_melody_id=${melodyId}`);
onSuccess();
onSuccess({ name: builtMelody.name, pid: builtMelody.pid });
} catch (err) {
setError(err.message);
} finally {