App.tsx main element now has pb-24 (96px bottom padding) so the gallery content always has breathing room above the fixed player bar. Prevents content from sitting flush against the player controls.
220 lines
7.2 KiB
TypeScript
220 lines
7.2 KiB
TypeScript
import { useState, useEffect } from 'react'
|
|
import Generator from './components/Generator'
|
|
import Gallery from './components/Gallery'
|
|
import Player from './components/Player'
|
|
import DetailPanel from './components/DetailPanel'
|
|
import SettingsModal from './components/SettingsModal'
|
|
|
|
interface Clip {
|
|
id: string
|
|
title?: string
|
|
prompt: string
|
|
model: string
|
|
duration_seconds: number
|
|
status: string
|
|
error_message?: string
|
|
file_size_bytes?: number
|
|
artwork_filename?: string | null
|
|
created_at: string
|
|
}
|
|
|
|
function App() {
|
|
const [clips, setClips] = useState<Clip[]>([])
|
|
const [currentClip, setCurrentClip] = useState<Clip | null>(null)
|
|
const [isPlaying, setIsPlaying] = useState(false)
|
|
const [selectedClip, setSelectedClip] = useState<Clip | null>(null)
|
|
const [settingsOpen, setSettingsOpen] = useState(false)
|
|
|
|
const fetchClips = async () => {
|
|
try {
|
|
const res = await fetch('/api/clips')
|
|
const data = await res.json()
|
|
setClips(data.clips)
|
|
} catch (err) {
|
|
console.error('Failed to fetch clips:', err)
|
|
}
|
|
}
|
|
|
|
useEffect(() => {
|
|
fetchClips()
|
|
const interval = setInterval(fetchClips, 5000)
|
|
return () => clearInterval(interval)
|
|
}, [])
|
|
|
|
const handlePlay = (clip: Clip) => {
|
|
setCurrentClip(clip)
|
|
setIsPlaying(true)
|
|
}
|
|
|
|
const handleDelete = async (id: string) => {
|
|
try {
|
|
await fetch(`/api/clips/${id}`, { method: 'DELETE' })
|
|
if (currentClip?.id === id) {
|
|
setCurrentClip(null)
|
|
setIsPlaying(false)
|
|
}
|
|
fetchClips()
|
|
} catch (err) {
|
|
console.error('Failed to delete clip:', err)
|
|
}
|
|
}
|
|
|
|
const handleRename = async (id: string, title: string) => {
|
|
try {
|
|
await fetch(`/api/clips/${id}`, {
|
|
method: 'PATCH',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ title }),
|
|
})
|
|
setClips(prev => prev.map(c => c.id === id ? { ...c, title } : c))
|
|
if (currentClip?.id === id) {
|
|
setCurrentClip({ ...currentClip, title })
|
|
}
|
|
if (selectedClip?.id === id) {
|
|
setSelectedClip({ ...selectedClip, title })
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to rename clip:', err)
|
|
}
|
|
}
|
|
|
|
const handleUploadArtwork = async (id: string, file: File) => {
|
|
try {
|
|
const formData = new FormData()
|
|
formData.append('file', file)
|
|
const res = await fetch(`/api/clips/${id}/artwork`, {
|
|
method: 'PATCH',
|
|
body: formData,
|
|
})
|
|
if (!res.ok) {
|
|
const err = await res.json()
|
|
console.error('Artwork upload failed:', err.detail)
|
|
return
|
|
}
|
|
const updated = await res.json()
|
|
setClips(prev => prev.map(c => c.id === id ? { ...c, artwork_filename: updated.artwork_filename } : c))
|
|
if (currentClip?.id === id) {
|
|
setCurrentClip({ ...currentClip, artwork_filename: updated.artwork_filename })
|
|
}
|
|
if (selectedClip?.id === id) {
|
|
setSelectedClip({ ...selectedClip, artwork_filename: updated.artwork_filename })
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to upload artwork:', err)
|
|
}
|
|
}
|
|
|
|
const handleRemoveArtwork = async (id: string) => {
|
|
try {
|
|
await fetch(`/api/clips/${id}/artwork`, { method: 'DELETE' })
|
|
setClips(prev => prev.map(c => c.id === id ? { ...c, artwork_filename: null } : c))
|
|
if (currentClip?.id === id) {
|
|
setCurrentClip({ ...currentClip, artwork_filename: null })
|
|
}
|
|
if (selectedClip?.id === id) {
|
|
setSelectedClip({ ...selectedClip, artwork_filename: null })
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to remove artwork:', err)
|
|
}
|
|
}
|
|
|
|
const handleClearWaveforms = async () => {
|
|
try {
|
|
await fetch('/api/admin/clear-waveforms', { method: 'POST' })
|
|
} catch (err) {
|
|
console.error('Failed to clear waveforms:', err)
|
|
}
|
|
}
|
|
|
|
const handleDeleteAll = async () => {
|
|
try {
|
|
await fetch('/api/admin/delete-all-clips', { method: 'POST' })
|
|
setClips([])
|
|
setCurrentClip(null)
|
|
setIsPlaying(false)
|
|
setSelectedClip(null)
|
|
} catch (err) {
|
|
console.error('Failed to delete all clips:', err)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="min-h-screen bg-lyria-dark">
|
|
<header className="border-b border-white/10 px-6 py-4">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-3">
|
|
<div className="w-8 h-8 rounded bg-lyria-accent flex items-center justify-center text-white font-bold text-sm">
|
|
L
|
|
</div>
|
|
<h1 className="text-xl font-bold text-white">Lyria Studio</h1>
|
|
<span className="text-xs text-lyria-muted bg-white/5 px-2 py-0.5 rounded">v0.1</span>
|
|
</div>
|
|
<button
|
|
onClick={() => setSettingsOpen(true)}
|
|
className="text-lyria-muted hover:text-white transition-colors p-1 rounded hover:bg-white/5"
|
|
title="Settings"
|
|
aria-label="Open settings"
|
|
>
|
|
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
</header>
|
|
|
|
<main className="max-w-6xl mx-auto p-6 pb-24 grid grid-cols-1 lg:grid-cols-3 gap-6">
|
|
<div className="lg:col-span-1">
|
|
<Generator onGenerate={fetchClips} />
|
|
</div>
|
|
<div className="lg:col-span-2 space-y-4">
|
|
<Gallery
|
|
clips={clips}
|
|
onPlay={handlePlay}
|
|
onDelete={handleDelete}
|
|
onRename={handleRename}
|
|
onSelect={(clip) => setSelectedClip(clip)}
|
|
onUploadArtwork={handleUploadArtwork}
|
|
currentClipId={currentClip?.id}
|
|
/>
|
|
</div>
|
|
</main>
|
|
|
|
{currentClip && (
|
|
<Player
|
|
clip={currentClip}
|
|
isPlaying={isPlaying}
|
|
onTogglePlay={() => setIsPlaying(!isPlaying)}
|
|
onStop={() => setIsPlaying(false)}
|
|
onClose={() => {
|
|
setCurrentClip(null)
|
|
setIsPlaying(false)
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
{selectedClip && (
|
|
<DetailPanel
|
|
clip={selectedClip}
|
|
onClose={() => setSelectedClip(null)}
|
|
onRename={handleRename}
|
|
onDelete={handleDelete}
|
|
onUploadArtwork={handleUploadArtwork}
|
|
onRemoveArtwork={handleRemoveArtwork}
|
|
/>
|
|
)}
|
|
|
|
<SettingsModal
|
|
isOpen={settingsOpen}
|
|
onClose={() => setSettingsOpen(false)}
|
|
onClearWaveforms={handleClearWaveforms}
|
|
onDeleteAll={handleDeleteAll}
|
|
clipCount={clips.length}
|
|
/>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default App
|