Spaces:
Sleeping
Sleeping
| 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> | |
| </> | |
| ) | |
| } |