// 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 (
{label}
{children ?? —}
) } 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 */} {/* View — neutral pill */} {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 && (
            {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} /> ) }