// frontend/src/pages/melodies/MelodyDetail.jsx
import { useState, useEffect } from 'react'
import { useParams, useNavigate } from 'react-router-dom'
import PageHeader from '@/components/ui/PageHeader'
import Card from '@/components/ui/Card'
import Button from '@/components/ui/Button'
import StatusBadge from '@/components/ui/StatusBadge'
import Spinner from '@/components/ui/Spinner'
import ConfirmDialog from '@/components/ui/ConfirmDialog'
import { useToast } from '@/components/ui/Toast'
import { useAuth } from '@/hooks/useAuth'
import api from '@/lib/api'
import {
getLocalizedValue,
getLanguageName,
normalizeColor,
formatDuration,
} from '@/lib/melodyUtils'
import RowActions from '@/components/ui/RowActions'
import PillButton from '@/components/ui/PillButton'
import SpeedCalculatorModal from '@/modals/bellcloud/melodies/SpeedCalculatorModal'
import SpeedIcon from '@/assets/global-icons/speed.svg?react'
import PlaybackModal from '@/modals/bellcloud/melodies/PlaybackModal'
import BinaryTableModal from '@/modals/bellcloud/melodies/BinaryTableModal'
// ─── Helpers ─────────────────────────────────────────────────────────────────
function formatBpm(ms) {
const v = Number(ms)
if (!v || v <= 0) return null
return Math.round(60000 / v)
}
function mapPercentageToStepDelay(percent, minSpeed, maxSpeed) {
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 copyText(text, onSuccess) {
if (navigator.clipboard) {
navigator.clipboard.writeText(text).then(onSuccess).catch(() => fallbackCopy(text, onSuccess))
} else {
fallbackCopy(text, onSuccess)
}
}
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)
}
// ─── Sub-components ──────────────────────────────────────────────────────────
function DetailField({ label, children, span = 1 }) {
return (
)
}
function MonoField({ label, value, span = 1 }) {
const [copied, setCopied] = useState(false)
if (!value) return
return (
{label}
{value}
)
}
// ─── Component ────────────────────────────────────────────────────────────────
export default function MelodyDetail() {
const { id } = useParams()
const navigate = useNavigate()
const { toast } = useToast()
const { hasPermission } = useAuth()
const canEdit = hasPermission('melodies', 'edit')
const [melody, setMelody] = useState(null)
const [files, setFiles] = useState({})
const [builtMelody, setBuiltMelody] = useState(null)
const [melodySettings, setMelodySettings] = useState(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [actionLoading, setActionLoading] = useState(false)
const [displayLang, setDisplayLang] = useState('en')
// Modal state
const [showDelete, setShowDelete] = useState(false)
const [showUnpublish, setShowUnpublish] = useState(false)
const [showSpeedCalc, setShowSpeedCalc] = useState(false)
const [showPlayback, setShowPlayback] = useState(false)
const [showBinaryView, setShowBinaryView] = useState(false)
const [codeCopied, setCodeCopied] = useState(false)
useEffect(() => {
api.get('/settings/melody').then((ms) => {
setMelodySettings(ms)
setDisplayLang(ms.primary_language || 'en')
}).catch(() => {})
}, [])
useEffect(() => { loadData() }, [id])
const loadData = async () => {
setLoading(true)
setError('')
try {
const [m, f] = await Promise.all([
api.get(`/melodies/${id}`),
api.get(`/melodies/${id}/files`),
])
setMelody(m)
setFiles(f)
try {
const bm = await api.get(`/builder/melodies/for-melody/${id}`)
setBuiltMelody(bm || null)
} catch { setBuiltMelody(null) }
} catch (err) {
setError(err.message || 'Failed to load melody.')
} finally {
setLoading(false)
}
}
const handleDelete = async () => {
try {
await api.delete(`/melodies/${id}`)
toast.success('Deleted', 'Melody removed.')
navigate('/melodies')
} catch (err) {
toast.danger('Error', err.message)
setShowDelete(false)
}
}
const handlePublish = async () => {
setActionLoading(true)
try {
await api.post(`/melodies/${id}/publish`)
await loadData()
toast.success('Published', 'Melody is now live.')
} catch (err) {
toast.danger('Error', err.message)
} finally {
setActionLoading(false)
}
}
const handleUnpublish = async () => {
setActionLoading(true)
try {
await api.post(`/melodies/${id}/unpublish`)
setShowUnpublish(false)
await loadData()
toast.success('Unpublished', 'Melody reverted to draft.')
} catch (err) {
toast.danger('Error', err.message)
setShowUnpublish(false)
} finally {
setActionLoading(false)
}
}
// ── Loading / error states ────────────────────────────────────────────────
if (loading) {
return (
)
}
if (error || !melody) {
return (
{error || 'Melody not found.'}
)
}
// ── Derived values ────────────────────────────────────────────────────────
const info = melody.information || {}
const settings = melody.default_settings || {}
const languages = melodySettings?.available_languages || ['en']
const displayName = getLocalizedValue(info.name, displayLang, 'Untitled Melody')
const isPublished = melody.status === 'published'
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 binaryUrl = normalizeFileUrl(
(builtMelody?.binary_url ? `/api${builtMelody.binary_url}` : null) ||
melody.url ||
files.binary_url ||
null
)
const binaryPid = builtMelody?.pid || melody.pid || 'binary'
const binaryFilename = `${binaryPid}.bsm`
const handleBinaryDownload = async (e) => {
e.preventDefault()
const endpoint = `/api/melodies/${melody.id}/download/binary`
try {
const token = localStorage.getItem('access_token')
const res = await fetch(endpoint, { headers: token ? { Authorization: `Bearer ${token}` } : {} })
if (!res.ok) throw new Error(res.statusText)
const blob = await res.blob()
const url = URL.createObjectURL(blob)
const a = document.createElement('a'); a.href = url; a.download = binaryFilename; a.click()
URL.revokeObjectURL(url)
} catch {
if (binaryUrl) { const a = document.createElement('a'); a.href = binaryUrl; a.download = binaryFilename; a.rel = 'noopener noreferrer'; document.body.appendChild(a); a.click(); document.body.removeChild(a) }
}
}
// ─── Render ───────────────────────────────────────────────────────────────
return (
{/* ── Header ──────────────────────────────────────────────────────── */}
{displayName}
{isPublished ? 'Live' : 'Draft'}
{languages.length > 1 && (
({ label: getLanguageName(l), value: l }))}
onChange={(val) => setDisplayLang(val)}
>
{getLanguageName(displayLang)}
)}
}
>
{canEdit && (
<>
{/* Playback — success icon-only */}
{/* ── Outdated archetype warning ───────────────────────────────────── */}
{info.outdated_archetype && (
Outdated Archetype — The archetype assigned to this melody has changed or been removed. Re-assign one in the editor to clear this warning.
)}
{/* ── Main grid — masonry layout ──────────────────────────────────── */}
{/* Melody Information — masonry item 1 */}
{/* Row 1: Color | Tone | Steps */}
{info.color ? (
{info.color}
) : null}
{info.melodyTone ?? '—'}
{info.steps}
{/* Row 2: Min Speed | Max Speed | Unique Bells */}
{info.minSpeed ? (
{minBpm} bpm · {info.minSpeed} ms
) : null}
{info.maxSpeed ? (
{maxBpm} bpm · {info.maxSpeed} ms
) : null}
{info.totalActiveBells}
{/* Row 3: True Ring | Type | Tags */}
{info.isTrueRing ? 'Yes' : 'No'}
{melody.type ?? '—'}
{info.customTags?.length > 0 ? (
{info.customTags.map((tag) => (
{tag}
))}
) : (
—
)}
{/* Row 4: Description (full width) */}
{getLocalizedValue(info.description, displayLang) || null}
{/* Files — masonry item 2 */}
{/* Binary */}
Binary File
{binaryUrl ? (
{/* Main row: name + pills + note count */}
{builtMelody?.name || binaryFilename}
{missingArchetype && (
⚠ Missing
)}
{/* Download — blue pill */}
Download
{/* View — neutral pill */}
setShowBinaryView(true)}
style={{
padding: '2px 10px',
borderRadius: 'var(--radius-full)',
fontSize: 'var(--font-size-xs)',
fontWeight: 'var(--font-weight-medium)',
color: 'var(--color-text-secondary)',
backgroundColor: 'var(--color-bg-elevated)',
border: '1px solid var(--color-border)',
cursor: 'pointer',
}}
>
View
{info.totalNotes ?? 0} active notes
{/* Subtitle: file: archetypename.bsm */}
file: {binaryFilename}
) : (
Not uploaded
)}
{/* Audio preview */}
Audio Preview
{normalizeFileUrl(files.preview_url) ? (
) : (
Not uploaded
)}
{/* Default Settings — masonry item 3 */}
{/* Row 1: Speed | Duration | Total Run Duration */}
{settings.speed != null ? (
{settings.speed}%
{speedBpm && · {speedBpm} bpm}
{speedMs && · {speedMs} ms}
) : null}
{formatDuration(settings.duration ?? 0)}
{settings.totalRunDuration}
{/* Row 2: Pause Duration | Infinite Loop */}
{settings.pauseDuration}
{settings.infiniteLoop ? 'Yes' : 'No'}
{/* Row 3: Echo Ring */}
{settings.echoRing?.length ? (
{settings.echoRing.join(', ')}
) : null}
{/* Note assignments */}
{settings.noteAssignments?.length > 0 && (
Note Assignments
{settings.noteAssignments.map((bell, i) => (
{String.fromCharCode(65 + i)}
0 ? 'var(--color-text-primary)' : 'var(--color-text-muted)', fontFamily: 'var(--font-family-mono)' }}>
{bell > 0 ? bell : '—'}
))}
Top = Note label · Bottom = Assigned bell number
)}
{/* Identifiers — masonry item 4 */}
{melody.url && }
{/* History — masonry item 5 */}
{melody.metadata && (
{melody.metadata.dateCreated && (
{melody.metadata.dateCreated}
)}
{melody.metadata.createdBy && (
{melody.metadata.createdBy}
)}
{melody.metadata.dateEdited && (
{melody.metadata.dateEdited}
)}
{melody.metadata.lastEditedBy && (
{melody.metadata.lastEditedBy}
)}
)}
{/* Admin Notes — masonry item 5 */}
{(melody.metadata?.adminNotes?.length || 0) > 0 ? (
{melody.metadata.adminNotes.map((note, i) => (
{note}
))}
) : (
No admin notes yet. Add them in the editor.
)}
{/* ── Firmware code ────────────────────────────────────────────────── */}
{builtMelody?.progmem_code && (
copyText(builtMelody.progmem_code, () => { setCodeCopied(true); setTimeout(() => setCodeCopied(false), 2000) })}
>
{codeCopied ? 'Copied!' : 'Copy Code'}
{builtMelody.progmem_code}
)}
{/* ── Modals ───────────────────────────────────────────────────────── */}
setShowDelete(false)}
onConfirm={handleDelete}
variant="danger"
title="Delete Melody"
message={`Are you sure you want to delete "${displayName}"? This action cannot be undone.`}
confirmLabel="Delete"
/>
setShowUnpublish(false)}
onConfirm={handleUnpublish}
variant="danger"
title="Unpublish Melody"
message={`Unpublish "${displayName}"? It will revert to draft and become unavailable to devices.`}
confirmLabel="Unpublish"
loading={actionLoading}
/>
setShowSpeedCalc(false)}
melody={melody}
builtMelody={builtMelody}
archetypeCsv={info.archetype_csv || null}
onSaved={() => { setShowSpeedCalc(false); loadData() }}
/>
setShowPlayback(false)}
melody={melody}
builtMelody={builtMelody}
files={files}
archetypeCsv={info.archetype_csv || null}
/>
setShowBinaryView(false)}
melody={melody}
builtMelody={builtMelody}
files={files}
archetypeCsv={info.archetype_csv || null}
/>
)
}