diff --git a/backend/melodies/router.py b/backend/melodies/router.py index 15b7281..35a5944 100644 --- a/backend/melodies/router.py +++ b/backend/melodies/router.py @@ -43,7 +43,7 @@ async def create_melody( publish: bool = Query(False), _user: TokenPayload = Depends(require_permission("melodies", "add")), ): - return await service.create_melody(body, publish=publish) + return await service.create_melody(body, publish=publish, actor_name=_user.name) @router.put("/{melody_id}", response_model=MelodyInDB) @@ -52,7 +52,7 @@ async def update_melody( body: MelodyUpdate, _user: TokenPayload = Depends(require_permission("melodies", "edit")), ): - return await service.update_melody(melody_id, body) + return await service.update_melody(melody_id, body, actor_name=_user.name) @router.delete("/{melody_id}", status_code=204) @@ -108,9 +108,9 @@ async def upload_file( name=melody.information.name, previewURL=url, ) - )) + ), actor_name=_user.name) elif file_type == "binary": - await service.update_melody(melody_id, MelodyUpdate(url=url)) + await service.update_melody(melody_id, MelodyUpdate(url=url), actor_name=_user.name) return {"url": url, "file_type": file_type} diff --git a/backend/melodies/service.py b/backend/melodies/service.py index eeeede7..78d7a4a 100644 --- a/backend/melodies/service.py +++ b/backend/melodies/service.py @@ -1,6 +1,7 @@ import json import uuid import logging +from datetime import datetime from shared.firebase import get_db as get_firestore, get_bucket from shared.exceptions import NotFoundError @@ -93,10 +94,44 @@ async def get_melody(melody_id: str) -> MelodyInDB: raise NotFoundError("Melody") -async def create_melody(data: MelodyCreate, publish: bool = False) -> MelodyInDB: +def _sanitize_metadata_for_create(existing: dict | None, actor_name: str | None) -> dict: + now = datetime.utcnow().isoformat() + "Z" + metadata = dict(existing or {}) + creator = metadata.get("createdBy") or actor_name or "Unknown" + created_at = metadata.get("dateCreated") or now + metadata["createdBy"] = creator + metadata["dateCreated"] = created_at + metadata["lastEditedBy"] = actor_name or metadata.get("lastEditedBy") or creator + metadata["dateEdited"] = now + if "adminNotes" not in metadata: + metadata["adminNotes"] = [] + return metadata + + +def _sanitize_metadata_for_update(existing: dict | None, incoming: dict | None, actor_name: str | None) -> dict: + now = datetime.utcnow().isoformat() + "Z" + existing_meta = dict(existing or {}) + incoming_meta = dict(incoming or {}) + + # Created fields are immutable after first set. + created_by = existing_meta.get("createdBy") or incoming_meta.get("createdBy") or actor_name or "Unknown" + date_created = existing_meta.get("dateCreated") or incoming_meta.get("dateCreated") or now + + merged = {**existing_meta, **incoming_meta} + merged["createdBy"] = created_by + merged["dateCreated"] = date_created + merged["lastEditedBy"] = actor_name or incoming_meta.get("lastEditedBy") or existing_meta.get("lastEditedBy") or created_by + merged["dateEdited"] = now + if "adminNotes" not in merged: + merged["adminNotes"] = existing_meta.get("adminNotes", []) + return merged + + +async def create_melody(data: MelodyCreate, publish: bool = False, actor_name: str | None = None) -> MelodyInDB: """Create a new melody. If publish=True, also push to Firestore.""" melody_id = str(uuid.uuid4()) doc_data = data.model_dump() + doc_data["metadata"] = _sanitize_metadata_for_create(doc_data.get("metadata"), actor_name) status = "published" if publish else "draft" # Always save to SQLite @@ -110,7 +145,7 @@ async def create_melody(data: MelodyCreate, publish: bool = False) -> MelodyInDB return MelodyInDB(id=melody_id, status=status, **doc_data) -async def update_melody(melody_id: str, data: MelodyUpdate) -> MelodyInDB: +async def update_melody(melody_id: str, data: MelodyUpdate, actor_name: str | None = None) -> MelodyInDB: """Update an existing melody. If published, also update Firestore.""" row = await melody_db.get_melody(melody_id) if not row: @@ -124,6 +159,12 @@ async def update_melody(melody_id: str, data: MelodyUpdate) -> MelodyInDB: if key in update_data and key in existing_data: merged = {**existing_data[key], **update_data[key]} update_data[key] = merged + if "metadata" in update_data or "metadata" in existing_data: + update_data["metadata"] = _sanitize_metadata_for_update( + existing_data.get("metadata"), + update_data.get("metadata"), + actor_name, + ) merged_data = {**existing_data, **update_data} diff --git a/frontend/src/melodies/MelodyDetail.jsx b/frontend/src/melodies/MelodyDetail.jsx index 5718f2b..1037be1 100644 --- a/frontend/src/melodies/MelodyDetail.jsx +++ b/frontend/src/melodies/MelodyDetail.jsx @@ -249,6 +249,7 @@ export default function MelodyDetail() { const speedBpm = formatBpm(speedMs); const minBpm = formatBpm(info.minSpeed); const maxBpm = formatBpm(info.maxSpeed); + const missingArchetype = Boolean(melody.pid) && !builtMelody?.id; const languages = melodySettings?.available_languages || ["en"]; const displayName = getLocalizedValue(info.name, displayLang, "Untitled Melody"); @@ -556,10 +557,6 @@ export default function MelodyDetail() { const handleDownload = async (e) => { e.preventDefault(); - if (binaryUrl.startsWith("http")) { - window.open(binaryUrl, "_blank", "noopener,noreferrer"); - return; - } try { const token = localStorage.getItem("access_token"); let res = null; @@ -586,7 +583,13 @@ export default function MelodyDetail() { a.click(); URL.revokeObjectURL(objectUrl); } catch (err) { - window.open(binaryUrl, "_blank", "noopener,noreferrer"); + const a = document.createElement("a"); + a.href = binaryUrl; + a.download = downloadName; + a.rel = "noopener noreferrer"; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); } }; @@ -600,6 +603,15 @@ export default function MelodyDetail() { {downloadName} )} + {missingArchetype && ( + + ⚠ + + )} Binary File (.bsm) {(() => { const { effectiveUrl: binaryUrl, effectiveName: binaryName } = getEffectiveBinary(); + const missingArchetype = Boolean(pid) && !builtMelody?.id; return ( {binaryUrl ? ( - - {binaryName} + + {binaryName} + {missingArchetype && ( + + ⚠ + + )} ) : ( No binary uploaded diff --git a/frontend/src/melodies/MelodyList.jsx b/frontend/src/melodies/MelodyList.jsx index c5fe467..b0d3593 100644 --- a/frontend/src/melodies/MelodyList.jsx +++ b/frontend/src/melodies/MelodyList.jsx @@ -375,6 +375,7 @@ export default function MelodyList() { : `/api/${candidate}`) : null; const source = built?.binary_url ? "Archetype" : (url ? "Melody URL" : null); + const missingArchetype = Boolean(row?.pid) && !built?.id; const filename = (() => { if (built?.pid) return `${built.pid}.bsm`; if (row?.pid) return `${row.pid}.bsm`; @@ -391,7 +392,7 @@ export default function MelodyList() { } return "melody.bsm"; })(); - return { url, filename, source, built }; + return { url, filename, source, built, missingArchetype }; }; const handleDelete = async () => { @@ -466,9 +467,16 @@ export default function MelodyList() { a.click(); URL.revokeObjectURL(objectUrl); } catch (err) { - const fallbackUrl = resolveEffectiveBinary(row).url; + const fallback = resolveEffectiveBinary(row); + const fallbackUrl = fallback.url; if (fallbackUrl) { - window.open(fallbackUrl, "_blank", "noopener,noreferrer"); + const a = document.createElement("a"); + a.href = fallbackUrl; + a.download = fallback.filename || "melody.bsm"; + a.rel = "noopener noreferrer"; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); } else { setError(err.message); } @@ -876,9 +884,13 @@ export default function MelodyList() { {filename || "binary.bsm"} - {resolved.source && ( - - {resolved.source} + {resolved.missingArchetype && ( + + ⚠ )} diff --git a/frontend/src/melodies/PlaybackModal.jsx b/frontend/src/melodies/PlaybackModal.jsx index 175039a..22c9b66 100644 --- a/frontend/src/melodies/PlaybackModal.jsx +++ b/frontend/src/melodies/PlaybackModal.jsx @@ -110,6 +110,14 @@ function applyNoteAssignments(rawStepValue, noteAssignments) { return result; } +function bellDotColor(assignedBell) { + const bell = Number(assignedBell || 0); + if (bell <= 0) return "rgba(148,163,184,0.7)"; + const t = Math.min(1, Math.max(0, (bell - 1) / 15)); + const light = 62 - t * 40; // bright green -> very dark green + return `hsl(132, 74%, ${light}%)`; +} + const mutedStyle = { color: "var(--text-muted)" }; const labelStyle = { color: "var(--text-secondary)" }; const NOTE_LABELS = "ABCDEFGHIJKLMNOP"; @@ -460,7 +468,9 @@ export default function PlaybackModal({ open, melody, builtMelody, files, archet const enabled = Boolean(stepValue & (1 << noteIdx)); const isCurrent = currentStep === stepIdx; const assignedBell = Number(noteAssignments[noteIdx] || 0); - const dotLabel = assignedBell > 0 ? assignedBell : noteIdx + 1; + const dotLabel = assignedBell > 0 ? assignedBell : ""; + const isUnassigned = assignedBell <= 0; + const dotVisible = enabled || (isUnassigned && Boolean(stepValue & (1 << noteIdx))); return ( - {enabled ? dotLabel : ""} + {dotVisible ? dotLabel : ""}