CODEX - Improvements to the page
This commit is contained in:
@@ -1,6 +1,9 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import api from "../api/client";
|
||||
|
||||
const MAX_NOTES = 16;
|
||||
const NOTE_LABELS = "ABCDEFGHIJKLMNOP";
|
||||
|
||||
function bellFrequency(bellNumber) {
|
||||
return 880 * Math.pow(Math.pow(2, 1 / 12), -2 * (bellNumber - 1));
|
||||
@@ -50,6 +53,7 @@ function playStep(audioCtx, stepValue, noteDurationMs) {
|
||||
}
|
||||
|
||||
export default function MelodyComposer() {
|
||||
const navigate = useNavigate();
|
||||
const [steps, setSteps] = useState(Array.from({ length: 16 }, () => 0));
|
||||
const [noteCount, setNoteCount] = useState(8);
|
||||
const [stepDelayMs, setStepDelayMs] = useState(280);
|
||||
@@ -57,6 +61,13 @@ export default function MelodyComposer() {
|
||||
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 audioCtxRef = useRef(null);
|
||||
const playbackRef = useRef(null);
|
||||
@@ -171,10 +182,74 @@ export default function MelodyComposer() {
|
||||
|
||||
const handlePlay = () => {
|
||||
if (!stepsRef.current.length) return;
|
||||
setError("");
|
||||
setIsPlaying(true);
|
||||
scheduleStep(0);
|
||||
};
|
||||
|
||||
const openDeployModal = () => {
|
||||
setError("");
|
||||
setSuccessMsg("");
|
||||
setDeployError("");
|
||||
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 existing = await api.get("/builder/melodies");
|
||||
const list = existing.melodies || [];
|
||||
const dupName = list.find((m) => m.name.toLowerCase() === name.toLowerCase());
|
||||
if (dupName) {
|
||||
setDeployError(`An archetype with the name "${name}" already exists.`);
|
||||
return;
|
||||
}
|
||||
const dupPid = list.find((m) => m.pid && m.pid.toLowerCase() === pid.toLowerCase());
|
||||
if (dupPid) {
|
||||
setDeployError(`An archetype with the PID "${pid}" already exists.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const stepsStr = steps.map(stepToNotation).join(",");
|
||||
const created = await api.post("/builder/melodies", {
|
||||
name,
|
||||
pid,
|
||||
steps: stepsStr,
|
||||
});
|
||||
|
||||
setSuccessMsg(`Archetype "${name}" deployed successfully.`);
|
||||
setShowDeployModal(false);
|
||||
setDeployName("");
|
||||
setDeployPid("");
|
||||
if (created?.id) {
|
||||
navigate(`/melodies/archetypes/${created.id}`);
|
||||
}
|
||||
} catch (err) {
|
||||
setDeployError(err.message);
|
||||
} finally {
|
||||
setDeploying(false);
|
||||
}
|
||||
};
|
||||
|
||||
const activeBellsInCurrentStep = useMemo(() => {
|
||||
if (currentStep < 0 || !steps[currentStep]) return [];
|
||||
const active = [];
|
||||
@@ -195,6 +270,31 @@ export default function MelodyComposer() {
|
||||
</p>
|
||||
</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="rounded-lg border p-4"
|
||||
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
|
||||
@@ -216,6 +316,7 @@ export default function MelodyComposer() {
|
||||
>
|
||||
- Step
|
||||
</button>
|
||||
<span className="mx-1 text-sm" style={{ color: "var(--border-primary)" }}>|</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={addNote}
|
||||
@@ -234,6 +335,7 @@ export default function MelodyComposer() {
|
||||
>
|
||||
- Note
|
||||
</button>
|
||||
<span className="mx-1 text-sm" style={{ color: "var(--border-primary)" }}>|</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={clearAll}
|
||||
@@ -253,8 +355,49 @@ export default function MelodyComposer() {
|
||||
className="rounded-lg border p-4"
|
||||
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
|
||||
>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
||||
<div>
|
||||
<div className="grid grid-cols-1 xl:grid-cols-12 gap-4 items-end">
|
||||
<div className="xl:col-span-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="inline-flex items-center gap-2 text-sm" style={{ color: "var(--text-secondary)" }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={loopEnabled}
|
||||
onChange={(e) => setLoopEnabled(e.target.checked)}
|
||||
/>
|
||||
Loop
|
||||
</label>
|
||||
{!isPlaying ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handlePlay}
|
||||
className="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="px-4 py-2 rounded-md text-sm"
|
||||
style={{ backgroundColor: "var(--danger-btn)", color: "var(--text-white)" }}
|
||||
>
|
||||
Stop
|
||||
</button>
|
||||
)}
|
||||
<span className="mx-1 text-sm" style={{ color: "var(--border-primary)" }}>|</span>
|
||||
<button
|
||||
type="button"
|
||||
onClick={openDeployModal}
|
||||
className="px-4 py-2 rounded-md text-sm"
|
||||
style={{ backgroundColor: "var(--text-link)", color: "var(--text-white)" }}
|
||||
>
|
||||
Deploy as Archetype
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="xl:col-span-5">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-sm font-medium" style={{ color: "var(--text-secondary)" }}>
|
||||
Step Delay
|
||||
@@ -274,7 +417,7 @@ export default function MelodyComposer() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="xl:col-span-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-sm font-medium" style={{ color: "var(--text-secondary)" }}>
|
||||
Note Duration
|
||||
@@ -286,7 +429,7 @@ export default function MelodyComposer() {
|
||||
<input
|
||||
type="range"
|
||||
min="20"
|
||||
max="1200"
|
||||
max="500"
|
||||
step="10"
|
||||
value={noteDurationMs}
|
||||
onChange={(e) => setNoteDurationMs(Number(e.target.value))}
|
||||
@@ -294,36 +437,6 @@ export default function MelodyComposer() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2 lg:justify-end">
|
||||
{!isPlaying ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handlePlay}
|
||||
className="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="px-4 py-2 rounded-md text-sm"
|
||||
style={{ backgroundColor: "var(--danger-btn)", color: "var(--text-white)" }}
|
||||
>
|
||||
Stop
|
||||
</button>
|
||||
)}
|
||||
|
||||
<label className="inline-flex items-center gap-2 text-sm" style={{ color: "var(--text-secondary)" }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={loopEnabled}
|
||||
onChange={(e) => setLoopEnabled(e.target.checked)}
|
||||
/>
|
||||
Loop
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{currentStep >= 0 && (
|
||||
@@ -338,7 +451,7 @@ export default function MelodyComposer() {
|
||||
className="rounded-lg border"
|
||||
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
|
||||
>
|
||||
<div className="overflow-auto" style={{ maxHeight: "60vh" }}>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="min-w-max border-separate border-spacing-0">
|
||||
<thead>
|
||||
<tr>
|
||||
@@ -378,7 +491,7 @@ export default function MelodyComposer() {
|
||||
color: "var(--text-secondary)",
|
||||
}}
|
||||
>
|
||||
{noteIndex + 1}
|
||||
{NOTE_LABELS[noteIndex]}
|
||||
</th>
|
||||
{steps.map((stepValue, stepIndex) => {
|
||||
const enabled = Boolean(stepValue & (1 << noteIndex));
|
||||
@@ -386,25 +499,39 @@ export default function MelodyComposer() {
|
||||
return (
|
||||
<td
|
||||
key={`${noteIndex}-${stepIndex}`}
|
||||
className="border-b border-r p-1"
|
||||
className="border-b border-r"
|
||||
style={{
|
||||
borderColor: "var(--border-primary)",
|
||||
backgroundColor: isCurrent ? "rgba(116,184,22,0.08)" : "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-8 h-8 rounded-md border transition-colors"
|
||||
className="w-full h-full flex items-center justify-center transition-colors"
|
||||
style={{
|
||||
borderColor: enabled ? "var(--accent)" : "var(--border-primary)",
|
||||
backgroundColor: enabled ? "var(--accent)" : "var(--bg-primary)",
|
||||
color: enabled ? "var(--bg-primary)" : "var(--text-muted)",
|
||||
fontSize: "11px",
|
||||
backgroundColor: "transparent",
|
||||
border: "none",
|
||||
outline: "none",
|
||||
}}
|
||||
>
|
||||
{enabled ? "ON" : ""}
|
||||
<span
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
width: "64%",
|
||||
height: "64%",
|
||||
borderRadius: "9999px",
|
||||
backgroundColor: "var(--btn-primary)",
|
||||
opacity: enabled ? 1 : 0,
|
||||
transform: enabled ? "scale(1)" : "scale(0.4)",
|
||||
boxShadow: enabled ? "0 0 12px 4px rgba(116, 184, 22, 0.55)" : "none",
|
||||
transition: "opacity 140ms ease, transform 140ms ease, box-shadow 180ms ease",
|
||||
}}
|
||||
/>
|
||||
</button>
|
||||
</td>
|
||||
);
|
||||
@@ -444,6 +571,107 @@ export default function MelodyComposer() {
|
||||
</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)" }}>
|
||||
Deploy as Archetype
|
||||
</h2>
|
||||
<p className="text-xs mt-0.5" style={{ color: "var(--text-muted)" }}>
|
||||
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}
|
||||
>
|
||||
×
|
||||
</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 ? "Deploying..." : "Deploy"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user