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) && ( +

+ {isEdit ? "Edit Archetype" : "New Archetype"} +

+
+
+ + {isEdit && ( + + )} + +
+
+ + {error && ( +
+ {error} +
+ )} + {successMsg && ( +
+ {successMsg} +
+ )} + +
+
+

Archetype Info

+
+
+ + handleNameChange(e.target.value)} placeholder="e.g. Doksologia_3k" className={inputClass} /> +
+
+ + handlePidChange(e.target.value)} placeholder="e.g. builtin_doksologia_3k" className={inputClass} /> +

Used as the built-in firmware identifier. Must be unique.

+
+
+
+ + {countSteps(steps)} steps +
+