More Archetype Fixes
This commit is contained in:
@@ -42,6 +42,44 @@ function Field({ label, children }) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function UrlField({ label, value }) {
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<dt className="text-xs font-medium uppercase tracking-wide mb-1" style={{ color: "var(--text-muted)" }}>
|
||||||
|
{label}
|
||||||
|
</dt>
|
||||||
|
<dd className="flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
className="text-sm font-mono flex-1 min-w-0"
|
||||||
|
style={{
|
||||||
|
color: "var(--text-primary)",
|
||||||
|
overflow: "hidden",
|
||||||
|
textOverflow: "ellipsis",
|
||||||
|
whiteSpace: "nowrap",
|
||||||
|
display: "block",
|
||||||
|
userSelect: "text",
|
||||||
|
}}
|
||||||
|
title={value}
|
||||||
|
>
|
||||||
|
{value}
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={() => copyText(value, () => { setCopied(true); setTimeout(() => setCopied(false), 2000); })}
|
||||||
|
className="flex-shrink-0 px-2 py-0.5 text-xs rounded transition-colors"
|
||||||
|
style={{
|
||||||
|
backgroundColor: copied ? "var(--success-bg)" : "var(--bg-card-hover)",
|
||||||
|
color: copied ? "var(--success-text)" : "var(--text-muted)",
|
||||||
|
border: "1px solid var(--border-primary)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{copied ? "Copied!" : "Copy"}
|
||||||
|
</button>
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function MelodyDetail() {
|
export default function MelodyDetail() {
|
||||||
const { id } = useParams();
|
const { id } = useParams();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -333,9 +371,11 @@ export default function MelodyDetail() {
|
|||||||
<dl className="grid grid-cols-2 md:grid-cols-3 gap-4">
|
<dl className="grid grid-cols-2 md:grid-cols-3 gap-4">
|
||||||
<Field label="Document ID">{melody.id}</Field>
|
<Field label="Document ID">{melody.id}</Field>
|
||||||
<Field label="PID (Playback ID)">{melody.pid}</Field>
|
<Field label="PID (Playback ID)">{melody.pid}</Field>
|
||||||
|
{melody.url && (
|
||||||
<div className="col-span-2 md:col-span-3">
|
<div className="col-span-2 md:col-span-3">
|
||||||
<Field label="URL">{melody.url}</Field>
|
<UrlField label="URL" value={melody.url} />
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</dl>
|
</dl>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
@@ -384,7 +424,7 @@ export default function MelodyDetail() {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span className="text-xs font-bold leading-tight" style={{ color: "var(--text-secondary)" }}>
|
<span className="text-xs font-bold leading-tight" style={{ color: "var(--text-secondary)" }}>
|
||||||
{noteIdx + 1}
|
{String.fromCharCode(65 + noteIdx)}
|
||||||
</span>
|
</span>
|
||||||
<div className="w-full my-0.5" style={{ height: "1px", backgroundColor: "var(--border-primary)" }} />
|
<div className="w-full my-0.5" style={{ height: "1px", backgroundColor: "var(--border-primary)" }} />
|
||||||
<span className="text-xs leading-tight" style={{ color: "var(--text-muted)" }}>
|
<span className="text-xs leading-tight" style={{ color: "var(--text-muted)" }}>
|
||||||
@@ -396,7 +436,7 @@ export default function MelodyDetail() {
|
|||||||
) : (
|
) : (
|
||||||
<span style={{ color: "var(--text-muted)" }}>-</span>
|
<span style={{ color: "var(--text-muted)" }}>-</span>
|
||||||
)}
|
)}
|
||||||
<p className="text-xs mt-1" style={{ color: "var(--text-muted)" }}>Top = Note #, Bottom = Assigned Bell</p>
|
<p className="text-xs mt-1" style={{ color: "var(--text-muted)" }}>Top = Note, Bottom = Assigned Bell</p>
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
</dl>
|
</dl>
|
||||||
@@ -432,9 +472,13 @@ export default function MelodyDetail() {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
try {
|
try {
|
||||||
const token = localStorage.getItem("access_token");
|
const token = localStorage.getItem("access_token");
|
||||||
const res = await fetch(binaryUrl, {
|
let res = await fetch(binaryUrl, {
|
||||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||||
});
|
});
|
||||||
|
// For external URLs (e.g. Firebase Storage), retry without auth header
|
||||||
|
if (!res.ok && binaryUrl.startsWith("http")) {
|
||||||
|
res = await fetch(binaryUrl);
|
||||||
|
}
|
||||||
if (!res.ok) throw new Error(`Download failed: ${res.statusText}`);
|
if (!res.ok) throw new Error(`Download failed: ${res.statusText}`);
|
||||||
const blob = await res.blob();
|
const blob = await res.blob();
|
||||||
const objectUrl = URL.createObjectURL(blob);
|
const objectUrl = URL.createObjectURL(blob);
|
||||||
|
|||||||
@@ -616,7 +616,7 @@ export default function MelodyForm() {
|
|||||||
<div className="grid grid-cols-4 sm:grid-cols-8 gap-2">
|
<div className="grid grid-cols-4 sm:grid-cols-8 gap-2">
|
||||||
{Array.from({ length: information.totalNotes }, (_, i) => (
|
{Array.from({ length: information.totalNotes }, (_, i) => (
|
||||||
<div key={i}>
|
<div key={i}>
|
||||||
<label className="block text-xs mb-0.5 text-left" style={mutedStyle}>Note #{i + 1}</label>
|
<label className="block text-xs mb-0.5 text-left" style={mutedStyle}>Note #{String.fromCharCode(65 + i)}</label>
|
||||||
<input type="number" min="0" required value={settings.noteAssignments[i] ?? 0}
|
<input type="number" min="0" required value={settings.noteAssignments[i] ?? 0}
|
||||||
onChange={(e) => { const na = [...settings.noteAssignments]; while (na.length <= i) na.push(0); na[i] = parseInt(e.target.value, 10) || 0; updateSettings("noteAssignments", na); }}
|
onChange={(e) => { const na = [...settings.noteAssignments]; while (na.length <= i) na.push(0); na[i] = parseInt(e.target.value, 10) || 0; updateSettings("noteAssignments", na); }}
|
||||||
className="w-full px-2 py-1.5 rounded-md text-sm text-center border"
|
className="w-full px-2 py-1.5 rounded-md text-sm text-center border"
|
||||||
@@ -789,6 +789,7 @@ export default function MelodyForm() {
|
|||||||
open={showSelectBuilt}
|
open={showSelectBuilt}
|
||||||
melodyId={id || savedMelodyId}
|
melodyId={id || savedMelodyId}
|
||||||
currentMelody={{ information, default_settings: settings, type, url, uid, pid }}
|
currentMelody={{ information, default_settings: settings, type, url, uid, pid }}
|
||||||
|
currentBuiltMelody={builtMelody}
|
||||||
onClose={() => setShowSelectBuilt(false)}
|
onClose={() => setShowSelectBuilt(false)}
|
||||||
onSuccess={(archetype) => {
|
onSuccess={(archetype) => {
|
||||||
setShowSelectBuilt(false);
|
setShowSelectBuilt(false);
|
||||||
@@ -801,6 +802,7 @@ export default function MelodyForm() {
|
|||||||
open={showBuildOnTheFly}
|
open={showBuildOnTheFly}
|
||||||
melodyId={id || savedMelodyId}
|
melodyId={id || savedMelodyId}
|
||||||
currentMelody={{ information, default_settings: settings, type, url, uid, pid }}
|
currentMelody={{ information, default_settings: settings, type, url, uid, pid }}
|
||||||
|
currentBuiltMelody={builtMelody}
|
||||||
defaultName={getLocalizedValue(information.name, "en", "") || getLocalizedValue(information.name, editLang, "")}
|
defaultName={getLocalizedValue(information.name, "en", "") || getLocalizedValue(information.name, editLang, "")}
|
||||||
defaultPid={pid}
|
defaultPid={pid}
|
||||||
onClose={() => setShowBuildOnTheFly(false)}
|
onClose={() => setShowBuildOnTheFly(false)}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { useState, useEffect, useRef, useCallback } from "react";
|
import { useState, useEffect, useRef, useCallback } from "react";
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Web Audio Engine (shared with SpeedCalculatorModal pattern)
|
// Web Audio Engine
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
function bellFrequency(bellNumber) {
|
function bellFrequency(bellNumber) {
|
||||||
@@ -34,14 +34,6 @@ function playStep(audioCtx, stepValue, beatDurationMs) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function getActiveBells(stepValue) {
|
|
||||||
const bells = [];
|
|
||||||
for (let bit = 0; bit < 16; bit++) {
|
|
||||||
if (stepValue & (1 << bit)) bells.push(bit + 1);
|
|
||||||
}
|
|
||||||
return bells;
|
|
||||||
}
|
|
||||||
|
|
||||||
function parseBellNotation(notation) {
|
function parseBellNotation(notation) {
|
||||||
notation = notation.trim();
|
notation = notation.trim();
|
||||||
if (notation === "0" || !notation) return 0;
|
if (notation === "0" || !notation) return 0;
|
||||||
@@ -59,10 +51,27 @@ function parseStepsString(stepsStr) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function decodeBsmBinary(url) {
|
async function decodeBsmBinary(url) {
|
||||||
|
// Try with auth token first (for our API endpoints), then without (for Firebase URLs)
|
||||||
const token = localStorage.getItem("access_token");
|
const token = localStorage.getItem("access_token");
|
||||||
const res = await fetch(url, {
|
let res = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
res = await fetch(url, {
|
||||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||||
});
|
});
|
||||||
|
} catch {
|
||||||
|
throw new Error("Failed to fetch binary: network error");
|
||||||
|
}
|
||||||
|
|
||||||
|
// If unauthorized and it looks like a Firebase URL, try without auth header
|
||||||
|
if (!res.ok && res.status === 401 && url.startsWith("http")) {
|
||||||
|
try {
|
||||||
|
res = await fetch(url);
|
||||||
|
} catch {
|
||||||
|
throw new Error("Failed to fetch binary: network error");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!res.ok) throw new Error(`Failed to fetch binary: ${res.statusText}`);
|
if (!res.ok) throw new Error(`Failed to fetch binary: ${res.statusText}`);
|
||||||
const buf = await res.arrayBuffer();
|
const buf = await res.arrayBuffer();
|
||||||
const view = new DataView(buf);
|
const view = new DataView(buf);
|
||||||
@@ -74,9 +83,7 @@ async function decodeBsmBinary(url) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Speed math — exponential mapping matching the Flutter app:
|
// Speed math — exponential mapping
|
||||||
// value = minSpeed * pow(maxSpeed / minSpeed, t) where t = percent / 100
|
|
||||||
// Note: in this system, MIN ms > MAX ms (MIN = slowest, MAX = fastest).
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
function mapPercentageToSpeed(percent, minSpeed, maxSpeed) {
|
function mapPercentageToSpeed(percent, minSpeed, maxSpeed) {
|
||||||
@@ -88,20 +95,44 @@ function mapPercentageToSpeed(percent, minSpeed, maxSpeed) {
|
|||||||
return Math.round(a * Math.pow(b / a, t));
|
return Math.round(a * Math.pow(b / a, t));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Apply note assignments: map archetype note bits → assigned bell bits
|
||||||
|
//
|
||||||
|
// The archetype steps encode which NOTES fire using bit flags (note 1 = bit 0,
|
||||||
|
// note 2 = bit 1, etc). noteAssignments[noteIdx] gives the bell number to fire
|
||||||
|
// for that note (0 = silence / no bell). We rebuild the step value using the
|
||||||
|
// assigned bells instead of the raw note numbers.
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
function applyNoteAssignments(rawStepValue, noteAssignments) {
|
||||||
|
if (!noteAssignments || noteAssignments.length === 0) return rawStepValue;
|
||||||
|
let result = 0;
|
||||||
|
for (let bit = 0; bit < 16; bit++) {
|
||||||
|
if (rawStepValue & (1 << bit)) {
|
||||||
|
const noteIdx = bit; // bit 0 = note 1, bit 1 = note 2, ...
|
||||||
|
const assignedBell = noteAssignments[noteIdx];
|
||||||
|
if (assignedBell && assignedBell > 0) {
|
||||||
|
result |= 1 << (assignedBell - 1);
|
||||||
|
}
|
||||||
|
// assignedBell === 0 means silence — do not set any bell bit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
// Component
|
// Component
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|
||||||
const BEAT_DURATION_MS = 80; // fixed tone length for playback
|
|
||||||
|
|
||||||
const mutedStyle = { color: "var(--text-muted)" };
|
const mutedStyle = { color: "var(--text-muted)" };
|
||||||
const labelStyle = { color: "var(--text-secondary)" };
|
const labelStyle = { color: "var(--text-secondary)" };
|
||||||
|
|
||||||
|
const NOTE_LABELS = "ABCDEFGHIJKLMNOP";
|
||||||
|
|
||||||
export default function PlaybackModal({ open, melody, builtMelody, files, archetypeCsv, onClose }) {
|
export default function PlaybackModal({ open, melody, builtMelody, files, archetypeCsv, onClose }) {
|
||||||
const info = melody?.information || {};
|
const info = melody?.information || {};
|
||||||
const minSpeed = info.minSpeed || null;
|
const minSpeed = info.minSpeed || null;
|
||||||
const maxSpeed = info.maxSpeed || null;
|
const maxSpeed = info.maxSpeed || null;
|
||||||
// Note assignments: maps note index → bell number to fire
|
|
||||||
const noteAssignments = melody?.default_settings?.noteAssignments || [];
|
const noteAssignments = melody?.default_settings?.noteAssignments || [];
|
||||||
|
|
||||||
const [steps, setSteps] = useState([]);
|
const [steps, setSteps] = useState([]);
|
||||||
@@ -111,29 +142,37 @@ export default function PlaybackModal({ open, melody, builtMelody, files, archet
|
|||||||
const [playing, setPlaying] = useState(false);
|
const [playing, setPlaying] = useState(false);
|
||||||
const [currentStep, setCurrentStep] = useState(-1);
|
const [currentStep, setCurrentStep] = useState(-1);
|
||||||
const [speedPercent, setSpeedPercent] = useState(50);
|
const [speedPercent, setSpeedPercent] = useState(50);
|
||||||
|
const [toneLengthMs, setToneLengthMs] = useState(80);
|
||||||
|
|
||||||
|
// activeBells: Set of bell numbers currently lit (for flash effect)
|
||||||
|
const [activeBells, setActiveBells] = useState(new Set());
|
||||||
|
|
||||||
const audioCtxRef = useRef(null);
|
const audioCtxRef = useRef(null);
|
||||||
const playbackRef = useRef(null);
|
const playbackRef = useRef(null);
|
||||||
const stepsRef = useRef([]);
|
const stepsRef = useRef([]);
|
||||||
const speedMsRef = useRef(500);
|
const speedMsRef = useRef(500);
|
||||||
|
const toneLengthRef = useRef(80);
|
||||||
|
const noteAssignmentsRef = useRef(noteAssignments);
|
||||||
|
|
||||||
// Derived speed in ms from the current percent
|
|
||||||
const speedMs = mapPercentageToSpeed(speedPercent, minSpeed, maxSpeed) ?? 500;
|
const speedMs = mapPercentageToSpeed(speedPercent, minSpeed, maxSpeed) ?? 500;
|
||||||
|
|
||||||
// Keep refs in sync so the playback loop reads live values
|
|
||||||
useEffect(() => { stepsRef.current = steps; }, [steps]);
|
useEffect(() => { stepsRef.current = steps; }, [steps]);
|
||||||
useEffect(() => { speedMsRef.current = speedMs; }, [speedMs]);
|
useEffect(() => { speedMsRef.current = speedMs; }, [speedMs]);
|
||||||
|
useEffect(() => { toneLengthRef.current = toneLengthMs; }, [toneLengthMs]);
|
||||||
|
useEffect(() => { noteAssignmentsRef.current = noteAssignments; }, [noteAssignments]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
const stopPlayback = useCallback(() => {
|
const stopPlayback = useCallback(() => {
|
||||||
if (playbackRef.current) {
|
if (playbackRef.current) {
|
||||||
clearTimeout(playbackRef.current.timer);
|
clearTimeout(playbackRef.current.timer);
|
||||||
|
if (playbackRef.current.flashTimer) clearTimeout(playbackRef.current.flashTimer);
|
||||||
playbackRef.current = null;
|
playbackRef.current = null;
|
||||||
}
|
}
|
||||||
setPlaying(false);
|
setPlaying(false);
|
||||||
setCurrentStep(-1);
|
setCurrentStep(-1);
|
||||||
|
setActiveBells(new Set());
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Load steps on open — prefer archetype_csv, fall back to binary
|
// Load steps on open
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open) {
|
if (!open) {
|
||||||
stopPlayback();
|
stopPlayback();
|
||||||
@@ -141,10 +180,10 @@ export default function PlaybackModal({ open, melody, builtMelody, files, archet
|
|||||||
setCurrentStep(-1);
|
setCurrentStep(-1);
|
||||||
setLoadError("");
|
setLoadError("");
|
||||||
setSpeedPercent(50);
|
setSpeedPercent(50);
|
||||||
|
setActiveBells(new Set());
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Prefer CSV from archetype (no network request needed)
|
|
||||||
const csv = archetypeCsv || info.archetype_csv || null;
|
const csv = archetypeCsv || info.archetype_csv || null;
|
||||||
if (csv) {
|
if (csv) {
|
||||||
const parsed = parseStepsString(csv);
|
const parsed = parseStepsString(csv);
|
||||||
@@ -154,7 +193,7 @@ export default function PlaybackModal({ open, melody, builtMelody, files, archet
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fall back to binary fetch — prefer uploaded file, then legacy melody.url
|
// Fall back to binary
|
||||||
const binaryUrl = builtMelody?.binary_url
|
const binaryUrl = builtMelody?.binary_url
|
||||||
? `/api${builtMelody.binary_url}`
|
? `/api${builtMelody.binary_url}`
|
||||||
: files?.binary_url || melody?.url || null;
|
: files?.binary_url || melody?.url || null;
|
||||||
@@ -191,49 +230,36 @@ export default function PlaybackModal({ open, melody, builtMelody, files, archet
|
|||||||
|
|
||||||
const playFrom = stepIndex % currentSteps.length;
|
const playFrom = stepIndex % currentSteps.length;
|
||||||
|
|
||||||
// End of sequence — loop back
|
|
||||||
if (playFrom === 0 && stepIndex > 0) {
|
|
||||||
scheduleStep(0);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const ctx = ensureAudioCtx();
|
const ctx = ensureAudioCtx();
|
||||||
const rawStepValue = currentSteps[playFrom];
|
const rawStepValue = currentSteps[playFrom];
|
||||||
|
|
||||||
// Apply note assignments: each note in the step maps to an assigned bell number
|
// Map archetype notes → assigned bells
|
||||||
// noteAssignments[noteIndex] = bellNumber (1-based). We rebuild the step value
|
const stepValue = applyNoteAssignments(rawStepValue, noteAssignmentsRef.current);
|
||||||
// using assigned bells instead of the raw ones.
|
|
||||||
let stepValue = rawStepValue;
|
|
||||||
if (noteAssignments.length > 0) {
|
|
||||||
// Determine which notes (1-based) are active in this step
|
|
||||||
const activeNotes = [];
|
|
||||||
for (let bit = 0; bit < 16; bit++) {
|
|
||||||
if (rawStepValue & (1 << bit)) activeNotes.push(bit + 1);
|
|
||||||
}
|
|
||||||
// For each active note, look up the noteAssignment by note index (note-1)
|
|
||||||
// noteAssignments array is indexed by note position (0-based)
|
|
||||||
stepValue = 0;
|
|
||||||
for (const note of activeNotes) {
|
|
||||||
const assignedBell = noteAssignments[note - 1];
|
|
||||||
const bellToFire = (assignedBell && assignedBell > 0) ? assignedBell : note;
|
|
||||||
stepValue |= 1 << (bellToFire - 1);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
setCurrentStep(playFrom);
|
setCurrentStep(playFrom);
|
||||||
playStep(ctx, stepValue, BEAT_DURATION_MS);
|
|
||||||
|
|
||||||
|
// Flash active bells for tone length, then clear
|
||||||
|
const bellsNow = new Set();
|
||||||
|
for (let bit = 0; bit < 16; bit++) {
|
||||||
|
if (stepValue & (1 << bit)) bellsNow.add(bit + 1);
|
||||||
|
}
|
||||||
|
setActiveBells(bellsNow);
|
||||||
|
|
||||||
|
playStep(ctx, stepValue, toneLengthRef.current);
|
||||||
|
|
||||||
|
// Clear bell highlight after tone length
|
||||||
|
const flashTimer = setTimeout(() => {
|
||||||
|
setActiveBells(new Set());
|
||||||
|
}, toneLengthRef.current);
|
||||||
|
|
||||||
|
// Schedule next step after step interval
|
||||||
const timer = setTimeout(() => {
|
const timer = setTimeout(() => {
|
||||||
const next = playFrom + 1;
|
const next = playFrom + 1;
|
||||||
if (next >= stepsRef.current.length) {
|
scheduleStep(next >= stepsRef.current.length ? 0 : next);
|
||||||
scheduleStep(0);
|
|
||||||
} else {
|
|
||||||
scheduleStep(next);
|
|
||||||
}
|
|
||||||
}, speedMsRef.current);
|
}, speedMsRef.current);
|
||||||
|
|
||||||
playbackRef.current = { timer, stepIndex: playFrom };
|
playbackRef.current = { timer, flashTimer, stepIndex: playFrom };
|
||||||
}, []); // no deps — reads everything from refs
|
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||||
|
|
||||||
const handlePlay = () => {
|
const handlePlay = () => {
|
||||||
if (!stepsRef.current.length) return;
|
if (!stepsRef.current.length) return;
|
||||||
@@ -248,12 +274,16 @@ export default function PlaybackModal({ open, melody, builtMelody, files, archet
|
|||||||
if (!open) return null;
|
if (!open) return null;
|
||||||
|
|
||||||
const totalSteps = steps.length;
|
const totalSteps = steps.length;
|
||||||
|
|
||||||
|
// Compute which bells are actually used (after assignment mapping)
|
||||||
const allBellsUsed = steps.reduce((set, v) => {
|
const allBellsUsed = steps.reduce((set, v) => {
|
||||||
getActiveBells(v).forEach((b) => set.add(b));
|
const mapped = applyNoteAssignments(v, noteAssignments);
|
||||||
|
for (let bit = 0; bit < 16; bit++) {
|
||||||
|
if (mapped & (1 << bit)) set.add(bit + 1);
|
||||||
|
}
|
||||||
return set;
|
return set;
|
||||||
}, new Set());
|
}, new Set());
|
||||||
const maxBell = allBellsUsed.size > 0 ? Math.max(...Array.from(allBellsUsed)) : 0;
|
const maxBell = allBellsUsed.size > 0 ? Math.max(...Array.from(allBellsUsed)) : 0;
|
||||||
const currentBells = currentStep >= 0 ? getActiveBells(steps[currentStep] || 0) : [];
|
|
||||||
|
|
||||||
const hasSpeedInfo = minSpeed != null && maxSpeed != null;
|
const hasSpeedInfo = minSpeed != null && maxSpeed != null;
|
||||||
|
|
||||||
@@ -268,7 +298,7 @@ export default function PlaybackModal({ open, melody, builtMelody, files, archet
|
|||||||
style={{
|
style={{
|
||||||
backgroundColor: "var(--bg-card)",
|
backgroundColor: "var(--bg-card)",
|
||||||
borderColor: "var(--border-primary)",
|
borderColor: "var(--border-primary)",
|
||||||
maxWidth: "460px",
|
maxWidth: "480px",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
@@ -295,7 +325,6 @@ export default function PlaybackModal({ open, melody, builtMelody, files, archet
|
|||||||
|
|
||||||
{/* Body */}
|
{/* Body */}
|
||||||
<div className="px-6 py-5 space-y-5">
|
<div className="px-6 py-5 space-y-5">
|
||||||
{/* Loading / error states */}
|
|
||||||
{loading && (
|
{loading && (
|
||||||
<p className="text-sm text-center py-4" style={mutedStyle}>Loading binary...</p>
|
<p className="text-sm text-center py-4" style={mutedStyle}>Loading binary...</p>
|
||||||
)}
|
)}
|
||||||
@@ -322,15 +351,15 @@ export default function PlaybackModal({ open, melody, builtMelody, files, archet
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Note + Assignment visualizer */}
|
{/* Note → Bell assignment visualizer (shows when assignments exist) */}
|
||||||
{noteAssignments.length > 0 ? (
|
{noteAssignments.length > 0 ? (
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs mb-2" style={mutedStyle}>Note → Assigned Bell</p>
|
<p className="text-xs mb-2" style={mutedStyle}>Note → Assigned Bell</p>
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
{noteAssignments.map((assignedBell, noteIdx) => {
|
{noteAssignments.map((assignedBell, noteIdx) => {
|
||||||
const noteNum = noteIdx + 1;
|
// A note is active if the current step has this note's bit set (raw archetype value)
|
||||||
// A note is active if the current step has this note bit set (raw)
|
const isActive = currentStep >= 0 && Boolean(steps[currentStep] & (1 << noteIdx));
|
||||||
const isActive = currentStep >= 0 && Boolean(steps[currentStep] & (1 << (noteNum - 1)));
|
const firesABell = assignedBell && assignedBell > 0;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={noteIdx}
|
key={noteIdx}
|
||||||
@@ -338,41 +367,49 @@ export default function PlaybackModal({ open, melody, builtMelody, files, archet
|
|||||||
style={{
|
style={{
|
||||||
minWidth: "36px",
|
minWidth: "36px",
|
||||||
padding: "4px 6px",
|
padding: "4px 6px",
|
||||||
backgroundColor: isActive ? "var(--accent)" : "var(--bg-card-hover)",
|
backgroundColor: isActive && firesABell
|
||||||
|
? "var(--accent)"
|
||||||
|
: isActive && !firesABell
|
||||||
|
? "rgba(156,163,175,0.15)"
|
||||||
|
: "var(--bg-card-hover)",
|
||||||
borderColor: isActive ? "var(--accent)" : "var(--border-primary)",
|
borderColor: isActive ? "var(--accent)" : "var(--border-primary)",
|
||||||
transform: isActive ? "scale(1.1)" : "scale(1)",
|
transform: isActive && firesABell ? "scale(1.1)" : "scale(1)",
|
||||||
|
opacity: isActive && !firesABell ? 0.5 : 1,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<span className="text-xs font-bold leading-tight" style={{ color: isActive ? "var(--bg-primary)" : "var(--text-secondary)" }}>
|
<span className="text-xs font-bold leading-tight" style={{ color: isActive && firesABell ? "var(--bg-primary)" : "var(--text-secondary)" }}>
|
||||||
{noteNum}
|
{NOTE_LABELS[noteIdx]}
|
||||||
</span>
|
</span>
|
||||||
<div className="w-full my-0.5" style={{ height: "1px", backgroundColor: isActive ? "rgba(255,255,255,0.4)" : "var(--border-primary)" }} />
|
<div className="w-full my-0.5" style={{ height: "1px", backgroundColor: isActive && firesABell ? "rgba(255,255,255,0.4)" : "var(--border-primary)" }} />
|
||||||
<span className="text-xs leading-tight" style={{ color: isActive ? "var(--bg-primary)" : "var(--text-muted)" }}>
|
<span className="text-xs leading-tight" style={{ color: isActive && firesABell ? "var(--bg-primary)" : "var(--text-muted)" }}>
|
||||||
{assignedBell > 0 ? assignedBell : "—"}
|
{assignedBell > 0 ? assignedBell : "—"}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-3 mt-1">
|
<p className="text-xs mt-1" style={mutedStyle}>Top = Note, Bottom = Bell assigned</p>
|
||||||
<span className="text-xs" style={mutedStyle}>Top = Note, Bottom = Bell</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
) : null}
|
||||||
) : (
|
|
||||||
/* Fallback: simple bell circles when no assignments */
|
{/* Active Bell circles (always shown) */}
|
||||||
|
{maxBell > 0 && (
|
||||||
|
<div>
|
||||||
|
<p className="text-xs mb-2" style={mutedStyle}>Active Bells</p>
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
{Array.from({ length: maxBell }, (_, i) => i + 1).map((b) => {
|
{Array.from({ length: maxBell }, (_, i) => i + 1).map((b) => {
|
||||||
const isActive = currentBells.includes(b);
|
const isActive = activeBells.has(b);
|
||||||
const isUsed = allBellsUsed.has(b);
|
const isUsed = allBellsUsed.has(b);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={b}
|
key={b}
|
||||||
className="w-7 h-7 rounded-full flex items-center justify-center text-xs font-bold transition-all"
|
className="w-8 h-8 rounded-full flex items-center justify-center text-xs font-bold"
|
||||||
style={{
|
style={{
|
||||||
backgroundColor: isActive ? "var(--accent)" : isUsed ? "var(--bg-card-hover)" : "var(--bg-primary)",
|
backgroundColor: isActive ? "#22c55e" : isUsed ? "var(--bg-card-hover)" : "var(--bg-primary)",
|
||||||
color: isActive ? "var(--bg-primary)" : isUsed ? "var(--text-secondary)" : "var(--border-primary)",
|
color: isActive ? "#fff" : isUsed ? "var(--text-secondary)" : "var(--border-primary)",
|
||||||
border: `2px solid ${isActive ? "var(--accent)" : "var(--border-primary)"}`,
|
border: `2px solid ${isActive ? "#22c55e" : "var(--border-primary)"}`,
|
||||||
transform: isActive ? "scale(1.2)" : "scale(1)",
|
transition: "background-color 0.05s, border-color 0.05s",
|
||||||
|
transform: isActive ? "scale(1.15)" : "scale(1)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{b}
|
{b}
|
||||||
@@ -380,6 +417,7 @@ export default function PlaybackModal({ open, melody, builtMelody, files, archet
|
|||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Play / Stop */}
|
{/* Play / Stop */}
|
||||||
@@ -414,7 +452,7 @@ export default function PlaybackModal({ open, melody, builtMelody, files, archet
|
|||||||
</span>
|
</span>
|
||||||
{hasSpeedInfo && (
|
{hasSpeedInfo && (
|
||||||
<span className="text-xs ml-2" style={mutedStyle}>
|
<span className="text-xs ml-2" style={mutedStyle}>
|
||||||
({speedMs} ms)
|
({speedMs} ms/step)
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -438,6 +476,29 @@ export default function PlaybackModal({ open, melody, builtMelody, files, archet
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Tone Length Slider */}
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-1.5">
|
||||||
|
<label className="text-sm font-medium" style={labelStyle}>Tone Length</label>
|
||||||
|
<span className="text-sm font-bold" style={{ color: "var(--accent)" }}>
|
||||||
|
{toneLengthMs} ms
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="20"
|
||||||
|
max="400"
|
||||||
|
step="10"
|
||||||
|
value={toneLengthMs}
|
||||||
|
onChange={(e) => setToneLengthMs(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>Short (20 ms)</span>
|
||||||
|
<span>Long (400 ms)</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -381,6 +381,14 @@ export default function ArchetypeList() {
|
|||||||
<strong>{deleteTarget.assigned_melody_ids.length} {deleteTarget.assigned_melody_ids.length === 1 ? "melody" : "melodies"}</strong>.
|
<strong>{deleteTarget.assigned_melody_ids.length} {deleteTarget.assigned_melody_ids.length === 1 ? "melody" : "melodies"}</strong>.
|
||||||
Deleting it will remove the archetype and its binary file, but existing melodies will keep their uploaded binary.
|
Deleting it will remove the archetype and its binary file, but existing melodies will keep their uploaded binary.
|
||||||
</div>
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={(e) => handleShowAssigned(e, deleteTarget)}
|
||||||
|
className="text-sm underline text-left"
|
||||||
|
style={{ color: "var(--accent)", background: "none", border: "none", cursor: "pointer", padding: 0 }}
|
||||||
|
disabled={loadingAssigned}
|
||||||
|
>
|
||||||
|
View assigned {deleteTarget.assigned_melody_ids.length === 1 ? "melody" : "melodies"} →
|
||||||
|
</button>
|
||||||
<p className="text-sm" style={{ color: "var(--text-muted)" }}>Do you still want to delete this archetype?</p>
|
<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">
|
<div className="flex justify-end gap-3">
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ function validateSteps(stepsStr) {
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function BuildOnTheFlyModal({ open, melodyId, currentMelody, defaultName, defaultPid, onClose, onSuccess }) {
|
export default function BuildOnTheFlyModal({ open, melodyId, currentMelody, currentBuiltMelody, defaultName, defaultPid, onClose, onSuccess }) {
|
||||||
const [name, setName] = useState(defaultName || "");
|
const [name, setName] = useState(defaultName || "");
|
||||||
const [pid, setPid] = useState(defaultPid || "");
|
const [pid, setPid] = useState(defaultPid || "");
|
||||||
const [steps, setSteps] = useState("");
|
const [steps, setSteps] = useState("");
|
||||||
@@ -102,6 +102,12 @@ export default function BuildOnTheFlyModal({ open, melodyId, currentMelody, defa
|
|||||||
await api.upload(`/melodies/${melodyId}/upload/binary`, file);
|
await api.upload(`/melodies/${melodyId}/upload/binary`, file);
|
||||||
|
|
||||||
setStatusMsg("Linking to melody...");
|
setStatusMsg("Linking to melody...");
|
||||||
|
// Unassign from any previously assigned archetype first
|
||||||
|
if (currentBuiltMelody?.id) {
|
||||||
|
try {
|
||||||
|
await api.post(`/builder/melodies/${currentBuiltMelody.id}/unassign?firestore_melody_id=${melodyId}`);
|
||||||
|
} catch { /* best-effort */ }
|
||||||
|
}
|
||||||
await api.post(`/builder/melodies/${builtId}/assign?firestore_melody_id=${melodyId}`);
|
await api.post(`/builder/melodies/${builtId}/assign?firestore_melody_id=${melodyId}`);
|
||||||
|
|
||||||
setStatusMsg("Saving archetype data...");
|
setStatusMsg("Saving archetype data...");
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ function computeStepsAndNotes(stepsStr) {
|
|||||||
return { steps: tokens.length, totalNotes: bellSet.size || 1 };
|
return { steps: tokens.length, totalNotes: bellSet.size || 1 };
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function SelectArchetypeModal({ open, melodyId, currentMelody, onClose, onSuccess }) {
|
export default function SelectArchetypeModal({ open, melodyId, currentMelody, currentBuiltMelody, onClose, onSuccess }) {
|
||||||
const [archetypes, setArchetypes] = useState([]);
|
const [archetypes, setArchetypes] = useState([]);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [assigning, setAssigning] = useState(null);
|
const [assigning, setAssigning] = useState(null);
|
||||||
@@ -41,6 +41,13 @@ export default function SelectArchetypeModal({ open, melodyId, currentMelody, on
|
|||||||
setAssigning(archetype.id);
|
setAssigning(archetype.id);
|
||||||
setError("");
|
setError("");
|
||||||
try {
|
try {
|
||||||
|
// Unassign from any previously assigned archetype first
|
||||||
|
if (currentBuiltMelody?.id && currentBuiltMelody.id !== archetype.id) {
|
||||||
|
try {
|
||||||
|
await api.post(`/builder/melodies/${currentBuiltMelody.id}/unassign?firestore_melody_id=${melodyId}`);
|
||||||
|
} catch { /* best-effort */ }
|
||||||
|
}
|
||||||
|
|
||||||
const token = localStorage.getItem("access_token");
|
const token = localStorage.getItem("access_token");
|
||||||
const res = await fetch(`/api${archetype.binary_url}`, {
|
const res = await fetch(`/api${archetype.binary_url}`, {
|
||||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||||
|
|||||||
@@ -1,235 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
function computeStepsAndNotes(stepsStr) {
|
|
||||||
if (!stepsStr || !stepsStr.trim()) return { steps: 0, totalNotes: 0 };
|
|
||||||
const tokens = stepsStr.trim().split(",");
|
|
||||||
const bellSet = new Set();
|
|
||||||
for (const token of tokens) {
|
|
||||||
for (const part of token.split("+")) {
|
|
||||||
const n = parseInt(part.trim(), 10);
|
|
||||||
if (!isNaN(n) && n >= 1 && n <= 16) bellSet.add(n);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { steps: tokens.length, totalNotes: bellSet.size || 1 };
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate steps string. Each comma-separated token must be:
|
|
||||||
* - "0" (silence)
|
|
||||||
* - a number 1-16
|
|
||||||
* - multiple of the above joined by "+"
|
|
||||||
* Returns null if valid, or an error string if invalid.
|
|
||||||
*/
|
|
||||||
function validateSteps(stepsStr) {
|
|
||||||
if (!stepsStr || !stepsStr.trim()) return "Steps are required.";
|
|
||||||
const tokens = stepsStr.trim().split(",");
|
|
||||||
for (const token of tokens) {
|
|
||||||
const parts = token.split("+");
|
|
||||||
for (const part of parts) {
|
|
||||||
const trimmed = part.trim();
|
|
||||||
if (trimmed === "") return `Invalid token near: "${token.trim()}" — empty part found.`;
|
|
||||||
const n = parseInt(trimmed, 10);
|
|
||||||
if (isNaN(n)) return `Invalid step value: "${trimmed}" — must be a number 0–16.`;
|
|
||||||
if (n < 0 || n > 16) return `Step value "${trimmed}" is out of range (must be 0–16).`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function BuildOnTheFlyModal({ open, melodyId, currentMelody, 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 () => {
|
|
||||||
// Mandatory field checks
|
|
||||||
if (!name.trim()) { setError("Name is required."); return; }
|
|
||||||
if (!pid.trim()) { setError("PID is required."); return; }
|
|
||||||
if (!steps.trim()) { setError("Steps are required."); return; }
|
|
||||||
|
|
||||||
// Steps format validation
|
|
||||||
const stepsError = validateSteps(steps);
|
|
||||||
if (stepsError) { setError(stepsError); return; }
|
|
||||||
|
|
||||||
setBuilding(true);
|
|
||||||
setError("");
|
|
||||||
setStatusMsg("");
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Uniqueness check: fetch all existing archetypes
|
|
||||||
setStatusMsg("Checking for conflicts...");
|
|
||||||
const existing = await api.get("/builder/melodies");
|
|
||||||
const list = existing.melodies || [];
|
|
||||||
const dupName = list.find((m) => m.name.toLowerCase() === name.trim().toLowerCase());
|
|
||||||
if (dupName) {
|
|
||||||
setError(`An archetype with the name "${name.trim()}" already exists.`);
|
|
||||||
setBuilding(false);
|
|
||||||
setStatusMsg("");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const dupPid = list.find((m) => m.pid && m.pid.toLowerCase() === pid.trim().toLowerCase());
|
|
||||||
if (dupPid) {
|
|
||||||
setError(`An archetype with the PID "${pid.trim()}" already exists.`);
|
|
||||||
setBuilding(false);
|
|
||||||
setStatusMsg("");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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(),
|
|
||||||
});
|
|
||||||
const 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();
|
|
||||||
// File is named by PID, not friendly name
|
|
||||||
const file = new File([blob], `${pid.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}`);
|
|
||||||
|
|
||||||
// Step 5: Update the melody's information with archetype_csv, steps, and totalNotes
|
|
||||||
setStatusMsg("Saving archetype data...");
|
|
||||||
const csv = steps.trim();
|
|
||||||
const { steps: stepCount, totalNotes } = computeStepsAndNotes(csv);
|
|
||||||
if (currentMelody && csv) {
|
|
||||||
const existingInfo = currentMelody.information || {};
|
|
||||||
await api.put(`/melodies/${melodyId}`, {
|
|
||||||
information: {
|
|
||||||
...existingInfo,
|
|
||||||
archetype_csv: csv,
|
|
||||||
steps: stepCount,
|
|
||||||
totalNotes,
|
|
||||||
},
|
|
||||||
default_settings: currentMelody.default_settings,
|
|
||||||
type: currentMelody.type,
|
|
||||||
url: currentMelody.url,
|
|
||||||
uid: currentMelody.uid,
|
|
||||||
pid: currentMelody.pid,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
setStatusMsg("Done!");
|
|
||||||
onSuccess({ name: name.trim(), pid: pid.trim(), steps: stepCount, totalNotes, archetype_csv: csv });
|
|
||||||
} 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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,403 +0,0 @@
|
|||||||
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 copyText(text, onSuccess) {
|
|
||||||
const fallback = () => {
|
|
||||||
const ta = document.createElement("textarea");
|
|
||||||
ta.value = text;
|
|
||||||
ta.style.cssText = "position:fixed;top:0;left:0;opacity:0";
|
|
||||||
document.body.appendChild(ta);
|
|
||||||
ta.focus();
|
|
||||||
ta.select();
|
|
||||||
try { document.execCommand("copy"); onSuccess?.(); } catch (_) {}
|
|
||||||
document.body.removeChild(ta);
|
|
||||||
};
|
|
||||||
if (navigator.clipboard) {
|
|
||||||
navigator.clipboard.writeText(text).then(onSuccess).catch(fallback);
|
|
||||||
} else {
|
|
||||||
fallback();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function countSteps(stepsStr) {
|
|
||||||
if (!stepsStr || !stepsStr.trim()) return 0;
|
|
||||||
return stepsStr.trim().split(",").length;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Validate steps string. Each comma-separated token must be:
|
|
||||||
* - "0" (silence)
|
|
||||||
* - a number 1-16
|
|
||||||
* - multiple of the above joined by "+"
|
|
||||||
* Returns null if valid, or an error string if invalid.
|
|
||||||
*/
|
|
||||||
function validateSteps(stepsStr) {
|
|
||||||
if (!stepsStr || !stepsStr.trim()) return "Steps are required.";
|
|
||||||
const tokens = stepsStr.trim().split(",");
|
|
||||||
for (const token of tokens) {
|
|
||||||
const parts = token.split("+");
|
|
||||||
for (const part of parts) {
|
|
||||||
const trimmed = part.trim();
|
|
||||||
if (trimmed === "") return `Invalid token near: "${token.trim()}" — empty part found.`;
|
|
||||||
const n = parseInt(trimmed, 10);
|
|
||||||
if (isNaN(n)) return `Invalid step value: "${trimmed}" — must be a number 0–16.`;
|
|
||||||
if (n < 0 || n > 16) return `Step value "${trimmed}" is out of range (must be 0–16).`;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
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);
|
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
// Form state (what the user is editing)
|
|
||||||
const [name, setName] = useState("");
|
|
||||||
const [pid, setPid] = useState("");
|
|
||||||
const [steps, setSteps] = useState("");
|
|
||||||
|
|
||||||
// Saved state (what's actually stored — build actions use this)
|
|
||||||
const [savedPid, setSavedPid] = 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);
|
|
||||||
|
|
||||||
// Track whether the form has unsaved changes relative to what's built
|
|
||||||
const [hasUnsavedChanges, setHasUnsavedChanges] = 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 || "");
|
|
||||||
setSavedPid(data.pid || "");
|
|
||||||
setBinaryBuilt(Boolean(data.binary_path));
|
|
||||||
setBinaryUrl(data.binary_url || null);
|
|
||||||
setProgmemCode(data.progmem_code || "");
|
|
||||||
setHasUnsavedChanges(false);
|
|
||||||
} catch (err) {
|
|
||||||
setError(err.message);
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// Track form dirtiness whenever name/pid/steps change after initial load
|
|
||||||
const handleNameChange = (v) => { setName(v); setHasUnsavedChanges(true); };
|
|
||||||
const handlePidChange = (v) => { setPid(v); setHasUnsavedChanges(true); };
|
|
||||||
const handleStepsChange = (v) => { setSteps(v); setHasUnsavedChanges(true); };
|
|
||||||
|
|
||||||
const handleSave = async () => {
|
|
||||||
if (!name.trim()) { setError("Name is required."); return; }
|
|
||||||
if (!pid.trim()) { setError("PID is required."); return; }
|
|
||||||
|
|
||||||
const stepsError = validateSteps(steps);
|
|
||||||
if (stepsError) { setError(stepsError); return; }
|
|
||||||
|
|
||||||
setSaving(true);
|
|
||||||
setError("");
|
|
||||||
setSuccessMsg("");
|
|
||||||
try {
|
|
||||||
// Uniqueness check
|
|
||||||
const existing = await api.get("/builder/melodies");
|
|
||||||
const list = existing.melodies || [];
|
|
||||||
|
|
||||||
const dupName = list.find((m) => m.id !== id && m.name.toLowerCase() === name.trim().toLowerCase());
|
|
||||||
if (dupName) { setError(`An archetype with the name "${name.trim()}" already exists.`); return; }
|
|
||||||
|
|
||||||
const dupPid = list.find((m) => m.id !== id && m.pid && m.pid.toLowerCase() === pid.trim().toLowerCase());
|
|
||||||
if (dupPid) { setError(`An archetype with the PID "${pid.trim()}" already exists.`); return; }
|
|
||||||
|
|
||||||
const body = { name: name.trim(), pid: pid.trim(), steps: steps.trim() };
|
|
||||||
if (isEdit) {
|
|
||||||
await api.put(`/builder/melodies/${id}`, body);
|
|
||||||
setSavedPid(pid.trim());
|
|
||||||
setHasUnsavedChanges(false);
|
|
||||||
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; }
|
|
||||||
if (hasUnsavedChanges) { setError("You have unsaved changes. Save before rebuilding."); 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; }
|
|
||||||
if (hasUnsavedChanges) { setError("You have unsaved changes. Save before regenerating."); 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;
|
|
||||||
copyText(progmemCode, () => { 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 Archetype" : "New Archetype"}
|
|
||||||
</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)" }}>Archetype 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) => handleNameChange(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) => handlePidChange(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. Must be unique.</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) => handleStepsChange(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.
|
|
||||||
{hasUnsavedChanges && (
|
|
||||||
<span style={{ color: "var(--warning, #f59e0b)", fontWeight: 600 }}> — You have unsaved changes.</span>
|
|
||||||
)}
|
|
||||||
</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 || hasUnsavedChanges}
|
|
||||||
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)" }}
|
|
||||||
title={hasUnsavedChanges ? "Save changes first" : undefined}
|
|
||||||
>
|
|
||||||
{buildingBinary ? "Building..." : binaryBuilt ? "Rebuild Binary" : "Build Binary"}
|
|
||||||
</button>
|
|
||||||
{binaryUrl && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => downloadBinary(binaryUrl, `${savedPid}.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 {savedPid}.bsm
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</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 || hasUnsavedChanges}
|
|
||||||
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)" }}
|
|
||||||
title={hasUnsavedChanges ? "Save changes first" : undefined}
|
|
||||||
>
|
|
||||||
{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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,308 +0,0 @@
|
|||||||
import { useState, useEffect } from "react";
|
|
||||||
import { useNavigate } from "react-router-dom";
|
|
||||||
import api from "../../api/client";
|
|
||||||
import ConfirmDialog from "../../components/ConfirmDialog";
|
|
||||||
|
|
||||||
function fallbackCopy(text, onSuccess) {
|
|
||||||
const ta = document.createElement("textarea");
|
|
||||||
ta.value = text;
|
|
||||||
ta.style.cssText = "position:fixed;top:0;left:0;opacity:0";
|
|
||||||
document.body.appendChild(ta);
|
|
||||||
ta.focus();
|
|
||||||
ta.select();
|
|
||||||
try { document.execCommand("copy"); onSuccess?.(); } catch (_) {}
|
|
||||||
document.body.removeChild(ta);
|
|
||||||
}
|
|
||||||
|
|
||||||
function copyText(text, onSuccess) {
|
|
||||||
if (navigator.clipboard) {
|
|
||||||
navigator.clipboard.writeText(text).then(onSuccess).catch(() => fallbackCopy(text, onSuccess));
|
|
||||||
} else {
|
|
||||||
fallbackCopy(text, onSuccess);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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={() => copyText(melody.progmem_code, () => { 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)" }}>×</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() {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const [melodies, setMelodies] = useState([]);
|
|
||||||
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();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
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;
|
|
||||||
};
|
|
||||||
|
|
||||||
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">
|
|
||||||
<div>
|
|
||||||
<h1 className="text-2xl font-bold" style={{ color: "var(--text-heading)" }}>
|
|
||||||
Archetype Builder
|
|
||||||
</h1>
|
|
||||||
<p className="text-sm mt-1" style={{ color: "var(--text-muted)" }}>
|
|
||||||
Add Archetypes, and Build binary (.bsm) files and firmware PROGMEM code.
|
|
||||||
</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 Archetype
|
|
||||||
</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
|
|
||||||
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>
|
|
||||||
) : (
|
|
||||||
<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
|
|
||||||
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>
|
|
||||||
) : (
|
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* 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) && (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); setDeleteWarningConfirmed(false); }}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<CodeSnippetModal
|
|
||||||
melody={codeSnippetMelody}
|
|
||||||
onClose={() => setCodeSnippetMelody(null)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,154 +0,0 @@
|
|||||||
import { useState, useEffect } from "react";
|
|
||||||
import api from "../../api/client";
|
|
||||||
|
|
||||||
function computeStepsAndNotes(stepsStr) {
|
|
||||||
if (!stepsStr || !stepsStr.trim()) return { steps: 0, totalNotes: 0 };
|
|
||||||
const tokens = stepsStr.trim().split(",");
|
|
||||||
const bellSet = new Set();
|
|
||||||
for (const token of tokens) {
|
|
||||||
for (const part of token.split("+")) {
|
|
||||||
const n = parseInt(part.trim(), 10);
|
|
||||||
if (!isNaN(n) && n >= 1 && n <= 16) bellSet.add(n);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { steps: tokens.length, totalNotes: bellSet.size || 1 };
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function SelectBuiltMelodyModal({ open, melodyId, currentMelody, 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}`);
|
|
||||||
|
|
||||||
// 4. Update the melody's information with archetype_csv, steps, and totalNotes
|
|
||||||
const csv = builtMelody.steps || "";
|
|
||||||
const { steps, totalNotes } = computeStepsAndNotes(csv);
|
|
||||||
if (currentMelody && csv) {
|
|
||||||
const existingInfo = currentMelody.information || {};
|
|
||||||
await api.put(`/melodies/${melodyId}`, {
|
|
||||||
information: {
|
|
||||||
...existingInfo,
|
|
||||||
archetype_csv: csv,
|
|
||||||
steps,
|
|
||||||
totalNotes,
|
|
||||||
},
|
|
||||||
default_settings: currentMelody.default_settings,
|
|
||||||
type: currentMelody.type,
|
|
||||||
url: currentMelody.url,
|
|
||||||
uid: currentMelody.uid,
|
|
||||||
pid: currentMelody.pid,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
onSuccess({ name: builtMelody.name, pid: builtMelody.pid, steps, totalNotes, archetype_csv: csv });
|
|
||||||
} 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