Fixes and Changes again

This commit is contained in:
2026-02-22 17:28:27 +02:00
parent 8abb65ac8d
commit ae4b31328f
11 changed files with 1617 additions and 96 deletions

View File

@@ -1,5 +1,5 @@
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from typing import List, Optional from typing import Any, Dict, List, Optional
from enum import Enum from enum import Enum
@@ -24,6 +24,7 @@ class MelodyInfo(BaseModel):
minSpeed: int = 0 minSpeed: int = 0
maxSpeed: int = 0 maxSpeed: int = 0
totalNotes: int = Field(default=1, ge=1, le=16) totalNotes: int = Field(default=1, ge=1, le=16)
totalActiveBells: int = 0
steps: int = 0 steps: int = 0
color: str = "" color: str = ""
isTrueRing: bool = False isTrueRing: bool = False
@@ -50,6 +51,7 @@ class MelodyCreate(BaseModel):
url: str = "" url: str = ""
uid: str = "" uid: str = ""
pid: str = "" pid: str = ""
metadata: Optional[Dict[str, Any]] = None
class MelodyUpdate(BaseModel): class MelodyUpdate(BaseModel):
@@ -59,6 +61,7 @@ class MelodyUpdate(BaseModel):
url: Optional[str] = None url: Optional[str] = None
uid: Optional[str] = None uid: Optional[str] = None
pid: Optional[str] = None pid: Optional[str] = None
metadata: Optional[Dict[str, Any]] = None
class MelodyInDB(MelodyCreate): class MelodyInDB(MelodyCreate):

View File

@@ -6,8 +6,8 @@ import MelodyList from "./melodies/MelodyList";
import MelodyDetail from "./melodies/MelodyDetail"; import MelodyDetail from "./melodies/MelodyDetail";
import MelodyForm from "./melodies/MelodyForm"; import MelodyForm from "./melodies/MelodyForm";
import MelodySettings from "./melodies/MelodySettings"; import MelodySettings from "./melodies/MelodySettings";
import BuilderList from "./melodies/builder/BuilderList"; import ArchetypeList from "./melodies/archetypes/ArchetypeList";
import BuilderForm from "./melodies/builder/BuilderForm"; import ArchetypeForm from "./melodies/archetypes/ArchetypeForm";
import DeviceList from "./devices/DeviceList"; import DeviceList from "./devices/DeviceList";
import DeviceDetail from "./devices/DeviceDetail"; import DeviceDetail from "./devices/DeviceDetail";
import DeviceForm from "./devices/DeviceForm"; import DeviceForm from "./devices/DeviceForm";
@@ -118,9 +118,9 @@ export default function App() {
<Route path="melodies" element={<PermissionGate section="melodies"><MelodyList /></PermissionGate>} /> <Route path="melodies" element={<PermissionGate section="melodies"><MelodyList /></PermissionGate>} />
<Route path="melodies/settings" element={<PermissionGate section="melodies"><MelodySettings /></PermissionGate>} /> <Route path="melodies/settings" element={<PermissionGate section="melodies"><MelodySettings /></PermissionGate>} />
<Route path="melodies/new" element={<PermissionGate section="melodies" action="add"><MelodyForm /></PermissionGate>} /> <Route path="melodies/new" element={<PermissionGate section="melodies" action="add"><MelodyForm /></PermissionGate>} />
<Route path="melodies/builder" element={<PermissionGate section="melodies" action="edit"><BuilderList /></PermissionGate>} /> <Route path="melodies/archetypes" element={<PermissionGate section="melodies" action="edit"><ArchetypeList /></PermissionGate>} />
<Route path="melodies/builder/new" element={<PermissionGate section="melodies" action="edit"><BuilderForm /></PermissionGate>} /> <Route path="melodies/archetypes/new" element={<PermissionGate section="melodies" action="edit"><ArchetypeForm /></PermissionGate>} />
<Route path="melodies/builder/:id" element={<PermissionGate section="melodies" action="edit"><BuilderForm /></PermissionGate>} /> <Route path="melodies/archetypes/:id" element={<PermissionGate section="melodies" action="edit"><ArchetypeForm /></PermissionGate>} />
<Route path="melodies/:id" element={<PermissionGate section="melodies"><MelodyDetail /></PermissionGate>} /> <Route path="melodies/:id" element={<PermissionGate section="melodies"><MelodyDetail /></PermissionGate>} />
<Route path="melodies/:id/edit" element={<PermissionGate section="melodies" action="edit"><MelodyForm /></PermissionGate>} /> <Route path="melodies/:id/edit" element={<PermissionGate section="melodies" action="edit"><MelodyForm /></PermissionGate>} />

View File

@@ -9,7 +9,7 @@ const navItems = [
permission: "melodies", permission: "melodies",
children: [ children: [
{ to: "/melodies", label: "Main Editor" }, { to: "/melodies", label: "Main Editor" },
{ to: "/melodies/builder", label: "Archetypes" }, { to: "/melodies/archetypes", label: "Archetypes" },
{ to: "/melodies/settings", label: "Settings" }, { to: "/melodies/settings", label: "Settings" },
], ],
}, },

View File

@@ -273,7 +273,8 @@ export default function MelodyDetail() {
<span className="capitalize">{info.melodyTone}</span> <span className="capitalize">{info.melodyTone}</span>
</Field> </Field>
<Field label="Steps">{info.steps}</Field> <Field label="Steps">{info.steps}</Field>
<Field label="Total Active Notes (bells)">{info.totalNotes}</Field> <Field label="Total Archetype Notes">{info.totalNotes}</Field>
<Field label="Total Active Bells">{info.totalActiveBells ?? "-"}</Field>
<Field label="Min Speed">{info.minSpeed}</Field> <Field label="Min Speed">{info.minSpeed}</Field>
<Field label="Max Speed">{info.maxSpeed}</Field> <Field label="Max Speed">{info.maxSpeed}</Field>
<Field label="Color"> <Field label="Color">
@@ -367,11 +368,36 @@ export default function MelodyDetail() {
</Field> </Field>
</div> </div>
<div className="col-span-2 md:col-span-3"> <div className="col-span-2 md:col-span-3">
<Field label="Note Assignments"> <dt className="text-xs font-medium uppercase tracking-wide mb-2" style={{ color: "var(--text-muted)" }}>Note Assignments</dt>
{settings.noteAssignments?.length > 0 <dd>
? settings.noteAssignments.join(", ") {settings.noteAssignments?.length > 0 ? (
: "-"} <div className="flex flex-wrap gap-1.5">
</Field> {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)" }}>
{noteIdx + 1}
</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>
))}
</div>
) : (
<span style={{ color: "var(--text-muted)" }}>-</span>
)}
<p className="text-xs mt-1" style={{ color: "var(--text-muted)" }}>Top = Note #, Bottom = Assigned Bell</p>
</dd>
</div> </div>
</dl> </dl>
</section> </section>
@@ -384,42 +410,62 @@ export default function MelodyDetail() {
<h2 className="text-lg font-semibold mb-4" style={{ color: "var(--text-heading)" }}>Files</h2> <h2 className="text-lg font-semibold mb-4" style={{ color: "var(--text-heading)" }}>Files</h2>
<dl className="space-y-4"> <dl className="space-y-4">
<Field label="Binary File"> <Field label="Binary File">
{files.binary_url ? (() => { {(() => {
// Prefer the uploaded file URL, fall back to melody.url (legacy/firebase storage URL)
const binaryUrl = 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"; const binaryPid = builtMelody?.pid || melody.pid || "binary";
const binaryFilename = `${binaryPid}.bsm`; const binaryFilename = `${binaryPid}.bsm`;
// Derive a display name: for firebase URLs extract the filename portion
let displayName = binaryFilename;
if (!files.binary_url && melody.url) {
try {
const urlPath = decodeURIComponent(new URL(melody.url).pathname);
const parts = urlPath.split("/");
displayName = parts[parts.length - 1] || binaryFilename;
} catch { /* keep binaryFilename */ }
}
const handleDownload = async (e) => { const handleDownload = async (e) => {
e.preventDefault(); e.preventDefault();
try { try {
const token = localStorage.getItem("access_token"); const token = localStorage.getItem("access_token");
const res = await fetch(files.binary_url, { const res = await fetch(binaryUrl, {
headers: token ? { Authorization: `Bearer ${token}` } : {}, headers: token ? { Authorization: `Bearer ${token}` } : {},
}); });
if (!res.ok) throw new Error(`Download failed: ${res.statusText}`); if (!res.ok) throw new Error(`Download failed: ${res.statusText}`);
const blob = await res.blob(); const blob = await res.blob();
const url = URL.createObjectURL(blob); const objectUrl = URL.createObjectURL(blob);
const a = document.createElement("a"); const a = document.createElement("a");
a.href = url; a.href = objectUrl;
a.download = binaryFilename; a.download = displayName;
a.click(); a.click();
URL.revokeObjectURL(url); URL.revokeObjectURL(objectUrl);
} catch (err) { } catch (err) {
// surface error in page error state if possible
console.error(err); console.error(err);
} }
}; };
return ( return (
<a <span className="inline-flex items-center gap-2">
href={files.binary_url} <a
onClick={handleDownload} href={binaryUrl}
className="underline" onClick={handleDownload}
style={{ color: "var(--accent)" }} className="underline"
> style={{ color: "var(--accent)" }}
{binaryFilename} >
</a> {displayName}
</a>
{!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
</span>
)}
</span>
); );
})() : ( })()}
<span style={{ color: "var(--text-muted)" }}>Not uploaded</span>
)}
</Field> </Field>
<Field label="Audio Preview"> <Field label="Audio Preview">
{files.preview_url ? ( {files.preview_url ? (
@@ -486,6 +532,57 @@ export default function MelodyDetail() {
</section> </section>
)} )}
{/* Metadata section */}
{melody.metadata && (
<section
className="rounded-lg p-6 border mt-6"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
>
<h2 className="text-lg font-semibold mb-4" style={{ color: "var(--text-heading)" }}>History</h2>
<dl className="grid grid-cols-2 md:grid-cols-4 gap-4">
{melody.metadata.dateCreated && (
<Field label="Date Created">
{new Date(melody.metadata.dateCreated).toLocaleString()}
</Field>
)}
{melody.metadata.createdBy && (
<Field label="Created By">{melody.metadata.createdBy}</Field>
)}
{melody.metadata.dateEdited && (
<Field label="Last Edited">
{new Date(melody.metadata.dateEdited).toLocaleString()}
</Field>
)}
{melody.metadata.lastEditedBy && (
<Field label="Last Edited By">{melody.metadata.lastEditedBy}</Field>
)}
</dl>
</section>
)}
{/* Admin Notes section */}
<section
className="rounded-lg p-6 border mt-6"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
>
<h2 className="text-lg font-semibold mb-4" style={{ color: "var(--text-heading)" }}>Admin Notes</h2>
{(melody.metadata?.adminNotes?.length || 0) > 0 ? (
<div className="space-y-2">
{melody.metadata.adminNotes.map((note, i) => (
<div
key={i}
className="rounded-lg p-3 border text-sm"
style={{ borderColor: "var(--border-primary)", backgroundColor: "var(--bg-primary)", color: "var(--text-primary)" }}
>
{note}
</div>
))}
</div>
) : (
<p className="text-sm" style={{ color: "var(--text-muted)" }}>No admin notes yet. Edit this melody to add notes.</p>
)}
</section>
<PlaybackModal <PlaybackModal
open={showPlayback} open={showPlayback}
melody={melody} melody={melody}

View File

@@ -1,9 +1,10 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { useNavigate, useParams } from "react-router-dom"; import { useNavigate, useParams } from "react-router-dom";
import api from "../api/client"; import api from "../api/client";
import { useAuth } from "../auth/AuthContext";
import TranslationModal from "./TranslationModal"; import TranslationModal from "./TranslationModal";
import SelectBuiltMelodyModal from "./builder/SelectBuiltMelodyModal"; import SelectArchetypeModal from "./archetypes/SelectArchetypeModal";
import BuildOnTheFlyModal from "./builder/BuildOnTheFlyModal"; import BuildOnTheFlyModal from "./archetypes/BuildOnTheFlyModal";
import SpeedCalculatorModal from "./SpeedCalculatorModal"; import SpeedCalculatorModal from "./SpeedCalculatorModal";
import PlaybackModal from "./PlaybackModal"; import PlaybackModal from "./PlaybackModal";
import { import {
@@ -26,6 +27,7 @@ const defaultInfo = {
minSpeed: 0, minSpeed: 0,
maxSpeed: 0, maxSpeed: 0,
totalNotes: 1, totalNotes: 1,
totalActiveBells: 0,
steps: 0, steps: 0,
color: "", color: "",
isTrueRing: false, isTrueRing: false,
@@ -55,6 +57,7 @@ export default function MelodyForm() {
const { id } = useParams(); const { id } = useParams();
const isEdit = Boolean(id); const isEdit = Boolean(id);
const navigate = useNavigate(); const navigate = useNavigate();
const { user } = useAuth();
const [information, setInformation] = useState({ ...defaultInfo }); const [information, setInformation] = useState({ ...defaultInfo });
const [settings, setSettings] = useState({ ...defaultSettings }); const [settings, setSettings] = useState({ ...defaultSettings });
@@ -91,6 +94,11 @@ export default function MelodyForm() {
const [builtMelody, setBuiltMelody] = useState(null); const [builtMelody, setBuiltMelody] = useState(null);
const [assignedBinaryName, setAssignedBinaryName] = useState(null); const [assignedBinaryName, setAssignedBinaryName] = useState(null);
// Metadata / Admin Notes
const [adminNotes, setAdminNotes] = useState([]);
const [newNote, setNewNote] = useState("");
const [savedMelodyId, setSavedMelodyId] = useState(null);
useEffect(() => { useEffect(() => {
api.get("/settings/melody").then((ms) => { api.get("/settings/melody").then((ms) => {
setMelodySettings(ms); setMelodySettings(ms);
@@ -130,6 +138,8 @@ export default function MelodyForm() {
setPid(melody.pid || ""); setPid(melody.pid || "");
setMelodyStatus(melody.status || "published"); setMelodyStatus(melody.status || "published");
setExistingFiles(files); setExistingFiles(files);
// Load admin notes from metadata
setAdminNotes(melody.metadata?.adminNotes || []);
// Load built melody assignment (non-fatal) // Load built melody assignment (non-fatal)
try { try {
const bm = await api.get(`/builder/melodies/for-melody/${id}`); const bm = await api.get(`/builder/melodies/for-melody/${id}`);
@@ -171,12 +181,27 @@ export default function MelodyForm() {
updateInfo(fieldKey, serializeLocalizedString(dict)); updateInfo(fieldKey, serializeLocalizedString(dict));
}; };
const buildBody = () => { // Compute totalActiveBells from the current noteAssignments (unique non-zero values)
const computeTotalActiveBells = (assignments) => {
const unique = new Set((assignments || []).filter((v) => v > 0));
return unique.size;
};
const buildBody = (overrideMetadata) => {
const { notes, ...infoWithoutNotes } = information; const { notes, ...infoWithoutNotes } = information;
const totalActiveBells = computeTotalActiveBells(settings.noteAssignments);
const now = new Date().toISOString();
const userName = user?.name || "Unknown";
const metadata = overrideMetadata || {
dateEdited: now,
lastEditedBy: userName,
adminNotes,
};
return { return {
information: infoWithoutNotes, information: { ...infoWithoutNotes, totalActiveBells },
default_settings: settings, default_settings: settings,
type, url, uid, pid, type, url, uid, pid,
metadata,
}; };
}; };
@@ -193,14 +218,18 @@ export default function MelodyForm() {
setSaving(true); setSaving(true);
setError(""); setError("");
try { try {
const body = buildBody(); const now = new Date().toISOString();
const userName = user?.name || "Unknown";
let melodyId = id; let melodyId = id;
if (isEdit) { if (isEdit) {
const body = buildBody({ dateEdited: now, lastEditedBy: userName, adminNotes });
await api.put(`/melodies/${id}`, body); await api.put(`/melodies/${id}`, body);
} else { } else {
const body = buildBody({ dateCreated: now, dateEdited: now, createdBy: userName, lastEditedBy: userName, adminNotes });
const created = await api.post(`/melodies?publish=${publish}`, body); const created = await api.post(`/melodies?publish=${publish}`, body);
melodyId = created.id; melodyId = created.id;
setSavedMelodyId(melodyId);
} }
await uploadFiles(melodyId); await uploadFiles(melodyId);
@@ -217,7 +246,9 @@ export default function MelodyForm() {
setSaving(true); setSaving(true);
setError(""); setError("");
try { try {
const body = buildBody(); const now = new Date().toISOString();
const userName = user?.name || "Unknown";
const body = buildBody({ dateEdited: now, lastEditedBy: userName, adminNotes });
await api.put(`/melodies/${id}`, body); await api.put(`/melodies/${id}`, body);
await uploadFiles(id); await uploadFiles(id);
await api.post(`/melodies/${id}/publish`); await api.post(`/melodies/${id}/publish`);
@@ -446,7 +477,7 @@ export default function MelodyForm() {
</div> </div>
<div> <div>
<label className="block text-sm font-medium mb-1" style={labelStyle}>Total Active Notes (bells)</label> <label className="block text-sm font-medium mb-1" style={labelStyle}>Total Archetype Notes</label>
<input type="number" min="1" max="16" value={information.totalNotes} onChange={(e) => { const val = parseInt(e.target.value, 10); updateInfo("totalNotes", Math.max(1, Math.min(16, val || 1))); }} className={inputClass} /> <input type="number" min="1" max="16" value={information.totalNotes} onChange={(e) => { const val = parseInt(e.target.value, 10); updateInfo("totalNotes", Math.max(1, Math.min(16, val || 1))); }} className={inputClass} />
</div> </div>
@@ -576,7 +607,12 @@ export default function MelodyForm() {
</div> </div>
<div className="md:col-span-2"> <div className="md:col-span-2">
<label className="block text-sm font-medium mb-2" style={labelStyle}>Note Assignments</label> <div className="flex items-center justify-between mb-2">
<label className="block text-sm font-medium" style={labelStyle}>Note Assignments</label>
<span className="text-xs px-2 py-0.5 rounded-full" style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-muted)" }}>
{computeTotalActiveBells(settings.noteAssignments)} active bell{computeTotalActiveBells(settings.noteAssignments) !== 1 ? "s" : ""}
</span>
</div>
<div className="grid grid-cols-4 sm:grid-cols-8 gap-2"> <div className="grid grid-cols-4 sm:grid-cols-8 gap-2">
{Array.from({ length: information.totalNotes }, (_, i) => ( {Array.from({ length: information.totalNotes }, (_, i) => (
<div key={i}> <div key={i}>
@@ -588,7 +624,7 @@ export default function MelodyForm() {
</div> </div>
))} ))}
</div> </div>
<p className="text-xs mt-1" style={mutedStyle}>Assign which bell rings for each note (0 = none)</p> <p className="text-xs mt-1" style={mutedStyle}>Assign which bell rings for each note (0 = none). Total Active Bells = unique assigned values.</p>
</div> </div>
</div> </div>
</section> </section>
@@ -607,26 +643,51 @@ export default function MelodyForm() {
</p> </p>
)} )}
<input type="file" accept=".bin,.bsm" onChange={(e) => setBinaryFile(e.target.files[0] || null)} className="w-full text-sm" style={mutedStyle} /> <input type="file" accept=".bin,.bsm" onChange={(e) => setBinaryFile(e.target.files[0] || null)} className="w-full text-sm" style={mutedStyle} />
{isEdit && ( <div className="flex gap-2 mt-2">
<div className="flex gap-2 mt-2"> <button
<button type="button"
type="button" onClick={async () => {
onClick={() => setShowSelectBuilt(true)} if (!isEdit && !savedMelodyId) {
className="px-3 py-1.5 text-xs rounded-md transition-colors" // Auto-save draft first for new melodies
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-secondary)", border: "1px solid var(--border-primary)" }} setSaving(true); setError("");
> try {
Select Built Melody const now = new Date().toISOString();
</button> const userName = user?.name || "Unknown";
<button const body = buildBody({ dateCreated: now, dateEdited: now, createdBy: userName, lastEditedBy: userName, adminNotes });
type="button" const created = await api.post(`/melodies?publish=false`, body);
onClick={() => setShowBuildOnTheFly(true)} setSavedMelodyId(created.id);
className="px-3 py-1.5 text-xs rounded-md transition-colors" } catch (err) { setError(err.message); setSaving(false); return; }
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-secondary)", border: "1px solid var(--border-primary)" }} setSaving(false);
> }
Build on the Fly setShowSelectBuilt(true);
</button> }}
</div> className="px-3 py-1.5 text-xs rounded-md transition-colors"
)} style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-secondary)", border: "1px solid var(--border-primary)" }}
>
Select Archetype
</button>
<button
type="button"
onClick={async () => {
if (!isEdit && !savedMelodyId) {
setSaving(true); setError("");
try {
const now = new Date().toISOString();
const userName = user?.name || "Unknown";
const body = buildBody({ dateCreated: now, dateEdited: now, createdBy: userName, lastEditedBy: userName, adminNotes });
const created = await api.post(`/melodies?publish=false`, body);
setSavedMelodyId(created.id);
} catch (err) { setError(err.message); setSaving(false); return; }
setSaving(false);
}
setShowBuildOnTheFly(true);
}}
className="px-3 py-1.5 text-xs rounded-md transition-colors"
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-secondary)", border: "1px solid var(--border-primary)" }}
>
Build on the Fly
</button>
</div>
</div> </div>
<div> <div>
<label className="block text-sm font-medium mb-1" style={labelStyle}>Audio Preview (.mp3)</label> <label className="block text-sm font-medium mb-1" style={labelStyle}>Audio Preview (.mp3)</label>
@@ -642,6 +703,53 @@ export default function MelodyForm() {
</section> </section>
</div> </div>
</div> </div>
{/* --- Admin Notes Section --- */}
<section className="rounded-lg p-6 border mt-6" style={sectionStyle}>
<h2 className="text-lg font-semibold mb-4" style={headingStyle}>Admin Notes</h2>
<div className="space-y-3">
{adminNotes.map((note, i) => (
<div key={i} className="flex items-start gap-3 rounded-lg p-3 border" style={{ borderColor: "var(--border-primary)", backgroundColor: "var(--bg-primary)" }}>
<p className="flex-1 text-sm" style={{ color: "var(--text-primary)" }}>{note}</p>
<button
type="button"
onClick={() => setAdminNotes((prev) => prev.filter((_, j) => j !== i))}
className="text-xs px-2 py-0.5 rounded transition-colors flex-shrink-0"
style={{ color: "var(--danger)", backgroundColor: "var(--danger-bg)" }}
>
Remove
</button>
</div>
))}
<div className="flex gap-2">
<input
type="text"
value={newNote}
onChange={(e) => setNewNote(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
const trimmed = newNote.trim();
if (trimmed) { setAdminNotes((prev) => [...prev, trimmed]); setNewNote(""); }
}
}}
placeholder="Add a note and press Enter or click Add"
className="flex-1 px-3 py-2 rounded-md text-sm border"
/>
<button
type="button"
onClick={() => {
const trimmed = newNote.trim();
if (trimmed) { setAdminNotes((prev) => [...prev, trimmed]); setNewNote(""); }
}}
className="px-3 py-2 text-sm rounded-md transition-colors"
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-primary)" }}
>
Add
</button>
</div>
</div>
</section>
</form> </form>
<TranslationModal <TranslationModal
@@ -671,21 +779,27 @@ export default function MelodyForm() {
onClose={() => setShowSpeedCalc(false)} onClose={() => setShowSpeedCalc(false)}
onSaved={() => { setShowSpeedCalc(false); loadMelody(); }} onSaved={() => { setShowSpeedCalc(false); loadMelody(); }}
/> />
<SelectBuiltMelodyModal </>
)}
{/* Archetype modals — available for both new and edit (need a melodyId) */}
{(isEdit || savedMelodyId) && (
<>
<SelectArchetypeModal
open={showSelectBuilt} open={showSelectBuilt}
melodyId={id} melodyId={id || savedMelodyId}
currentMelody={{ information, default_settings: settings, type, url, uid, pid }} currentMelody={{ information, default_settings: settings, type, url, uid, pid }}
onClose={() => setShowSelectBuilt(false)} onClose={() => setShowSelectBuilt(false)}
onSuccess={(archetype) => { onSuccess={(archetype) => {
setShowSelectBuilt(false); setShowSelectBuilt(false);
setAssignedBinaryName(archetype.name); setAssignedBinaryName(archetype.name);
if (!pid.trim() && archetype.pid) setPid(archetype.pid); if (!pid.trim() && archetype.pid) setPid(archetype.pid);
loadMelody(); if (isEdit) loadMelody();
}} }}
/> />
<BuildOnTheFlyModal <BuildOnTheFlyModal
open={showBuildOnTheFly} open={showBuildOnTheFly}
melodyId={id} melodyId={id || savedMelodyId}
currentMelody={{ information, default_settings: settings, type, url, uid, pid }} currentMelody={{ information, default_settings: settings, type, url, uid, pid }}
defaultName={getLocalizedValue(information.name, "en", "") || getLocalizedValue(information.name, editLang, "")} defaultName={getLocalizedValue(information.name, "en", "") || getLocalizedValue(information.name, editLang, "")}
defaultPid={pid} defaultPid={pid}
@@ -694,7 +808,7 @@ export default function MelodyForm() {
setShowBuildOnTheFly(false); setShowBuildOnTheFly(false);
setAssignedBinaryName(archetype.name); setAssignedBinaryName(archetype.name);
if (!pid.trim() && archetype.pid) setPid(archetype.pid); if (!pid.trim() && archetype.pid) setPid(archetype.pid);
loadMelody(); if (isEdit) loadMelody();
}} }}
/> />
</> </>

View File

@@ -101,6 +101,8 @@ export default function PlaybackModal({ open, melody, builtMelody, files, archet
const info = melody?.information || {}; const info = melody?.information || {};
const minSpeed = info.minSpeed || null; const minSpeed = info.minSpeed || null;
const maxSpeed = info.maxSpeed || null; const maxSpeed = info.maxSpeed || null;
// Note assignments: maps note index → bell number to fire
const noteAssignments = melody?.default_settings?.noteAssignments || [];
const [steps, setSteps] = useState([]); const [steps, setSteps] = useState([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
@@ -152,10 +154,10 @@ export default function PlaybackModal({ open, melody, builtMelody, files, archet
return; return;
} }
// Fall back to binary fetch // Fall back to binary fetch — prefer uploaded file, then legacy melody.url
const binaryUrl = builtMelody?.binary_url const binaryUrl = builtMelody?.binary_url
? `/api${builtMelody.binary_url}` ? `/api${builtMelody.binary_url}`
: files?.binary_url || null; : files?.binary_url || melody?.url || null;
if (!binaryUrl) { if (!binaryUrl) {
setLoadError("No binary or archetype data available for this melody."); setLoadError("No binary or archetype data available for this melody.");
@@ -196,7 +198,28 @@ export default function PlaybackModal({ open, melody, builtMelody, files, archet
} }
const ctx = ensureAudioCtx(); const ctx = ensureAudioCtx();
const stepValue = currentSteps[playFrom]; const rawStepValue = currentSteps[playFrom];
// Apply note assignments: each note in the step maps to an assigned bell number
// noteAssignments[noteIndex] = bellNumber (1-based). We rebuild the step value
// using assigned bells instead of the raw ones.
let stepValue = rawStepValue;
if (noteAssignments.length > 0) {
// Determine which notes (1-based) are active in this step
const activeNotes = [];
for (let bit = 0; bit < 16; bit++) {
if (rawStepValue & (1 << bit)) activeNotes.push(bit + 1);
}
// For each active note, look up the noteAssignment by note index (note-1)
// noteAssignments array is indexed by note position (0-based)
stepValue = 0;
for (const note of activeNotes) {
const assignedBell = noteAssignments[note - 1];
const bellToFire = (assignedBell && assignedBell > 0) ? assignedBell : note;
stepValue |= 1 << (bellToFire - 1);
}
}
setCurrentStep(playFrom); setCurrentStep(playFrom);
playStep(ctx, stepValue, BEAT_DURATION_MS); playStep(ctx, stepValue, BEAT_DURATION_MS);
@@ -299,27 +322,65 @@ export default function PlaybackModal({ open, melody, builtMelody, files, archet
)} )}
</div> </div>
{/* Bell visualizer */} {/* Note + Assignment visualizer */}
<div className="flex flex-wrap gap-1.5"> {noteAssignments.length > 0 ? (
{Array.from({ length: maxBell }, (_, i) => i + 1).map((b) => { <div>
const isActive = currentBells.includes(b); <p className="text-xs mb-2" style={mutedStyle}>Note Assigned Bell</p>
const isUsed = allBellsUsed.has(b); <div className="flex flex-wrap gap-1.5">
return ( {noteAssignments.map((assignedBell, noteIdx) => {
<div const noteNum = noteIdx + 1;
key={b} // A note is active if the current step has this note bit set (raw)
className="w-7 h-7 rounded-full flex items-center justify-center text-xs font-bold transition-all" const isActive = currentStep >= 0 && Boolean(steps[currentStep] & (1 << (noteNum - 1)));
style={{ return (
backgroundColor: isActive ? "var(--accent)" : isUsed ? "var(--bg-card-hover)" : "var(--bg-primary)", <div
color: isActive ? "var(--bg-primary)" : isUsed ? "var(--text-secondary)" : "var(--border-primary)", key={noteIdx}
border: `2px solid ${isActive ? "var(--accent)" : "var(--border-primary)"}`, className="flex flex-col items-center rounded-md border transition-all"
transform: isActive ? "scale(1.2)" : "scale(1)", style={{
}} minWidth: "36px",
> padding: "4px 6px",
{b} backgroundColor: isActive ? "var(--accent)" : "var(--bg-card-hover)",
</div> borderColor: isActive ? "var(--accent)" : "var(--border-primary)",
); transform: isActive ? "scale(1.1)" : "scale(1)",
})} }}
</div> >
<span className="text-xs font-bold leading-tight" style={{ color: isActive ? "var(--bg-primary)" : "var(--text-secondary)" }}>
{noteNum}
</span>
<div className="w-full my-0.5" style={{ height: "1px", backgroundColor: isActive ? "rgba(255,255,255,0.4)" : "var(--border-primary)" }} />
<span className="text-xs leading-tight" style={{ color: isActive ? "var(--bg-primary)" : "var(--text-muted)" }}>
{assignedBell > 0 ? assignedBell : "—"}
</span>
</div>
);
})}
</div>
<div className="flex gap-3 mt-1">
<span className="text-xs" style={mutedStyle}>Top = Note, Bottom = Bell</span>
</div>
</div>
) : (
/* Fallback: simple bell circles when no assignments */
<div className="flex flex-wrap gap-1.5">
{Array.from({ length: maxBell }, (_, i) => i + 1).map((b) => {
const isActive = currentBells.includes(b);
const isUsed = allBellsUsed.has(b);
return (
<div
key={b}
className="w-7 h-7 rounded-full flex items-center justify-center text-xs font-bold transition-all"
style={{
backgroundColor: isActive ? "var(--accent)" : isUsed ? "var(--bg-card-hover)" : "var(--bg-primary)",
color: isActive ? "var(--bg-primary)" : isUsed ? "var(--text-secondary)" : "var(--border-primary)",
border: `2px solid ${isActive ? "var(--accent)" : "var(--border-primary)"}`,
transform: isActive ? "scale(1.2)" : "scale(1)",
}}
>
{b}
</div>
);
})}
</div>
)}
{/* Play / Stop */} {/* Play / Stop */}
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
@@ -329,7 +390,7 @@ export default function PlaybackModal({ open, melody, builtMelody, files, archet
className="px-5 py-2 text-sm rounded-md font-medium transition-colors" className="px-5 py-2 text-sm rounded-md font-medium transition-colors"
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }} style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
> >
Play Play
</button> </button>
) : ( ) : (
<button <button
@@ -337,7 +398,7 @@ export default function PlaybackModal({ open, melody, builtMelody, files, archet
className="px-5 py-2 text-sm rounded-md font-medium transition-colors" className="px-5 py-2 text-sm rounded-md font-medium transition-colors"
style={{ backgroundColor: "var(--danger-btn)", color: "var(--text-white)" }} style={{ backgroundColor: "var(--danger-btn)", color: "var(--text-white)" }}
> >
Stop Stop
</button> </button>
)} )}
<span className="text-xs" style={mutedStyle}>Loops continuously</span> <span className="text-xs" style={mutedStyle}>Loops continuously</span>

View File

@@ -130,11 +130,11 @@ export default function SpeedCalculatorModal({ open, melody, builtMelody, archet
const stepsRef = useRef([]); const stepsRef = useRef([]);
const stepDelayRef = useRef(500); const stepDelayRef = useRef(500);
const effectiveBeatRef = useRef(100); const effectiveBeatRef = useRef(100);
const loopRef = useRef(false); const loopRef = useRef(true);
const [playing, setPlaying] = useState(false); const [playing, setPlaying] = useState(false);
const [paused, setPaused] = useState(false); const [paused, setPaused] = useState(false);
const [loop, setLoop] = useState(false); const [loop, setLoop] = useState(true);
const [currentStep, setCurrentStep] = useState(-1); const [currentStep, setCurrentStep] = useState(-1);
// Sliders // Sliders
@@ -285,11 +285,14 @@ export default function SpeedCalculatorModal({ open, melody, builtMelody, archet
}; };
const handleLoadFromBinary = async () => { const handleLoadFromBinary = async () => {
if (!builtMelody?.binary_url) return; const binaryUrl = builtMelody?.binary_url
? `/api${builtMelody.binary_url}`
: melody?.url || null;
if (!binaryUrl) return;
setLoadingBinary(true); setLoadingBinary(true);
setBinaryLoadError(""); setBinaryLoadError("");
try { try {
const decoded = await decodeBsmBinary(`/api${builtMelody.binary_url}`); const decoded = await decodeBsmBinary(binaryUrl);
setSteps(decoded); setSteps(decoded);
stepsRef.current = decoded; stepsRef.current = decoded;
stopPlayback(); stopPlayback();
@@ -388,7 +391,7 @@ export default function SpeedCalculatorModal({ open, melody, builtMelody, archet
<label className="text-sm font-medium" style={labelStyle}> <label className="text-sm font-medium" style={labelStyle}>
Melody Steps Melody Steps
</label> </label>
{builtMelody?.binary_url && ( {(builtMelody?.binary_url || melody?.url) && (
<button <button
onClick={handleLoadFromBinary} onClick={handleLoadFromBinary}
disabled={loadingBinary} disabled={loadingBinary}

View File

@@ -0,0 +1,451 @@
import { useState, useEffect, useRef } from "react";
import { useNavigate, useParams } from "react-router-dom";
import api from "../../api/client";
import ConfirmDialog from "../../components/ConfirmDialog";
const sectionStyle = { backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" };
const labelStyle = { color: "var(--text-secondary)" };
const mutedStyle = { color: "var(--text-muted)" };
const inputClass = "w-full px-3 py-2 rounded-md text-sm border";
function copyText(text, onSuccess) {
const fallback = () => {
const ta = document.createElement("textarea");
ta.value = text;
ta.style.cssText = "position:fixed;top:0;left:0;opacity:0";
document.body.appendChild(ta);
ta.focus();
ta.select();
try { document.execCommand("copy"); onSuccess?.(); } catch (_) {}
document.body.removeChild(ta);
};
if (navigator.clipboard) {
navigator.clipboard.writeText(text).then(onSuccess).catch(fallback);
} else {
fallback();
}
}
function countSteps(stepsStr) {
if (!stepsStr || !stepsStr.trim()) return 0;
return stepsStr.trim().split(",").length;
}
function validateSteps(stepsStr) {
if (!stepsStr || !stepsStr.trim()) return "Steps are required.";
const tokens = stepsStr.trim().split(",");
for (const token of tokens) {
const parts = token.split("+");
for (const part of parts) {
const trimmed = part.trim();
if (trimmed === "") return `Invalid token near: "${token.trim()}" — empty part found.`;
const n = parseInt(trimmed, 10);
if (isNaN(n)) return `Invalid step value: "${trimmed}" — must be a number 016.`;
if (n < 0 || n > 16) return `Step value "${trimmed}" is out of range (must be 016).`;
}
}
return null;
}
async function downloadBinary(binaryUrl, filename) {
const token = localStorage.getItem("access_token");
const res = await fetch(`/api${binaryUrl}`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
});
if (!res.ok) throw new Error(`Download failed: ${res.statusText}`);
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = filename;
a.click();
URL.revokeObjectURL(url);
}
export default function ArchetypeForm() {
const { id } = useParams();
const isEdit = Boolean(id);
const navigate = useNavigate();
const [name, setName] = useState("");
const [pid, setPid] = useState("");
const [steps, setSteps] = useState("");
const [savedPid, setSavedPid] = useState("");
const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [buildingBinary, setBuildingBinary] = useState(false);
const [buildingBuiltin, setBuildingBuiltin] = useState(false);
const [error, setError] = useState("");
const [successMsg, setSuccessMsg] = useState("");
const [binaryBuilt, setBinaryBuilt] = useState(false);
const [binaryUrl, setBinaryUrl] = useState(null);
const [progmemCode, setProgmemCode] = useState("");
const [copied, setCopied] = useState(false);
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false);
const [showDelete, setShowDelete] = useState(false);
const [deleteWarningConfirmed, setDeleteWarningConfirmed] = useState(false);
const [assignedCount, setAssignedCount] = useState(0);
const codeRef = useRef(null);
useEffect(() => {
if (isEdit) loadArchetype();
}, [id]);
const loadArchetype = async () => {
setLoading(true);
try {
const data = await api.get(`/builder/melodies/${id}`);
setName(data.name || "");
setPid(data.pid || "");
setSteps(data.steps || "");
setSavedPid(data.pid || "");
setBinaryBuilt(Boolean(data.binary_path));
setBinaryUrl(data.binary_url || null);
setProgmemCode(data.progmem_code || "");
setAssignedCount(data.assigned_melody_ids?.length || 0);
setHasUnsavedChanges(false);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
const handleNameChange = (v) => { setName(v); setHasUnsavedChanges(true); };
const handlePidChange = (v) => { setPid(v); setHasUnsavedChanges(true); };
const handleStepsChange = (v) => { setSteps(v); setHasUnsavedChanges(true); };
const handleSave = async () => {
if (!name.trim()) { setError("Name is required."); return; }
if (!pid.trim()) { setError("PID is required."); return; }
const stepsError = validateSteps(steps);
if (stepsError) { setError(stepsError); return; }
setSaving(true);
setError("");
setSuccessMsg("");
try {
const existing = await api.get("/builder/melodies");
const list = existing.melodies || [];
const dupName = list.find((m) => m.id !== id && m.name.toLowerCase() === name.trim().toLowerCase());
if (dupName) { setError(`An archetype with the name "${name.trim()}" already exists.`); return; }
const dupPid = list.find((m) => m.id !== id && m.pid && m.pid.toLowerCase() === pid.trim().toLowerCase());
if (dupPid) { setError(`An archetype with the PID "${pid.trim()}" already exists.`); return; }
const body = { name: name.trim(), pid: pid.trim(), steps: steps.trim() };
if (isEdit) {
await api.put(`/builder/melodies/${id}`, body);
setSavedPid(pid.trim());
setHasUnsavedChanges(false);
setSuccessMsg("Saved.");
} else {
const created = await api.post("/builder/melodies", body);
navigate(`/melodies/archetypes/${created.id}`, { replace: true });
}
} catch (err) {
setError(err.message);
} finally {
setSaving(false);
}
};
const handleBuildBinary = async () => {
if (!isEdit) { setError("Save the archetype first before building."); return; }
if (hasUnsavedChanges) { setError("You have unsaved changes. Save before rebuilding."); return; }
setBuildingBinary(true);
setError("");
setSuccessMsg("");
try {
const data = await api.post(`/builder/melodies/${id}/build-binary`);
setBinaryBuilt(Boolean(data.binary_path));
setBinaryUrl(data.binary_url || null);
setSuccessMsg("Binary built successfully.");
} catch (err) {
setError(err.message);
} finally {
setBuildingBinary(false);
}
};
const handleBuildBuiltin = async () => {
if (!isEdit) { setError("Save the archetype first before building."); return; }
if (hasUnsavedChanges) { setError("You have unsaved changes. Save before regenerating."); return; }
setBuildingBuiltin(true);
setError("");
setSuccessMsg("");
try {
const data = await api.post(`/builder/melodies/${id}/build-builtin`);
setProgmemCode(data.progmem_code || "");
setSuccessMsg("PROGMEM code generated.");
} catch (err) {
setError(err.message);
} finally {
setBuildingBuiltin(false);
}
};
const handleCopy = () => {
if (!progmemCode) return;
copyText(progmemCode, () => { setCopied(true); setTimeout(() => setCopied(false), 2000); });
};
const handleDelete = async () => {
try {
await api.delete(`/builder/melodies/${id}`);
navigate("/melodies/archetypes");
} catch (err) {
setError(err.message);
setShowDelete(false);
setDeleteWarningConfirmed(false);
}
};
if (loading) {
return <div className="text-center py-8" style={mutedStyle}>Loading...</div>;
}
return (
<div>
<div className="flex items-center justify-between mb-6">
<div>
<button onClick={() => navigate("/melodies/archetypes")} className="text-sm mb-2 inline-block" style={{ color: "var(--accent)" }}>
&larr; Back to Archetypes
</button>
<h1 className="text-2xl font-bold" style={{ color: "var(--text-heading)" }}>
{isEdit ? "Edit Archetype" : "New Archetype"}
</h1>
</div>
<div className="flex gap-3">
<button
onClick={() => navigate("/melodies/archetypes")}
className="px-4 py-2 text-sm rounded-md transition-colors"
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-primary)" }}
>
Cancel
</button>
{isEdit && (
<button
onClick={() => setShowDelete(true)}
className="px-4 py-2 text-sm rounded-md transition-colors"
style={{ backgroundColor: "var(--danger)", color: "#fff" }}
>
Delete
</button>
)}
<button
onClick={handleSave}
disabled={saving}
className="px-4 py-2 text-sm rounded-md disabled:opacity-50 transition-colors"
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
>
{saving ? "Saving..." : isEdit ? "Save Changes" : "Create"}
</button>
</div>
</div>
{error && (
<div className="text-sm rounded-md p-3 mb-4 border" style={{ backgroundColor: "var(--danger-bg)", borderColor: "var(--danger)", color: "var(--danger-text)" }}>
{error}
</div>
)}
{successMsg && (
<div className="text-sm rounded-md p-3 mb-4 border" style={{ backgroundColor: "var(--success-bg)", borderColor: "var(--success)", color: "var(--success-text)" }}>
{successMsg}
</div>
)}
<div className="space-y-6">
<section className="rounded-lg p-6 border" style={sectionStyle}>
<h2 className="text-lg font-semibold mb-4" style={{ color: "var(--text-heading)" }}>Archetype Info</h2>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium mb-1" style={labelStyle}>Name *</label>
<input type="text" value={name} onChange={(e) => handleNameChange(e.target.value)} placeholder="e.g. Doksologia_3k" className={inputClass} />
</div>
<div>
<label className="block text-sm font-medium mb-1" style={labelStyle}>PID (Playback ID) *</label>
<input type="text" value={pid} onChange={(e) => handlePidChange(e.target.value)} placeholder="e.g. builtin_doksologia_3k" className={inputClass} />
<p className="text-xs mt-1" style={mutedStyle}>Used as the built-in firmware identifier. Must be unique.</p>
</div>
<div className="md:col-span-2">
<div className="flex items-center justify-between mb-1">
<label className="block text-sm font-medium" style={labelStyle}>Steps *</label>
<span className="text-xs" style={mutedStyle}>{countSteps(steps)} steps</span>
</div>
<textarea
value={steps}
onChange={(e) => handleStepsChange(e.target.value)}
rows={5}
placeholder={"e.g. 1,2,2+1,1,2,3+1,0,3\n\n• Comma-separated values\n• Single bell: 1, 2, 3...\n• Multiple bells: 2+3+1\n• Silence: 0"}
className={inputClass}
style={{ fontFamily: "monospace", resize: "vertical" }}
/>
<p className="text-xs mt-1" style={mutedStyle}>
Each value = one step. Bell numbers 116 (1 = highest). Combine with +. Silence = 0.
</p>
</div>
</div>
</section>
{isEdit && (
<section className="rounded-lg p-6 border" style={sectionStyle}>
<h2 className="text-lg font-semibold mb-1" style={{ color: "var(--text-heading)" }}>Build</h2>
<p className="text-sm mb-4" style={mutedStyle}>
Save any changes above before building. Rebuilding will overwrite previous output.
{hasUnsavedChanges && (
<span style={{ color: "var(--warning, #f59e0b)", fontWeight: 600 }}> You have unsaved changes.</span>
)}
</p>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Binary */}
<div className="rounded-lg p-4 border space-y-3" style={{ borderColor: "var(--border-primary)", backgroundColor: "var(--bg-primary)" }}>
<div className="flex items-center justify-between">
<div>
<h3 className="text-sm font-semibold" style={{ color: "var(--text-heading)" }}>Binary (.bsm)</h3>
<p className="text-xs mt-0.5" style={mutedStyle}>For SD card playback on the controller</p>
</div>
{binaryBuilt && (
<span className="px-2 py-0.5 text-xs rounded-full" style={{ backgroundColor: "var(--success-bg)", color: "var(--success-text)" }}>
Built
</span>
)}
</div>
<button
onClick={handleBuildBinary}
disabled={buildingBinary || hasUnsavedChanges}
className="w-full px-3 py-2 text-sm rounded-md disabled:opacity-50 transition-colors font-medium"
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
title={hasUnsavedChanges ? "Save changes first" : undefined}
>
{buildingBinary ? "Building..." : binaryBuilt ? "Rebuild Binary" : "Build Binary"}
</button>
{binaryUrl && (
<button
type="button"
onClick={() => downloadBinary(binaryUrl, `${savedPid}.bsm`).catch((e) => setError(e.message))}
className="block w-full text-center text-xs underline cursor-pointer"
style={{ color: "var(--accent)", background: "none", border: "none", padding: 0 }}
>
Download {savedPid}.bsm
</button>
)}
</div>
{/* Builtin Code */}
<div className="rounded-lg p-4 border space-y-3" style={{ borderColor: "var(--border-primary)", backgroundColor: "var(--bg-primary)" }}>
<div className="flex items-center justify-between">
<div>
<h3 className="text-sm font-semibold" style={{ color: "var(--text-heading)" }}>PROGMEM Code</h3>
<p className="text-xs mt-0.5" style={mutedStyle}>For built-in firmware (ESP32 PROGMEM)</p>
</div>
{progmemCode && (
<span className="px-2 py-0.5 text-xs rounded-full" style={{ backgroundColor: "var(--success-bg)", color: "var(--success-text)" }}>
Generated
</span>
)}
</div>
<button
onClick={handleBuildBuiltin}
disabled={buildingBuiltin || hasUnsavedChanges}
className="w-full px-3 py-2 text-sm rounded-md disabled:opacity-50 transition-colors font-medium"
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-primary)", border: "1px solid var(--border-primary)" }}
title={hasUnsavedChanges ? "Save changes first" : undefined}
>
{buildingBuiltin ? "Generating..." : progmemCode ? "Regenerate Code" : "Build Builtin Code"}
</button>
</div>
</div>
{progmemCode && (
<div className="mt-4 rounded-lg border overflow-hidden" style={{ borderColor: "var(--border-primary)" }}>
<div className="flex items-center justify-between px-4 py-2 border-b" style={{ backgroundColor: "var(--bg-card-hover)", borderColor: "var(--border-primary)" }}>
<span className="text-xs font-medium font-mono" style={{ color: "var(--text-muted)" }}>
PROGMEM C Code copy into your firmware
</span>
<button
onClick={handleCopy}
className="text-xs px-3 py-1 rounded transition-colors"
style={{
backgroundColor: copied ? "var(--success-bg)" : "var(--bg-primary)",
color: copied ? "var(--success-text)" : "var(--text-secondary)",
border: "1px solid var(--border-primary)",
}}
>
{copied ? "Copied!" : "Copy"}
</button>
</div>
<pre
ref={codeRef}
className="p-4 text-xs overflow-x-auto"
style={{
backgroundColor: "var(--bg-primary)",
color: "var(--text-primary)",
fontFamily: "monospace",
whiteSpace: "pre",
maxHeight: "400px",
overflowY: "auto",
}}
>
{progmemCode}
</pre>
</div>
)}
</section>
)}
{!isEdit && (
<div className="rounded-lg p-4 border text-sm" style={{ borderColor: "var(--border-primary)", ...sectionStyle, color: "var(--text-muted)" }}>
Build actions (Binary + PROGMEM Code) will be available after saving.
</div>
)}
</div>
{/* Delete: two-stage if assigned */}
{showDelete && !deleteWarningConfirmed && assignedCount > 0 && (
<div className="fixed inset-0 flex items-center justify-center z-50" style={{ backgroundColor: "rgba(0,0,0,0.6)" }}>
<div className="w-full max-w-md rounded-lg border shadow-xl p-6 space-y-4" style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}>
<h3 className="text-base font-semibold" style={{ color: "var(--text-heading)" }}>Archetype In Use</h3>
<div className="rounded-md p-3 border text-sm" style={{ backgroundColor: "var(--warning-bg, rgba(245,158,11,0.1))", borderColor: "var(--warning, #f59e0b)", color: "var(--warning-text, #f59e0b)" }}>
<strong>"{name}"</strong> is currently assigned to{" "}
<strong>{assignedCount} {assignedCount === 1 ? "melody" : "melodies"}</strong>.
Deleting it will remove the archetype and its binary file, but existing melodies will keep their uploaded binary.
</div>
<p className="text-sm" style={{ color: "var(--text-muted)" }}>Do you still want to delete this archetype?</p>
<div className="flex justify-end gap-3">
<button
onClick={() => { setShowDelete(false); setDeleteWarningConfirmed(false); }}
className="px-4 py-2 text-sm rounded-md transition-colors"
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-primary)" }}
>
Cancel
</button>
<button
onClick={() => setDeleteWarningConfirmed(true)}
className="px-4 py-2 text-sm rounded-md transition-colors font-medium"
style={{ backgroundColor: "var(--danger)", color: "#fff" }}
>
Yes, Delete Anyway
</button>
</div>
</div>
</div>
)}
<ConfirmDialog
open={showDelete && (deleteWarningConfirmed || assignedCount === 0)}
title="Delete Archetype"
message={`Are you sure you want to permanently delete "${name}"? This will also delete the .bsm binary file. This action cannot be undone.`}
onConfirm={handleDelete}
onCancel={() => { setShowDelete(false); setDeleteWarningConfirmed(false); }}
/>
</div>
);
}

View File

@@ -0,0 +1,425 @@
import { useState, useEffect } from "react";
import { useNavigate, Link } from "react-router-dom";
import api from "../../api/client";
import ConfirmDialog from "../../components/ConfirmDialog";
function fallbackCopy(text, onSuccess) {
const ta = document.createElement("textarea");
ta.value = text;
ta.style.cssText = "position:fixed;top:0;left:0;opacity:0";
document.body.appendChild(ta);
ta.focus();
ta.select();
try { document.execCommand("copy"); onSuccess?.(); } catch (_) {}
document.body.removeChild(ta);
}
function copyText(text, onSuccess) {
if (navigator.clipboard) {
navigator.clipboard.writeText(text).then(onSuccess).catch(() => fallbackCopy(text, onSuccess));
} else {
fallbackCopy(text, onSuccess);
}
}
function CodeSnippetModal({ melody, onClose }) {
const [copied, setCopied] = useState(false);
if (!melody) return null;
return (
<div
className="fixed inset-0 flex items-center justify-center z-50"
style={{ backgroundColor: "rgba(0,0,0,0.7)" }}
onClick={(e) => e.target === e.currentTarget && onClose()}
>
<div
className="w-full rounded-lg border shadow-xl"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)", maxWidth: "720px", maxHeight: "80vh", display: "flex", flexDirection: "column" }}
>
<div className="flex items-center justify-between px-6 py-4 border-b flex-shrink-0" style={{ borderColor: "var(--border-primary)" }}>
<div>
<h2 className="text-base font-semibold" style={{ color: "var(--text-heading)" }}>Firmware Code Snippet</h2>
<p className="text-xs mt-0.5" style={{ color: "var(--text-muted)" }}>{melody.name} · PID: <span className="font-mono">{melody.pid || "—"}</span></p>
</div>
<div className="flex items-center gap-3">
<button
onClick={() => copyText(melody.progmem_code, () => { setCopied(true); setTimeout(() => setCopied(false), 2000); })}
className="px-3 py-1.5 text-xs rounded transition-colors"
style={{
backgroundColor: copied ? "var(--success-bg)" : "var(--bg-card-hover)",
color: copied ? "var(--success-text)" : "var(--text-secondary)",
border: "1px solid var(--border-primary)",
}}
>
{copied ? "Copied!" : "Copy"}
</button>
<button onClick={onClose} className="text-xl leading-none" style={{ color: "var(--text-muted)" }}>&times;</button>
</div>
</div>
<pre
className="flex-1 overflow-auto p-5 text-xs"
style={{ fontFamily: "monospace", color: "var(--text-primary)", backgroundColor: "var(--bg-primary)", whiteSpace: "pre", margin: 0 }}
>
{melody.progmem_code}
</pre>
</div>
</div>
);
}
// Modal to show melodies that use this archetype
function AssignedMelodiesModal({ archetype, melodyDetails, onClose }) {
if (!archetype) return null;
return (
<div
className="fixed inset-0 flex items-center justify-center z-50"
style={{ backgroundColor: "rgba(0,0,0,0.6)" }}
onClick={(e) => e.target === e.currentTarget && onClose()}
>
<div
className="w-full max-w-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)" }}>
<h2 className="text-base font-semibold" style={{ color: "var(--text-heading)" }}>
Melodies using "{archetype.name}"
</h2>
<button onClick={onClose} className="text-xl leading-none" style={{ color: "var(--text-muted)" }}>&times;</button>
</div>
<div className="p-4 space-y-2 max-h-72 overflow-y-auto">
{melodyDetails.length === 0 ? (
<p className="text-sm text-center py-4" style={{ color: "var(--text-muted)" }}>No melodies assigned.</p>
) : (
melodyDetails.map((m) => (
<Link
key={m.id}
to={`/melodies/${m.id}`}
onClick={onClose}
className="flex items-center justify-between px-3 py-2 rounded-lg border transition-colors hover:bg-[var(--bg-card-hover)]"
style={{ borderColor: "var(--border-primary)", color: "var(--text-heading)", textDecoration: "none" }}
>
<span className="text-sm font-medium">{m.name || m.id}</span>
<span className="text-xs" style={{ color: "var(--accent)" }}>View </span>
</Link>
))
)}
</div>
<div className="flex justify-end px-6 py-4 border-t" style={{ borderColor: "var(--border-primary)" }}>
<button onClick={onClose} className="px-4 py-2 text-sm rounded-md transition-colors" style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-primary)" }}>
Close
</button>
</div>
</div>
</div>
);
}
const sectionStyle = { backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" };
export default function ArchetypeList() {
const navigate = useNavigate();
const [archetypes, setArchetypes] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
const [deleteTarget, setDeleteTarget] = useState(null);
const [deleteWarningConfirmed, setDeleteWarningConfirmed] = useState(false);
const [codeSnippetMelody, setCodeSnippetMelody] = useState(null);
const [assignedModal, setAssignedModal] = useState(null); // { archetype, melodyDetails }
const [loadingAssigned, setLoadingAssigned] = useState(false);
useEffect(() => {
loadArchetypes();
}, []);
const loadArchetypes = async () => {
setLoading(true);
setError("");
try {
const data = await api.get("/builder/melodies");
setArchetypes(data.melodies || []);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
// Verify actual assignment status: check each assigned_melody_id still references this archetype in Firestore
const getVerifiedAssignedCount = (archetype) => {
// assigned_melody_ids is authoritative — the backend manages this list
return archetype.assigned_melody_ids?.length || 0;
};
const handleDelete = async () => {
if (!deleteTarget) return;
try {
await api.delete(`/builder/melodies/${deleteTarget.id}`);
setDeleteTarget(null);
setDeleteWarningConfirmed(false);
loadArchetypes();
} catch (err) {
setError(err.message);
setDeleteTarget(null);
setDeleteWarningConfirmed(false);
}
};
const countSteps = (stepsStr) => {
if (!stepsStr) return 0;
return stepsStr.split(",").length;
};
const handleDownloadBinary = async (e, m) => {
e.stopPropagation();
const token = localStorage.getItem("access_token");
const res = await fetch(`/api/builder/melodies/${m.id}/download`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
});
if (!res.ok) return;
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url;
a.download = `${m.name || m.id}.bsm`;
a.click();
URL.revokeObjectURL(url);
};
const handleShowAssigned = async (e, archetype) => {
e.stopPropagation();
const ids = archetype.assigned_melody_ids || [];
if (ids.length === 0) return;
setLoadingAssigned(true);
try {
// Fetch melody details for each assigned ID
const results = await Promise.allSettled(ids.map((mid) => api.get(`/melodies/${mid}`)));
const melodyDetails = results
.filter((r) => r.status === "fulfilled" && r.value)
.map((r) => {
const m = r.value;
// Extract a display name
const nameRaw = m.information?.name;
let name = "";
if (typeof nameRaw === "string") name = nameRaw;
else if (nameRaw && typeof nameRaw === "object") name = Object.values(nameRaw)[0] || m.id;
return { id: m.id, name: name || m.pid || m.id };
});
// IDs that were NOT found (melody deleted)
const foundIds = results
.filter((r, i) => r.status === "fulfilled" && r.value)
.map((_, i) => ids[results.findIndex((r, j) => j === i && r.status === "fulfilled")]);
// Unassign any IDs that no longer exist
const missingIds = results
.map((r, i) => r.status === "rejected" || !r.value ? ids[i] : null)
.filter(Boolean);
for (const mid of missingIds) {
try {
await api.post(`/builder/melodies/${archetype.id}/unassign?firestore_melody_id=${mid}`);
} catch { /* best-effort */ }
}
if (missingIds.length > 0) {
loadArchetypes(); // Refresh to show updated counts
}
setAssignedModal({ archetype, melodyDetails });
} catch (err) {
setError(err.message);
} finally {
setLoadingAssigned(false);
}
};
return (
<div>
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-2xl font-bold" style={{ color: "var(--text-heading)" }}>
Archetype Builder
</h1>
<p className="text-sm mt-1" style={{ color: "var(--text-muted)" }}>
Add Archetypes, and Build binary (.bsm) files and firmware PROGMEM code.
</p>
</div>
<button
onClick={() => navigate("/melodies/archetypes/new")}
className="px-4 py-2 text-sm rounded-md transition-colors"
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
>
+ Add Archetype
</button>
</div>
{error && (
<div className="text-sm rounded-md p-3 mb-4 border" style={{ backgroundColor: "var(--danger-bg)", borderColor: "var(--danger)", color: "var(--danger-text)" }}>
{error}
</div>
)}
{loading ? (
<div className="text-center py-8" style={{ color: "var(--text-muted)" }}>Loading...</div>
) : archetypes.length === 0 ? (
<div className="rounded-lg border p-12 text-center" style={sectionStyle}>
<p className="text-sm mb-4" style={{ color: "var(--text-muted)" }}>No archetypes yet.</p>
<button
onClick={() => navigate("/melodies/archetypes/new")}
className="px-4 py-2 text-sm rounded-md transition-colors"
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
>
Add Your First Archetype
</button>
</div>
) : (
<div className="rounded-lg border overflow-hidden" style={sectionStyle}>
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left" style={{ borderColor: "var(--border-primary)", backgroundColor: "var(--bg-card-hover)" }}>
<th className="px-4 py-3 font-medium" style={{ color: "var(--text-secondary)" }}>Name</th>
<th className="px-4 py-3 font-medium" style={{ color: "var(--text-secondary)" }}>PID</th>
<th className="px-4 py-3 font-medium text-center" style={{ color: "var(--text-secondary)" }}>Steps</th>
<th className="px-4 py-3 font-medium text-center" style={{ color: "var(--text-secondary)" }}>Binary</th>
<th className="px-4 py-3 font-medium text-center" style={{ color: "var(--text-secondary)" }}>Builtin Code</th>
<th className="px-4 py-3 font-medium text-center" style={{ color: "var(--text-secondary)" }}>Assigned</th>
<th className="px-4 py-3 font-medium" style={{ color: "var(--text-secondary)" }}>Updated</th>
<th className="px-4 py-3" />
</tr>
</thead>
<tbody>
{archetypes.map((m) => {
const assignedCount = getVerifiedAssignedCount(m);
return (
<tr
key={m.id}
onClick={() => navigate(`/melodies/archetypes/${m.id}`)}
className="border-b cursor-pointer transition-colors hover:bg-[var(--bg-card-hover)]"
style={{ borderColor: "var(--border-primary)" }}
>
<td className="px-4 py-3 font-medium" style={{ color: "var(--text-heading)" }}>
{m.name}
</td>
<td className="px-4 py-3" style={{ color: "var(--text-secondary)" }}>
<span className="font-mono text-xs px-2 py-0.5 rounded" style={{ backgroundColor: "var(--bg-card-hover)" }}>
{m.pid || "-"}
</span>
</td>
<td className="px-4 py-3 text-center" style={{ color: "var(--text-secondary)" }}>
{countSteps(m.steps)}
</td>
<td className="px-4 py-3 text-center">
{m.binary_path ? (
<span
title="Click to Download"
onClick={(e) => handleDownloadBinary(e, m)}
className="px-2 py-0.5 text-xs rounded-full cursor-pointer transition-opacity hover:opacity-70"
style={{ backgroundColor: "var(--success-bg)", color: "var(--success-text)" }}
>
Built
</span>
) : (
<span className="px-2 py-0.5 text-xs rounded-full" style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-muted)" }}>
</span>
)}
</td>
<td className="px-4 py-3 text-center">
{m.progmem_code ? (
<span
title="Click to View Code Snippet"
onClick={(e) => { e.stopPropagation(); setCodeSnippetMelody(m); }}
className="px-2 py-0.5 text-xs rounded-full cursor-pointer transition-opacity hover:opacity-70"
style={{ backgroundColor: "var(--success-bg)", color: "var(--success-text)" }}
>
Generated
</span>
) : (
<span className="px-2 py-0.5 text-xs rounded-full" style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-muted)" }}>
</span>
)}
</td>
<td className="px-4 py-3 text-center" style={{ color: "var(--text-secondary)" }}>
{assignedCount > 0 ? (
<button
title="Click to see assigned melodies"
onClick={(e) => handleShowAssigned(e, m)}
className="px-2 py-0.5 text-xs rounded-full cursor-pointer transition-opacity hover:opacity-70"
style={{ backgroundColor: "rgba(59,130,246,0.15)", color: "#60a5fa", border: "none" }}
disabled={loadingAssigned}
>
{assignedCount} {assignedCount === 1 ? "melody" : "melodies"}
</button>
) : (
<span style={{ color: "var(--text-muted)" }}></span>
)}
</td>
<td className="px-4 py-3 text-xs" style={{ color: "var(--text-muted)" }}>
{new Date(m.updated_at).toLocaleDateString()}
</td>
<td className="px-4 py-3">
<button
onClick={(e) => { e.stopPropagation(); setDeleteTarget(m); setDeleteWarningConfirmed(false); }}
className="px-2 py-1 text-xs rounded transition-colors"
style={{ color: "var(--danger)", backgroundColor: "var(--danger-bg)" }}
>
Delete
</button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
{/* Two-stage delete: warn if assigned, then confirm */}
{deleteTarget && !deleteWarningConfirmed && (deleteTarget.assigned_melody_ids?.length || 0) > 0 && (
<div className="fixed inset-0 flex items-center justify-center z-50" style={{ backgroundColor: "rgba(0,0,0,0.6)" }}>
<div className="w-full max-w-md rounded-lg border shadow-xl p-6 space-y-4" style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}>
<h3 className="text-base font-semibold" style={{ color: "var(--text-heading)" }}>Archetype In Use</h3>
<div className="rounded-md p-3 border text-sm" style={{ backgroundColor: "var(--warning-bg, rgba(245,158,11,0.1))", borderColor: "var(--warning, #f59e0b)", color: "var(--warning-text, #f59e0b)" }}>
<strong>"{deleteTarget.name}"</strong> is currently assigned to{" "}
<strong>{deleteTarget.assigned_melody_ids.length} {deleteTarget.assigned_melody_ids.length === 1 ? "melody" : "melodies"}</strong>.
Deleting it will remove the archetype and its binary file, but existing melodies will keep their uploaded binary.
</div>
<p className="text-sm" style={{ color: "var(--text-muted)" }}>Do you still want to delete this archetype?</p>
<div className="flex justify-end gap-3">
<button
onClick={() => { setDeleteTarget(null); setDeleteWarningConfirmed(false); }}
className="px-4 py-2 text-sm rounded-md transition-colors"
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-primary)" }}
>
Cancel
</button>
<button
onClick={() => setDeleteWarningConfirmed(true)}
className="px-4 py-2 text-sm rounded-md transition-colors font-medium"
style={{ backgroundColor: "var(--danger)", color: "#fff" }}
>
Yes, Delete Anyway
</button>
</div>
</div>
</div>
)}
<ConfirmDialog
open={Boolean(deleteTarget) && (deleteWarningConfirmed || (deleteTarget?.assigned_melody_ids?.length || 0) === 0)}
title="Delete Archetype"
message={`Are you sure you want to permanently delete "${deleteTarget?.name}"? This will also delete the .bsm binary file. This action cannot be undone.`}
onConfirm={handleDelete}
onCancel={() => { setDeleteTarget(null); setDeleteWarningConfirmed(false); }}
/>
<CodeSnippetModal
melody={codeSnippetMelody}
onClose={() => setCodeSnippetMelody(null)}
/>
<AssignedMelodiesModal
archetype={assignedModal?.archetype}
melodyDetails={assignedModal?.melodyDetails || []}
onClose={() => setAssignedModal(null)}
/>
</div>
);
}

View File

@@ -0,0 +1,219 @@
import { useState } from "react";
import api from "../../api/client";
const labelStyle = { color: "var(--text-secondary)" };
const mutedStyle = { color: "var(--text-muted)" };
const inputClass = "w-full px-3 py-2 rounded-md text-sm border";
function countSteps(stepsStr) {
if (!stepsStr || !stepsStr.trim()) return 0;
return stepsStr.trim().split(",").length;
}
function computeStepsAndNotes(stepsStr) {
if (!stepsStr || !stepsStr.trim()) return { steps: 0, totalNotes: 0 };
const tokens = stepsStr.trim().split(",");
const bellSet = new Set();
for (const token of tokens) {
for (const part of token.split("+")) {
const n = parseInt(part.trim(), 10);
if (!isNaN(n) && n >= 1 && n <= 16) bellSet.add(n);
}
}
return { steps: tokens.length, totalNotes: bellSet.size || 1 };
}
function validateSteps(stepsStr) {
if (!stepsStr || !stepsStr.trim()) return "Steps are required.";
const tokens = stepsStr.trim().split(",");
for (const token of tokens) {
const parts = token.split("+");
for (const part of parts) {
const trimmed = part.trim();
if (trimmed === "") return `Invalid token near: "${token.trim()}" — empty part found.`;
const n = parseInt(trimmed, 10);
if (isNaN(n)) return `Invalid step value: "${trimmed}" — must be a number 016.`;
if (n < 0 || n > 16) return `Step value "${trimmed}" is out of range (must be 016).`;
}
}
return null;
}
export default function BuildOnTheFlyModal({ open, melodyId, currentMelody, defaultName, defaultPid, onClose, onSuccess }) {
const [name, setName] = useState(defaultName || "");
const [pid, setPid] = useState(defaultPid || "");
const [steps, setSteps] = useState("");
const [building, setBuilding] = useState(false);
const [error, setError] = useState("");
const [statusMsg, setStatusMsg] = useState("");
if (!open) return null;
const handleBuildAndUpload = async () => {
if (!name.trim()) { setError("Name is required."); return; }
if (!pid.trim()) { setError("PID is required."); return; }
if (!steps.trim()) { setError("Steps are required."); return; }
const stepsError = validateSteps(steps);
if (stepsError) { setError(stepsError); return; }
setBuilding(true);
setError("");
setStatusMsg("");
try {
setStatusMsg("Checking for conflicts...");
const existing = await api.get("/builder/melodies");
const list = existing.melodies || [];
const dupName = list.find((m) => m.name.toLowerCase() === name.trim().toLowerCase());
if (dupName) {
setError(`An archetype with the name "${name.trim()}" already exists.`);
setBuilding(false);
setStatusMsg("");
return;
}
const dupPid = list.find((m) => m.pid && m.pid.toLowerCase() === pid.trim().toLowerCase());
if (dupPid) {
setError(`An archetype with the PID "${pid.trim()}" already exists.`);
setBuilding(false);
setStatusMsg("");
return;
}
setStatusMsg("Creating archetype record...");
const created = await api.post("/builder/melodies", {
name: name.trim(),
pid: pid.trim(),
steps: steps.trim(),
});
const builtId = created.id;
setStatusMsg("Building binary...");
const built = await api.post(`/builder/melodies/${builtId}/build-binary`);
setStatusMsg("Uploading to cloud storage...");
const token = localStorage.getItem("access_token");
const res = await fetch(`/api${built.binary_url}`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
});
if (!res.ok) throw new Error(`Failed to fetch binary: ${res.statusText}`);
const blob = await res.blob();
const file = new File([blob], `${pid.trim()}.bsm`, { type: "application/octet-stream" });
await api.upload(`/melodies/${melodyId}/upload/binary`, file);
setStatusMsg("Linking to melody...");
await api.post(`/builder/melodies/${builtId}/assign?firestore_melody_id=${melodyId}`);
setStatusMsg("Saving archetype data...");
const csv = steps.trim();
const { steps: stepCount, totalNotes } = computeStepsAndNotes(csv);
if (currentMelody && csv) {
const existingInfo = currentMelody.information || {};
await api.put(`/melodies/${melodyId}`, {
information: {
...existingInfo,
archetype_csv: csv,
steps: stepCount,
totalNotes,
},
default_settings: currentMelody.default_settings,
type: currentMelody.type,
url: currentMelody.url,
uid: currentMelody.uid,
pid: currentMelody.pid,
});
}
setStatusMsg("Done!");
onSuccess({ name: name.trim(), pid: pid.trim(), steps: stepCount, totalNotes, archetype_csv: csv });
} catch (err) {
setError(err.message);
setStatusMsg("");
} finally {
setBuilding(false);
}
};
return (
<div
className="fixed inset-0 flex items-center justify-center z-50"
style={{ backgroundColor: "rgba(0,0,0,0.6)" }}
onClick={(e) => e.target === e.currentTarget && !building && onClose()}
>
<div
className="w-full max-w-xl rounded-lg border shadow-xl"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
>
<div className="flex items-center justify-between px-6 py-4 border-b" style={{ borderColor: "var(--border-primary)" }}>
<div>
<h2 className="text-lg font-semibold" style={{ color: "var(--text-heading)" }}>Build on the Fly</h2>
<p className="text-xs mt-0.5" style={mutedStyle}>Enter steps, build binary, and upload all in one step.</p>
</div>
{!building && (
<button onClick={onClose} className="text-xl leading-none" style={mutedStyle}>&times;</button>
)}
</div>
<div className="p-6 space-y-4">
{error && (
<div className="text-sm rounded-md p-3 border" style={{ backgroundColor: "var(--danger-bg)", borderColor: "var(--danger)", color: "var(--danger-text)" }}>
{error}
</div>
)}
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium mb-1" style={labelStyle}>Name *</label>
<input type="text" value={name} onChange={(e) => setName(e.target.value)} placeholder="e.g. Doksologia_3k" className={inputClass} disabled={building} />
</div>
<div>
<label className="block text-sm font-medium mb-1" style={labelStyle}>PID *</label>
<input type="text" value={pid} onChange={(e) => setPid(e.target.value)} placeholder="e.g. builtin_doksologia_3k" className={inputClass} disabled={building} />
</div>
</div>
<div>
<div className="flex items-center justify-between mb-1">
<label className="block text-sm font-medium" style={labelStyle}>Steps *</label>
<span className="text-xs" style={mutedStyle}>{countSteps(steps)} steps</span>
</div>
<textarea
value={steps}
onChange={(e) => setSteps(e.target.value)}
rows={5}
placeholder={"e.g. 1,2,2+1,1,2,3+1,0,3\n\n• Comma-separated values\n• Single bell: 1, 2, 3...\n• Multiple: 2+3+1\n• Silence: 0"}
className={inputClass}
style={{ fontFamily: "monospace", resize: "vertical" }}
disabled={building}
/>
</div>
{statusMsg && !error && (
<div className="text-sm rounded-md p-2 text-center" style={{ color: "var(--text-muted)", backgroundColor: "var(--bg-primary)" }}>
{statusMsg}
</div>
)}
</div>
<div className="flex items-center justify-end gap-3 px-6 py-4 border-t" style={{ borderColor: "var(--border-primary)" }}>
<button
onClick={onClose}
disabled={building}
className="px-4 py-2 text-sm rounded-md disabled:opacity-50 transition-colors"
style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-primary)" }}
>
Cancel
</button>
<button
onClick={handleBuildAndUpload}
disabled={building}
className="px-4 py-2 text-sm rounded-md disabled:opacity-50 transition-colors font-medium"
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
>
{building ? "Building & Uploading..." : "Build & Upload"}
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,148 @@
import { useState, useEffect } from "react";
import api from "../../api/client";
function computeStepsAndNotes(stepsStr) {
if (!stepsStr || !stepsStr.trim()) return { steps: 0, totalNotes: 0 };
const tokens = stepsStr.trim().split(",");
const bellSet = new Set();
for (const token of tokens) {
for (const part of token.split("+")) {
const n = parseInt(part.trim(), 10);
if (!isNaN(n) && n >= 1 && n <= 16) bellSet.add(n);
}
}
return { steps: tokens.length, totalNotes: bellSet.size || 1 };
}
export default function SelectArchetypeModal({ open, melodyId, currentMelody, onClose, onSuccess }) {
const [archetypes, setArchetypes] = useState([]);
const [loading, setLoading] = useState(false);
const [assigning, setAssigning] = useState(null);
const [error, setError] = useState("");
useEffect(() => {
if (open) loadArchetypes();
}, [open]);
const loadArchetypes = async () => {
setLoading(true);
setError("");
try {
const data = await api.get("/builder/melodies");
setArchetypes((data.melodies || []).filter((m) => m.binary_path));
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
const handleSelect = async (archetype) => {
setAssigning(archetype.id);
setError("");
try {
const token = localStorage.getItem("access_token");
const res = await fetch(`/api${archetype.binary_url}`, {
headers: token ? { Authorization: `Bearer ${token}` } : {},
});
if (!res.ok) throw new Error(`Failed to download binary: ${res.statusText}`);
const blob = await res.blob();
const file = new File([blob], `${archetype.name}.bsm`, { type: "application/octet-stream" });
await api.upload(`/melodies/${melodyId}/upload/binary`, file);
await api.post(`/builder/melodies/${archetype.id}/assign?firestore_melody_id=${melodyId}`);
const csv = archetype.steps || "";
const { steps, totalNotes } = computeStepsAndNotes(csv);
if (currentMelody && csv) {
const existingInfo = currentMelody.information || {};
await api.put(`/melodies/${melodyId}`, {
information: {
...existingInfo,
archetype_csv: csv,
steps,
totalNotes,
},
default_settings: currentMelody.default_settings,
type: currentMelody.type,
url: currentMelody.url,
uid: currentMelody.uid,
pid: currentMelody.pid,
});
}
onSuccess({ name: archetype.name, pid: archetype.pid, steps, totalNotes, archetype_csv: csv });
} catch (err) {
setError(err.message);
} finally {
setAssigning(null);
}
};
if (!open) return null;
return (
<div
className="fixed inset-0 flex items-center justify-center z-50"
style={{ backgroundColor: "rgba(0,0,0,0.6)" }}
onClick={(e) => e.target === e.currentTarget && onClose()}
>
<div
className="w-full max-w-2xl rounded-lg border shadow-xl"
style={{ backgroundColor: "var(--bg-card)", borderColor: "var(--border-primary)" }}
>
<div className="flex items-center justify-between px-6 py-4 border-b" style={{ borderColor: "var(--border-primary)" }}>
<h2 className="text-lg font-semibold" style={{ color: "var(--text-heading)" }}>Select Archetype</h2>
<button onClick={onClose} className="text-xl leading-none" style={{ color: "var(--text-muted)" }}>&times;</button>
</div>
<div className="p-6">
{error && (
<div className="text-sm rounded-md p-3 mb-4 border" style={{ backgroundColor: "var(--danger-bg)", borderColor: "var(--danger)", color: "var(--danger-text)" }}>
{error}
</div>
)}
{loading ? (
<div className="text-center py-8" style={{ color: "var(--text-muted)" }}>Loading...</div>
) : archetypes.length === 0 ? (
<div className="text-center py-8 text-sm" style={{ color: "var(--text-muted)" }}>
No built binaries found. Go to <strong>Archetype Builder</strong> to create one first.
</div>
) : (
<div className="space-y-2 max-h-96 overflow-y-auto">
{archetypes.map((m) => (
<div
key={m.id}
className="flex items-center justify-between rounded-lg px-4 py-3 border transition-colors"
style={{ borderColor: "var(--border-primary)", backgroundColor: "var(--bg-primary)" }}
>
<div>
<p className="text-sm font-medium" style={{ color: "var(--text-heading)" }}>{m.name}</p>
<p className="text-xs mt-0.5 font-mono" style={{ color: "var(--text-muted)" }}>
PID: {m.pid || "—"} &nbsp;·&nbsp; {m.steps?.split(",").length || 0} steps
</p>
</div>
<button
onClick={() => handleSelect(m)}
disabled={Boolean(assigning)}
className="px-3 py-1.5 text-xs rounded-md disabled:opacity-50 transition-colors font-medium"
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
>
{assigning === m.id ? "Uploading..." : "Select & Upload"}
</button>
</div>
))}
</div>
)}
</div>
<div className="flex justify-end px-6 py-4 border-t" style={{ borderColor: "var(--border-primary)" }}>
<button onClick={onClose} className="px-4 py-2 text-sm rounded-md transition-colors" style={{ backgroundColor: "var(--bg-card-hover)", color: "var(--text-primary)" }}>
Cancel
</button>
</div>
</div>
</div>
);
}