CODEX - Improvements to the page
This commit is contained in:
@@ -1,6 +1,9 @@
|
|||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||||
|
import { useNavigate } from "react-router-dom";
|
||||||
|
import api from "../api/client";
|
||||||
|
|
||||||
const MAX_NOTES = 16;
|
const MAX_NOTES = 16;
|
||||||
|
const NOTE_LABELS = "ABCDEFGHIJKLMNOP";
|
||||||
|
|
||||||
function bellFrequency(bellNumber) {
|
function bellFrequency(bellNumber) {
|
||||||
return 880 * Math.pow(Math.pow(2, 1 / 12), -2 * (bellNumber - 1));
|
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() {
|
export default function MelodyComposer() {
|
||||||
|
const navigate = useNavigate();
|
||||||
const [steps, setSteps] = useState(Array.from({ length: 16 }, () => 0));
|
const [steps, setSteps] = useState(Array.from({ length: 16 }, () => 0));
|
||||||
const [noteCount, setNoteCount] = useState(8);
|
const [noteCount, setNoteCount] = useState(8);
|
||||||
const [stepDelayMs, setStepDelayMs] = useState(280);
|
const [stepDelayMs, setStepDelayMs] = useState(280);
|
||||||
@@ -57,6 +61,13 @@ export default function MelodyComposer() {
|
|||||||
const [loopEnabled, setLoopEnabled] = useState(true);
|
const [loopEnabled, setLoopEnabled] = useState(true);
|
||||||
const [isPlaying, setIsPlaying] = useState(false);
|
const [isPlaying, setIsPlaying] = useState(false);
|
||||||
const [currentStep, setCurrentStep] = useState(-1);
|
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 audioCtxRef = useRef(null);
|
||||||
const playbackRef = useRef(null);
|
const playbackRef = useRef(null);
|
||||||
@@ -171,10 +182,74 @@ export default function MelodyComposer() {
|
|||||||
|
|
||||||
const handlePlay = () => {
|
const handlePlay = () => {
|
||||||
if (!stepsRef.current.length) return;
|
if (!stepsRef.current.length) return;
|
||||||
|
setError("");
|
||||||
setIsPlaying(true);
|
setIsPlaying(true);
|
||||||
scheduleStep(0);
|
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(() => {
|
const activeBellsInCurrentStep = useMemo(() => {
|
||||||
if (currentStep < 0 || !steps[currentStep]) return [];
|
if (currentStep < 0 || !steps[currentStep]) return [];
|
||||||
const active = [];
|
const active = [];
|
||||||
@@ -195,6 +270,31 @@ export default function MelodyComposer() {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</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
|
<section
|
||||||
className="rounded-lg border p-4"
|
className="rounded-lg border p-4"
|
||||||
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
|
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
|
||||||
@@ -216,6 +316,7 @@ export default function MelodyComposer() {
|
|||||||
>
|
>
|
||||||
- Step
|
- Step
|
||||||
</button>
|
</button>
|
||||||
|
<span className="mx-1 text-sm" style={{ color: "var(--border-primary)" }}>|</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={addNote}
|
onClick={addNote}
|
||||||
@@ -234,6 +335,7 @@ export default function MelodyComposer() {
|
|||||||
>
|
>
|
||||||
- Note
|
- Note
|
||||||
</button>
|
</button>
|
||||||
|
<span className="mx-1 text-sm" style={{ color: "var(--border-primary)" }}>|</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={clearAll}
|
onClick={clearAll}
|
||||||
@@ -253,48 +355,17 @@ export default function MelodyComposer() {
|
|||||||
className="rounded-lg border p-4"
|
className="rounded-lg border p-4"
|
||||||
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
|
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
|
||||||
>
|
>
|
||||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-4">
|
<div className="grid grid-cols-1 xl:grid-cols-12 gap-4 items-end">
|
||||||
<div>
|
<div className="xl:col-span-3">
|
||||||
<div className="flex items-center justify-between">
|
<div className="flex items-center gap-2">
|
||||||
<label className="text-sm font-medium" style={{ color: "var(--text-secondary)" }}>
|
<label className="inline-flex items-center gap-2 text-sm" style={{ color: "var(--text-secondary)" }}>
|
||||||
Step Delay
|
|
||||||
</label>
|
|
||||||
<span className="text-sm font-semibold" style={{ color: "var(--accent)" }}>
|
|
||||||
{stepDelayMs} ms
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="checkbox"
|
||||||
min="40"
|
checked={loopEnabled}
|
||||||
max="2000"
|
onChange={(e) => setLoopEnabled(e.target.checked)}
|
||||||
step="10"
|
|
||||||
value={stepDelayMs}
|
|
||||||
onChange={(e) => setStepDelayMs(Number(e.target.value))}
|
|
||||||
className="w-full mt-2"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
Loop
|
||||||
|
|
||||||
<div>
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<label className="text-sm font-medium" style={{ color: "var(--text-secondary)" }}>
|
|
||||||
Note Duration
|
|
||||||
</label>
|
</label>
|
||||||
<span className="text-sm font-semibold" style={{ color: "var(--accent)" }}>
|
|
||||||
{noteDurationMs} ms
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
min="20"
|
|
||||||
max="1200"
|
|
||||||
step="10"
|
|
||||||
value={noteDurationMs}
|
|
||||||
onChange={(e) => setNoteDurationMs(Number(e.target.value))}
|
|
||||||
className="w-full mt-2"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-2 lg:justify-end">
|
|
||||||
{!isPlaying ? (
|
{!isPlaying ? (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -314,18 +385,60 @@ export default function MelodyComposer() {
|
|||||||
Stop
|
Stop
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
|
<span className="mx-1 text-sm" style={{ color: "var(--border-primary)" }}>|</span>
|
||||||
<label className="inline-flex items-center gap-2 text-sm" style={{ color: "var(--text-secondary)" }}>
|
<button
|
||||||
<input
|
type="button"
|
||||||
type="checkbox"
|
onClick={openDeployModal}
|
||||||
checked={loopEnabled}
|
className="px-4 py-2 rounded-md text-sm"
|
||||||
onChange={(e) => setLoopEnabled(e.target.checked)}
|
style={{ backgroundColor: "var(--text-link)", color: "var(--text-white)" }}
|
||||||
/>
|
>
|
||||||
Loop
|
Deploy as Archetype
|
||||||
</label>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</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
|
||||||
|
</label>
|
||||||
|
<span className="text-sm font-semibold" style={{ color: "var(--accent)" }}>
|
||||||
|
{stepDelayMs} ms
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="40"
|
||||||
|
max="2000"
|
||||||
|
step="10"
|
||||||
|
value={stepDelayMs}
|
||||||
|
onChange={(e) => setStepDelayMs(Number(e.target.value))}
|
||||||
|
className="w-full mt-2"
|
||||||
|
/>
|
||||||
|
</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
|
||||||
|
</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>
|
||||||
|
|
||||||
{currentStep >= 0 && (
|
{currentStep >= 0 && (
|
||||||
<p className="text-xs mt-3" style={{ color: "var(--text-muted)" }}>
|
<p className="text-xs mt-3" style={{ color: "var(--text-muted)" }}>
|
||||||
Playing step {currentStep + 1}/{steps.length}
|
Playing step {currentStep + 1}/{steps.length}
|
||||||
@@ -338,7 +451,7 @@ export default function MelodyComposer() {
|
|||||||
className="rounded-lg border"
|
className="rounded-lg border"
|
||||||
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
|
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">
|
<table className="min-w-max border-separate border-spacing-0">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -378,7 +491,7 @@ export default function MelodyComposer() {
|
|||||||
color: "var(--text-secondary)",
|
color: "var(--text-secondary)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{noteIndex + 1}
|
{NOTE_LABELS[noteIndex]}
|
||||||
</th>
|
</th>
|
||||||
{steps.map((stepValue, stepIndex) => {
|
{steps.map((stepValue, stepIndex) => {
|
||||||
const enabled = Boolean(stepValue & (1 << noteIndex));
|
const enabled = Boolean(stepValue & (1 << noteIndex));
|
||||||
@@ -386,25 +499,39 @@ export default function MelodyComposer() {
|
|||||||
return (
|
return (
|
||||||
<td
|
<td
|
||||||
key={`${noteIndex}-${stepIndex}`}
|
key={`${noteIndex}-${stepIndex}`}
|
||||||
className="border-b border-r p-1"
|
className="border-b border-r"
|
||||||
style={{
|
style={{
|
||||||
borderColor: "var(--border-primary)",
|
borderColor: "var(--border-primary)",
|
||||||
backgroundColor: isCurrent ? "rgba(116,184,22,0.08)" : "transparent",
|
backgroundColor: isCurrent ? "rgba(116,184,22,0.08)" : "transparent",
|
||||||
|
width: "44px",
|
||||||
|
height: "44px",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label={`Toggle note ${noteIndex + 1} on step ${stepIndex + 1}`}
|
aria-label={`Toggle note ${noteIndex + 1} on step ${stepIndex + 1}`}
|
||||||
|
aria-pressed={enabled}
|
||||||
onClick={() => toggleCell(noteIndex, stepIndex)}
|
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={{
|
style={{
|
||||||
borderColor: enabled ? "var(--accent)" : "var(--border-primary)",
|
backgroundColor: "transparent",
|
||||||
backgroundColor: enabled ? "var(--accent)" : "var(--bg-primary)",
|
border: "none",
|
||||||
color: enabled ? "var(--bg-primary)" : "var(--text-muted)",
|
outline: "none",
|
||||||
fontSize: "11px",
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{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>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
);
|
);
|
||||||
@@ -444,6 +571,107 @@ export default function MelodyComposer() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user