Compare commits

...

2 Commits

4 changed files with 874 additions and 146 deletions

View File

@@ -1,4 +1,4 @@
import { useState, useEffect } from "react";
import { useState, useEffect } from "react";
import { useParams, useNavigate } from "react-router-dom";
import api from "../api/client";
import { useAuth } from "../auth/AuthContext";
@@ -47,6 +47,19 @@ function mapPercentageToStepDelay(percent, minSpeed, maxSpeed) {
return Math.round(a * Math.pow(b / a, t));
}
function normalizeFileUrl(url) {
if (!url || typeof url !== "string") return null;
if (url.startsWith("http") || url.startsWith("/api")) return url;
if (url.startsWith("/")) return `/api${url}`;
return `/api/${url}`;
}
function hueForDepth(index, count) {
const safeCount = Math.max(1, count);
const t = Math.max(0, Math.min(1, index / safeCount));
return 190 + (15 - 190) * t;
}
function Field({ label, children }) {
return (
<div>
@@ -115,6 +128,7 @@ export default function MelodyDetail() {
const [codeCopied, setCodeCopied] = useState(false);
const [showSpeedCalc, setShowSpeedCalc] = useState(false);
const [showPlayback, setShowPlayback] = useState(false);
const [offlineSaving, setOfflineSaving] = useState(false);
useEffect(() => {
api.get("/settings/melody").then((ms) => {
@@ -186,6 +200,32 @@ export default function MelodyDetail() {
}
};
const handleToggleAvailableOffline = async (nextValue) => {
if (!canEdit || !melody) return;
setOfflineSaving(true);
setError("");
try {
const body = {
information: { ...(melody.information || {}), available_offline: nextValue },
default_settings: melody.default_settings || {},
type: melody.type || "all",
uid: melody.uid || "",
pid: melody.pid || "",
metadata: melody.metadata || {},
};
if (melody.url) body.url = melody.url;
await api.put(`/melodies/${id}`, body);
setMelody((prev) => ({
...prev,
information: { ...(prev?.information || {}), available_offline: nextValue },
}));
} catch (err) {
setError(err.message);
} finally {
setOfflineSaving(false);
}
};
if (loading) {
return <div className="text-center py-8" style={{ color: "var(--text-muted)" }}>Loading...</div>;
}
@@ -324,31 +364,6 @@ export default function MelodyDetail() {
Melody Information
</h2>
<dl className="grid grid-cols-2 md:grid-cols-3 gap-4">
<Field label="Type">
<span className="capitalize">{melody.type}</span>
</Field>
<Field label="Tone">
<span className="capitalize">{info.melodyTone}</span>
</Field>
<Field label="Steps">{info.steps}</Field>
<Field label="Total Archetype Notes">{info.totalNotes}</Field>
<Field label="Total Active Bells">{info.totalActiveBells ?? "-"}</Field>
<Field label="Min Speed">
{info.minSpeed ? (
<div>
<div>{minBpm} BPM</div>
<div className="text-xs" style={{ color: "var(--text-muted)" }}>{info.minSpeed} ms</div>
</div>
) : "-"}
</Field>
<Field label="Max Speed">
{info.maxSpeed ? (
<div>
<div>{maxBpm} BPM</div>
<div className="text-xs" style={{ color: "var(--text-muted)" }}>{info.maxSpeed} ms</div>
</div>
) : "-"}
</Field>
<Field label="Color">
{info.color ? (
<span className="inline-flex items-center gap-2">
@@ -362,11 +377,25 @@ export default function MelodyDetail() {
"-"
)}
</Field>
<Field label="True Ring">
<span className="px-2 py-0.5 text-xs rounded-full" style={badgeStyle(info.isTrueRing)}>
{info.isTrueRing ? "Yes" : "No"}
</span>
<Field label="Tone">
<span className="capitalize">{info.melodyTone}</span>
</Field>
<Field label="Steps">{info.steps}</Field>
<Field label="Min Speed">
{info.minSpeed ? (
<span>
{minBpm} bpm <span className="text-xs" style={{ color: "var(--text-muted)" }}>· {info.minSpeed} ms</span>
</span>
) : "-"}
</Field>
<Field label="Max Speed">
{info.maxSpeed ? (
<span>
{maxBpm} bpm <span className="text-xs" style={{ color: "var(--text-muted)" }}>· {info.maxSpeed} ms</span>
</span>
) : "-"}
</Field>
<Field label="Unique Bells">{info.totalActiveBells ?? "-"}</Field>
<div className="col-span-2 md:col-span-3">
<Field label="Description">
{getLocalizedValue(info.description, displayLang)}
@@ -428,7 +457,7 @@ export default function MelodyDetail() {
<Field label="Speed">
{settings.speed != null ? (
<span>
{settings.speed}%{speedBpm ? ` · ${speedBpm} BPM` : ""}{speedMs ? ` · ${speedMs} ms` : ""}
{settings.speed}%{speedBpm ? <span className="text-xs" style={{ color: "var(--text-muted)" }}>{` · ${speedBpm} bpm`}</span> : ""}{speedMs ? <span className="text-xs" style={{ color: "var(--text-muted)" }}>{` · ${speedMs} ms`}</span> : ""}
</span>
) : "-"}
</Field>
@@ -452,26 +481,32 @@ export default function MelodyDetail() {
<dd>
{settings.noteAssignments?.length > 0 ? (
<div className="flex flex-wrap gap-1.5">
{settings.noteAssignments.map((assignedBell, noteIdx) => (
<div
key={noteIdx}
className="flex flex-col items-center rounded-md border"
style={{
minWidth: "36px",
padding: "4px 6px",
backgroundColor: "var(--bg-card-hover)",
borderColor: "var(--border-primary)",
}}
>
<span className="text-xs font-bold leading-tight" style={{ color: "var(--text-secondary)" }}>
{String.fromCharCode(65 + noteIdx)}
</span>
<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)" }}>
{assignedBell > 0 ? assignedBell : "—"}
</span>
</div>
))}
{settings.noteAssignments.map((assignedBell, noteIdx) => {
const noteHue = hueForDepth(noteIdx, settings.noteAssignments.length - 1);
const bellDepthIdx = Math.max(0, Math.min(15, (assignedBell || 1) - 1));
const bellHue = hueForDepth(bellDepthIdx, 15);
return (
<div
key={noteIdx}
className="flex flex-col items-center rounded-md border"
style={{
minWidth: "36px",
padding: "4px 6px",
backgroundColor: "var(--bg-card-hover)",
borderColor: `hsla(${noteHue}, 65%, 55%, 0.45)`,
boxShadow: `0 0 6px hsla(${noteHue}, 75%, 55%, 0.25) inset`,
}}
>
<span className="text-xs font-bold leading-tight" style={{ color: `hsl(${noteHue}, 80%, 72%)` }}>
{String.fromCharCode(65 + noteIdx)}
</span>
<div className="w-full my-0.5" style={{ height: "1px", backgroundColor: "var(--border-primary)" }} />
<span className="text-xs leading-tight" style={{ color: assignedBell > 0 ? `hsl(${bellHue}, 80%, 70%)` : "var(--text-muted)" }}>
{assignedBell > 0 ? assignedBell : "—"}
</span>
</div>
);
})}
</div>
) : (
<span style={{ color: "var(--text-muted)" }}>-</span>
@@ -489,10 +524,24 @@ export default function MelodyDetail() {
>
<h2 className="text-lg font-semibold mb-4" style={{ color: "var(--text-heading)" }}>Files</h2>
<dl className="space-y-4">
<Field label="Available as Built-In">
<label className="inline-flex items-center gap-2">
<input
type="checkbox"
checked={Boolean(info.available_offline)}
disabled={!canEdit || offlineSaving}
onChange={(e) => handleToggleAvailableOffline(e.target.checked)}
className="h-4 w-4 rounded"
/>
<span style={{ color: "var(--text-secondary)" }}>
{info.available_offline ? "Enabled" : "Disabled"}
</span>
</label>
</Field>
<Field label="Binary File">
{(() => {
// Prefer the uploaded file URL, fall back to melody.url (legacy/firebase storage URL)
const binaryUrl = files.binary_url || melody.url || null;
const binaryUrl = normalizeFileUrl(files.binary_url || melody.url || null);
if (!binaryUrl) return <span style={{ color: "var(--text-muted)" }}>Not uploaded</span>;
const binaryPid = builtMelody?.pid || melody.pid || "binary";
@@ -538,15 +587,26 @@ export default function MelodyDetail() {
{builtMelody?.name ? (
<strong style={{ color: "var(--text-heading)" }}>{builtMelody.name}</strong>
) : (
<a
href={binaryUrl}
onClick={handleDownload}
className="underline"
style={{ color: "var(--accent)" }}
>
<span className="text-sm" style={{ color: "var(--text-secondary)" }}>
{downloadName}
</a>
</span>
)}
<button
type="button"
onClick={handleDownload}
className="px-2 py-0.5 text-xs rounded-full"
style={{ color: "var(--text-link)", backgroundColor: "rgba(88,156,250,0.14)" }}
>
Download
</button>
<button
type="button"
onClick={() => setShowPlayback(true)}
className="px-2 py-0.5 text-xs rounded-full"
style={{ color: "var(--text-secondary)", backgroundColor: "var(--bg-card-hover)" }}
>
View
</button>
{!files.binary_url && melody.url && (
<span className="text-xs px-1.5 py-0.5 rounded" style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-muted)" }}>
via URL
@@ -566,6 +626,9 @@ export default function MelodyDetail() {
</a>
</span>
)}
<span className="text-xs" style={{ color: "var(--text-muted)" }}>
{info.totalNotes ?? 0} active notes
</span>
</span>
);
})()}
@@ -725,3 +788,7 @@ export default function MelodyDetail() {
</div>
);
}

View File

@@ -1,4 +1,4 @@
import { useState, useEffect } from "react";
import { useState, useEffect, useRef } from "react";
import { useNavigate, useParams } from "react-router-dom";
import api from "../api/client";
import { useAuth } from "../auth/AuthContext";
@@ -31,6 +31,7 @@ const defaultInfo = {
steps: 0,
color: "",
isTrueRing: false,
available_offline: false,
previewURL: "",
};
@@ -69,6 +70,13 @@ function mapPercentageToStepDelay(percent, minSpeed, maxSpeed) {
return Math.round(a * Math.pow(b / a, t));
}
function normalizeFileUrl(url) {
if (!url || typeof url !== "string") return null;
if (url.startsWith("http") || url.startsWith("/api")) return url;
if (url.startsWith("/")) return `/api${url}`;
return `/api/${url}`;
}
export default function MelodyForm() {
const { id } = useParams();
const isEdit = Boolean(id);
@@ -110,6 +118,8 @@ export default function MelodyForm() {
const [builtMelody, setBuiltMelody] = useState(null);
const [assignedBinaryName, setAssignedBinaryName] = useState(null);
const [assignedBinaryPid, setAssignedBinaryPid] = useState(null);
const binaryInputRef = useRef(null);
const previewInputRef = useRef(null);
// Metadata / Admin Notes
const [adminNotes, setAdminNotes] = useState([]);
@@ -192,6 +202,40 @@ export default function MelodyForm() {
if (!str.trim()) return [];
return str.split(",").map((s) => parseInt(s.trim(), 10)).filter((n) => !isNaN(n));
};
const resolveFilename = (fileUrl, fallbackName) => {
if (!fileUrl) return fallbackName;
try {
const path = decodeURIComponent(new URL(fileUrl, window.location.origin).pathname);
const parts = path.split("/");
return parts[parts.length - 1] || fallbackName;
} catch {
return fallbackName;
}
};
const downloadExistingFile = async (fileUrl, fallbackName, e) => {
e?.preventDefault?.();
if (!fileUrl) return;
try {
const token = localStorage.getItem("access_token");
let res = await fetch(fileUrl, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
});
if (!res.ok && fileUrl.startsWith("http")) {
res = await fetch(fileUrl);
}
if (!res.ok) throw new Error(`Download failed: ${res.statusText}`);
const blob = await res.blob();
const objectUrl = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = objectUrl;
a.download = fallbackName || "download.file";
a.click();
URL.revokeObjectURL(objectUrl);
} catch (err) {
setError(err.message);
}
};
const updateLocalizedField = (fieldKey, text) => {
const dict = parseLocalizedString(information[fieldKey]);
@@ -529,7 +573,7 @@ export default function MelodyForm() {
<label className="block text-sm font-medium mb-1" style={labelStyle}>Min Speed</label>
<input type="number" min="0" value={information.minSpeed} onChange={(e) => updateInfo("minSpeed", parseInt(e.target.value, 10) || 0)} className={inputClass} />
{information.minSpeed > 0 && (
<p className="text-xs mt-1" style={mutedStyle}>{minBpm} BPM · {information.minSpeed} ms</p>
<p className="text-xs mt-1" style={mutedStyle}>{minBpm} bpm · {information.minSpeed} ms</p>
)}
</div>
@@ -537,7 +581,7 @@ export default function MelodyForm() {
<label className="block text-sm font-medium mb-1" style={labelStyle}>Max Speed</label>
<input type="number" min="0" value={information.maxSpeed} onChange={(e) => updateInfo("maxSpeed", parseInt(e.target.value, 10) || 0)} className={inputClass} />
{information.maxSpeed > 0 && (
<p className="text-xs mt-1" style={mutedStyle}>{maxBpm} BPM · {information.maxSpeed} ms</p>
<p className="text-xs mt-1" style={mutedStyle}>{maxBpm} bpm · {information.maxSpeed} ms</p>
)}
</div>
@@ -621,7 +665,7 @@ export default function MelodyForm() {
<span className="text-sm font-medium w-12 text-right" style={labelStyle}>{settings.speed}%</span>
</div>
<div className="text-xs mt-1" style={mutedStyle}>
{speedBpm && speedMs ? `${speedBpm} BPM · ${speedMs} ms` : "Set MIN/MAX speed to compute BPM"}
{speedBpm && speedMs ? `${speedBpm} bpm · ${speedMs} ms` : "Set MIN/MAX speed to compute bpm"}
</div>
</div>
@@ -681,25 +725,77 @@ export default function MelodyForm() {
<section className="rounded-lg p-6 border" style={sectionStyle}>
<h2 className="text-lg font-semibold mb-4" style={headingStyle}>Files</h2>
<div className="space-y-4">
<div className="flex items-center gap-2">
<input
type="checkbox"
id="available-offline"
checked={Boolean(information.available_offline)}
onChange={(e) => updateInfo("available_offline", e.target.checked)}
className="h-4 w-4 rounded"
/>
<label htmlFor="available-offline" className="text-sm font-medium" style={labelStyle}>
Available as Built-In
</label>
</div>
<div>
<label className="block text-sm font-medium mb-1" style={labelStyle}>Binary File (.bsm / .bin)</label>
{existingFiles.binary_url && (
<div className="text-xs mb-1" style={{ color: "var(--success)" }}>
{assignedBinaryName ? (
<>
<p><strong>{assignedBinaryName}</strong> has been assigned. Selecting a new file will replace it.</p>
{assignedBinaryPid && (
<p className="mt-0.5" style={{ color: "var(--text-muted)" }}>
filename: <span className="font-mono">{assignedBinaryPid}.bsm</span>
</p>
)}
</>
) : (
<p>Current file uploaded. Selecting a new file will replace it.</p>
)}
</div>
{(() => {
const binaryUrl = normalizeFileUrl(existingFiles.binary_url || url || null);
const fallback = assignedBinaryPid ? `${assignedBinaryPid}.bsm` : (pid ? `${pid}.bsm` : "Click to Download");
const binaryName = resolveFilename(binaryUrl, fallback);
return (
<div className="text-xs mb-2" style={{ color: "var(--text-muted)" }}>
{binaryUrl ? (
<span className="text-sm" style={{ color: "var(--text-secondary)" }}>
{binaryName}
</span>
) : (
<span>No binary uploaded</span>
)}
{binaryUrl && (
<div className="mt-1 inline-flex gap-1.5">
<button
type="button"
onClick={(e) => downloadExistingFile(binaryUrl, binaryName, e)}
className="px-2 py-0.5 text-xs rounded-full"
style={{ color: "var(--text-link)", backgroundColor: "rgba(88,156,250,0.14)" }}
>
Download
</button>
<button
type="button"
onClick={() => setShowPlayback(true)}
className="px-2 py-0.5 text-xs rounded-full"
style={{ color: "var(--text-secondary)", backgroundColor: "var(--bg-card-hover)" }}
>
View
</button>
</div>
)}
<div className="mt-0.5">{information.totalNotes ?? 0} active notes</div>
</div>
);
})()}
<input
ref={binaryInputRef}
type="file"
accept=".bin,.bsm"
onChange={(e) => setBinaryFile(e.target.files[0] || null)}
className="hidden"
/>
<button
type="button"
onClick={() => binaryInputRef.current?.click()}
className="px-3 py-1.5 text-xs rounded-full transition-colors"
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-secondary)", border: "1px solid var(--border-primary)" }}
>
Choose Binary File
</button>
{binaryFile && (
<p className="text-xs mt-1" style={mutedStyle}>
selected: {binaryFile.name}
</p>
)}
<input type="file" accept=".bin,.bsm" onChange={(e) => setBinaryFile(e.target.files[0] || null)} className="w-full text-sm" style={mutedStyle} />
<div className="flex gap-2 mt-2">
<button
type="button"
@@ -748,13 +844,41 @@ export default function MelodyForm() {
</div>
<div>
<label className="block text-sm font-medium mb-1" style={labelStyle}>Audio Preview (.mp3)</label>
{existingFiles.preview_url && (
<div className="mb-1">
<p className="text-xs mb-1" style={{ color: "var(--success)" }}>Current preview uploaded. Selecting a new file will replace it.</p>
{existingFiles.preview_url ? (
<div className="mb-2 space-y-1">
<a
href={existingFiles.preview_url}
onClick={(e) => downloadExistingFile(existingFiles.preview_url, resolveFilename(existingFiles.preview_url, "preview.mp3"), e)}
className="underline text-xs"
style={{ color: "var(--accent)" }}
>
{resolveFilename(existingFiles.preview_url, "Click to Download")}
</a>
<audio controls src={existingFiles.preview_url} className="h-8" />
</div>
) : (
<p className="text-xs mb-2" style={mutedStyle}>No preview uploaded</p>
)}
<input
ref={previewInputRef}
type="file"
accept=".mp3,.wav,.ogg"
onChange={(e) => setPreviewFile(e.target.files[0] || null)}
className="hidden"
/>
<button
type="button"
onClick={() => previewInputRef.current?.click()}
className="px-3 py-1.5 text-xs rounded-full transition-colors"
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-secondary)", border: "1px solid var(--border-primary)" }}
>
Choose Preview File
</button>
{previewFile && (
<p className="text-xs mt-1" style={mutedStyle}>
selected: {previewFile.name}
</p>
)}
<input type="file" accept=".mp3,.wav,.ogg" onChange={(e) => setPreviewFile(e.target.files[0] || null)} className="w-full text-sm" style={mutedStyle} />
</div>
</div>
</section>
@@ -819,15 +943,17 @@ export default function MelodyForm() {
multiline={translationModal.multiline}
/>
<PlaybackModal
open={showPlayback}
melody={{ id: id || savedMelodyId || "preview", information, default_settings: settings, type, url, uid, pid }}
builtMelody={builtMelody}
files={{ binary_url: normalizeFileUrl(existingFiles.binary_url || url || null) }}
archetypeCsv={information.archetype_csv || null}
onClose={() => setShowPlayback(false)}
/>
{isEdit && (
<>
<PlaybackModal
open={showPlayback}
melody={{ id, information, default_settings: settings, type, url, uid, pid }}
builtMelody={builtMelody}
archetypeCsv={information.archetype_csv || null}
onClose={() => setShowPlayback(false)}
/>
<SpeedCalculatorModal
open={showSpeedCalc}
melody={{ id, information, default_settings: settings, type, url, uid, pid }}
@@ -910,3 +1036,8 @@ export default function MelodyForm() {
</div>
);
}

View File

@@ -4,6 +4,7 @@ import api from "../api/client";
import { useAuth } from "../auth/AuthContext";
import SearchBar from "../components/SearchBar";
import ConfirmDialog from "../components/ConfirmDialog";
import PlaybackModal from "./PlaybackModal";
import {
getLocalizedValue,
getLanguageName,
@@ -29,8 +30,7 @@ const ALL_COLUMNS = [
{ key: "description", label: "Description", defaultOn: false },
{ key: "type", label: "Type", defaultOn: true },
{ key: "tone", label: "Tone", defaultOn: true },
{ key: "totalNotes", label: "Total Notes", defaultOn: true },
{ key: "totalActiveBells", label: "Total Active Bells", defaultOn: true },
{ key: "totalActiveBells", label: "Unique Bells", defaultOn: true },
{ key: "minSpeed", label: "Min Speed", defaultOn: false },
{ key: "maxSpeed", label: "Max Speed", defaultOn: false },
{ key: "tags", label: "Tags", defaultOn: false },
@@ -40,9 +40,10 @@ const ALL_COLUMNS = [
{ key: "pauseDuration", label: "Pause", defaultOn: false },
{ key: "infiniteLoop", label: "Infinite", defaultOn: false },
{ key: "noteAssignments", label: "Note Assignments", defaultOn: false },
{ key: "builtIn", label: "Built-in", defaultOn: false },
{ key: "binaryFile", label: "Binary File", defaultOn: false },
{ key: "dateCreated", label: "Date Created", defaultOn: false },
{ key: "dateEdited", label: "Date Edited", defaultOn: false },
{ key: "dateEdited", label: "Last Edited", defaultOn: false },
{ key: "createdBy", label: "Created By", defaultOn: false },
{ key: "lastEditedBy", label: "Last Edited By", defaultOn: false },
{ key: "isTrueRing", label: "True Ring", defaultOn: true },
@@ -102,6 +103,49 @@ function parseDateValue(isoValue) {
return Number.isNaN(time) ? 0 : time;
}
function formatDurationVerbose(seconds) {
const total = Number(seconds || 0);
const mins = Math.floor(total / 60);
const secs = total % 60;
return `${mins} min ${secs} sec`;
}
function formatDateTwoLine(isoValue) {
const d = new Date(isoValue);
if (Number.isNaN(d.getTime())) return { date: "-", time: "-" };
const dd = String(d.getDate()).padStart(2, "0");
const mm = String(d.getMonth() + 1).padStart(2, "0");
const yyyy = d.getFullYear();
const hh = String(d.getHours()).padStart(2, "0");
const min = String(d.getMinutes()).padStart(2, "0");
return { date: `${dd}/${mm}/${yyyy}`, time: `${hh}:${min}` };
}
function formatRelativeTime(isoValue) {
if (!isoValue) return "-";
const ts = new Date(isoValue).getTime();
if (Number.isNaN(ts)) return "-";
const now = Date.now();
const diffSec = Math.max(1, Math.floor((now - ts) / 1000));
if (diffSec < 60) return `${diffSec} sec ago`;
const diffMin = Math.floor(diffSec / 60);
if (diffMin < 60) return `${diffMin} min ago`;
const diffHours = Math.floor(diffMin / 60);
if (diffHours < 24) return `${diffHours} hour${diffHours === 1 ? "" : "s"} ago`;
const diffDays = Math.floor(diffHours / 24);
if (diffDays < 30) return `${diffDays} day${diffDays === 1 ? "" : "s"} ago`;
const diffMonths = Math.floor(diffDays / 30);
if (diffMonths < 12) return `${diffMonths} month${diffMonths === 1 ? "" : "s"} ago`;
const diffYears = Math.floor(diffMonths / 12);
return `${diffYears} year${diffYears === 1 ? "" : "s"} ago`;
}
function hueForDepth(index, count) {
const safeCount = Math.max(1, count);
const t = Math.max(0, Math.min(1, index / safeCount));
return 190 + (15 - 190) * t; // high notes blue-ish -> deep notes warm red
}
function getBinaryUrl(row) {
const candidate = row?.url;
if (!candidate || typeof candidate !== "string") return null;
@@ -127,6 +171,106 @@ function getBinaryFilename(row) {
return row?.pid ? `${row.pid}.bsm` : "melody.bsm";
}
function toSafeCppSymbol(input, fallback = "melody") {
const base = String(input || "").trim().toLowerCase();
const cleaned = base.replace(/[^a-z0-9_]+/g, "_").replace(/^_+|_+$/g, "");
const symbol = cleaned || fallback;
return /^[a-z_]/.test(symbol) ? symbol : `m_${symbol}`;
}
function escapeCppString(value) {
return String(value || "").replace(/\\/g, "\\\\").replace(/"/g, '\\"');
}
function parseStepTokenToMask(token) {
const raw = String(token || "").trim();
if (!raw) return 0;
if (raw.startsWith("0x") || raw.startsWith("0X")) {
const parsed = Number.parseInt(raw, 16);
return Number.isNaN(parsed) ? 0 : parsed & 0xffff;
}
if (raw.includes("+")) {
return raw
.split("+")
.map((part) => Number.parseInt(part.trim(), 10))
.filter((n) => Number.isInteger(n) && n > 0)
.reduce((mask, bell) => {
if (bell > 16) return mask;
return mask | (1 << (bell - 1));
}, 0) & 0xffff;
}
const n = Number.parseInt(raw, 10);
if (!Number.isInteger(n) || n < 0) return 0;
if (n === 0) return 0;
if (n <= 16) return (1 << (n - 1)) & 0xffff;
return n & 0xffff;
}
function parseArchetypeCsv(archetypeCsv) {
if (!archetypeCsv || typeof archetypeCsv !== "string") return [];
return archetypeCsv
.split(",")
.map((step) => step.trim())
.filter(Boolean)
.map(parseStepTokenToMask);
}
function formatHex16(value) {
return `0x${(Number(value || 0) & 0xffff).toString(16).toUpperCase().padStart(4, "0")}`;
}
function buildOfflineCppCode(rows) {
const selected = (rows || []).filter((row) => Boolean(row?.information?.available_offline));
const generatedAt = new Date().toISOString().replace("T", " ").slice(0, 19);
if (selected.length === 0) {
return `// Generated: ${generatedAt}\n// No melodies marked as built-in.\n`;
}
const arrays = [];
const libraryEntries = [];
for (const row of selected) {
const info = row?.information || {};
const displayName = getLocalizedValue(info.name, "en", getLocalizedValue(info.name, "en", "Untitled Melody"));
const uid = row?.uid || "";
const symbol = `melody_builtin_${toSafeCppSymbol(uid || displayName)}`;
const steps = parseArchetypeCsv(info.archetype_csv);
const stepCount = Number(info.steps || 0);
arrays.push(`// Melody: ${escapeCppString(displayName)} | UID: ${escapeCppString(uid || "missing_uid")}`);
arrays.push(`const uint16_t PROGMEM ${symbol}[] = {`);
if (steps.length === 0) {
arrays.push(" // No archetype_csv step data found");
} else {
for (let i = 0; i < steps.length; i += 8) {
const chunk = steps.slice(i, i + 8).map(formatHex16).join(", ");
arrays.push(` ${chunk}${i + 8 < steps.length ? "," : ""}`);
}
}
arrays.push("};");
arrays.push("");
libraryEntries.push(" {");
libraryEntries.push(` "${escapeCppString(displayName)}",`);
libraryEntries.push(` "${escapeCppString(uid || toSafeCppSymbol(displayName))}",`);
libraryEntries.push(` ${symbol},`);
libraryEntries.push(` ${stepCount > 0 ? stepCount : steps.length}`);
libraryEntries.push(" }");
}
return [
`// Generated: ${generatedAt}`,
"",
...arrays,
"// --- Add or replace your MELODY_LIBRARY[] with this: ---",
"const MelodyInfo MELODY_LIBRARY[] = {",
libraryEntries.map((entry, idx) => `${entry}${idx < libraryEntries.length - 1 ? "," : ""}`).join("\n"),
"};",
"",
].join("\n");
}
export default function MelodyList() {
const [melodies, setMelodies] = useState([]);
const [total, setTotal] = useState(0);
@@ -147,6 +291,9 @@ export default function MelodyList() {
const [visibleColumns, setVisibleColumns] = useState(getDefaultVisibleColumns);
const [showColumnPicker, setShowColumnPicker] = useState(false);
const [showCreatorPicker, setShowCreatorPicker] = useState(false);
const [showOfflineModal, setShowOfflineModal] = useState(false);
const [builtInSavingId, setBuiltInSavingId] = useState(null);
const [viewRow, setViewRow] = useState(null);
const columnPickerRef = useRef(null);
const creatorPickerRef = useRef(null);
const navigate = useNavigate();
@@ -160,6 +307,20 @@ export default function MelodyList() {
});
}, []);
useEffect(() => {
setVisibleColumns((prev) => {
const known = new Set(ALL_COLUMNS.map((c) => c.key));
let next = prev.filter((k) => known.has(k));
for (const col of ALL_COLUMNS) {
if (col.alwaysOn && !next.includes(col.key)) next.push(col.key);
}
if (JSON.stringify(next) !== JSON.stringify(prev)) {
localStorage.setItem("melodyListColumns", JSON.stringify(next));
}
return next;
});
}, []);
// Close dropdowns on outside click
useEffect(() => {
const handleClick = (e) => {
@@ -267,6 +428,41 @@ export default function MelodyList() {
}
};
const openBinaryView = (e, row) => {
e.stopPropagation();
setViewRow(row);
};
const updateBuiltInState = async (e, row, nextValue) => {
e.stopPropagation();
if (!canEdit) return;
setBuiltInSavingId(row.id);
setError("");
try {
const body = {
information: { ...(row.information || {}), available_offline: nextValue },
default_settings: row.default_settings || {},
type: row.type || "all",
uid: row.uid || "",
pid: row.pid || "",
metadata: row.metadata || {},
};
if (row.url) body.url = row.url;
await api.put(`/melodies/${row.id}`, body);
setMelodies((prev) =>
prev.map((m) =>
m.id === row.id
? { ...m, information: { ...(m.information || {}), available_offline: nextValue } }
: m
)
);
} catch (err) {
setError(err.message);
} finally {
setBuiltInSavingId(null);
}
};
const toggleColumn = (key) => {
const col = ALL_COLUMNS.find((c) => c.key === key);
if (col?.alwaysOn) return;
@@ -279,6 +475,19 @@ export default function MelodyList() {
});
};
const moveColumn = (key, direction) => {
setVisibleColumns((prev) => {
const idx = prev.indexOf(key);
if (idx < 0) return prev;
const swapIdx = direction === "up" ? idx - 1 : idx + 1;
if (swapIdx < 0 || swapIdx >= prev.length) return prev;
const next = [...prev];
[next[idx], next[swapIdx]] = [next[swapIdx], next[idx]];
localStorage.setItem("melodyListColumns", JSON.stringify(next));
return next;
});
};
const toggleCreator = (creator) => {
setCreatedByFilter((prev) =>
prev.includes(creator) ? prev.filter((c) => c !== creator) : [...prev, creator]
@@ -334,6 +543,8 @@ export default function MelodyList() {
});
}, [melodies, createdByFilter, sortBy, sortDir]); // eslint-disable-line react-hooks/exhaustive-deps
const offlineCode = useMemo(() => buildOfflineCppCode(displayRows), [displayRows]);
const handleSortClick = (columnKey) => {
const nextSortKey = SORTABLE_COLUMN_TO_KEY[columnKey];
if (!nextSortKey) return;
@@ -416,8 +627,6 @@ export default function MelodyList() {
}
case "tone":
return <span className="capitalize">{info.melodyTone || "-"}</span>;
case "totalNotes":
return info.totalNotes ?? "-";
case "totalActiveBells":
return info.totalActiveBells ?? "-";
case "minSpeed":
@@ -499,7 +708,7 @@ export default function MelodyList() {
return (
<div className="min-w-28">
<div className="text-xs mb-1" style={{ color: "var(--text-secondary)" }}>
{formatDuration(ds.duration)}
{formatDurationVerbose(ds.duration)}
</div>
<div
className="w-full h-2 rounded-full"
@@ -536,6 +745,11 @@ export default function MelodyList() {
return ds.noteAssignments?.length > 0 ? (
<div className="flex flex-nowrap gap-1 whitespace-nowrap">
{ds.noteAssignments.map((assignedBell, noteIdx) => (
(() => {
const noteHue = hueForDepth(noteIdx, ds.noteAssignments.length - 1);
const bellDepthIdx = Math.max(0, Math.min(15, (assignedBell || 1) - 1));
const bellHue = hueForDepth(bellDepthIdx, 15);
return (
<div
key={noteIdx}
className="flex flex-col items-center rounded-md border"
@@ -543,42 +757,110 @@ export default function MelodyList() {
minWidth: "26px",
padding: "3px 3px",
backgroundColor: "var(--bg-card-hover)",
borderColor: "var(--border-primary)",
borderColor: `hsla(${noteHue}, 65%, 55%, 0.45)`,
boxShadow: `0 0 6px hsla(${noteHue}, 75%, 55%, 0.25) inset`,
}}
>
<span className="text-xs font-bold leading-tight" style={{ color: "var(--text-secondary)" }}>
<span className="text-xs font-bold leading-tight" style={{ color: `hsl(${noteHue}, 80%, 72%)` }}>
{NOTE_LABELS[noteIdx]}
</span>
<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: assignedBell > 0 ? `hsl(${bellHue}, 80%, 70%)` : "var(--text-muted)" }}>
{assignedBell > 0 ? assignedBell : "—"}
</span>
</div>
);
})()
))}
</div>
) : (
"-"
);
case "binaryFile": {
const binaryUrl = getBinaryUrl(row);
const filename = getBinaryFilename(row);
if (!binaryUrl) return <span style={{ color: "var(--text-muted)" }}>-</span>;
case "builtIn": {
const enabled = Boolean(info.available_offline);
const saving = builtInSavingId === row.id;
return (
<button
type="button"
onClick={(e) => downloadBinary(e, row)}
className="underline text-xs text-left"
style={{ color: "var(--accent)", background: "none", border: "none", padding: 0 }}
title={binaryUrl}
onClick={(e) => updateBuiltInState(e, row, !enabled)}
disabled={!canEdit || saving}
className="inline-flex items-center gap-2 cursor-pointer disabled:opacity-50"
style={{ background: "none", border: "none", padding: 0, color: "var(--text-secondary)" }}
title={canEdit ? "Click to toggle built-in availability" : "Built-in availability"}
>
{filename}
<span
className="w-4 h-4 rounded-full border inline-flex items-center justify-center"
style={{
borderColor: enabled ? "rgba(34,197,94,0.7)" : "var(--border-primary)",
backgroundColor: enabled ? "rgba(34,197,94,0.15)" : "transparent",
}}
>
<span
className="w-2 h-2 rounded-full"
style={{ backgroundColor: enabled ? "#22c55e" : "transparent" }}
/>
</span>
<span className="text-xs" style={{ color: enabled ? "var(--success-text)" : "var(--text-muted)" }}>
{enabled ? "Yes" : "No"}
</span>
</button>
);
}
case "binaryFile": {
const binaryUrl = getBinaryUrl(row);
const filename = getBinaryFilename(row);
const totalNotes = info.totalNotes ?? 0;
if (!binaryUrl) {
return (
<span className="inline-flex flex-col">
<span style={{ color: "var(--text-muted)" }}>-</span>
<span className="text-xs" style={{ color: "var(--text-muted)" }}>{totalNotes} active notes</span>
</span>
);
}
return (
<span className="inline-flex flex-col">
<span className="text-xs" style={{ color: "var(--text-secondary)" }}>{filename || "binary.bsm"}</span>
<span className="mt-1 inline-flex gap-1.5">
<button
type="button"
onClick={(e) => downloadBinary(e, row)}
className="px-2 py-0.5 text-xs rounded-full"
style={{ color: "var(--text-link)", backgroundColor: "rgba(88,156,250,0.14)" }}
title={binaryUrl}
>
Download
</button>
<button
type="button"
onClick={(e) => openBinaryView(e, row)}
className="px-2 py-0.5 text-xs rounded-full"
style={{ color: "var(--text-secondary)", backgroundColor: "var(--bg-card-hover)" }}
>
View
</button>
</span>
<span className="text-xs" style={{ color: "var(--text-muted)" }}>{totalNotes} active notes</span>
</span>
);
}
case "dateCreated":
return metadata.dateCreated ? new Date(metadata.dateCreated).toLocaleString() : "-";
if (!metadata.dateCreated) return "-";
{
const parts = formatDateTwoLine(metadata.dateCreated);
return (
<span className="inline-flex flex-col leading-tight">
<span>{parts.date}</span>
<span className="text-xs" style={{ color: "var(--text-muted)" }}>{parts.time}</span>
</span>
);
}
case "dateEdited":
return metadata.dateEdited ? new Date(metadata.dateEdited).toLocaleString() : "-";
return metadata.dateEdited ? (
<span title={new Date(metadata.dateEdited).toLocaleString()}>
{formatRelativeTime(metadata.dateEdited)}
</span>
) : "-";
case "createdBy":
return metadata.createdBy || "-";
case "lastEditedBy":
@@ -604,16 +886,19 @@ export default function MelodyList() {
}
};
// Build visible column list (description is rendered inside name, not as its own column)
const activeColumns = ALL_COLUMNS.filter((c) => c.key !== "description" && isVisible(c.key));
// Build visible column list in user-defined order (description is rendered inside name)
const activeColumns = visibleColumns
.filter((key) => key !== "description")
.map((key) => ALL_COLUMNS.find((c) => c.key === key))
.filter(Boolean);
const languages = melodySettings?.available_languages || ["en"];
const selectClass = "px-3 py-2 rounded-md text-sm cursor-pointer border";
return (
<div>
<div className="flex items-center justify-between mb-6">
<div className="w-full min-w-0">
<div className="flex items-center justify-between mb-6 w-full">
<h1 className="text-2xl font-bold" style={{ color: "var(--text-heading)" }}>Melodies</h1>
{canEdit && (
<button
@@ -626,7 +911,7 @@ export default function MelodyList() {
)}
</div>
<div className="mb-4 space-y-3">
<div className="mb-4 space-y-3 w-full">
<SearchBar
onSearch={setSearch}
placeholder="Search by name, description, or tags..."
@@ -754,7 +1039,10 @@ export default function MelodyList() {
borderColor: "var(--border-primary)",
}}
>
{ALL_COLUMNS.map((col) => (
{ALL_COLUMNS.map((col) => {
const orderIdx = visibleColumns.indexOf(col.key);
const canMove = orderIdx >= 0;
return (
<label
key={col.key}
className="flex items-center gap-2 px-3 py-1.5 text-sm cursor-pointer"
@@ -767,16 +1055,55 @@ export default function MelodyList() {
disabled={col.alwaysOn}
className="h-3.5 w-3.5 rounded cursor-pointer"
/>
{col.label}
<span className="flex-1">{col.label}</span>
{canMove && (
<span className="inline-flex gap-1" onClick={(e) => e.stopPropagation()}>
<button
type="button"
onClick={() => moveColumn(col.key, "up")}
className="text-[10px] px-1 rounded border"
style={{ borderColor: "var(--border-primary)", color: "var(--text-muted)", background: "transparent" }}
title="Move left"
>
</button>
<button
type="button"
onClick={() => moveColumn(col.key, "down")}
className="text-[10px] px-1 rounded border"
style={{ borderColor: "var(--border-primary)", color: "var(--text-muted)", background: "transparent" }}
title="Move right"
>
</button>
</span>
)}
</label>
))}
);
})}
</div>
)}
</div>
<span className="flex items-center text-sm" style={{ color: "var(--text-muted)" }}>
{displayRows.length} / {total} {total === 1 ? "melody" : "melodies"}
</span>
<div className="ml-auto flex items-center gap-3">
{canEdit && (
<button
type="button"
onClick={() => setShowOfflineModal(true)}
className="px-3 py-2 text-sm rounded-md border transition-colors cursor-pointer"
style={{
borderColor: "var(--border-primary)",
color: "var(--text-secondary)",
backgroundColor: "var(--bg-card)",
}}
>
Build Offline List
</button>
)}
<span className="flex items-center text-sm" style={{ color: "var(--text-muted)" }}>
{displayRows.length} / {total} {total === 1 ? "melody" : "melodies"}
</span>
</div>
</div>
</div>
@@ -808,14 +1135,14 @@ export default function MelodyList() {
</div>
) : (
<div
className="rounded-lg overflow-hidden border"
className="rounded-lg overflow-hidden border max-w-full"
style={{
backgroundColor: "var(--bg-card)",
borderColor: "var(--border-primary)",
}}
>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<div className="overflow-x-auto max-w-full">
<table className="w-full text-sm min-w-max">
<thead>
<tr style={{ backgroundColor: "var(--bg-primary)", borderBottom: "1px solid var(--border-primary)" }}>
{activeColumns.map((col) => (
@@ -938,7 +1265,78 @@ export default function MelodyList() {
onConfirm={handleUnpublish}
onCancel={() => setUnpublishTarget(null)}
/>
{showOfflineModal && (
<div
className="fixed inset-0 z-50 flex items-center justify-center p-4"
style={{ backgroundColor: "rgba(0,0,0,0.55)" }}
onClick={() => setShowOfflineModal(false)}
>
<div
className="w-full max-w-5xl max-h-[85vh] rounded-lg border shadow-xl flex flex-col"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
onClick={(e) => e.stopPropagation()}
>
<div className="px-5 py-4 border-b flex items-center justify-between" style={{ borderColor: "var(--border-primary)" }}>
<div>
<h2 className="text-lg font-semibold" style={{ color: "var(--text-heading)" }}>Offline Built-In Code</h2>
<p className="text-xs mt-0.5" style={{ color: "var(--text-muted)" }}>
Includes melodies where Built-in = Yes
</p>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={async () => {
try {
await navigator.clipboard.writeText(offlineCode);
} catch {
// ignore
}
}}
className="px-3 py-1.5 text-xs rounded border"
style={{ borderColor: "var(--border-primary)", color: "var(--text-secondary)", backgroundColor: "var(--bg-card-hover)" }}
>
Copy
</button>
<button
type="button"
onClick={() => setShowOfflineModal(false)}
className="px-3 py-1.5 text-xs rounded border"
style={{ borderColor: "var(--border-primary)", color: "var(--text-secondary)", backgroundColor: "var(--bg-card-hover)" }}
>
Close
</button>
</div>
</div>
<div className="p-5 overflow-auto">
<pre
className="text-xs rounded-lg p-4 overflow-auto"
style={{
backgroundColor: "var(--bg-primary)",
color: "var(--text-primary)",
border: "1px solid var(--border-primary)",
maxHeight: "62vh",
whiteSpace: "pre",
}}
>
{offlineCode}
</pre>
</div>
</div>
</div>
)}
<PlaybackModal
open={!!viewRow}
melody={viewRow || null}
builtMelody={null}
files={viewRow ? { binary_url: getBinaryUrl(viewRow) } : null}
archetypeCsv={viewRow?.information?.archetype_csv || null}
onClose={() => setViewRow(null)}
/>
</div>
);
}

View File

@@ -50,6 +50,13 @@ function parseStepsString(stepsStr) {
return stepsStr.trim().split(",").map((s) => parseBellNotation(s));
}
function normalizePlaybackUrl(url) {
if (!url || typeof url !== "string") return null;
if (url.startsWith("http") || url.startsWith("/api")) return url;
if (url.startsWith("/")) return `/api${url}`;
return `/api/${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");
@@ -143,6 +150,7 @@ export default function PlaybackModal({ open, melody, builtMelody, files, archet
const [currentStep, setCurrentStep] = useState(-1);
const [speedPercent, setSpeedPercent] = useState(50);
const [toneLengthMs, setToneLengthMs] = useState(80);
const [loopEnabled, setLoopEnabled] = useState(true);
// activeBells: Set of bell numbers currently lit (for flash effect)
const [activeBells, setActiveBells] = useState(new Set());
@@ -153,6 +161,7 @@ export default function PlaybackModal({ open, melody, builtMelody, files, archet
const speedMsRef = useRef(500);
const toneLengthRef = useRef(80);
const noteAssignmentsRef = useRef(noteAssignments);
const loopEnabledRef = useRef(true);
const speedMs = mapPercentageToSpeed(speedPercent, minSpeed, maxSpeed) ?? 500;
@@ -160,6 +169,7 @@ export default function PlaybackModal({ open, melody, builtMelody, files, archet
useEffect(() => { speedMsRef.current = speedMs; }, [speedMs]);
useEffect(() => { toneLengthRef.current = toneLengthMs; }, [toneLengthMs]);
useEffect(() => { noteAssignmentsRef.current = noteAssignments; }, [noteAssignments]); // eslint-disable-line react-hooks/exhaustive-deps
useEffect(() => { loopEnabledRef.current = loopEnabled; }, [loopEnabled]);
const stopPlayback = useCallback(() => {
if (playbackRef.current) {
@@ -180,11 +190,39 @@ export default function PlaybackModal({ open, melody, builtMelody, files, archet
setCurrentStep(-1);
setLoadError("");
setSpeedPercent(50);
setLoopEnabled(true);
setActiveBells(new Set());
return;
}
const binaryUrlCandidate = builtMelody?.binary_url
? `/api${builtMelody.binary_url}`
: files?.binary_url || melody?.url || null;
const binaryUrl = normalizePlaybackUrl(binaryUrlCandidate);
const csv = archetypeCsv || info.archetype_csv || null;
if (binaryUrl) {
setLoading(true);
setLoadError("");
decodeBsmBinary(binaryUrl)
.then((decoded) => {
setSteps(decoded);
stepsRef.current = decoded;
})
.catch((err) => {
if (csv) {
const parsed = parseStepsString(csv);
setSteps(parsed);
stepsRef.current = parsed;
setLoadError("");
return;
}
setLoadError(err.message);
})
.finally(() => setLoading(false));
return;
}
if (csv) {
const parsed = parseStepsString(csv);
setSteps(parsed);
@@ -193,25 +231,7 @@ export default function PlaybackModal({ open, melody, builtMelody, files, archet
return;
}
// Fall back to binary
const binaryUrl = builtMelody?.binary_url
? `/api${builtMelody.binary_url}`
: files?.binary_url || melody?.url || null;
if (!binaryUrl) {
setLoadError("No binary or archetype data available for this melody.");
return;
}
setLoading(true);
setLoadError("");
decodeBsmBinary(binaryUrl)
.then((decoded) => {
setSteps(decoded);
stepsRef.current = decoded;
})
.catch((err) => setLoadError(err.message))
.finally(() => setLoading(false));
setLoadError("No binary or archetype data available for this melody.");
}, [open]); // eslint-disable-line react-hooks/exhaustive-deps
const ensureAudioCtx = () => {
@@ -255,11 +275,19 @@ export default function PlaybackModal({ open, melody, builtMelody, files, archet
// Schedule next step after step interval
const timer = setTimeout(() => {
const next = playFrom + 1;
scheduleStep(next >= stepsRef.current.length ? 0 : next);
if (next >= stepsRef.current.length) {
if (loopEnabledRef.current) {
scheduleStep(0);
} else {
stopPlayback();
}
return;
}
scheduleStep(next);
}, speedMsRef.current);
playbackRef.current = { timer, flashTimer, stepIndex: playFrom };
}, []); // eslint-disable-line react-hooks/exhaustive-deps
}, [stopPlayback]); // eslint-disable-line react-hooks/exhaustive-deps
const handlePlay = () => {
if (!stepsRef.current.length) return;
@@ -286,6 +314,18 @@ export default function PlaybackModal({ open, melody, builtMelody, files, archet
const maxBell = allBellsUsed.size > 0 ? Math.max(...Array.from(allBellsUsed)) : 0;
const hasSpeedInfo = minSpeed != null && maxSpeed != null;
const detectedNoteCount = steps.reduce((max, stepValue) => {
let highest = 0;
for (let bit = 15; bit >= 0; bit--) {
if (stepValue & (1 << bit)) {
highest = bit + 1;
break;
}
}
return Math.max(max, highest);
}, 0);
const configuredNoteCount = Number(info.totalNotes || noteAssignments.length || 0);
const gridNoteCount = Math.max(1, Math.min(16, configuredNoteCount || detectedNoteCount || 1));
return (
<div
@@ -439,7 +479,99 @@ export default function PlaybackModal({ open, melody, builtMelody, files, archet
Stop
</button>
)}
<span className="text-xs" style={mutedStyle}>Loops continuously</span>
<label className="inline-flex items-center gap-2 text-xs" style={mutedStyle}>
<input
type="checkbox"
checked={loopEnabled}
onChange={(e) => setLoopEnabled(e.target.checked)}
className="h-3.5 w-3.5 rounded"
/>
Loop
</label>
</div>
{/* Steps matrix */}
<div>
<p className="text-xs mb-2" style={mutedStyle}>Note/Step Matrix</p>
<div
className="rounded-md border overflow-auto"
style={{ borderColor: "var(--border-primary)", maxHeight: "280px" }}
>
<table className="min-w-max border-separate border-spacing-0 text-xs">
<thead>
<tr>
<th
className="sticky top-0 left-0 z-20 px-2 py-1.5 text-left border-b border-r"
style={{ backgroundColor: "var(--bg-primary)", borderColor: "var(--border-primary)", color: "var(--text-muted)" }}
>
Note \ Step
</th>
{steps.map((_, stepIdx) => (
<th
key={stepIdx}
className="sticky top-0 z-10 px-2 py-1.5 text-center border-b border-r"
style={{
minWidth: "36px",
backgroundColor: currentStep === stepIdx ? "rgba(116,184,22,0.2)" : "var(--bg-primary)",
borderColor: "var(--border-primary)",
color: currentStep === stepIdx ? "var(--accent)" : "var(--text-muted)",
}}
>
{stepIdx + 1}
</th>
))}
</tr>
</thead>
<tbody>
{Array.from({ length: gridNoteCount }, (_, noteIdx) => (
<tr
key={noteIdx}
>
<th
className="sticky left-0 z-[1] px-2 py-1.5 text-left border-b border-r"
style={{ backgroundColor: "var(--bg-primary)", borderColor: "var(--border-primary)", color: "var(--text-secondary)" }}
>
{NOTE_LABELS[noteIdx]}
</th>
{steps.map((stepValue, stepIdx) => {
const enabled = Boolean(stepValue & (1 << noteIdx));
const isCurrent = currentStep === stepIdx;
return (
<td
key={`${noteIdx}-${stepIdx}`}
className="border-b border-r"
style={{
width: "36px",
height: "36px",
borderColor: "var(--border-primary)",
backgroundColor: isCurrent ? "rgba(116,184,22,0.06)" : "transparent",
}}
>
<span
className="w-full h-full flex items-center justify-center"
aria-hidden="true"
>
<span
style={{
width: "54%",
height: "54%",
borderRadius: "9999px",
backgroundColor: "var(--btn-primary)",
opacity: enabled ? 1 : 0,
transform: enabled ? "scale(1)" : "scale(0.4)",
boxShadow: enabled ? "0 0 10px 3px rgba(116, 184, 22, 0.5)" : "none",
transition: "opacity 140ms ease, transform 140ms ease, box-shadow 180ms ease",
}}
/>
</span>
</td>
);
})}
</tr>
))}
</tbody>
</table>
</div>
</div>
{/* Speed Slider */}