From ae4b31328feace5f9f5930ca3f719e87b32b9b36 Mon Sep 17 00:00:00 2001
From: bonamin
Date: Sun, 22 Feb 2026 17:28:27 +0200
Subject: [PATCH] Fixes and Changes again
---
backend/melodies/models.py | 5 +-
frontend/src/App.jsx | 10 +-
frontend/src/layout/Sidebar.jsx | 2 +-
frontend/src/melodies/MelodyDetail.jsx | 145 +++++-
frontend/src/melodies/MelodyForm.jsx | 182 +++++--
frontend/src/melodies/PlaybackModal.jsx | 113 ++++-
.../src/melodies/SpeedCalculatorModal.jsx | 13 +-
.../src/melodies/archetypes/ArchetypeForm.jsx | 451 ++++++++++++++++++
.../src/melodies/archetypes/ArchetypeList.jsx | 425 +++++++++++++++++
.../archetypes/BuildOnTheFlyModal.jsx | 219 +++++++++
.../archetypes/SelectArchetypeModal.jsx | 148 ++++++
11 files changed, 1617 insertions(+), 96 deletions(-)
create mode 100644 frontend/src/melodies/archetypes/ArchetypeForm.jsx
create mode 100644 frontend/src/melodies/archetypes/ArchetypeList.jsx
create mode 100644 frontend/src/melodies/archetypes/BuildOnTheFlyModal.jsx
create mode 100644 frontend/src/melodies/archetypes/SelectArchetypeModal.jsx
diff --git a/backend/melodies/models.py b/backend/melodies/models.py
index 9364cbc..c238c8a 100644
--- a/backend/melodies/models.py
+++ b/backend/melodies/models.py
@@ -1,5 +1,5 @@
from pydantic import BaseModel, Field
-from typing import List, Optional
+from typing import Any, Dict, List, Optional
from enum import Enum
@@ -24,6 +24,7 @@ class MelodyInfo(BaseModel):
minSpeed: int = 0
maxSpeed: int = 0
totalNotes: int = Field(default=1, ge=1, le=16)
+ totalActiveBells: int = 0
steps: int = 0
color: str = ""
isTrueRing: bool = False
@@ -50,6 +51,7 @@ class MelodyCreate(BaseModel):
url: str = ""
uid: str = ""
pid: str = ""
+ metadata: Optional[Dict[str, Any]] = None
class MelodyUpdate(BaseModel):
@@ -59,6 +61,7 @@ class MelodyUpdate(BaseModel):
url: Optional[str] = None
uid: Optional[str] = None
pid: Optional[str] = None
+ metadata: Optional[Dict[str, Any]] = None
class MelodyInDB(MelodyCreate):
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index 8a4159d..a5e1dc5 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -6,8 +6,8 @@ import MelodyList from "./melodies/MelodyList";
import MelodyDetail from "./melodies/MelodyDetail";
import MelodyForm from "./melodies/MelodyForm";
import MelodySettings from "./melodies/MelodySettings";
-import BuilderList from "./melodies/builder/BuilderList";
-import BuilderForm from "./melodies/builder/BuilderForm";
+import ArchetypeList from "./melodies/archetypes/ArchetypeList";
+import ArchetypeForm from "./melodies/archetypes/ArchetypeForm";
import DeviceList from "./devices/DeviceList";
import DeviceDetail from "./devices/DeviceDetail";
import DeviceForm from "./devices/DeviceForm";
@@ -118,9 +118,9 @@ export default function App() {
} />
} />
} />
- } />
- } />
- } />
+ } />
+ } />
+ } />
} />
} />
diff --git a/frontend/src/layout/Sidebar.jsx b/frontend/src/layout/Sidebar.jsx
index 6e14a58..650849e 100644
--- a/frontend/src/layout/Sidebar.jsx
+++ b/frontend/src/layout/Sidebar.jsx
@@ -9,7 +9,7 @@ const navItems = [
permission: "melodies",
children: [
{ to: "/melodies", label: "Main Editor" },
- { to: "/melodies/builder", label: "Archetypes" },
+ { to: "/melodies/archetypes", label: "Archetypes" },
{ to: "/melodies/settings", label: "Settings" },
],
},
diff --git a/frontend/src/melodies/MelodyDetail.jsx b/frontend/src/melodies/MelodyDetail.jsx
index 96f20ae..7e72450 100644
--- a/frontend/src/melodies/MelodyDetail.jsx
+++ b/frontend/src/melodies/MelodyDetail.jsx
@@ -273,7 +273,8 @@ export default function MelodyDetail() {
{info.melodyTone}
{info.steps}
- {info.totalNotes}
+ {info.totalNotes}
+ {info.totalActiveBells ?? "-"}
{info.minSpeed}
{info.maxSpeed}
@@ -367,11 +368,36 @@ export default function MelodyDetail() {
-
- {settings.noteAssignments?.length > 0
- ? settings.noteAssignments.join(", ")
- : "-"}
-
+
Note Assignments
+
+ {settings.noteAssignments?.length > 0 ? (
+
+ {settings.noteAssignments.map((assignedBell, noteIdx) => (
+
+
+ {noteIdx + 1}
+
+
+
+ {assignedBell > 0 ? assignedBell : "—"}
+
+
+ ))}
+
+ ) : (
+ -
+ )}
+ Top = Note #, Bottom = Assigned Bell
+
@@ -384,42 +410,62 @@ export default function MelodyDetail() {
Files
- {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 Not uploaded;
+
const binaryPid = builtMelody?.pid || melody.pid || "binary";
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) => {
e.preventDefault();
try {
const token = localStorage.getItem("access_token");
- const res = await fetch(files.binary_url, {
+ const res = await fetch(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 objectUrl = URL.createObjectURL(blob);
const a = document.createElement("a");
- a.href = url;
- a.download = binaryFilename;
+ a.href = objectUrl;
+ a.download = displayName;
a.click();
- URL.revokeObjectURL(url);
+ URL.revokeObjectURL(objectUrl);
} catch (err) {
- // surface error in page error state if possible
console.error(err);
}
};
+
return (
-
- {binaryFilename}
-
+
+
+ {displayName}
+
+ {!files.binary_url && melody.url && (
+
+ via URL
+
+ )}
+
);
- })() : (
- Not uploaded
- )}
+ })()}
{files.preview_url ? (
@@ -486,6 +532,57 @@ export default function MelodyDetail() {
)}
+ {/* Metadata section */}
+ {melody.metadata && (
+
+ History
+
+ {melody.metadata.dateCreated && (
+
+ {new Date(melody.metadata.dateCreated).toLocaleString()}
+
+ )}
+ {melody.metadata.createdBy && (
+ {melody.metadata.createdBy}
+ )}
+ {melody.metadata.dateEdited && (
+
+ {new Date(melody.metadata.dateEdited).toLocaleString()}
+
+ )}
+ {melody.metadata.lastEditedBy && (
+ {melody.metadata.lastEditedBy}
+ )}
+
+
+ )}
+
+ {/* Admin Notes section */}
+
+ Admin Notes
+ {(melody.metadata?.adminNotes?.length || 0) > 0 ? (
+
+ {melody.metadata.adminNotes.map((note, i) => (
+
+ {note}
+
+ ))}
+
+ ) : (
+ No admin notes yet. Edit this melody to add notes.
+ )}
+
+
{
api.get("/settings/melody").then((ms) => {
setMelodySettings(ms);
@@ -130,6 +138,8 @@ export default function MelodyForm() {
setPid(melody.pid || "");
setMelodyStatus(melody.status || "published");
setExistingFiles(files);
+ // Load admin notes from metadata
+ setAdminNotes(melody.metadata?.adminNotes || []);
// Load built melody assignment (non-fatal)
try {
const bm = await api.get(`/builder/melodies/for-melody/${id}`);
@@ -171,12 +181,27 @@ export default function MelodyForm() {
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 totalActiveBells = computeTotalActiveBells(settings.noteAssignments);
+ const now = new Date().toISOString();
+ const userName = user?.name || "Unknown";
+ const metadata = overrideMetadata || {
+ dateEdited: now,
+ lastEditedBy: userName,
+ adminNotes,
+ };
return {
- information: infoWithoutNotes,
+ information: { ...infoWithoutNotes, totalActiveBells },
default_settings: settings,
type, url, uid, pid,
+ metadata,
};
};
@@ -193,14 +218,18 @@ export default function MelodyForm() {
setSaving(true);
setError("");
try {
- const body = buildBody();
+ const now = new Date().toISOString();
+ const userName = user?.name || "Unknown";
let melodyId = id;
if (isEdit) {
+ const body = buildBody({ dateEdited: now, lastEditedBy: userName, adminNotes });
await api.put(`/melodies/${id}`, body);
} else {
+ const body = buildBody({ dateCreated: now, dateEdited: now, createdBy: userName, lastEditedBy: userName, adminNotes });
const created = await api.post(`/melodies?publish=${publish}`, body);
melodyId = created.id;
+ setSavedMelodyId(melodyId);
}
await uploadFiles(melodyId);
@@ -217,7 +246,9 @@ export default function MelodyForm() {
setSaving(true);
setError("");
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 uploadFiles(id);
await api.post(`/melodies/${id}/publish`);
@@ -446,7 +477,7 @@ export default function MelodyForm() {
-
+
{ const val = parseInt(e.target.value, 10); updateInfo("totalNotes", Math.max(1, Math.min(16, val || 1))); }} className={inputClass} />
@@ -576,7 +607,12 @@ export default function MelodyForm() {
-
+
+
+
+ {computeTotalActiveBells(settings.noteAssignments)} active bell{computeTotalActiveBells(settings.noteAssignments) !== 1 ? "s" : ""}
+
+
{Array.from({ length: information.totalNotes }, (_, i) => (
@@ -588,7 +624,7 @@ export default function MelodyForm() {
))}
-
Assign which bell rings for each note (0 = none)
+
Assign which bell rings for each note (0 = none). Total Active Bells = unique assigned values.
@@ -607,26 +643,51 @@ export default function MelodyForm() {
)}
setBinaryFile(e.target.files[0] || null)} className="w-full text-sm" style={mutedStyle} />
- {isEdit && (
-
-
-
-
- )}
+
+
+
+
@@ -642,6 +703,53 @@ export default function MelodyForm() {
+
+ {/* --- Admin Notes Section --- */}
+
+ Admin Notes
+
+ {adminNotes.map((note, i) => (
+
+
{note}
+
+
+ ))}
+
+ 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"
+ />
+
+
+
+
setShowSpeedCalc(false)}
onSaved={() => { setShowSpeedCalc(false); loadMelody(); }}
/>
-
+ )}
+
+ {/* Archetype modals — available for both new and edit (need a melodyId) */}
+ {(isEdit || savedMelodyId) && (
+ <>
+ setShowSelectBuilt(false)}
onSuccess={(archetype) => {
setShowSelectBuilt(false);
setAssignedBinaryName(archetype.name);
if (!pid.trim() && archetype.pid) setPid(archetype.pid);
- loadMelody();
+ if (isEdit) loadMelody();
}}
/>
>
diff --git a/frontend/src/melodies/PlaybackModal.jsx b/frontend/src/melodies/PlaybackModal.jsx
index 3e6b8e7..d53a12a 100644
--- a/frontend/src/melodies/PlaybackModal.jsx
+++ b/frontend/src/melodies/PlaybackModal.jsx
@@ -101,6 +101,8 @@ export default function PlaybackModal({ open, melody, builtMelody, files, archet
const info = melody?.information || {};
const minSpeed = info.minSpeed || 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 [loading, setLoading] = useState(false);
@@ -152,10 +154,10 @@ export default function PlaybackModal({ open, melody, builtMelody, files, archet
return;
}
- // Fall back to binary fetch
+ // Fall back to binary fetch — prefer uploaded file, then legacy melody.url
const binaryUrl = builtMelody?.binary_url
? `/api${builtMelody.binary_url}`
- : files?.binary_url || null;
+ : files?.binary_url || melody?.url || null;
if (!binaryUrl) {
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 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);
playStep(ctx, stepValue, BEAT_DURATION_MS);
@@ -299,27 +322,65 @@ export default function PlaybackModal({ open, melody, builtMelody, files, archet
)}
- {/* Bell visualizer */}
-
- {Array.from({ length: maxBell }, (_, i) => i + 1).map((b) => {
- const isActive = currentBells.includes(b);
- const isUsed = allBellsUsed.has(b);
- return (
-
- {b}
-
- );
- })}
-
+ {/* Note + Assignment visualizer */}
+ {noteAssignments.length > 0 ? (
+
+
Note → Assigned Bell
+
+ {noteAssignments.map((assignedBell, noteIdx) => {
+ const noteNum = noteIdx + 1;
+ // A note is active if the current step has this note bit set (raw)
+ const isActive = currentStep >= 0 && Boolean(steps[currentStep] & (1 << (noteNum - 1)));
+ return (
+
+
+ {noteNum}
+
+
+
+ {assignedBell > 0 ? assignedBell : "—"}
+
+
+ );
+ })}
+
+
+ Top = Note, Bottom = Bell
+
+
+ ) : (
+ /* Fallback: simple bell circles when no assignments */
+
+ {Array.from({ length: maxBell }, (_, i) => i + 1).map((b) => {
+ const isActive = currentBells.includes(b);
+ const isUsed = allBellsUsed.has(b);
+ return (
+
+ {b}
+
+ );
+ })}
+
+ )}
{/* Play / Stop */}
@@ -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"
style={{ backgroundColor: "var(--btn-primary)", color: "var(--text-white)" }}
>
- ▶ Play
+ Play
) : (
)}
Loops continuously
diff --git a/frontend/src/melodies/SpeedCalculatorModal.jsx b/frontend/src/melodies/SpeedCalculatorModal.jsx
index 6e98e0a..bb3b6b9 100644
--- a/frontend/src/melodies/SpeedCalculatorModal.jsx
+++ b/frontend/src/melodies/SpeedCalculatorModal.jsx
@@ -130,11 +130,11 @@ export default function SpeedCalculatorModal({ open, melody, builtMelody, archet
const stepsRef = useRef([]);
const stepDelayRef = useRef(500);
const effectiveBeatRef = useRef(100);
- const loopRef = useRef(false);
+ const loopRef = useRef(true);
const [playing, setPlaying] = useState(false);
const [paused, setPaused] = useState(false);
- const [loop, setLoop] = useState(false);
+ const [loop, setLoop] = useState(true);
const [currentStep, setCurrentStep] = useState(-1);
// Sliders
@@ -285,11 +285,14 @@ export default function SpeedCalculatorModal({ open, melody, builtMelody, archet
};
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);
setBinaryLoadError("");
try {
- const decoded = await decodeBsmBinary(`/api${builtMelody.binary_url}`);
+ const decoded = await decodeBsmBinary(binaryUrl);
setSteps(decoded);
stepsRef.current = decoded;
stopPlayback();
@@ -388,7 +391,7 @@ export default function SpeedCalculatorModal({ open, melody, builtMelody, archet
- {builtMelody?.binary_url && (
+ {(builtMelody?.binary_url || melody?.url) && (