liminal-sessions / src /components /ConfigPanel.tsx
Severian's picture
Upload 32 files
6a03d7f verified
import React, { useState, useEffect } from 'react'
import configLoader from '../utils/configLoader'
interface ConfigPanelProps {
visible: boolean
onToggle: () => void
}
export const ConfigPanel: React.FC<ConfigPanelProps> = ({ visible, onToggle }) => {
const [config, setConfig] = useState<any>(null)
const [activeSection, setActiveSection] = useState<string>('particles')
const [searchTerm, setSearchTerm] = useState<string>('')
useEffect(() => {
const loadConfig = async () => {
const loadedConfig = await configLoader.loadConfig()
setConfig(loadedConfig)
}
loadConfig()
}, [])
const updateConfig = (path: string, value: any) => {
if (!config) return
const pathArray = path.split('.')
const newConfig = { ...config }
let current = newConfig
// Navigate to the parent object
for (let i = 0; i < pathArray.length - 1; i++) {
current = current[pathArray[i]]
}
// Set the value
current[pathArray[pathArray.length - 1]] = value
setConfig(newConfig)
// Apply to configLoader (this would trigger re-render of visualizer)
configLoader['config'] = newConfig
console.log(`๐Ÿ”ง Config updated: ${path} = ${value}`)
}
const applyPreset = (presetName: string) => {
configLoader.applyPreset(presetName)
const newConfig = configLoader.getConfig()
setConfig(newConfig)
console.log(`๐ŸŽจ Applied preset: ${presetName}`)
}
const exportConfig = () => {
if (!config) return
const configBlob = new Blob([JSON.stringify(config, null, 2)], { type: 'application/json' })
const url = URL.createObjectURL(configBlob)
const a = document.createElement('a')
a.href = url
a.download = 'visualizer-config.json'
a.click()
URL.revokeObjectURL(url)
}
const reloadConfig = async () => {
const newConfig = await configLoader.reloadConfig()
setConfig(newConfig)
console.log('๐Ÿ”„ Config reloaded from file')
}
const renderConfigSection = (sectionData: any, basePath: string = '') => {
if (!sectionData) return null
return Object.keys(sectionData).map(key => {
const value = sectionData[key]
const fullPath = basePath ? `${basePath}.${key}` : key
// Filter by search term
if (searchTerm && !fullPath.toLowerCase().includes(searchTerm.toLowerCase()) &&
typeof value !== 'object') {
return null
}
if (typeof value === 'object' && !Array.isArray(value)) {
return (
<details key={fullPath} open={!basePath}>
<summary style={{
fontWeight: 'bold',
marginBottom: '8px',
cursor: 'pointer',
color: '#FFD700'
}}>
{key}
</summary>
<div style={{ marginLeft: '16px', borderLeft: '1px solid rgba(255, 215, 0, 0.3)', paddingLeft: '12px' }}>
{renderConfigSection(value, fullPath)}
</div>
</details>
)
}
return (
<div key={fullPath} style={{ marginBottom: '8px' }}>
<label style={{
display: 'block',
marginBottom: '4px',
fontSize: '11px',
color: '#C9B037'
}}>
{key}: <span style={{ opacity: 0.7 }}>({typeof value})</span>
</label>
{typeof value === 'boolean' ? (
<input
type="checkbox"
checked={value}
onChange={(e) => updateConfig(fullPath, e.target.checked)}
style={{ marginBottom: '4px' }}
/>
) : typeof value === 'number' ? (
<input
type="number"
value={value}
onChange={(e) => updateConfig(fullPath, parseFloat(e.target.value) || 0)}
step={value < 1 ? 0.001 : value < 10 ? 0.1 : 1}
style={{
width: '100%',
padding: '4px',
background: 'rgba(0, 0, 0, 0.5)',
color: '#FFD700',
border: '1px solid rgba(255, 215, 0, 0.3)',
borderRadius: '3px',
fontSize: '10px'
}}
/>
) : typeof value === 'string' ? (
value.startsWith('#') ? (
<input
type="color"
value={value}
onChange={(e) => updateConfig(fullPath, e.target.value)}
style={{ width: '100%', height: '24px' }}
/>
) : (
<input
type="text"
value={value}
onChange={(e) => updateConfig(fullPath, e.target.value)}
style={{
width: '100%',
padding: '4px',
background: 'rgba(0, 0, 0, 0.5)',
color: '#FFD700',
border: '1px solid rgba(255, 215, 0, 0.3)',
borderRadius: '3px',
fontSize: '10px'
}}
/>
)
) : Array.isArray(value) ? (
<textarea
value={JSON.stringify(value, null, 2)}
onChange={(e) => {
try {
updateConfig(fullPath, JSON.parse(e.target.value))
} catch (err) {
console.warn('Invalid JSON in array field:', err)
}
}}
rows={3}
style={{
width: '100%',
padding: '4px',
background: 'rgba(0, 0, 0, 0.5)',
color: '#FFD700',
border: '1px solid rgba(255, 215, 0, 0.3)',
borderRadius: '3px',
fontSize: '9px',
fontFamily: 'monospace'
}}
/>
) : (
<span style={{ fontSize: '10px', opacity: 0.7 }}>
Complex object - edit in JSON
</span>
)}
</div>
)
}).filter(Boolean)
}
if (!visible || !config) return null
const sections = [
{ key: 'particles', label: 'Particles', data: config.visualization?.particles },
{ key: 'spheres', label: 'Spheres', data: config.visualization?.spheres },
{ key: 'physics', label: 'Physics', data: config.visualization?.physics },
{ key: 'rotation', label: 'Rotation', data: config.visualization?.rotation },
{ key: 'audio', label: 'Audio', data: config.audio },
{ key: 'entronaut', label: 'Entronaut SEFA', data: config.entronaut },
{ key: 'tendrils', label: 'Tendrils', data: config.tendrils },
{ key: 'cymatics', label: 'Cymatics', data: config.cymatics },
{ key: 'colors', label: 'Colors', data: config.colors },
{ key: 'camera', label: 'Camera', data: config.camera },
{ key: 'fog', label: 'Fog', data: config.fog },
{ key: 'rendering', label: 'Rendering', data: config.rendering },
{ key: 'performance', label: 'Performance', data: config.performance }
]
return (
<>
{/* Toggle Button */}
<button
onClick={onToggle}
style={{
position: 'fixed',
top: '20px',
left: '20px',
zIndex: 10001,
background: 'rgba(0, 0, 0, 0.8)',
border: '1px solid rgba(255, 215, 0, 0.5)',
borderRadius: '4px',
color: '#FFD700',
padding: '8px 12px',
cursor: 'pointer',
fontSize: '12px',
fontFamily: 'Cinzel, serif'
}}
title="Toggle Config Panel"
>
โš™๏ธ Config
</button>
{/* Config Panel */}
<div
style={{
position: 'fixed',
top: '0',
left: '0',
width: '400px',
height: '100vh',
background: 'rgba(0, 0, 0, 0.95)',
color: '#FFD700',
fontFamily: 'Cinzel, serif',
zIndex: 10000,
overflowY: 'auto',
borderRight: '2px solid rgba(255, 215, 0, 0.3)',
backdropFilter: 'blur(10px)',
pointerEvents: 'auto'
}}
onMouseEnter={() => {
// Disable camera controls when mouse enters config panel
const event = new CustomEvent('disableCameraControls', { detail: true })
window.dispatchEvent(event)
}}
onMouseLeave={() => {
// Re-enable camera controls when mouse leaves config panel
const event = new CustomEvent('disableCameraControls', { detail: false })
window.dispatchEvent(event)
}}
>
<div style={{ padding: '20px' }}>
{/* Header */}
<div style={{ marginBottom: '20px' }}>
<h2 style={{ margin: '0 0 10px 0', fontSize: '18px' }}>
๐Ÿ› ๏ธ Developer Config
</h2>
<div style={{ fontSize: '10px', opacity: 0.8, marginBottom: '15px' }}>
Live configuration editor
</div>
{/* Search */}
<input
type="text"
placeholder="Search parameters..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
style={{
width: '100%',
padding: '6px',
background: 'rgba(0, 0, 0, 0.5)',
color: '#FFD700',
border: '1px solid rgba(255, 215, 0, 0.3)',
borderRadius: '4px',
fontSize: '11px',
marginBottom: '15px'
}}
/>
{/* Action Buttons */}
<div style={{
display: 'grid',
gridTemplateColumns: '1fr 1fr',
gap: '8px',
marginBottom: '15px'
}}>
<button
onClick={exportConfig}
style={{
background: 'rgba(255, 215, 0, 0.2)',
border: '1px solid rgba(255, 215, 0, 0.4)',
color: '#FFD700',
padding: '6px',
borderRadius: '4px',
fontSize: '10px',
cursor: 'pointer'
}}
>
๐Ÿ’พ Export
</button>
<button
onClick={reloadConfig}
style={{
background: 'rgba(255, 215, 0, 0.2)',
border: '1px solid rgba(255, 215, 0, 0.4)',
color: '#FFD700',
padding: '6px',
borderRadius: '4px',
fontSize: '10px',
cursor: 'pointer'
}}
>
๐Ÿ”„ Reload
</button>
</div>
{/* Presets */}
{config.presets && Object.keys(config.presets).length > 0 && (
<div style={{ marginBottom: '15px' }}>
<div style={{ fontSize: '11px', marginBottom: '5px' }}>Presets:</div>
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gap: '4px' }}>
{Object.keys(config.presets).map(presetName => (
<button
key={presetName}
onClick={() => applyPreset(presetName)}
style={{
background: 'rgba(255, 215, 0, 0.1)',
border: '1px solid rgba(255, 215, 0, 0.3)',
color: '#FFD700',
padding: '4px',
borderRadius: '3px',
fontSize: '9px',
cursor: 'pointer'
}}
>
{presetName}
</button>
))}
</div>
</div>
)}
</div>
{/* Section Tabs */}
<div style={{
display: 'flex',
flexWrap: 'wrap',
gap: '4px',
marginBottom: '15px',
borderBottom: '1px solid rgba(255, 215, 0, 0.3)',
paddingBottom: '10px'
}}>
{sections.map(section => (
<button
key={section.key}
onClick={() => setActiveSection(section.key)}
style={{
background: activeSection === section.key ? 'rgba(255, 215, 0, 0.3)' : 'rgba(255, 215, 0, 0.1)',
border: '1px solid rgba(255, 215, 0, 0.4)',
color: '#FFD700',
padding: '4px 8px',
borderRadius: '3px',
fontSize: '9px',
cursor: 'pointer'
}}
>
{section.label}
</button>
))}
</div>
{/* Active Section Content */}
<div style={{ fontSize: '11px' }}>
{(() => {
const activeData = sections.find(s => s.key === activeSection)?.data
if (!activeData) return <div>Section not found</div>
return (
<div>
<h3 style={{
margin: '0 0 15px 0',
fontSize: '14px',
color: '#FFD700',
borderBottom: '1px solid rgba(255, 215, 0, 0.2)',
paddingBottom: '5px'
}}>
{sections.find(s => s.key === activeSection)?.label}
</h3>
{renderConfigSection(activeData, `${activeSection === 'audio' || activeSection === 'entronaut' || activeSection === 'tendrils' || activeSection === 'cymatics' || activeSection === 'colors' || activeSection === 'camera' || activeSection === 'fog' || activeSection === 'rendering' || activeSection === 'performance' ? activeSection : `visualization.${activeSection}`}`)}
</div>
)
})()}
</div>
</div>
</div>
</>
)
}