- 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
363 lines
13 KiB
TypeScript
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>
|
|
)
|
|
}
|