Files
kithara-studio/frontend/src/components/SettingsModal.tsx
T
rin.kagamine 816f8fbd2f refactor: rename project Lyria Studio → Kithara Studio
- App name, UI text, page titles → Kithara Studio
- Python module: lyria_client.py → kithara_client.py
- Tailwind color classes: lyria-* → kithara-*
- CSS custom properties: --lyria-* → --kithara-*
- Database: lyria.db → kithara.db
- Package name: kithara-studio-frontend
- localStorage key: kithara-saved-searches
- Add project logo (lyre icon, black on transparent, invert via CSS)

Preserved as Google's property:
- LYRIA_API_KEY, LYRIA_BASE_URL, LYRIA_DEFAULT_MODEL env vars
- LYRIA_MODELS dict and model IDs (lyria-3-clip-preview, lyria-3-pro-preview)
- Google Lyria API references in error messages and docstrings
2026-05-31 09:45:22 -04:00

363 lines
13 KiB
TypeScript

import { useState, useEffect } from 'react'
interface GenConfig {
model: string
has_api_key: boolean
safety_harassment: string
safety_hate_speech: string
safety_sexually_explicit: string
safety_dangerous: string
}
interface GenModel {
id: string
name: string
description: string
status: string
}
interface Props {
isOpen: boolean
onClose: () => void
onClearWaveforms: () => Promise<void>
onDeleteAll: () => Promise<void>
clipCount: number
}
const SAFETY_OPTIONS = [
{ value: 'OFF', label: 'Off' },
{ value: 'BLOCK_NONE', label: 'Block None' },
{ value: 'BLOCK_ONLY_HIGH', label: 'Block Few (High only)' },
{ value: 'BLOCK_MEDIUM_AND_ABOVE', label: 'Block Some (Medium+)' },
{ value: 'BLOCK_LOW_AND_ABOVE', label: 'Block Most (Low+)' },
]
function useGenSettings(apiPrefix: string) {
const [models, setModels] = useState<GenModel[]>([])
const [config, setConfig] = useState<GenConfig | null>(null)
const [edited, setEdited] = useState<Partial<GenConfig>>({})
const [expanded, setExpanded] = useState(false)
const [saving, setSaving] = useState(false)
const [saveMessage, setSaveMessage] = useState('')
const fetchModels = async () => {
try {
const res = await fetch(`/api/${apiPrefix}/models`)
if (res.ok) {
const data = await res.json()
setModels(data.models)
}
} catch (e) {
console.error(`Failed to fetch ${apiPrefix} models:`, e)
}
}
const fetchConfig = async () => {
try {
const res = await fetch(`/api/${apiPrefix}/config`)
if (res.ok) {
const data = await res.json()
setConfig(data)
}
} catch (e) {
console.error(`Failed to fetch ${apiPrefix} config:`, e)
}
}
const save = async () => {
if (!edited || Object.keys(edited).length === 0) return
setSaving(true)
try {
const res = await fetch(`/api/${apiPrefix}/config`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(edited),
})
if (res.ok) {
setSaveMessage('Settings saved!')
setConfig(prev => prev ? { ...prev, ...edited } : prev)
setEdited({})
setTimeout(() => setSaveMessage(''), 2000)
} else {
setSaveMessage('Failed to save')
}
} catch (e) {
setSaveMessage('Failed to save')
} finally {
setSaving(false)
}
}
const update = (key: string, value: string) => {
setEdited(prev => ({ ...prev, [key]: value }))
setSaveMessage('')
}
const getValue = (key: keyof GenConfig): string => {
const val = edited[key] ?? config?.[key]
return typeof val === 'string' ? val : 'BLOCK_ONLY_HIGH'
}
const hasChanges = Object.keys(edited).length > 0
return {
models, config, edited, expanded, setExpanded,
saving, saveMessage, fetchModels, fetchConfig,
save, update, getValue, hasChanges,
}
}
export default function SettingsModal({ isOpen, onClose, onClearWaveforms, onDeleteAll, clipCount }: Props) {
const [confirmDelete, setConfirmDelete] = useState(false)
const [loading, setLoading] = useState(false)
const [dangerExpanded, setDangerExpanded] = useState(false)
const imageGen = useGenSettings('image-gen')
const textGen = useGenSettings('text-gen')
useEffect(() => {
if (isOpen) {
imageGen.fetchModels()
imageGen.fetchConfig()
textGen.fetchModels()
textGen.fetchConfig()
}
}, [isOpen])
if (!isOpen) return null
const handleClearWaveforms = async () => {
setLoading(true)
try {
await onClearWaveforms()
} finally {
setLoading(false)
}
}
const handleDeleteAll = async () => {
if (!confirmDelete) {
setConfirmDelete(true)
return
}
setLoading(true)
try {
await onDeleteAll()
setConfirmDelete(false)
} finally {
setLoading(false)
}
}
const renderGenSection = (
title: string,
icon: React.ReactNode,
gen: ReturnType<typeof useGenSettings>
) => (
<div className="border border-white/10 rounded-lg overflow-hidden">
<button
onClick={() => gen.setExpanded(!gen.expanded)}
className="w-full flex items-center justify-between px-4 py-3 bg-white/5 hover:bg-white/10 transition-colors"
>
<div className="flex items-center gap-2">
{icon}
<span className="text-sm font-medium text-white">{title}</span>
</div>
<svg
className={`w-4 h-4 text-kithara-muted transition-transform ${gen.expanded ? 'rotate-180' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{gen.expanded && (
<div className="p-4 space-y-4">
{/* Model Selection */}
<div>
<label className="block text-xs text-kithara-muted mb-1.5">Model</label>
<select
className="w-full bg-black/30 border border-white/10 rounded-md px-3 py-2 text-sm text-white focus:outline-none focus:border-kithara-accent"
value={gen.getValue('model')}
onChange={(e) => gen.update('model', e.target.value)}
>
{gen.models.map((model) => (
<option key={model.id} value={model.id}>
{model.name} ({model.status})
</option>
))}
</select>
</div>
{/* API Key Status */}
<div className="flex items-center gap-2">
<div className={`w-2 h-2 rounded-full ${gen.config?.has_api_key ? 'bg-green-400' : 'bg-red-400'}`} />
<span className="text-xs text-kithara-muted">
{gen.config?.has_api_key ? 'API key configured' : 'No API key — set in .env'}
</span>
</div>
{/* Safety Settings */}
<div className="space-y-3">
<p className="text-xs font-medium text-white">Safety Filters</p>
{[
{ key: 'safety_harassment', label: 'Harassment' },
{ key: 'safety_hate_speech', label: 'Hate Speech' },
{ key: 'safety_sexually_explicit', label: 'Sexually Explicit' },
{ key: 'safety_dangerous', label: 'Dangerous Content' },
].map(({ key, label }) => (
<div key={key}>
<label className="block text-xs text-kithara-muted mb-1">{label}</label>
<select
className="w-full bg-black/30 border border-white/10 rounded-md px-3 py-1.5 text-sm text-white focus:outline-none focus:border-kithara-accent"
value={gen.getValue(key as keyof GenConfig)}
onChange={(e) => gen.update(key, e.target.value)}
>
{SAFETY_OPTIONS.map((opt) => (
<option key={opt.value} value={opt.value}>{opt.label}</option>
))}
</select>
</div>
))}
</div>
{/* Save Button */}
<div className="flex items-center gap-3 pt-2">
<button
onClick={gen.save}
disabled={!gen.hasChanges || gen.saving}
className="px-4 py-2 text-sm font-medium rounded-md transition-colors disabled:opacity-50 bg-kithara-accent text-black hover:bg-kithara-accent/90"
>
{gen.saving ? 'Saving...' : 'Save Changes'}
</button>
{gen.saveMessage && (
<span className={`text-xs ${gen.saveMessage.includes('Failed') ? 'text-red-400' : 'text-green-400'}`}>
{gen.saveMessage}
</span>
)}
</div>
<p className="text-xs text-kithara-muted/60">
Changes apply immediately but reset on server restart. Set .env for persistence.
</p>
</div>
)}
</div>
)
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* Backdrop */}
<div
className="absolute inset-0 bg-black/80 backdrop-blur-md"
onClick={onClose}
/>
{/* Modal */}
<div className="relative bg-kithara-surface border border-white/10 rounded-xl shadow-2xl w-full max-w-md mx-4 overflow-hidden max-h-[80vh] flex flex-col">
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b border-white/10 shrink-0">
<h2 className="text-lg font-semibold text-white">Settings</h2>
<button
onClick={onClose}
className="text-kithara-muted hover:text-white transition-colors"
aria-label="Close settings"
>
<svg className="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
</div>
{/* Content */}
<div className="px-6 py-5 space-y-6 overflow-y-auto">
{/* Image Generation */}
{renderGenSection('Image Generation', (
<svg className="w-4 h-4 text-kithara-accent" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
), imageGen)}
{/* Text Generation */}
{renderGenSection('Text Generation', (
<svg className="w-4 h-4 text-kithara-accent" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z" />
</svg>
), textGen)}
{/* Danger Zone */}
<div className="border border-red-500/30 rounded-lg overflow-hidden">
<button
onClick={() => setDangerExpanded(!dangerExpanded)}
className="w-full flex items-center justify-between px-4 py-3 bg-red-500/10 hover:bg-red-500/20 transition-colors"
>
<div className="flex items-center gap-2">
<svg className="w-4 h-4 text-red-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
</svg>
<span className="text-sm font-medium text-red-400">Danger Zone</span>
</div>
<svg
className={`w-4 h-4 text-red-400 transition-transform ${dangerExpanded ? 'rotate-180' : ''}`}
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" />
</svg>
</button>
{dangerExpanded && (
<div className="p-4 space-y-4">
{/* Clear Waveforms */}
<div className="flex items-start gap-4">
<div className="flex-1 min-w-0">
<p className="text-sm text-white font-medium">Clear Waveform Cache</p>
<p className="text-xs text-kithara-muted mt-0.5">Delete all cached SVGs. They regenerate on next view.</p>
</div>
<button
onClick={handleClearWaveforms}
disabled={loading}
className="shrink-0 px-3 py-1.5 text-sm rounded bg-white/5 text-white hover:bg-white/10 disabled:opacity-50 transition-colors border border-white/10"
>
Clear
</button>
</div>
<hr className="border-white/5" />
{/* Delete All */}
<div className="flex items-start gap-4">
<div className="flex-1 min-w-0">
<p className="text-sm text-white font-medium">Delete All Tracks</p>
<p className="text-xs text-kithara-muted mt-0.5">
Remove {clipCount} clip{clipCount !== 1 ? 's' : ''}. Cannot be undone.
</p>
{confirmDelete && (
<p className="text-xs text-red-400 mt-1.5">
Click again to confirm permanent deletion.
</p>
)}
</div>
<button
onClick={handleDeleteAll}
disabled={loading || clipCount === 0}
className={`shrink-0 px-3 py-1.5 text-sm rounded transition-colors border whitespace-nowrap ${
confirmDelete
? 'bg-red-500 text-white hover:bg-red-600 border-red-500'
: 'bg-white/5 text-red-400 hover:bg-red-500/10 border-red-500/30'
} disabled:opacity-30`}
>
{confirmDelete ? 'Confirm' : 'Delete All'}
</button>
</div>
</div>
)}
</div>
</div>
</div>
</div>
)
}