Initial Switch to V2. Completely Overhauled Backend, Frontend and General Structure.

This commit is contained in:
2026-04-17 14:45:30 +03:00
parent eb773c5531
commit 0a8a42d69b
447 changed files with 70696 additions and 492 deletions
@@ -0,0 +1,739 @@
// 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 (
<div style={{ gridColumn: `span ${span}` }}>
<div style={{ fontSize: 'var(--font-size-xs)', fontWeight: 'var(--font-weight-semibold)', textTransform: 'uppercase', letterSpacing: 'var(--tracking-wide)', color: 'var(--color-text-more-muted)', marginBottom: 'var(--space-1)' }}>
{label}
</div>
<div style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-text-primary)' }}>
{children ?? <span style={{ color: 'var(--color-text-muted)' }}>—</span>}
</div>
</div>
)
}
function MonoField({ label, value, span = 1 }) {
const [copied, setCopied] = useState(false)
if (!value) return <DetailField label={label} span={span} />
return (
<div style={{ gridColumn: `span ${span}` }}>
<div style={{ fontSize: 'var(--font-size-xs)', fontWeight: 'var(--font-weight-semibold)', textTransform: 'uppercase', letterSpacing: 'var(--tracking-wide)', color: 'var(--color-text-more-muted)', marginBottom: 'var(--space-1)' }}>
{label}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-2)', flexWrap: 'wrap' }}>
<span style={{ fontFamily: 'var(--font-family-mono)', fontSize: 'var(--font-size-sm)', color: 'var(--color-text-primary)', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap', minWidth: 0 }}>
{value}
</span>
<button
type="button"
onClick={() => copyText(value, () => { setCopied(true); setTimeout(() => setCopied(false), 2000) })}
style={{
flexShrink: 0,
fontSize: '10px',
padding: '1px var(--space-2)',
borderRadius: 'var(--radius-full)',
backgroundColor: copied ? 'var(--color-success-bg)' : 'var(--color-bg-island)',
color: copied ? 'var(--color-success)' : 'var(--color-text-muted)',
border: `1px solid ${copied ? 'rgba(74,222,128,0.3)' : 'var(--color-border)'}`,
cursor: 'pointer',
transition: 'background 120ms, color 120ms',
lineHeight: 1.6,
}}
>
{copied ? 'Copied' : 'Copy'}
</button>
</div>
</div>
)
}
// ─── 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 (
<div className="page-wrapper">
<PageHeader title="Melody" />
<div style={{ display: 'flex', justifyContent: 'center', padding: 'var(--space-16)' }}>
<Spinner size="lg" />
</div>
</div>
)
}
if (error || !melody) {
return (
<div className="page-wrapper">
<PageHeader title="Melody" />
<div style={{ padding: 'var(--space-4)', backgroundColor: 'var(--color-danger-bg)', border: '1px solid var(--color-danger)', borderRadius: 'var(--radius-md)', color: 'var(--color-danger)', fontSize: 'var(--font-size-sm)' }}>
{error || 'Melody not found.'}
</div>
</div>
)
}
// ── 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 (
<div className="page-wrapper">
{/* ── Header ──────────────────────────────────────────────────────── */}
<PageHeader
title={
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 'var(--space-3)' }}>
{displayName}
<StatusBadge variant={isPublished ? 'success' : 'neutral'}>
{isPublished ? 'Live' : 'Draft'}
</StatusBadge>
{languages.length > 1 && (
<PillButton
variant="neutral"
value={displayLang}
options={languages.map((l) => ({ label: getLanguageName(l), value: l }))}
onChange={(val) => setDisplayLang(val)}
>
{getLanguageName(displayLang)}
</PillButton>
)}
</span>
}
>
{canEdit && (
<>
{/* Playback — success icon-only */}
<Button
variant="success"
size="md"
aria-label="Playback"
onClick={() => setShowPlayback(true)}
icon={
<svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true" style={{ transform: 'scale(1.150)', transformOrigin: 'center' }}>
<path d="M5 3l14 9-14 9V3z"/>
</svg>
}
/>
{/* Speed Calc — info icon-only (rabbit) */}
<Button
variant="info"
size="md"
aria-label="Speed Calculator"
onClick={() => setShowSpeedCalc(true)}
icon={
<SpeedIcon width="16" height="16" aria-hidden="true" style={{ transform: 'scale(1.25)', transformOrigin: 'center' }} />
}
/>
{/* Actions dropdown — Edit, Publish/Unpublish, Delete */}
<RowActions
variant="primary"
size="md"
actions={[
{
label: 'Edit',
icon: <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7"/><path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z"/></svg>,
onClick: () => navigate(`/melodies/${id}/edit`),
},
isPublished
? {
label: 'Unpublish',
icon: <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><circle cx="12" cy="12" r="10"/><line x1="4.93" y1="4.93" x2="19.07" y2="19.07"/></svg>,
color: 'var(--color-warning)',
onClick: () => setShowUnpublish(true),
}
: {
label: 'Publish',
icon: <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="M12 2v20M2 12h20"/></svg>,
color: 'var(--color-success)',
onClick: handlePublish,
},
{
label: 'Delete',
icon: <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/><path d="M10 11v6M14 11v6"/><path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/></svg>,
color: 'var(--color-danger)',
divider: true,
onClick: () => setShowDelete(true),
},
]}
/>
</>
)}
</PageHeader>
{/* ── Outdated archetype warning ───────────────────────────────────── */}
{info.outdated_archetype && (
<div style={{
display: 'flex', alignItems: 'flex-start', gap: 'var(--space-3)',
padding: 'var(--space-3) var(--space-4)',
backgroundColor: 'var(--color-warning-bg)',
border: '1px solid var(--color-warning)',
borderRadius: 'var(--radius-lg)',
color: 'var(--color-warning)',
fontSize: 'var(--font-size-sm)',
}}>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" style={{ flexShrink: 0, marginTop: 2 }}>
<path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/><line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/>
</svg>
<span>
<strong>Outdated Archetype</strong> — The archetype assigned to this melody has changed or been removed. Re-assign one in the editor to clear this warning.
</span>
</div>
)}
{/* ── Main grid — masonry layout ──────────────────────────────────── */}
<div className="masonry-grid masonry-grid--2">
{/* Melody Information — masonry item 1 */}
<Card title="Melody Information">
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 'var(--space-4)' }}>
{/* Row 1: Color | Tone | Steps */}
<DetailField label="Color" span={1}>
{info.color ? (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 'var(--space-2)' }}>
<span style={{ width: 16, height: 16, borderRadius: 'var(--radius-sm)', backgroundColor: normalizeColor(info.color), border: '1px solid var(--color-border)', display: 'inline-block', flexShrink: 0 }} />
<span style={{ fontFamily: 'var(--font-family-mono)', fontSize: 'var(--font-size-xs)' }}>{info.color}</span>
</span>
) : null}
</DetailField>
<DetailField label="Tone">
<span style={{ textTransform: 'capitalize' }}>{info.melodyTone ?? '—'}</span>
</DetailField>
<DetailField label="Steps">{info.steps}</DetailField>
{/* Row 2: Min Speed | Max Speed | Unique Bells */}
<DetailField label="Min Speed">
{info.minSpeed ? (
<span>{minBpm} bpm <span style={{ color: 'var(--color-text-muted)', fontSize: 'var(--font-size-xs)' }}>· {info.minSpeed} ms</span></span>
) : null}
</DetailField>
<DetailField label="Max Speed">
{info.maxSpeed ? (
<span>{maxBpm} bpm <span style={{ color: 'var(--color-text-muted)', fontSize: 'var(--font-size-xs)' }}>· {info.maxSpeed} ms</span></span>
) : null}
</DetailField>
<DetailField label="Unique Bells">{info.totalActiveBells}</DetailField>
{/* Row 3: True Ring | Type | Tags */}
<DetailField label="True Ring">
<StatusBadge variant={info.isTrueRing ? 'success' : 'neutral'}>{info.isTrueRing ? 'Yes' : 'No'}</StatusBadge>
</DetailField>
<DetailField label="Type">
<span style={{ textTransform: 'capitalize' }}>{melody.type ?? '—'}</span>
</DetailField>
{info.customTags?.length > 0 ? (
<DetailField label="Tags">
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 'var(--space-1)', marginTop: 'var(--space-1)' }}>
{info.customTags.map((tag) => (
<span key={tag} style={{
fontSize: 'var(--font-size-xs)', padding: '2px var(--space-2)',
borderRadius: 'var(--radius-full)',
backgroundColor: 'var(--color-primary-subtle)',
color: 'var(--color-primary)',
border: '1px solid var(--color-primary)',
}}>{tag}</span>
))}
</div>
</DetailField>
) : (
<DetailField label="Tags">
<span style={{ color: 'var(--color-text-muted)' }}>—</span>
</DetailField>
)}
{/* Row 4: Description (full width) */}
<DetailField label="Description" span={3}>
{getLocalizedValue(info.description, displayLang) || null}
</DetailField>
</div>
</Card>
{/* Files — masonry item 2 */}
<Card title="Files">
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-4)' }}>
{/* Binary */}
<div>
<div style={{ fontSize: 'var(--font-size-xs)', fontWeight: 'var(--font-weight-semibold)', textTransform: 'uppercase', letterSpacing: 'var(--tracking-wide)', color: 'var(--color-text-more-muted)', marginBottom: 'var(--space-2)' }}>
Binary File
</div>
{binaryUrl ? (
<div>
{/* Main row: name + pills + note count */}
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-2)', flexWrap: 'wrap' }}>
<span style={{ fontSize: 'var(--font-size-sm)', fontWeight: 'var(--font-weight-semibold)', color: 'var(--color-text-primary)' }}>
{builtMelody?.name || binaryFilename}
</span>
{missingArchetype && (
<span title="This binary does not exist in the Archetypes" style={{ color: 'var(--color-warning)', fontSize: 'var(--font-size-xs)', cursor: 'help' }}>⚠ Missing</span>
)}
{/* Download — blue pill */}
<button
type="button"
onClick={handleBinaryDownload}
style={{
padding: '2px 10px',
borderRadius: 'var(--radius-full)',
fontSize: 'var(--font-size-xs)',
fontWeight: 'var(--font-weight-medium)',
color: 'var(--color-primary)',
backgroundColor: 'var(--color-primary-subtle)',
border: 'none',
cursor: 'pointer',
}}
>
Download
</button>
{/* View — neutral pill */}
<button
type="button"
onClick={() => 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
</button>
<span style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)' }}>
{info.totalNotes ?? 0} active notes
</span>
</div>
{/* Subtitle: file: archetypename.bsm */}
<div style={{ marginTop: 'var(--space-1)', fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)', fontFamily: 'var(--font-family-mono)' }}>
file: {binaryFilename}
</div>
</div>
) : (
<span style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-text-muted)' }}>Not uploaded</span>
)}
</div>
{/* Audio preview */}
<div>
<div style={{ fontSize: 'var(--font-size-xs)', fontWeight: 'var(--font-weight-semibold)', textTransform: 'uppercase', letterSpacing: 'var(--tracking-wide)', color: 'var(--color-text-more-muted)', marginBottom: 'var(--space-2)' }}>
Audio Preview
</div>
{normalizeFileUrl(files.preview_url) ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-2)' }}>
<audio controls src={normalizeFileUrl(files.preview_url)} style={{ height: 32, width: '100%' }} />
<a href={normalizeFileUrl(files.preview_url)} target="_blank" rel="noopener noreferrer"
style={{ fontSize: 'var(--font-size-xs)', color: 'var(--color-primary)', textDecoration: 'underline' }}>
Download preview
</a>
</div>
) : (
<span style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-text-muted)' }}>Not uploaded</span>
)}
</div>
</div>
</Card>
{/* Default Settings — masonry item 3 */}
<Card title="Default Settings">
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: 'var(--space-4)' }}>
{/* Row 1: Speed | Duration | Total Run Duration */}
<DetailField label="Speed">
{settings.speed != null ? (
<span>
{settings.speed}%
{speedBpm && <span style={{ color: 'var(--color-text-muted)', fontSize: 'var(--font-size-xs)' }}> · {speedBpm} bpm</span>}
{speedMs && <span style={{ color: 'var(--color-text-muted)', fontSize: 'var(--font-size-xs)' }}> · {speedMs} ms</span>}
</span>
) : null}
</DetailField>
<DetailField label="Duration">{formatDuration(settings.duration ?? 0)}</DetailField>
<DetailField label="Total Run Duration">{settings.totalRunDuration}</DetailField>
{/* Row 2: Pause Duration | Infinite Loop */}
<DetailField label="Pause Duration">{settings.pauseDuration}</DetailField>
<DetailField label="Infinite Loop">
<StatusBadge variant={settings.infiniteLoop ? 'success' : 'neutral'}>{settings.infiniteLoop ? 'Yes' : 'No'}</StatusBadge>
</DetailField>
{/* Row 3: Echo Ring */}
<DetailField label="Echo Ring">
{settings.echoRing?.length ? (
<span style={{ fontFamily: 'var(--font-family-mono)', fontSize: 'var(--font-size-xs)' }}>
{settings.echoRing.join(', ')}
</span>
) : null}
</DetailField>
</div>
{/* Note assignments */}
{settings.noteAssignments?.length > 0 && (
<div style={{ marginTop: 'var(--space-4)', paddingTop: 'var(--space-4)', borderTop: '1px solid var(--color-border)' }}>
<div style={{ fontSize: 'var(--font-size-xs)', fontWeight: 'var(--font-weight-semibold)', textTransform: 'uppercase', letterSpacing: 'var(--tracking-wide)', color: 'var(--color-text-more-muted)', marginBottom: 'var(--space-2)' }}>
Note Assignments
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 'var(--space-1)' }}>
{settings.noteAssignments.map((bell, i) => (
<div key={i} style={{
display: 'flex', flexDirection: 'column', alignItems: 'center',
minWidth: 36, padding: 'var(--space-1) var(--space-2)',
backgroundColor: 'var(--color-bg-elevated)',
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-md)',
}}>
<span style={{ fontSize: 'var(--font-size-xs)', fontWeight: 'var(--font-weight-semibold)', color: 'var(--color-text-secondary)' }}>
{String.fromCharCode(65 + i)}
</span>
<div style={{ width: '100%', height: 1, backgroundColor: 'var(--color-border)', margin: '2px 0' }} />
<span style={{ fontSize: 'var(--font-size-xs)', color: bell > 0 ? 'var(--color-text-primary)' : 'var(--color-text-muted)', fontFamily: 'var(--font-family-mono)' }}>
{bell > 0 ? bell : '—'}
</span>
</div>
))}
</div>
<p style={{ marginTop: 'var(--space-1)', fontSize: 'var(--font-size-xs)', color: 'var(--color-text-muted)' }}>
Top = Note label · Bottom = Assigned bell number
</p>
</div>
)}
</Card>
{/* Identifiers — masonry item 4 */}
<Card title="Identifiers">
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 'var(--space-4)' }}>
<MonoField label="Document ID" value={melody.id} />
<MonoField label="PID" value={melody.pid} />
{melody.url && <MonoField label="URL" value={melody.url} span={2} />}
</div>
</Card>
{/* History — masonry item 5 */}
{melody.metadata && (
<Card title="History">
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, 1fr)', gap: 'var(--space-4)' }}>
{melody.metadata.dateCreated && (
<DetailField label="Created">{melody.metadata.dateCreated}</DetailField>
)}
{melody.metadata.createdBy && (
<DetailField label="Created By">{melody.metadata.createdBy}</DetailField>
)}
{melody.metadata.dateEdited && (
<DetailField label="Last Edited">{melody.metadata.dateEdited}</DetailField>
)}
{melody.metadata.lastEditedBy && (
<DetailField label="Last Edited By">{melody.metadata.lastEditedBy}</DetailField>
)}
</div>
</Card>
)}
{/* Admin Notes — masonry item 5 */}
<Card title="Admin Notes">
{(melody.metadata?.adminNotes?.length || 0) > 0 ? (
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-2)' }}>
{melody.metadata.adminNotes.map((note, i) => (
<div key={i} style={{
padding: 'var(--space-3)',
backgroundColor: 'var(--color-bg-elevated)',
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-md)',
fontSize: 'var(--font-size-sm)',
color: 'var(--color-text-primary)',
}}>
{note}
</div>
))}
</div>
) : (
<p style={{ fontSize: 'var(--font-size-sm)', color: 'var(--color-text-muted)', margin: 0 }}>
No admin notes yet. Add them in the editor.
</p>
)}
</Card>
</div>
{/* ── Firmware code ────────────────────────────────────────────────── */}
{builtMelody?.progmem_code && (
<Card
title="Firmware Code"
subtitle={`PROGMEM code for built-in firmware playback · PID: ${builtMelody.pid}`}
>
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: 'var(--space-2)' }}>
<Button
variant={codeCopied ? 'success' : 'secondary'}
size="sm"
onClick={() => copyText(builtMelody.progmem_code, () => { setCodeCopied(true); setTimeout(() => setCodeCopied(false), 2000) })}
>
{codeCopied ? 'Copied!' : 'Copy Code'}
</Button>
</div>
<pre style={{
margin: 0, padding: 'var(--space-4)',
backgroundColor: 'var(--color-bg-abyss)',
border: '1px solid var(--color-border)',
borderRadius: 'var(--radius-md)',
fontSize: 'var(--font-size-sm)',
fontFamily: 'var(--font-family-mono)',
color: 'var(--color-text-primary)',
overflowX: 'auto', maxHeight: 360, overflowY: 'auto',
whiteSpace: 'pre',
}}>
{builtMelody.progmem_code}
</pre>
</Card>
)}
{/* ── Modals ───────────────────────────────────────────────────────── */}
<ConfirmDialog
open={showDelete}
onClose={() => 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"
/>
<ConfirmDialog
open={showUnpublish}
onClose={() => 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}
/>
<SpeedCalculatorModal
open={showSpeedCalc}
onClose={() => setShowSpeedCalc(false)}
melody={melody}
builtMelody={builtMelody}
archetypeCsv={info.archetype_csv || null}
onSaved={() => { setShowSpeedCalc(false); loadData() }}
/>
<PlaybackModal
open={showPlayback}
onClose={() => setShowPlayback(false)}
melody={melody}
builtMelody={builtMelody}
files={files}
archetypeCsv={info.archetype_csv || null}
/>
<BinaryTableModal
open={showBinaryView}
onClose={() => setShowBinaryView(false)}
melody={melody}
builtMelody={builtMelody}
files={files}
archetypeCsv={info.archetype_csv || null}
/>
</div>
)
}