Files
bellsystems-cp/frontend/src/melodies/MelodyComposer.jsx

819 lines
33 KiB
JavaScript

import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useNavigate, useLocation } from "react-router-dom";
import api from "../api/client";
const MAX_NOTES = 16;
const NOTE_LABELS = "ABCDEFGHIJKLMNOP";
function bellFrequency(bellNumber) {
// One octave every 8 notes.
return 880 * Math.pow(2, -((bellNumber - 1) / 8));
}
function stepToNotation(stepValue) {
if (!stepValue) return "0";
const active = [];
for (let bit = 0; bit < 16; bit++) {
if (stepValue & (1 << bit)) active.push(bit + 1);
}
return active.join("+");
}
function stepToHex(stepValue) {
return `0x${(stepValue >>> 0).toString(16).toUpperCase().padStart(4, "0")}`;
}
function msToBpm(ms) {
return Math.max(1, Math.round(60000 / Math.max(1, Number(ms) || 1)));
}
function bpmToMs(bpm) {
return Math.max(20, Math.round(60000 / Math.max(1, Number(bpm) || 1)));
}
function interpolateHue(t) {
const stops = [
[0.0, 190],
[0.24, 140],
[0.5, 56],
[0.82, 30],
[1.0, 0],
];
for (let i = 0; i < stops.length - 1; i++) {
const [aPos, aHue] = stops[i];
const [bPos, bHue] = stops[i + 1];
if (t >= aPos && t <= bPos) {
const local = (t - aPos) / (bPos - aPos || 1);
return aHue + (bHue - aHue) * local;
}
}
return stops[stops.length - 1][1];
}
function noteDotColor(noteNumber) {
const n = Number(noteNumber || 1);
const t = Math.min(1, Math.max(0, (n - 1) / 15));
const hue = interpolateHue(t);
return `hsl(${hue}, 78%, 68%)`;
}
function noteDotGlow(noteNumber) {
const n = Number(noteNumber || 1);
const t = Math.min(1, Math.max(0, (n - 1) / 15));
const hue = interpolateHue(t);
return `hsla(${hue}, 78%, 56%, 0.45)`;
}
function noteDotColorFromSettings(noteNumber, colorPalette) {
const n = Number(noteNumber || 1);
const custom = colorPalette?.[n - 1];
return custom || noteDotColor(n);
}
function noteDotGlowFromSettings(noteNumber, colorPalette) {
const n = Number(noteNumber || 1);
const custom = colorPalette?.[n - 1];
return custom ? `${custom}66` : noteDotGlow(n);
}
function playStep(audioCtx, stepValue, noteDurationMs) {
if (!audioCtx) return;
const now = audioCtx.currentTime;
const duration = Math.max(10, noteDurationMs) / 1000;
const fadeIn = 0.005;
const fadeOut = Math.min(0.03, duration / 2);
for (let bit = 0; bit < 16; bit++) {
if (stepValue & (1 << bit)) {
const freq = bellFrequency(bit + 1);
const osc = audioCtx.createOscillator();
const gain = audioCtx.createGain();
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 + Math.max(duration - fadeOut, fadeIn + 0.001));
gain.gain.linearRampToValueAtTime(0, now + duration);
osc.connect(gain);
gain.connect(audioCtx.destination);
osc.start(now);
osc.stop(now + duration);
}
}
}
function csvToSteps(csv) {
if (!csv || !csv.trim()) return null;
return csv.trim().split(",").map((token) => {
const parts = token.split("+");
let val = 0;
for (const p of parts) {
const n = parseInt(p.trim(), 10);
if (!isNaN(n) && n >= 1 && n <= 16) val |= (1 << (n - 1));
}
return val;
});
}
export default function MelodyComposer() {
const navigate = useNavigate();
const { state: routeState } = useLocation();
const loadedArchetype = routeState?.archetype || null;
const initialSteps = () => {
if (loadedArchetype?.steps) {
const parsed = csvToSteps(loadedArchetype.steps);
if (parsed?.length) return parsed;
}
return Array.from({ length: 16 }, () => 0);
};
const [steps, setSteps] = useState(initialSteps);
const [noteCount, setNoteCount] = useState(() => {
if (loadedArchetype?.steps) {
const parsed = csvToSteps(loadedArchetype.steps);
if (parsed?.length) {
let maxBit = 0;
for (const v of parsed) {
for (let b = 15; b >= 0; b--) {
if (v & (1 << b)) { maxBit = Math.max(maxBit, b + 1); break; }
}
}
return Math.max(8, maxBit);
}
}
return 8;
});
const [stepDelayMs, setStepDelayMs] = useState(280);
const [noteDurationMs, setNoteDurationMs] = useState(110);
const [measureEvery, setMeasureEvery] = useState(4);
const [loopEnabled, setLoopEnabled] = useState(true);
const [isPlaying, setIsPlaying] = useState(false);
const [currentStep, setCurrentStep] = useState(-1);
const [error, setError] = useState("");
const [successMsg, setSuccessMsg] = useState("");
const [showDeployModal, setShowDeployModal] = useState(false);
const [deployName, setDeployName] = useState("");
const [deployPid, setDeployPid] = useState("");
const [deployError, setDeployError] = useState("");
const [deploying, setDeploying] = useState(false);
const [deployMode, setDeployMode] = useState("new"); // "new" | "update"
const [noteColors, setNoteColors] = useState([]);
const [stepMenuIndex, setStepMenuIndex] = useState(null);
const audioCtxRef = useRef(null);
const playbackRef = useRef(null);
const stepsRef = useRef(steps);
const stepDelayRef = useRef(stepDelayMs);
const noteDurationRef = useRef(noteDurationMs);
const loopEnabledRef = useRef(loopEnabled);
const stepMenuRef = useRef(null);
useEffect(() => {
stepsRef.current = steps;
}, [steps]);
useEffect(() => {
stepDelayRef.current = stepDelayMs;
}, [stepDelayMs]);
useEffect(() => {
noteDurationRef.current = noteDurationMs;
}, [noteDurationMs]);
useEffect(() => {
loopEnabledRef.current = loopEnabled;
}, [loopEnabled]);
useEffect(() => {
if (stepMenuIndex == null) return undefined;
const onDocClick = (e) => {
if (stepMenuRef.current && !stepMenuRef.current.contains(e.target)) {
setStepMenuIndex(null);
}
};
document.addEventListener("mousedown", onDocClick);
return () => document.removeEventListener("mousedown", onDocClick);
}, [stepMenuIndex]);
const ensureAudioContext = useCallback(() => {
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 stopPlayback = useCallback(() => {
if (playbackRef.current?.timer) {
clearTimeout(playbackRef.current.timer);
}
playbackRef.current = null;
setIsPlaying(false);
setCurrentStep(-1);
}, []);
const scheduleStep = useCallback((stepIndex) => {
const currentSteps = stepsRef.current;
if (!currentSteps.length) {
stopPlayback();
return;
}
const ctx = ensureAudioContext();
const nextIndex = stepIndex % currentSteps.length;
const stepValue = currentSteps[nextIndex];
setCurrentStep(nextIndex);
playStep(ctx, stepValue, noteDurationRef.current);
const isLastStep = nextIndex >= currentSteps.length - 1;
const shouldContinue = !isLastStep || loopEnabledRef.current;
if (!shouldContinue) {
playbackRef.current = {
timer: setTimeout(() => stopPlayback(), stepDelayRef.current),
};
return;
}
playbackRef.current = {
timer: setTimeout(() => {
scheduleStep(isLastStep ? 0 : nextIndex + 1);
}, stepDelayRef.current),
};
}, [ensureAudioContext, stopPlayback]);
useEffect(() => {
return () => {
stopPlayback();
};
}, [stopPlayback]);
useEffect(() => {
let canceled = false;
api.get("/settings/melody")
.then((s) => {
if (canceled) return;
setNoteColors((s?.note_assignment_colors || []).slice(0, 16));
})
.catch(() => {
if (!canceled) setNoteColors([]);
});
return () => { canceled = true; };
}, []);
const toggleCell = (noteIndex, stepIndex) => {
const bit = 1 << noteIndex;
setSteps((prev) => {
const next = [...prev];
next[stepIndex] = (next[stepIndex] || 0) ^ bit;
return next;
});
};
const addStep = () => setSteps((prev) => [...prev, 0]);
const removeStep = () => {
setSteps((prev) => {
if (prev.length <= 1) return prev;
const next = prev.slice(0, prev.length - 1);
if (currentStep >= next.length) setCurrentStep(next.length - 1);
return next;
});
};
const addNote = () => setNoteCount((prev) => Math.min(MAX_NOTES, prev + 1));
const removeNote = () => {
setNoteCount((prev) => {
if (prev <= 1) return prev;
const nextCount = prev - 1;
const removedBitMask = ~((1 << nextCount) - 1);
setSteps((currentSteps) => currentSteps.map((value) => value & ~removedBitMask));
return nextCount;
});
};
const clearAll = () => setSteps((prev) => prev.map(() => 0));
const insertStepAt = (index) => {
setSteps((prev) => {
const next = [...prev];
next.splice(index, 0, 0);
return next;
});
setCurrentStep((prev) => (prev >= index ? prev + 1 : prev));
};
const deleteStepAt = (index) => {
setSteps((prev) => {
if (prev.length <= 1) return prev;
const next = [...prev];
next.splice(index, 1);
return next;
});
setCurrentStep((prev) => {
if (prev < 0) return prev;
if (prev === index) return -1;
if (prev > index) return prev - 1;
return prev;
});
};
const handlePlay = () => {
if (!stepsRef.current.length) return;
setError("");
setIsPlaying(true);
scheduleStep(0);
};
const openDeployModal = (mode = "new") => {
setError("");
setSuccessMsg("");
setDeployError("");
setDeployMode(mode);
if (mode === "update" && loadedArchetype) {
setDeployName(loadedArchetype.name || "");
setDeployPid(loadedArchetype.pid || "");
} else {
setDeployName("");
setDeployPid("");
}
setShowDeployModal(true);
};
const closeDeployModal = () => {
if (deploying) return;
setDeployError("");
setShowDeployModal(false);
};
const handleDeploy = async () => {
const name = deployName.trim();
const pid = deployPid.trim();
if (!name) { setDeployError("Name is required."); return; }
if (!pid) { setDeployError("PID is required."); return; }
setDeploying(true);
setDeployError("");
setSuccessMsg("");
try {
const stepsStr = steps.map(stepToNotation).join(",");
if (deployMode === "update" && loadedArchetype?.id) {
const updated = await api.put(`/builder/melodies/${loadedArchetype.id}`, { name, pid, steps: stepsStr });
setSuccessMsg(`Archetype "${name}" updated successfully.`);
setShowDeployModal(false);
if (updated?.id) navigate(`/melodies/archetypes/${updated.id}`);
} else {
const existing = await api.get("/builder/melodies");
const list = existing.melodies || [];
const dupName = list.find((m) => m.name.toLowerCase() === name.toLowerCase());
if (dupName) { setDeployError(`An archetype with the name "${name}" already exists.`); return; }
const dupPid = list.find((m) => m.pid && m.pid.toLowerCase() === pid.toLowerCase());
if (dupPid) { setDeployError(`An archetype with the PID "${pid}" already exists.`); return; }
const created = await api.post("/builder/melodies", { name, pid, steps: stepsStr });
setSuccessMsg(`Archetype "${name}" deployed successfully.`);
setShowDeployModal(false);
setDeployName("");
setDeployPid("");
if (created?.id) navigate(`/melodies/archetypes/${created.id}`);
}
} catch (err) {
setDeployError(err.message);
} finally {
setDeploying(false);
}
};
const activeBellsInCurrentStep = useMemo(() => {
if (currentStep < 0 || !steps[currentStep]) return [];
const active = [];
for (let bit = 0; bit < noteCount; bit++) {
if (steps[currentStep] & (1 << bit)) active.push(bit + 1);
}
return active;
}, [currentStep, noteCount, steps]);
const speedBpm = msToBpm(stepDelayMs);
const measureSliderValue = Math.max(1, Math.min(16, Number(measureEvery) || 1));
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold" style={{ color: "var(--text-heading)" }}>
Melody Composer
</h1>
<p className="text-sm mt-1" style={{ color: "var(--text-muted)" }}>
Build bell-step melodies visually. Notes map directly to bell numbers (1-16).
</p>
</div>
{loadedArchetype && (
<div className="rounded-lg border px-4 py-3 flex items-center gap-3"
style={{ backgroundColor: "rgba(139,92,246,0.08)", borderColor: "rgba(139,92,246,0.3)" }}>
<span className="text-xs font-semibold uppercase tracking-wide" style={{ color: "#a78bfa" }}>Editing Archetype</span>
<span className="text-sm font-medium" style={{ color: "var(--text-heading)" }}>{loadedArchetype.name}</span>
<span className="text-xs" style={{ color: "var(--text-muted)" }}>·</span>
<span className="text-xs font-mono" style={{ color: "var(--text-muted)" }}>{loadedArchetype.id}</span>
<button
type="button"
onClick={() => navigate("/melodies/composer", { replace: true, state: null })}
className="ml-auto text-xs px-2 py-1 rounded"
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-muted)", border: "1px solid var(--border-primary)" }}
>
Clear
</button>
</div>
)}
{error && (
<div
className="text-sm rounded-md p-3 border"
style={{
backgroundColor: "var(--danger-bg)",
borderColor: "var(--danger)",
color: "var(--danger-text)",
}}
>
{error}
</div>
)}
{successMsg && (
<div
className="text-sm rounded-md p-3 border"
style={{
backgroundColor: "var(--success-bg)",
borderColor: "var(--success)",
color: "var(--success-text)",
}}
>
{successMsg}
</div>
)}
<section className="ui-section-card">
<div className="flex flex-wrap items-center gap-2">
<button type="button" onClick={addStep} className="px-3 py-1.5 text-sm rounded-md" style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}>+ Step</button>
<button type="button" onClick={removeStep} className="px-3 py-1.5 text-sm rounded-md" style={{ backgroundColor: "var(--btn-neutral)", color: "var(--text-white)" }}>- Step</button>
<span className="mx-1 text-sm" style={{ color: "var(--border-primary)" }}>|</span>
<button type="button" onClick={addNote} disabled={noteCount >= MAX_NOTES} className="px-3 py-1.5 text-sm rounded-md disabled:opacity-50" style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}>+ Note</button>
<button type="button" onClick={removeNote} disabled={noteCount <= 1} className="px-3 py-1.5 text-sm rounded-md disabled:opacity-50" style={{ backgroundColor: "var(--btn-neutral)", color: "var(--text-white)" }}>- Note</button>
<span className="mx-1 text-sm" style={{ color: "var(--border-primary)" }}>|</span>
<button type="button" onClick={clearAll} className="px-3 py-1.5 text-sm rounded-md" style={{ backgroundColor: "var(--danger-btn)", color: "var(--text-white)" }}>Clear</button>
<div className="ml-auto flex items-center gap-3">
<div className="text-xs inline-flex items-center gap-2" style={{ color: "var(--text-muted)" }}>
{currentStep >= 0 && (
<>
<span>
Playing step {currentStep + 1}/{steps.length}
{activeBellsInCurrentStep.length ? ` (bells: ${activeBellsInCurrentStep.join(", ")})` : " (silence)"}
</span>
<span style={{ color: "var(--border-primary)" }}>|</span>
</>
)}
<span>{steps.length} steps, {noteCount} notes</span>
</div>
{loadedArchetype ? (
<>
<button type="button" onClick={() => openDeployModal("new")} className="px-3 py-1.5 rounded-md text-sm whitespace-nowrap" style={{ backgroundColor: "var(--btn-neutral)", color: "var(--text-white)" }}>Deploy as New Archetype</button>
<button type="button" onClick={() => openDeployModal("update")} className="px-3 py-1.5 rounded-md text-sm whitespace-nowrap" style={{ backgroundColor: "var(--text-link)", color: "var(--text-white)" }}>Update Current Archetype</button>
</>
) : (
<button type="button" onClick={() => openDeployModal("new")} className="px-3 py-1.5 rounded-md text-sm whitespace-nowrap" style={{ backgroundColor: "var(--text-link)", color: "var(--text-white)" }}>Deploy Archetype</button>
)}
</div>
</div>
<div className="mt-4 pt-4 border-t" style={{ borderColor: "var(--border-primary)" }}>
<div className="flex flex-wrap lg:flex-nowrap items-end gap-4">
<div className="flex items-center gap-2">
{!isPlaying ? (
<button type="button" onClick={handlePlay} className="w-24 px-4 py-2 rounded-md text-sm" style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}>Play</button>
) : (
<button type="button" onClick={stopPlayback} className="w-24 px-4 py-2 rounded-md text-sm" style={{ backgroundColor: "var(--danger-btn)", color: "var(--text-white)" }}>Stop</button>
)}
<button
type="button"
onClick={() => setLoopEnabled((v) => !v)}
className="px-4 py-2 rounded-md text-sm inline-flex items-center gap-2"
style={{
backgroundColor: loopEnabled ? "rgba(34,197,94,0.2)" : "var(--bg-card-hover)",
color: loopEnabled ? "#22c55e" : "var(--text-secondary)",
border: "1px solid var(--border-primary)",
}}
>
<span>Loop</span>
<span className="inline-block w-2.5 h-2.5 rounded-full" style={{ backgroundColor: loopEnabled ? "#22c55e" : "var(--text-muted)" }} />
</button>
<span className="mx-1 text-sm" style={{ color: "var(--border-primary)" }}>|</span>
</div>
<div className="flex-1 min-w-0 grid grid-cols-1 sm:grid-cols-5 gap-5 items-end">
<div className="sm:col-span-2 min-w-0">
<div className="flex items-center justify-between">
<label className="text-sm font-medium" style={{ color: "var(--text-secondary)" }}>Speed in BPM</label>
<span className="text-sm inline-flex items-baseline gap-2">
<span style={{ color: "var(--text-muted)", fontSize: "11px" }}>{stepDelayMs} ms</span>
<span style={{ color: "var(--accent)", fontWeight: 600 }}>{speedBpm} bpm</span>
</span>
</div>
<input type="range" min="30" max="1500" step="1" value={speedBpm} onChange={(e) => setStepDelayMs(bpmToMs(Number(e.target.value)))} className="w-full mt-2" />
</div>
<div className="sm:col-span-2 min-w-0">
<div className="flex items-center justify-between">
<label className="text-sm font-medium" style={{ color: "var(--text-secondary)" }}>Note Duration</label>
<span className="text-sm font-semibold" style={{ color: "var(--accent)" }}>{noteDurationMs} ms</span>
</div>
<input type="range" min="20" max="500" step="10" value={noteDurationMs} onChange={(e) => setNoteDurationMs(Number(e.target.value))} className="w-full mt-2" />
</div>
<div className="sm:col-span-1 min-w-0">
<div className="flex items-center justify-between">
<label className="text-sm font-medium" style={{ color: "var(--text-secondary)" }}>Measure</label>
<span className="text-sm font-semibold" style={{ color: "var(--accent)" }}>{measureEvery}</span>
</div>
<input type="range" min="1" max="16" step="1" value={measureSliderValue} onChange={(e) => setMeasureEvery(Number(e.target.value))} className="w-full mt-2" />
</div>
</div>
</div>
</div>
</section>
<section className="ui-section-card">
<div className="overflow-x-auto">
<table className="min-w-max border-separate border-spacing-0">
<thead>
<tr>
<th
className="sticky top-0 left-0 z-30 px-3 py-2 text-xs font-semibold border-b border-r"
style={{ backgroundColor: "var(--bg-secondary)", borderColor: "var(--border-primary)", color: "var(--text-secondary)" }}
>
Note \\ Step
</th>
{steps.map((_, stepIndex) => {
const isCurrent = stepIndex === currentStep;
const measureHit = measureEvery > 0 && (stepIndex + 1) % measureEvery === 0;
const measureBlockOdd = measureEvery > 0 && Math.floor(stepIndex / measureEvery) % 2 === 1;
return (
<th
key={stepIndex}
className="sticky top-0 z-20 px-1 py-1 text-xs font-semibold border-b border-r relative"
style={{
minWidth: "44px",
backgroundColor: isCurrent
? "rgba(116,184,22,0.24)"
: (measureBlockOdd ? "rgba(255,255,255,0.02)" : "var(--bg-secondary)"),
borderColor: measureHit ? "rgba(255,255,255,0.38)" : "var(--border-primary)",
borderRightWidth: measureHit ? "2px" : "1px",
color: isCurrent ? "var(--accent)" : "var(--text-secondary)",
}}
>
<button
type="button"
className="w-full px-1 py-1"
style={{ background: "transparent", border: "none", color: "inherit" }}
onClick={(e) => {
e.stopPropagation();
setStepMenuIndex((prev) => (prev === stepIndex ? null : stepIndex));
}}
>
{stepIndex + 1}
</button>
{stepMenuIndex === stepIndex && (
<div
ref={stepMenuRef}
className="absolute right-0 top-full mt-1 w-28 rounded-md border shadow-lg z-50"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
>
<button
type="button"
className="w-full text-left px-2 py-1.5 text-xs"
style={{ color: "var(--text-primary)", background: "transparent", border: "none" }}
onClick={() => { insertStepAt(stepIndex + 1); setStepMenuIndex(null); }}
>
Add After
</button>
<button
type="button"
className="w-full text-left px-2 py-1.5 text-xs"
style={{ color: "var(--text-primary)", background: "transparent", border: "none" }}
onClick={() => { insertStepAt(stepIndex); setStepMenuIndex(null); }}
>
Add Before
</button>
<button
type="button"
className="w-full text-left px-2 py-1.5 text-xs"
style={{ color: "var(--danger)", background: "transparent", border: "none" }}
onClick={() => { deleteStepAt(stepIndex); setStepMenuIndex(null); }}
>
Delete
</button>
</div>
)}
</th>
);
})}
</tr>
</thead>
<tbody>
{Array.from({ length: noteCount }, (_, noteIndex) => (
<tr key={noteIndex}>
<th
className="sticky left-0 z-10 px-3 py-2 text-xs font-medium border-b border-r"
style={{
backgroundColor: "var(--bg-secondary)",
borderColor: "var(--border-primary)",
color: "var(--text-secondary)",
}}
>
{NOTE_LABELS[noteIndex]}
</th>
{steps.map((stepValue, stepIndex) => {
const enabled = Boolean(stepValue & (1 << noteIndex));
const isCurrent = stepIndex === currentStep;
const measureHit = measureEvery > 0 && (stepIndex + 1) % measureEvery === 0;
const measureBlockOdd = measureEvery > 0 && Math.floor(stepIndex / measureEvery) % 2 === 1;
return (
<td
key={`${noteIndex}-${stepIndex}`}
className="border-b border-r"
style={{
borderColor: measureHit ? "rgba(255,255,255,0.34)" : "var(--border-primary)",
borderRightWidth: measureHit ? "2px" : "1px",
backgroundColor: isCurrent
? "rgba(116,184,22,0.08)"
: (measureBlockOdd ? "rgba(255,255,255,0.015)" : "transparent"),
width: "44px",
height: "44px",
}}
>
<button
type="button"
aria-label={`Toggle note ${noteIndex + 1} on step ${stepIndex + 1}`}
aria-pressed={enabled}
onClick={() => toggleCell(noteIndex, stepIndex)}
className="w-full h-full flex items-center justify-center transition-colors"
style={{
backgroundColor: "transparent",
border: "none",
outline: "none",
}}
>
<span
aria-hidden="true"
style={{
width: "54%",
height: "54%",
borderRadius: "9999px",
backgroundColor: noteDotColorFromSettings(noteIndex + 1, noteColors),
opacity: enabled ? 1 : 0,
transform: enabled ? "scale(1)" : "scale(0.4)",
boxShadow: enabled ? `0 0 10px 3px ${noteDotGlowFromSettings(noteIndex + 1, noteColors)}` : "none",
transition: "opacity 140ms ease, transform 140ms ease, box-shadow 180ms ease",
}}
/>
</button>
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
</section>
<section className="ui-section-card">
<div className="grid grid-cols-1 xl:grid-cols-2 gap-4">
<div>
<p className="text-xs mb-1" style={{ color: "var(--text-muted)" }}>Generated CSV notation</p>
<pre
className="rounded-md p-3 text-xs overflow-auto"
style={{ backgroundColor: "var(--bg-primary)", border: "1px solid var(--border-primary)", color: "var(--text-primary)" }}
>
{`{${steps.map(stepToNotation).join(",")}}`}
</pre>
</div>
<div>
<p className="text-xs mb-1" style={{ color: "var(--text-muted)" }}>Generated PROGMEM Values</p>
<pre
className="rounded-md p-3 text-xs overflow-auto"
style={{ backgroundColor: "var(--bg-primary)", border: "1px solid var(--border-primary)", color: "var(--text-primary)" }}
>
{`const uint16_t PROGMEM melody_builtin_custom[] = {\n ${steps.map(stepToHex).join(", ")}\n};`}
</pre>
</div>
</div>
</section>
{showDeployModal && (
<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 && closeDeployModal()}
>
<div
className="w-full max-w-md 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)" }}>
{deployMode === "update" ? "Update Archetype" : "Deploy Archetype"}
</h2>
<p className="text-xs mt-0.5" style={{ color: "var(--text-muted)" }}>
{deployMode === "update"
? "Rebuild the existing archetype with the current composer pattern."
: "Create a new archetype from this composer pattern."}
</p>
</div>
<button
type="button"
onClick={closeDeployModal}
className="text-xl leading-none"
style={{ color: "var(--text-muted)" }}
disabled={deploying}
>
&times;
</button>
</div>
<div className="px-6 py-5 space-y-4">
{deployError && (
<div
className="text-sm rounded-md p-3 border"
style={{
backgroundColor: "var(--danger-bg)",
borderColor: "var(--danger)",
color: "var(--danger-text)",
}}
>
{deployError}
</div>
)}
<div>
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}>
Name *
</label>
<input
type="text"
value={deployName}
onChange={(e) => setDeployName(e.target.value)}
className="w-full px-3 py-2 rounded-md text-sm border"
placeholder="e.g. Doksologia_3k"
disabled={deploying}
/>
</div>
<div>
<label className="block text-sm font-medium mb-1" style={{ color: "var(--text-secondary)" }}>
PID *
</label>
<input
type="text"
value={deployPid}
onChange={(e) => setDeployPid(e.target.value)}
className="w-full px-3 py-2 rounded-md text-sm border"
placeholder="e.g. builtin_doksologia_3k"
disabled={deploying}
/>
</div>
</div>
<div
className="flex justify-end gap-2 px-6 py-4 border-t"
style={{ borderColor: "var(--border-primary)" }}
>
<button
type="button"
onClick={closeDeployModal}
className="px-4 py-2 text-sm rounded-md transition-colors"
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-primary)" }}
disabled={deploying}
>
Cancel
</button>
<button
type="button"
onClick={handleDeploy}
className="px-4 py-2 text-sm rounded-md transition-colors disabled:opacity-50"
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
disabled={deploying}
>
{deploying ? (deployMode === "update" ? "Updating..." : "Deploying...") : (deployMode === "update" ? "Update" : "Deploy")}
</button>
</div>
</div>
</div>
)}
</div>
);
}