update: overhauled firmware ui. Added public flash page.

This commit is contained in:
2026-03-18 17:49:40 +02:00
parent 4381a6681d
commit d0ac4f1d91
45 changed files with 6798 additions and 1723 deletions

View File

@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useNavigate } from "react-router-dom";
import { useNavigate, useLocation } from "react-router-dom";
import api from "../api/client";
const MAX_NOTES = 16;
@@ -106,10 +106,48 @@ function playStep(audioCtx, stepValue, noteDurationMs) {
}
}
function csvToSteps(csv) {
if (!csv || !csv.trim()) return null;
return csv.trim().split(",").map((token) => {
const parts = token.split("+");
let val = 0;
for (const p of parts) {
const n = parseInt(p.trim(), 10);
if (!isNaN(n) && n >= 1 && n <= 16) val |= (1 << (n - 1));
}
return val;
});
}
export default function MelodyComposer() {
const navigate = useNavigate();
const [steps, setSteps] = useState(Array.from({ length: 16 }, () => 0));
const [noteCount, setNoteCount] = useState(8);
const { state: routeState } = useLocation();
const loadedArchetype = routeState?.archetype || null;
const initialSteps = () => {
if (loadedArchetype?.steps) {
const parsed = csvToSteps(loadedArchetype.steps);
if (parsed?.length) return parsed;
}
return Array.from({ length: 16 }, () => 0);
};
const [steps, setSteps] = useState(initialSteps);
const [noteCount, setNoteCount] = useState(() => {
if (loadedArchetype?.steps) {
const parsed = csvToSteps(loadedArchetype.steps);
if (parsed?.length) {
let maxBit = 0;
for (const v of parsed) {
for (let b = 15; b >= 0; b--) {
if (v & (1 << b)) { maxBit = Math.max(maxBit, b + 1); break; }
}
}
return Math.max(8, maxBit);
}
}
return 8;
});
const [stepDelayMs, setStepDelayMs] = useState(280);
const [noteDurationMs, setNoteDurationMs] = useState(110);
const [measureEvery, setMeasureEvery] = useState(4);
@@ -123,6 +161,7 @@ export default function MelodyComposer() {
const [deployPid, setDeployPid] = useState("");
const [deployError, setDeployError] = useState("");
const [deploying, setDeploying] = useState(false);
const [deployMode, setDeployMode] = useState("new"); // "new" | "update"
const [noteColors, setNoteColors] = useState([]);
const [stepMenuIndex, setStepMenuIndex] = useState(null);
@@ -290,10 +329,18 @@ export default function MelodyComposer() {
scheduleStep(0);
};
const openDeployModal = () => {
const openDeployModal = (mode = "new") => {
setError("");
setSuccessMsg("");
setDeployError("");
setDeployMode(mode);
if (mode === "update" && loadedArchetype) {
setDeployName(loadedArchetype.name || "");
setDeployPid(loadedArchetype.pid || "");
} else {
setDeployName("");
setDeployPid("");
}
setShowDeployModal(true);
};
@@ -306,45 +353,34 @@ export default function MelodyComposer() {
const handleDeploy = async () => {
const name = deployName.trim();
const pid = deployPid.trim();
if (!name) {
setDeployError("Name is required.");
return;
}
if (!pid) {
setDeployError("PID is required.");
return;
}
if (!name) { setDeployError("Name is required."); return; }
if (!pid) { setDeployError("PID is required."); return; }
setDeploying(true);
setDeployError("");
setSuccessMsg("");
try {
const existing = await api.get("/builder/melodies");
const list = existing.melodies || [];
const dupName = list.find((m) => m.name.toLowerCase() === name.toLowerCase());
if (dupName) {
setDeployError(`An archetype with the name "${name}" already exists.`);
return;
}
const dupPid = list.find((m) => m.pid && m.pid.toLowerCase() === pid.toLowerCase());
if (dupPid) {
setDeployError(`An archetype with the PID "${pid}" already exists.`);
return;
}
const stepsStr = steps.map(stepToNotation).join(",");
const created = await api.post("/builder/melodies", {
name,
pid,
steps: stepsStr,
});
setSuccessMsg(`Archetype "${name}" deployed successfully.`);
setShowDeployModal(false);
setDeployName("");
setDeployPid("");
if (created?.id) {
navigate(`/melodies/archetypes/${created.id}`);
if (deployMode === "update" && loadedArchetype?.id) {
const updated = await api.put(`/builder/melodies/${loadedArchetype.id}`, { name, pid, steps: stepsStr });
setSuccessMsg(`Archetype "${name}" updated successfully.`);
setShowDeployModal(false);
if (updated?.id) navigate(`/melodies/archetypes/${updated.id}`);
} else {
const existing = await api.get("/builder/melodies");
const list = existing.melodies || [];
const dupName = list.find((m) => m.name.toLowerCase() === name.toLowerCase());
if (dupName) { setDeployError(`An archetype with the name "${name}" already exists.`); return; }
const dupPid = list.find((m) => m.pid && m.pid.toLowerCase() === pid.toLowerCase());
if (dupPid) { setDeployError(`An archetype with the PID "${pid}" already exists.`); return; }
const created = await api.post("/builder/melodies", { name, pid, steps: stepsStr });
setSuccessMsg(`Archetype "${name}" deployed successfully.`);
setShowDeployModal(false);
setDeployName("");
setDeployPid("");
if (created?.id) navigate(`/melodies/archetypes/${created.id}`);
}
} catch (err) {
setDeployError(err.message);
@@ -375,6 +411,24 @@ export default function MelodyComposer() {
</p>
</div>
{loadedArchetype && (
<div className="rounded-lg border px-4 py-3 flex items-center gap-3"
style={{ backgroundColor: "rgba(139,92,246,0.08)", borderColor: "rgba(139,92,246,0.3)" }}>
<span className="text-xs font-semibold uppercase tracking-wide" style={{ color: "#a78bfa" }}>Editing Archetype</span>
<span className="text-sm font-medium" style={{ color: "var(--text-heading)" }}>{loadedArchetype.name}</span>
<span className="text-xs" style={{ color: "var(--text-muted)" }}>·</span>
<span className="text-xs font-mono" style={{ color: "var(--text-muted)" }}>{loadedArchetype.id}</span>
<button
type="button"
onClick={() => navigate("/melodies/composer", { replace: true, state: null })}
className="ml-auto text-xs px-2 py-1 rounded"
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-muted)", border: "1px solid var(--border-primary)" }}
>
Clear
</button>
</div>
)}
{error && (
<div
className="text-sm rounded-md p-3 border"
@@ -422,7 +476,14 @@ export default function MelodyComposer() {
)}
<span>{steps.length} steps, {noteCount} notes</span>
</div>
<button type="button" onClick={openDeployModal} className="px-3 py-1.5 rounded-md text-sm whitespace-nowrap" style={{ backgroundColor: "var(--text-link)", color: "var(--text-white)" }}>Deploy Archetype</button>
{loadedArchetype ? (
<>
<button type="button" onClick={() => openDeployModal("new")} className="px-3 py-1.5 rounded-md text-sm whitespace-nowrap" style={{ backgroundColor: "var(--btn-neutral)", color: "var(--text-white)" }}>Deploy as New Archetype</button>
<button type="button" onClick={() => openDeployModal("update")} className="px-3 py-1.5 rounded-md text-sm whitespace-nowrap" style={{ backgroundColor: "var(--text-link)", color: "var(--text-white)" }}>Update Current Archetype</button>
</>
) : (
<button type="button" onClick={() => openDeployModal("new")} className="px-3 py-1.5 rounded-md text-sm whitespace-nowrap" style={{ backgroundColor: "var(--text-link)", color: "var(--text-white)" }}>Deploy Archetype</button>
)}
</div>
</div>
@@ -666,10 +727,12 @@ export default function MelodyComposer() {
>
<div>
<h2 className="text-lg font-semibold" style={{ color: "var(--text-heading)" }}>
Deploy Archetype
{deployMode === "update" ? "Update Archetype" : "Deploy Archetype"}
</h2>
<p className="text-xs mt-0.5" style={{ color: "var(--text-muted)" }}>
Create a new archetype from this composer pattern.
{deployMode === "update"
? "Rebuild the existing archetype with the current composer pattern."
: "Create a new archetype from this composer pattern."}
</p>
</div>
<button
@@ -744,7 +807,7 @@ export default function MelodyComposer() {
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
disabled={deploying}
>
{deploying ? "Deploying..." : "Deploy"}
{deploying ? (deployMode === "update" ? "Updating..." : "Deploying...") : (deployMode === "update" ? "Update" : "Deploy")}
</button>
</div>
</div>