Initial Switch to V2. Completely Overhauled Backend, Frontend and General Structure.
This commit is contained in:
@@ -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>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user