import { useState, useEffect } from "react"; import { useParams, useNavigate } from "react-router-dom"; import api from "../api/client"; import { useAuth } from "../auth/AuthContext"; import ConfirmDialog from "../components/ConfirmDialog"; import SpeedCalculatorModal from "./SpeedCalculatorModal"; import PlaybackModal from "./PlaybackModal"; import BinaryTableModal from "./BinaryTableModal"; 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); } } import { getLocalizedValue, getLanguageName, normalizeColor, formatDuration, } from "./melodyUtils"; function formatBpm(ms) { const value = Number(ms); if (!value || value <= 0) return null; return Math.round(60000 / value); } function mapPercentageToStepDelay(percent, minSpeed, maxSpeed) { if (minSpeed == null || maxSpeed == null) return null; const p = Math.max(0, Math.min(100, Number(percent || 0))); const t = p / 100; const a = Number(minSpeed); const b = Number(maxSpeed); if (a <= 0 || b <= 0) return Math.round(a + (b - a) * t); return Math.round(a * Math.pow(b / a, t)); } function normalizeFileUrl(url) { if (!url || typeof url !== "string") return null; if (url.startsWith("http") || url.startsWith("/api")) return url; if (url.startsWith("/")) return `/api${url}`; return `/api/${url}`; } function Field({ label, children }) { return (
{label}
{children || "-"}
); } function UrlField({ label, value }) { const [copied, setCopied] = useState(false); return (
{label}
{value}
); } export default function MelodyDetail() { const { id } = useParams(); const navigate = useNavigate(); const { hasPermission } = useAuth(); const canEdit = hasPermission("melodies", "edit"); const [melody, setMelody] = useState(null); const [files, setFiles] = useState({}); const [loading, setLoading] = useState(true); const [error, setError] = useState(""); const [showDelete, setShowDelete] = useState(false); const [showUnpublish, setShowUnpublish] = useState(false); const [actionLoading, setActionLoading] = useState(false); const [displayLang, setDisplayLang] = useState("en"); const [melodySettings, setMelodySettings] = useState(null); const [builtMelody, setBuiltMelody] = useState(null); const [codeCopied, setCodeCopied] = useState(false); const [showSpeedCalc, setShowSpeedCalc] = useState(false); const [showPlayback, setShowPlayback] = useState(false); const [showBinaryView, setShowBinaryView] = useState(false); const [offlineSaving, setOfflineSaving] = useState(false); useEffect(() => { api.get("/settings/melody").then((ms) => { setMelodySettings(ms); setDisplayLang(ms.primary_language || "en"); }); }, []); useEffect(() => { loadData(); }, [id]); const loadData = async () => { setLoading(true); try { const [m, f] = await Promise.all([ api.get(`/melodies/${id}`), api.get(`/melodies/${id}/files`), ]); setMelody(m); setFiles(f); // Load built melody assignment (non-fatal if it fails) try { const bm = await api.get(`/builder/melodies/for-melody/${id}`); setBuiltMelody(bm || null); } catch { setBuiltMelody(null); } } catch (err) { setError(err.message); } finally { setLoading(false); } }; const handleDelete = async () => { try { await api.delete(`/melodies/${id}`); navigate("/melodies"); } catch (err) { setError(err.message); setShowDelete(false); } }; const handlePublish = async () => { setActionLoading(true); try { await api.post(`/melodies/${id}/publish`); await loadData(); } catch (err) { setError(err.message); } finally { setActionLoading(false); } }; const handleUnpublish = async () => { setActionLoading(true); try { await api.post(`/melodies/${id}/unpublish`); setShowUnpublish(false); await loadData(); } catch (err) { setError(err.message); setShowUnpublish(false); } finally { setActionLoading(false); } }; const handleToggleAvailableOffline = async (nextValue) => { if (!canEdit || !melody) return; setOfflineSaving(true); setError(""); try { const body = { information: { ...(melody.information || {}), available_offline: nextValue }, default_settings: melody.default_settings || {}, type: melody.type || "all", uid: melody.uid || "", pid: melody.pid || "", metadata: melody.metadata || {}, }; if (melody.url) body.url = melody.url; await api.put(`/melodies/${id}`, body); setMelody((prev) => ({ ...prev, information: { ...(prev?.information || {}), available_offline: nextValue }, })); } catch (err) { setError(err.message); } finally { setOfflineSaving(false); } }; if (loading) { return
Loading...
; } if (error) { return (
{error}
); } if (!melody) return null; const info = melody.information || {}; const settings = melody.default_settings || {}; const speedMs = mapPercentageToStepDelay(settings.speed, info.minSpeed, info.maxSpeed); 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"); const badgeStyle = (active) => ({ backgroundColor: active ? "var(--success-bg)" : "var(--bg-card-hover)", color: active ? "var(--success-text)" : "var(--text-muted)", }); return (

{displayName}

{melody.status === "published" ? "LIVE" : "DRAFT"} {languages.length > 1 && ( )}
{canEdit && (
{melody.status === "draft" ? ( ) : ( )}
)}
{/* Left column */}
{/* Melody Information */}

Melody Information

{info.color ? ( {info.color} ) : ( "-" )} {info.melodyTone} {info.steps} {info.minSpeed ? ( {minBpm} bpm · {info.minSpeed} ms ) : "-"} {info.maxSpeed ? ( {maxBpm} bpm · {info.maxSpeed} ms ) : "-"} {info.totalActiveBells ?? "-"}
{getLocalizedValue(info.description, displayLang)}
{info.customTags?.length > 0 ? (
{info.customTags.map((tag) => ( {tag} ))}
) : ( "-" )}
{/* Identifiers */}

Identifiers

{melody.id} {melody.pid} {melody.url && (
)}
{/* Right column */}
{/* Default Settings */}

Default Settings

{settings.speed != null ? ( {settings.speed}%{speedBpm ? {` · ${speedBpm} bpm`} : ""}{speedMs ? {` · ${speedMs} ms`} : ""} ) : "-"} {formatDuration(settings.duration)} {settings.totalRunDuration} {settings.pauseDuration} {settings.infiniteLoop ? "Yes" : "No"}
{settings.echoRing?.length > 0 ? settings.echoRing.join(", ") : "-"}
Note Assignments
{settings.noteAssignments?.length > 0 ? (
{settings.noteAssignments.map((assignedBell, noteIdx) => { return (
{String.fromCharCode(65 + noteIdx)}
{assignedBell > 0 ? assignedBell : "-"}
); })}
) : ( - )}

Top = Note, Bottom = Assigned Bell

{/* Files */}

Files

{(() => { // Common source of truth: assigned archetype binary first, then melody URL, then uploaded file URL. const binaryUrl = normalizeFileUrl( (builtMelody?.binary_url ? `/api${builtMelody.binary_url}` : null) || melody.url || files.binary_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 downloadName = binaryFilename; if (!builtMelody?.binary_url && !files.binary_url && melody.url) { try { const urlPath = decodeURIComponent(new URL(melody.url).pathname); const parts = urlPath.split("/"); downloadName = parts[parts.length - 1] || binaryFilename; } catch { /* keep binaryFilename */ } } const handleDownload = async (e) => { e.preventDefault(); const preferredUrl = melody?.id ? `/api/melodies/${melody.id}/download/binary` : binaryUrl; try { const token = localStorage.getItem("access_token"); let res = null; try { res = await fetch(preferredUrl, { headers: token ? { Authorization: `Bearer ${token}` } : {}, }); } catch { if (binaryUrl.startsWith("http")) { res = await fetch(binaryUrl); } else { throw new Error("Download failed: network error"); } } if ((!res || !res.ok) && binaryUrl.startsWith("http")) { res = await fetch(binaryUrl); } if (!res.ok) throw new Error(`Download failed: ${res.statusText}`); const blob = await res.blob(); const objectUrl = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = objectUrl; a.download = downloadName; a.click(); URL.revokeObjectURL(objectUrl); } catch (err) { 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); } }; return ( {builtMelody?.name ? ( {builtMelody.name} ) : ( {downloadName} )} {missingArchetype && ( )} {info.totalNotes ?? 0} active notes {!files.binary_url && melody.url && ( via URL )} {builtMelody?.name && ( file:{" "} {downloadName} )} ); })()} {normalizeFileUrl(files.preview_url) ? ( ) : ( Not uploaded )}
{/* Firmware Code section — only shown if a built melody with PROGMEM code is assigned */} {builtMelody?.progmem_code && (

Firmware Code

PROGMEM code for built-in firmware playback  ·  PID: {builtMelody.pid}

            {builtMelody.progmem_code}
          
)} {/* 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.

)}
setShowPlayback(false)} /> setShowBinaryView(false)} /> setShowSpeedCalc(false)} onSaved={() => { setShowSpeedCalc(false); loadData(); }} /> setShowDelete(false)} /> setShowUnpublish(false)} />
); }