Added SpeedCalc and MelodyBuilder. Evaluation Pending

This commit is contained in:
2026-02-22 13:17:54 +02:00
parent 8a8c665dfd
commit 8703c4fe26
27 changed files with 4075 additions and 3 deletions

View File

@@ -3,6 +3,7 @@ import { useParams, useNavigate } from "react-router-dom";
import api from "../api/client";
import { useAuth } from "../auth/AuthContext";
import ConfirmDialog from "../components/ConfirmDialog";
import SpeedCalculatorModal from "./SpeedCalculatorModal";
import {
getLocalizedValue,
getLanguageName,
@@ -36,6 +37,9 @@ export default function MelodyDetail() {
const [actionLoading, setActionLoading] = useState(false);
const [displayLang, setDisplayLang] = useState("en");
const [melodySettings, setMelodySettings] = useState(null);
const [builtMelody, setBuiltMelody] = useState(null);
const [codeCopied, setCodeCopied] = useState(false);
const [showSpeedCalc, setShowSpeedCalc] = useState(false);
useEffect(() => {
api.get("/settings/melody").then((ms) => {
@@ -57,6 +61,13 @@ export default function MelodyDetail() {
]);
setMelody(m);
setFiles(f);
// Load built melody assignment (non-fatal if it fails)
try {
const bm = await api.get(`/builder/melodies/for-melody/${id}`);
setBuiltMelody(bm || null);
} catch {
setBuiltMelody(null);
}
} catch (err) {
setError(err.message);
} finally {
@@ -189,6 +200,13 @@ export default function MelodyDetail() {
Unpublish
</button>
)}
<button
onClick={() => setShowSpeedCalc(true)}
className="px-4 py-2 text-sm rounded-md transition-colors"
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-primary)", border: "1px solid var(--border-primary)" }}
>
Speed Calculator
</button>
<button
onClick={() => navigate(`/melodies/${id}/edit`)}
className="px-4 py-2 text-sm rounded-md transition-colors"
@@ -375,6 +393,63 @@ export default function MelodyDetail() {
</div>
</div>
{/* Firmware Code section — only shown if a built melody with PROGMEM code is assigned */}
{builtMelody?.progmem_code && (
<section
className="rounded-lg p-6 border mt-6"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
>
<div className="flex items-center justify-between mb-3">
<div>
<h2 className="text-lg font-semibold" style={{ color: "var(--text-heading)" }}>Firmware Code</h2>
<p className="text-xs mt-0.5" style={{ color: "var(--text-muted)" }}>
PROGMEM code for built-in firmware playback &nbsp;·&nbsp; PID: <span className="font-mono">{builtMelody.pid}</span>
</p>
</div>
<button
onClick={() => {
navigator.clipboard.writeText(builtMelody.progmem_code).then(() => {
setCodeCopied(true);
setTimeout(() => setCodeCopied(false), 2000);
});
}}
className="px-3 py-1.5 text-xs rounded transition-colors"
style={{
backgroundColor: codeCopied ? "var(--success-bg)" : "var(--bg-card-hover)",
color: codeCopied ? "var(--success-text)" : "var(--text-secondary)",
border: "1px solid var(--border-primary)",
}}
>
{codeCopied ? "Copied!" : "Copy Code"}
</button>
</div>
<pre
className="p-4 text-xs overflow-x-auto rounded-lg"
style={{
backgroundColor: "var(--bg-primary)",
color: "var(--text-primary)",
fontFamily: "monospace",
whiteSpace: "pre",
maxHeight: "360px",
overflowY: "auto",
border: "1px solid var(--border-primary)",
}}
>
{builtMelody.progmem_code}
</pre>
</section>
)}
<SpeedCalculatorModal
open={showSpeedCalc}
melody={melody}
onClose={() => setShowSpeedCalc(false)}
onSaved={() => {
setShowSpeedCalc(false);
loadData();
}}
/>
<ConfirmDialog
open={showDelete}
title="Delete Melody"

View File

@@ -2,6 +2,8 @@ import { useState, useEffect } from "react";
import { useNavigate, useParams } from "react-router-dom";
import api from "../api/client";
import TranslationModal from "./TranslationModal";
import SelectBuiltMelodyModal from "./builder/SelectBuiltMelodyModal";
import BuildOnTheFlyModal from "./builder/BuildOnTheFlyModal";
import {
getLocalizedValue,
getLanguageName,
@@ -80,6 +82,9 @@ export default function MelodyForm() {
multiline: false,
});
const [showSelectBuilt, setShowSelectBuilt] = useState(false);
const [showBuildOnTheFly, setShowBuildOnTheFly] = useState(false);
useEffect(() => {
api.get("/settings/melody").then((ms) => {
setMelodySettings(ms);
@@ -553,9 +558,29 @@ export default function MelodyForm() {
<h2 className="text-lg font-semibold mb-4" style={headingStyle}>Files</h2>
<div className="space-y-4">
<div>
<label className="block text-sm font-medium mb-1" style={labelStyle}>Binary File (.bin)</label>
<label className="block text-sm font-medium mb-1" style={labelStyle}>Binary File (.bsm / .bin)</label>
{existingFiles.binary_url && (<p className="text-xs mb-1" style={{ color: "var(--success)" }}>Current file uploaded. Selecting a new file will replace it.</p>)}
<input type="file" accept=".bin" onChange={(e) => setBinaryFile(e.target.files[0] || null)} className="w-full text-sm" style={mutedStyle} />
<input type="file" accept=".bin,.bsm" onChange={(e) => setBinaryFile(e.target.files[0] || null)} className="w-full text-sm" style={mutedStyle} />
{isEdit && (
<div className="flex gap-2 mt-2">
<button
type="button"
onClick={() => setShowSelectBuilt(true)}
className="px-3 py-1.5 text-xs rounded-md transition-colors"
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-secondary)", border: "1px solid var(--border-primary)" }}
>
Select Built Melody
</button>
<button
type="button"
onClick={() => setShowBuildOnTheFly(true)}
className="px-3 py-1.5 text-xs rounded-md transition-colors"
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-secondary)", border: "1px solid var(--border-primary)" }}
>
Build on the Fly
</button>
</div>
)}
</div>
<div>
<label className="block text-sm font-medium mb-1" style={labelStyle}>Audio Preview (.mp3)</label>
@@ -582,6 +607,31 @@ export default function MelodyForm() {
languages={languages}
multiline={translationModal.multiline}
/>
{isEdit && (
<>
<SelectBuiltMelodyModal
open={showSelectBuilt}
melodyId={id}
onClose={() => setShowSelectBuilt(false)}
onSuccess={() => {
setShowSelectBuilt(false);
loadMelody();
}}
/>
<BuildOnTheFlyModal
open={showBuildOnTheFly}
melodyId={id}
defaultName={getLocalizedValue(information.name, "en", "") || getLocalizedValue(information.name, editLang, "")}
defaultPid={pid}
onClose={() => setShowBuildOnTheFly(false)}
onSuccess={() => {
setShowBuildOnTheFly(false);
loadMelody();
}}
/>
</>
)}
</div>
);
}

View File

@@ -0,0 +1,562 @@
import { useState, useEffect, useRef, useCallback } from "react";
import api from "../api/client";
// ============================================================================
// Web Audio Engine
// ============================================================================
/**
* Bell frequencies: Bell 1 = highest (880 Hz A5), each subsequent bell 2 semitones lower.
* freq = 880 * (2^(1/12))^(-2*(bell-1))
*/
function bellFrequency(bellNumber) {
return 880 * Math.pow(Math.pow(2, 1 / 12), -2 * (bellNumber - 1));
}
function playStep(audioCtx, stepValue, beatDurationMs) {
if (!stepValue || !audioCtx) return;
const now = audioCtx.currentTime;
const duration = beatDurationMs / 1000;
const fadeIn = 0.005;
const fadeOut = 0.03;
for (let bit = 0; bit < 16; bit++) {
if (stepValue & (1 << bit)) {
const bellNum = bit + 1;
const freq = bellFrequency(bellNum);
const osc = audioCtx.createOscillator();
const gain = audioCtx.createGain();
osc.connect(gain);
gain.connect(audioCtx.destination);
osc.type = "sine";
osc.frequency.setValueAtTime(freq, now);
gain.gain.setValueAtTime(0, now);
gain.gain.linearRampToValueAtTime(0.3, now + fadeIn);
gain.gain.setValueAtTime(0.3, now + duration - fadeOut);
gain.gain.linearRampToValueAtTime(0, now + duration);
osc.start(now);
osc.stop(now + duration);
}
}
}
// ============================================================================
// Parse raw steps string into list of uint16 values
// ============================================================================
function parseBellNotation(notation) {
notation = notation.trim();
if (notation === "0" || !notation) return 0;
let value = 0;
for (const part of notation.split("+")) {
const n = parseInt(part.trim(), 10);
if (!isNaN(n) && n >= 1 && n <= 16) value |= 1 << (n - 1);
}
return value;
}
function parseStepsString(stepsStr) {
if (!stepsStr || !stepsStr.trim()) return [];
return stepsStr.trim().split(",").map((s) => parseBellNotation(s));
}
function getActiveBells(stepValue) {
const bells = [];
for (let bit = 0; bit < 16; bit++) {
if (stepValue & (1 << bit)) bells.push(bit + 1);
}
return bells;
}
// ============================================================================
// Speed math
// MIN is derived so that at 50%, speed = normal (geometric mean)
// normal = sqrt(MIN * MAX) => MIN = normal^2 / MAX
// ============================================================================
function calcMin(normal, max) {
if (!normal || !max || max <= 0) return null;
return Math.round((normal * normal) / max);
}
// ============================================================================
// Component
// ============================================================================
const labelStyle = { color: "var(--text-secondary)" };
const mutedStyle = { color: "var(--text-muted)" };
export default function SpeedCalculatorModal({ open, melody, onClose, onSaved }) {
const info = melody?.information || {};
// Raw steps input (not stored in Firestore — only used locally for playback)
const [stepsInput, setStepsInput] = useState("");
const [steps, setSteps] = useState([]); // parsed uint16 values
// Playback
const audioCtxRef = useRef(null);
const playbackRef = useRef(null); // { timer, stepIndex }
const [playing, setPlaying] = useState(false);
const [paused, setPaused] = useState(false);
const [loop, setLoop] = useState(false);
const [currentStep, setCurrentStep] = useState(-1);
// Sliders
const [stepDelay, setStepDelay] = useState(500); // ms
const [beatDuration, setBeatDuration] = useState(100); // ms
const effectiveBeat = Math.min(beatDuration, Math.max(20, stepDelay - 20));
// Speed capture
const [capturedMax, setCapturedMax] = useState(null);
const [capturedNormal, setCapturedNormal] = useState(null);
const derivedMin = calcMin(capturedNormal, capturedMax);
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState("");
const [saveSuccess, setSaveSuccess] = useState(false);
// Warnings
const maxWarning = capturedMax !== null && capturedMax < 100;
const orderWarning = capturedMax !== null && capturedNormal !== null && capturedNormal > capturedMax;
// Pre-fill existing speeds from melody
useEffect(() => {
if (open) {
setCapturedMax(info.maxSpeed > 0 ? info.maxSpeed : null);
setCapturedNormal(null);
setStepsInput("");
setSteps([]);
setCurrentStep(-1);
setPlaying(false);
setPaused(false);
}
}, [open]);
const stopPlayback = useCallback(() => {
if (playbackRef.current) {
clearTimeout(playbackRef.current.timer);
playbackRef.current = null;
}
setPlaying(false);
setPaused(false);
setCurrentStep(-1);
}, []);
// Stop when modal closes
useEffect(() => {
if (!open) stopPlayback();
}, [open, stopPlayback]);
const ensureAudioCtx = () => {
if (!audioCtxRef.current || audioCtxRef.current.state === "closed") {
audioCtxRef.current = new (window.AudioContext || window.webkitAudioContext)();
}
if (audioCtxRef.current.state === "suspended") {
audioCtxRef.current.resume();
}
return audioCtxRef.current;
};
const scheduleStep = useCallback(
(stepIndex, startFrom = 0) => {
if (!steps.length) return;
const playFrom = stepIndex % steps.length;
if (!loop && playFrom === 0 && stepIndex > 0) {
stopPlayback();
return;
}
const ctx = ensureAudioCtx();
const stepValue = steps[playFrom];
setCurrentStep(playFrom);
playStep(ctx, stepValue, effectiveBeat);
const timer = setTimeout(() => {
const next = playFrom + 1;
if (next >= steps.length) {
if (loop) {
scheduleStep(0);
} else {
stopPlayback();
}
} else {
scheduleStep(next);
}
}, stepDelay);
playbackRef.current = { timer, stepIndex: playFrom };
},
[steps, stepDelay, effectiveBeat, loop, stopPlayback]
);
const handlePlay = () => {
if (!steps.length) return;
if (paused && playbackRef.current) {
// Resume from current step
setPaused(false);
setPlaying(true);
scheduleStep(playbackRef.current.stepIndex);
return;
}
setPlaying(true);
setPaused(false);
scheduleStep(0);
};
const handlePause = () => {
if (playbackRef.current) {
clearTimeout(playbackRef.current.timer);
}
setPaused(true);
setPlaying(false);
};
const handleStop = () => {
stopPlayback();
};
const handleStepsParse = () => {
const parsed = parseStepsString(stepsInput);
setSteps(parsed);
stopPlayback();
};
const handleSetSliderToCapture = (value) => {
if (value !== null) setStepDelay(value);
};
const handleSave = async () => {
if (!capturedMax || !derivedMin) return;
setSaving(true);
setSaveError("");
setSaveSuccess(false);
try {
await api.put(`/melodies/${melody.id}`, {
information: {
...info,
minSpeed: derivedMin,
maxSpeed: capturedMax,
},
default_settings: melody.default_settings,
type: melody.type,
url: melody.url,
uid: melody.uid,
pid: melody.pid,
});
setSaveSuccess(true);
setTimeout(() => {
onSaved();
}, 800);
} catch (err) {
setSaveError(err.message);
} finally {
setSaving(false);
}
};
if (!open) return null;
const totalSteps = steps.length;
const allBellsUsed = steps.reduce((set, v) => {
getActiveBells(v).forEach((b) => set.add(b));
return set;
}, new Set());
const currentBells = currentStep >= 0 ? getActiveBells(steps[currentStep] || 0) : [];
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 && !playing && onClose()}
>
<div
className="w-full max-w-2xl rounded-lg border shadow-xl"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)", maxHeight: "90vh", overflowY: "auto" }}
>
{/* Header */}
<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)" }}>Speed Calculator</h2>
<p className="text-xs mt-0.5" style={mutedStyle}>
Play the melody at different speeds to find the right MIN / MAX.
</p>
</div>
<button onClick={() => { stopPlayback(); onClose(); }} className="text-xl leading-none" style={mutedStyle}>&times;</button>
</div>
<div className="p-6 space-y-5">
{/* Steps Input */}
<div>
<label className="block text-sm font-medium mb-1" style={labelStyle}>
Melody Steps
<span className="font-normal ml-2" style={mutedStyle}>(paste your step notation)</span>
</label>
<div className="flex gap-2">
<textarea
value={stepsInput}
onChange={(e) => setStepsInput(e.target.value)}
rows={2}
placeholder="e.g. 1,2,2+1,1,2,3+1,0,3,2..."
className="flex-1 px-3 py-2 rounded-md text-sm border"
style={{ fontFamily: "monospace", resize: "none" }}
/>
<button
onClick={handleStepsParse}
className="px-4 py-2 text-sm rounded-md self-start transition-colors"
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
>
Load
</button>
</div>
{totalSteps > 0 && (
<p className="text-xs mt-1" style={{ color: "var(--success-text)" }}>
{totalSteps} steps loaded &nbsp;·&nbsp; {allBellsUsed.size} unique bell{allBellsUsed.size !== 1 ? "s" : ""} used
</p>
)}
</div>
{/* Bell visualizer */}
{totalSteps > 0 && (
<div>
<p className="text-xs mb-2" style={mutedStyle}>Bell indicator (current step)</p>
<div className="flex flex-wrap gap-1.5">
{Array.from({ length: Math.max(...Array.from(allBellsUsed), 1) }, (_, i) => i + 1).map((b) => {
const isActive = currentBells.includes(b);
const isUsed = allBellsUsed.has(b);
return (
<div
key={b}
className="w-8 h-8 rounded-full flex items-center justify-center text-xs font-bold transition-all"
style={{
backgroundColor: isActive
? "var(--accent)"
: isUsed
? "var(--bg-card-hover)"
: "var(--bg-primary)",
color: isActive ? "var(--bg-primary)" : isUsed ? "var(--text-secondary)" : "var(--border-primary)",
border: `2px solid ${isActive ? "var(--accent)" : isUsed ? "var(--border-primary)" : "var(--border-primary)"}`,
transform: isActive ? "scale(1.2)" : "scale(1)",
}}
>
{b}
</div>
);
})}
</div>
{currentStep >= 0 && (
<p className="text-xs mt-1" style={mutedStyle}>
Step {currentStep + 1} / {totalSteps}
</p>
)}
</div>
)}
{/* Playback Controls */}
<div className="flex items-center gap-3">
{!playing ? (
<button
onClick={handlePlay}
disabled={!totalSteps}
className="px-4 py-2 text-sm rounded-md disabled:opacity-40 transition-colors font-medium"
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
>
{paused ? "Resume" : "Play"}
</button>
) : (
<button
onClick={handlePause}
className="px-4 py-2 text-sm rounded-md transition-colors font-medium"
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-primary)", border: "1px solid var(--border-primary)" }}
>
Pause
</button>
)}
<button
onClick={handleStop}
disabled={!playing && !paused}
className="px-4 py-2 text-sm rounded-md disabled:opacity-40 transition-colors"
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-primary)", border: "1px solid var(--border-primary)" }}
>
Stop
</button>
<label className="flex items-center gap-2 ml-2 text-sm cursor-pointer" style={labelStyle}>
<input type="checkbox" checked={loop} onChange={(e) => setLoop(e.target.checked)} className="h-4 w-4 rounded" />
Loop
</label>
</div>
{/* Step Delay Slider */}
<div>
<div className="flex items-center justify-between mb-1">
<label className="text-sm font-medium" style={labelStyle}>Step Delay (Speed)</label>
<span className="text-sm font-bold" style={{ color: "var(--accent)" }}>{stepDelay} ms</span>
</div>
<input
type="range"
min="50"
max="3000"
step="10"
value={stepDelay}
onChange={(e) => setStepDelay(Number(e.target.value))}
className="w-full h-2 rounded-lg appearance-none cursor-pointer"
/>
<div className="flex justify-between text-xs mt-0.5" style={mutedStyle}>
<span>50ms (fastest)</span>
<span>3000ms (slowest)</span>
</div>
</div>
{/* Beat Duration Slider */}
<div>
<div className="flex items-center justify-between mb-1">
<label className="text-sm font-medium" style={labelStyle}>Tone Length</label>
<span className="text-sm" style={mutedStyle}>
{effectiveBeat} ms
{effectiveBeat < beatDuration && <span style={{ color: "var(--warning, #f59e0b)" }}> (capped)</span>}
</span>
</div>
<input
type="range"
min="20"
max="500"
step="5"
value={beatDuration}
onChange={(e) => setBeatDuration(Number(e.target.value))}
className="w-full h-2 rounded-lg appearance-none cursor-pointer"
/>
<div className="flex justify-between text-xs mt-0.5" style={mutedStyle}>
<span>20ms (short)</span>
<span>500ms (long)</span>
</div>
</div>
{/* Speed Capture Panel */}
<div className="rounded-lg border p-4 space-y-3" style={{ borderColor: "var(--border-primary)", backgroundColor: "var(--bg-primary)" }}>
<h3 className="text-sm font-semibold" style={{ color: "var(--text-heading)" }}>Speed Capture</h3>
<p className="text-xs" style={mutedStyle}>
Find the fastest speed the controller can handle (MAX), then find the speed that feels "normal" (Normal).
MIN is auto-calculated so that 50% = Normal speed.
</p>
{/* MAX */}
<div className="flex items-center gap-3">
<div className="flex-1">
<span className="text-xs font-medium block mb-0.5" style={labelStyle}>MAX (fastest safe)</span>
<div className="flex items-center gap-2">
<span className="text-lg font-bold w-20" style={{ color: capturedMax ? "var(--accent)" : "var(--text-muted)" }}>
{capturedMax !== null ? `${capturedMax} ms` : "—"}
</span>
<button
onClick={() => handleSetSliderToCapture(capturedMax)}
disabled={capturedMax === null}
className="px-2 py-1 text-xs rounded disabled:opacity-40 transition-colors"
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-muted)", border: "1px solid var(--border-primary)" }}
title="Load this value to slider"
>
Set
</button>
</div>
</div>
<button
onClick={() => setCapturedMax(stepDelay)}
className="px-3 py-2 text-xs rounded-md transition-colors font-medium"
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
>
Capture
</button>
</div>
{/* Normal */}
<div className="flex items-center gap-3">
<div className="flex-1">
<span className="text-xs font-medium block mb-0.5" style={labelStyle}>Normal (50% speed feel)</span>
<div className="flex items-center gap-2">
<span className="text-lg font-bold w-20" style={{ color: capturedNormal ? "var(--accent)" : "var(--text-muted)" }}>
{capturedNormal !== null ? `${capturedNormal} ms` : "—"}
</span>
<button
onClick={() => handleSetSliderToCapture(capturedNormal)}
disabled={capturedNormal === null}
className="px-2 py-1 text-xs rounded disabled:opacity-40 transition-colors"
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-muted)", border: "1px solid var(--border-primary)" }}
title="Load this value to slider"
>
Set
</button>
</div>
</div>
<button
onClick={() => setCapturedNormal(stepDelay)}
className="px-3 py-2 text-xs rounded-md transition-colors font-medium"
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
>
Capture
</button>
</div>
{/* MIN (auto-calculated) */}
<div className="pt-1 border-t" style={{ borderColor: "var(--border-primary)" }}>
<span className="text-xs font-medium block mb-0.5" style={mutedStyle}>MIN (auto-calculated)</span>
<span className="text-lg font-bold" style={{ color: derivedMin ? "var(--text-secondary)" : "var(--text-muted)" }}>
{derivedMin !== null ? `${derivedMin} ms` : "—"}
</span>
{derivedMin !== null && (
<span className="text-xs ml-2" style={mutedStyle}>(normal² / max)</span>
)}
</div>
{/* Warnings */}
{maxWarning && (
<div className="text-xs rounded p-2" style={{ backgroundColor: "var(--danger-bg)", color: "var(--danger-text)" }}>
Warning: MAX speed of {capturedMax}ms is very fast (&lt;100ms). This could damage the device.
</div>
)}
{orderWarning && (
<div className="text-xs rounded p-2" style={{ backgroundColor: "var(--danger-bg)", color: "var(--danger-text)" }}>
Warning: Normal speed ({capturedNormal}ms) is slower than MAX ({capturedMax}ms). Please check your values.
</div>
)}
</div>
{/* Save */}
{saveError && (
<div className="text-sm rounded-md p-3 border" style={{ backgroundColor: "var(--danger-bg)", borderColor: "var(--danger)", color: "var(--danger-text)" }}>
{saveError}
</div>
)}
{saveSuccess && (
<div className="text-sm rounded-md p-3 border" style={{ backgroundColor: "var(--success-bg)", borderColor: "var(--success)", color: "var(--success-text)" }}>
Speeds saved to melody!
</div>
)}
</div>
<div className="flex items-center justify-end gap-3 px-6 py-4 border-t" style={{ borderColor: "var(--border-primary)" }}>
<button
onClick={() => { stopPlayback(); onClose(); }}
className="px-4 py-2 text-sm rounded-md transition-colors"
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-primary)" }}
>
Close
</button>
<button
onClick={handleSave}
disabled={!capturedMax || !capturedNormal || !derivedMin || saving || orderWarning}
className="px-4 py-2 text-sm rounded-md disabled:opacity-40 transition-colors font-medium"
style={{ backgroundColor: "#16a34a", color: "#fff" }}
title={!capturedMax || !capturedNormal ? "Capture MAX and Normal speeds first" : ""}
>
{saving ? "Saving..." : "Save to Melody"}
</button>
</div>
</div>
</div>
);
}

View 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}>&times;</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>
);
}

View 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)" }}>
&larr; 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 116 (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>
);
}

View 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>
);
}

View 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)" }}>&times;</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 || "—"} &nbsp;·&nbsp; {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>
);
}