// Lighting Controller Web App class LightingController { constructor() { this.currentTab = null; this.state = { lights: {}, patterns: {}, tab_order: [], presets: {} }; this.selectedColorIndex = 0; this.updateTimeouts = {}; this.quickPaletteContext = null; this.init(); } async init() { await this.loadState(); this.setupEventListeners(); this.renderTabs(); if (this.state.tab_order.length > 0) { this.selectTab(this.state.tab_order[0]); } } async loadState() { try { // Load presets and profiles separately since /api/state doesn't exist const presetsResponse = await fetch('/presets'); const presets = await presetsResponse.json(); this.state.presets = presets || {}; const patternsResponse = await fetch('/patterns'); if (patternsResponse.ok) { const patterns = await patternsResponse.json(); this.state.patterns = patterns || {}; } // Load profiles const profilesResponse = await fetch('/profiles'); const profiles = await profilesResponse.json(); // Update current profile display this.updateCurrentProfileDisplay(); // Update current profile display if profiles modal is open if (document.getElementById('profiles-modal').classList.contains('active')) { await this.loadProfiles(); await this.loadProfilePalette(); } } catch (error) { console.error('Failed to load state:', error); } } updateCurrentProfileDisplay() { const currentProfileName = document.getElementById('current-profile-name'); if (currentProfileName) { const profile = this.state.current_profile || 'None'; currentProfileName.textContent = profile; } } setupEventListeners() { // Tab management document.getElementById('add-tab-btn').addEventListener('click', () => this.showAddTabModal()); document.getElementById('edit-tab-btn').addEventListener('click', () => this.showEditTabModal()); document.getElementById('delete-tab-btn').addEventListener('click', () => this.deleteCurrentTab()); document.getElementById('color-palette-btn').addEventListener('click', () => this.showColorPalette()); document.getElementById('presets-btn').addEventListener('click', () => this.showPresets()); document.getElementById('profiles-btn').addEventListener('click', () => this.showProfiles()); // Modal actions document.getElementById('add-tab-confirm').addEventListener('click', () => this.createTab()); document.getElementById('add-tab-cancel').addEventListener('click', () => this.hideModal('add-tab-modal')); document.getElementById('edit-tab-confirm').addEventListener('click', () => this.updateTab()); document.getElementById('edit-tab-cancel').addEventListener('click', () => this.hideModal('edit-tab-modal')); document.getElementById('profiles-close-btn').addEventListener('click', () => this.hideModal('profiles-modal')); document.getElementById('color-palette-close-btn').addEventListener('click', () => this.hideModal('color-palette-modal')); document.getElementById('presets-close-btn').addEventListener('click', () => this.hideModal('presets-modal')); document.getElementById('create-preset-btn').addEventListener('click', () => this.showPresetEditor()); document.getElementById('preset-editor-save-btn').addEventListener('click', () => this.savePreset()); document.getElementById('preset-editor-cancel-btn').addEventListener('click', () => this.hideModal('preset-editor-modal')); document.getElementById('preset-add-color-btn').addEventListener('click', () => this.addPresetColor()); document.getElementById('preset-remove-color-btn').addEventListener('click', () => this.removePresetColor()); document.getElementById('preset-editor-from-current-btn').addEventListener('click', () => this.loadCurrentTabToPresetEditor()); document.getElementById('preset-brightness-slider').addEventListener('input', (e) => { document.getElementById('preset-brightness-value').textContent = e.target.value; }); document.getElementById('quick-palette-close-btn').addEventListener('click', () => this.hideQuickPaletteModal()); document.getElementById('quick-palette-use-picker-btn').addEventListener('click', () => this.useColorPickerFromQuickPalette()); document.getElementById('create-profile-btn').addEventListener('click', () => this.createProfile()); document.getElementById('add-palette-color-btn').addEventListener('click', () => this.addPaletteColor()); document.getElementById('palette-add-color-btn').addEventListener('click', () => this.addPaletteColorFromModal()); document.getElementById('toggle-left-panel').addEventListener('click', () => this.toggleLeftPanel()); // Enter key for new profile name document.getElementById('new-profile-name').addEventListener('keypress', (e) => { if (e.key === 'Enter') { this.createProfile(); } }); // Brightness and delay sliders document.getElementById('brightness-slider').addEventListener('input', (e) => { document.getElementById('brightness-value').textContent = e.target.value; this.debounceUpdate('brightness', () => this.updateBrightness()); }); document.getElementById('delay-slider').addEventListener('input', (e) => { this.updateDelayValue(e.target.value); this.debounceUpdate('delay', () => this.updateDelay()); }); // N parameters for (let i = 1; i <= 8; i++) { document.getElementById(`n${i}-input`).addEventListener('input', (e) => { this.debounceUpdate('nparams', () => this.updateNParams()); }); } // Color palette document.getElementById('add-color-btn').addEventListener('click', () => this.addColorToPalette()); document.getElementById('remove-color-btn').addEventListener('click', () => this.removeSelectedColor()); } renderTabs() { const tabsList = document.getElementById('tabs-list'); tabsList.innerHTML = ''; this.state.tab_order.forEach(tabName => { const tabButton = document.createElement('button'); tabButton.className = 'tab-button'; tabButton.textContent = tabName; tabButton.addEventListener('click', () => this.selectTab(tabName)); if (tabName === this.currentTab) { tabButton.classList.add('active'); } tabsList.appendChild(tabButton); }); } toggleLeftPanel() { const leftPanel = document.querySelector('.left-panel'); if (!leftPanel) return; leftPanel.classList.toggle('collapsed'); } async selectTab(tabName) { if (!this.state.lights[tabName]) return; this.currentTab = tabName; this.renderTabs(); await this.loadTabContent(tabName); } async loadTabContent(tabName) { const light = this.state.lights[tabName]; if (!light) { return; } const settings = light.settings; const pattern = settings.pattern || 'on'; // Get pattern-specific settings const patternSettings = this.getPatternSettings(tabName, pattern); // Update IDs display document.getElementById('current-ids').textContent = light.names.join(', '); // Load and render colors in the palette const colors = patternSettings.colors || ['#000000']; this.renderColorPalette(tabName, colors); // Update brightness slider const brightness = settings.brightness || 127; document.getElementById('brightness-slider').value = brightness; document.getElementById('brightness-value').textContent = brightness; // Update delay slider const patternConfig = this.state.patterns[pattern] || {}; const minDelay = patternConfig.min_delay || 10; const maxDelay = patternConfig.max_delay || 10000; const delaySliderPos = this.delayToSlider(patternSettings.delay, minDelay, maxDelay); document.getElementById('delay-slider').value = delaySliderPos; this.updateDelayValue(delaySliderPos, minDelay, maxDelay); // Update n parameters for (let i = 1; i <= 8; i++) { document.getElementById(`n${i}-input`).value = patternSettings[`n${i}`] || 10; } this.updateNParamLabels(pattern); // Render presets this.renderPresets(tabName); } updateNParamLabels(patternName) { const defaultLabels = {}; for (let i = 1; i <= 8; i++) { defaultLabels[`n${i}`] = `n${i}:`; } const patternConfig = this.state.patterns ? this.state.patterns[patternName] : null; if (patternConfig && typeof patternConfig === 'object') { Object.entries(patternConfig).forEach(([label, key]) => { if (typeof key === 'string' && key.startsWith('n')) { defaultLabels[key] = `${label}:`; } }); } for (let i = 1; i <= 8; i++) { const labelEl = document.getElementById(`n${i}-label`); if (labelEl) { labelEl.textContent = defaultLabels[`n${i}`]; } } } renderPresets(tabName) { const presetsList = document.getElementById('presets-list-tab'); presetsList.innerHTML = ''; const presets = this.state.presets || {}; const presetNames = Object.keys(presets); // Get current tab's settings for comparison const currentSettings = this.getCurrentTabSettings(tabName); // Always include "on" and "off" presets const defaultPresets = { 'on': { pattern: 'on', colors: ['#FFFFFF'], brightness: 255, delay: 100, n1: 10, n2: 10, n3: 10, n4: 10, n5: 10, n6: 10, n7: 10, n8: 10 }, 'off': { pattern: 'off', colors: ['#000000'], brightness: 0, delay: 100, n1: 10, n2: 10, n3: 10, n4: 10, n5: 10, n6: 10, n7: 10, n8: 10 } }; // Create a combined list with default presets first, then user presets const allPresets = { ...defaultPresets, ...presets }; const allPresetNames = ['on', 'off', ...presetNames.filter(name => name !== 'on' && name !== 'off')]; allPresetNames.forEach(presetName => { const preset = allPresets[presetName]; const presetButton = document.createElement('button'); presetButton.className = 'pattern-button'; // Check if this preset matches the current tab's settings const isActive = this.presetMatchesSettings(preset, currentSettings); if (isActive) { presetButton.classList.add('active'); } // Mark default presets (on/off) with a special indicator const isDefault = presetName === 'on' || presetName === 'off'; if (isDefault) { presetButton.classList.add('default-preset'); } // Create preset info display const presetInfo = document.createElement('div'); presetInfo.style.cssText = 'display: flex; flex-direction: column; align-items: flex-start; width: 100%;'; const presetNameLabel = document.createElement('span'); presetNameLabel.textContent = presetName; presetNameLabel.style.fontWeight = 'bold'; presetNameLabel.style.marginBottom = '0.25rem'; const presetDetails = document.createElement('span'); presetDetails.style.fontSize = '0.85em'; presetDetails.style.color = '#aaa'; presetDetails.textContent = `${preset.pattern} • ${preset.colors.length} color${preset.colors.length !== 1 ? 's' : ''}`; presetInfo.appendChild(presetNameLabel); presetInfo.appendChild(presetDetails); presetButton.appendChild(presetInfo); presetButton.addEventListener('click', () => { if (isDefault && !presets[presetName]) { // Apply default preset directly this.applyDefaultPreset(tabName, presetName, preset); } else { // Apply regular preset this.applyPresetToTab(tabName, presetName); } }); presetsList.appendChild(presetButton); }); } async applyDefaultPreset(tabName, presetName, preset) { // Apply default preset by setting pattern and parameters directly try { // First set the pattern const patternResponse = await fetch('/api/pattern', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ tab_name: tabName, pattern: preset.pattern, delay: preset.delay, colors: preset.colors, n1: preset.n1, n2: preset.n2, n3: preset.n3, n4: preset.n4, n5: preset.n5, n6: preset.n6, n7: preset.n7, n8: preset.n8 }) }); if (patternResponse.ok) { // Then update brightness await fetch('/api/parameters', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ tab_name: tabName, brightness: preset.brightness }) }); // Reload state and tab content await this.loadState(); await this.loadTabContent(tabName); } else { const error = await patternResponse.json(); alert(`Failed to apply preset: ${error.error || 'Unknown error'}`); } } catch (error) { console.error('Failed to apply default preset:', error); alert('Failed to apply preset'); } } getCurrentTabSettings(tabName) { if (!this.state.lights[tabName]) { return null; } const light = this.state.lights[tabName]; const settings = light.settings; const pattern = settings.pattern || 'on'; const patternSettings = this.getPatternSettings(tabName, pattern); return { pattern: pattern, brightness: settings.brightness || 127, delay: patternSettings.delay || 100, colors: patternSettings.colors || ['#000000'], n1: patternSettings.n1 || 10, n2: patternSettings.n2 || 10, n3: patternSettings.n3 || 10, n4: patternSettings.n4 || 10, n5: patternSettings.n5 || 10, n6: patternSettings.n6 || 10, n7: patternSettings.n7 || 10, n8: patternSettings.n8 || 10 }; } presetMatchesSettings(preset, currentSettings) { if (!currentSettings) { return false; } // Compare all settings if (preset.pattern !== currentSettings.pattern) { return false; } if (preset.brightness !== currentSettings.brightness) { return false; } if (preset.delay !== currentSettings.delay) { return false; } // Compare n values for (let i = 1; i <= 8; i++) { if (preset[`n${i}`] !== currentSettings[`n${i}`]) { return false; } } // Compare colors (order matters for presets) if (preset.colors.length !== currentSettings.colors.length) { return false; } for (let i = 0; i < preset.colors.length; i++) { // Normalize color format (uppercase, no spaces) const presetColor = preset.colors[i].toUpperCase().trim(); const currentColor = currentSettings.colors[i].toUpperCase().trim(); if (presetColor !== currentColor) { return false; } } return true; } async applyPresetToTab(tabName, presetName) { try { const preset = this.state.presets[presetName]; if (!preset) { alert('Preset not found'); return; } // Apply preset using the apply endpoint const response = await fetch(`/api/presets/${presetName}/apply`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ tab_name: tabName }) }); if (response.ok) { await this.loadState(); await this.loadTabContent(tabName); } else { const error = await response.json(); alert(`Failed to apply preset: ${error.error || 'Unknown error'}`); } } catch (error) { console.error('Failed to apply preset:', error); alert('Failed to apply preset'); } } renderColorPalette(tabName, colors) { const palette = document.getElementById('color-palette'); if (!palette) { return; } palette.innerHTML = ''; colors.forEach((hexColor, index) => { const swatch = document.createElement('div'); swatch.className = 'color-swatch'; if (index === this.selectedColorIndex) { swatch.classList.add('selected'); } const preview = document.createElement('div'); preview.className = 'color-swatch-preview'; preview.style.backgroundColor = hexColor; const label = document.createElement('span'); label.className = 'color-swatch-label'; label.textContent = `Color ${index + 1}`; // Color picker input with palette quick-select const colorPickerWrapper = document.createElement('div'); colorPickerWrapper.style.cssText = 'position: relative; display: inline-block;'; const colorPicker = document.createElement('input'); colorPicker.type = 'color'; colorPicker.value = hexColor; colorPicker.className = 'color-picker-input'; colorPicker.dataset.tabName = tabName; colorPicker.dataset.colorIndex = index; colorPicker.addEventListener('change', (e) => { const newColor = e.target.value; this.updateColorInPalette(tabName, index, newColor); }); // Show quick palette on click, prevent native picker if palette has colors const clickHandler = (e) => { e.stopPropagation(); const palette = this.state.color_palette || []; // Check if we're allowing native picker (set by "Use Color Picker" button) if (palette.length > 0 && !colorPicker.dataset.allowNative) { e.preventDefault(); this.showPaletteQuickSelect(colorPickerWrapper, tabName, index, colorPicker); } // If no palette colors or allowNative is set, let native picker open }; colorPicker.addEventListener('click', clickHandler); // Store handler reference for later removal if needed colorPicker._clickHandler = clickHandler; colorPickerWrapper.appendChild(colorPicker); swatch.appendChild(preview); swatch.appendChild(label); swatch.appendChild(colorPickerWrapper); swatch.addEventListener('click', (e) => { // Don't trigger selection if clicking on the color picker if (e.target !== colorPicker && !colorPicker.contains(e.target)) { this.selectColor(tabName, index, hexColor); } }); palette.appendChild(swatch); }); } getPatternSettings(tabName, patternName) { const light = this.state.lights[tabName]; if (!light) return { colors: ['#000000'], delay: 100, n1: 10, n2: 10, n3: 10, n4: 10, n5: 10, n6: 10, n7: 10, n8: 10 }; const lightSettings = light.settings; if (!lightSettings.patterns) lightSettings.patterns = {}; if (!lightSettings.patterns[patternName]) lightSettings.patterns[patternName] = {}; const patternSettings = lightSettings.patterns[patternName]; // Fall back to global colors if pattern-specific colors don't exist const globalColors = lightSettings.colors || ['#000000']; return { colors: patternSettings.colors || globalColors, delay: patternSettings.delay || lightSettings.delay || 100, n1: patternSettings.n1 || lightSettings.n1 || 10, n2: patternSettings.n2 || lightSettings.n2 || 10, n3: patternSettings.n3 || lightSettings.n3 || 10, n4: patternSettings.n4 || lightSettings.n4 || 10, n5: patternSettings.n5 || lightSettings.n5 || 10, n6: patternSettings.n6 || lightSettings.n6 || 10, n7: patternSettings.n7 || lightSettings.n7 || 10, n8: patternSettings.n8 || lightSettings.n8 || 10 }; } async setPattern(tabName, patternName) { const patternSettings = this.getPatternSettings(tabName, patternName); try { const response = await fetch('/api/pattern', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ tab_name: tabName, pattern: patternName, delay: patternSettings.delay, colors: patternSettings.colors, n1: patternSettings.n1, n2: patternSettings.n2, n3: patternSettings.n3, n4: patternSettings.n4, n5: patternSettings.n5, n6: patternSettings.n6, n7: patternSettings.n7, n8: patternSettings.n8 }) }); if (response.ok) { // Update the pattern immediately in the state if (this.state.lights[tabName]) { this.state.lights[tabName].settings.pattern = patternName; } // Reload state from server to ensure consistency await this.loadState(); // Reload tab content to update UI await this.loadTabContent(tabName); } else { const errorText = await response.text(); console.error('Failed to set pattern:', errorText); alert(`Failed to set pattern: ${errorText}`); } } catch (error) { console.error('Failed to set pattern:', error); } } selectColor(tabName, index, hexColor) { this.selectedColorIndex = index; if (this.state.lights[tabName]) { const pattern = this.state.lights[tabName].settings.pattern; const patternSettings = this.getPatternSettings(tabName, pattern); this.renderColorPalette(tabName, patternSettings.colors); } } async updateColorInPalette(tabName, index, hexColor) { if (!this.currentTab || tabName !== this.currentTab) return; const pattern = this.state.lights[tabName].settings.pattern; const patternSettings = this.getPatternSettings(tabName, pattern); patternSettings.colors[index] = hexColor; // Update the preview const palette = document.getElementById('color-palette'); if (palette && palette.children[index]) { const preview = palette.children[index].querySelector('.color-swatch-preview'); if (preview) { preview.style.backgroundColor = hexColor; } } // Send update to backend to persist changes const rgb = this.hexToRgb(hexColor); try { const response = await fetch('/api/parameters', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ tab_name: this.currentTab, red: rgb.r, green: rgb.g, blue: rgb.b, color_index: index }) }); if (response.ok) { // Reload state to ensure persistence await this.loadState(); // Re-render palette to reflect persisted state const updatedPatternSettings = this.getPatternSettings(tabName, pattern); this.renderColorPalette(tabName, updatedPatternSettings.colors); } } catch (error) { console.error('Failed to update color:', error); } } async updateBrightness() { if (!this.currentTab) return; const brightness = parseInt(document.getElementById('brightness-slider').value) || 0; try { await fetch('/api/parameters', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ tab_name: this.currentTab, brightness: brightness }) }); } catch (error) { console.error('Failed to update brightness:', error); } } async updateDelay() { if (!this.currentTab) return; const sliderValue = parseInt(document.getElementById('delay-slider').value); const pattern = this.state.lights[this.currentTab].settings.pattern; const patternConfig = this.state.patterns[pattern] || {}; const minDelay = patternConfig.min_delay || 10; const maxDelay = patternConfig.max_delay || 10000; try { await fetch('/api/parameters', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ tab_name: this.currentTab, delay_slider: sliderValue }) }); } catch (error) { console.error('Failed to update delay:', error); } } async updateNParams() { if (!this.currentTab) return; const nParams = {}; for (let i = 1; i <= 8; i++) { nParams[`n${i}`] = parseInt(document.getElementById(`n${i}-input`).value) || 0; } try { await fetch('/api/parameters', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ tab_name: this.currentTab, ...nParams }) }); } catch (error) { console.error('Failed to update n params:', error); } } debounceUpdate(key, callback) { if (this.updateTimeouts[key]) { clearTimeout(this.updateTimeouts[key]); } this.updateTimeouts[key] = setTimeout(callback, 100); } delayToSlider(delayMs, minDelay = 10, maxDelay = 10000) { if (delayMs <= minDelay) return 0; if (delayMs >= maxDelay) return 1000; if (minDelay === maxDelay) return 0; return Math.floor(1000 * Math.log(delayMs / minDelay) / Math.log(maxDelay / minDelay)); } sliderToDelay(sliderValue, minDelay = 10, maxDelay = 10000) { if (sliderValue <= 0) return minDelay; if (sliderValue >= 1000) return maxDelay; if (minDelay === maxDelay) return minDelay; return Math.floor(minDelay * Math.pow(maxDelay / minDelay, sliderValue / 1000)); } updateDelayValue(sliderValue, minDelay, maxDelay) { if (!minDelay || !maxDelay) { const pattern = this.currentTab ? this.state.lights[this.currentTab]?.settings?.pattern : 'on'; const patternConfig = this.state.patterns[pattern] || {}; minDelay = patternConfig.min_delay || 10; maxDelay = patternConfig.max_delay || 10000; } const delay = this.sliderToDelay(parseInt(sliderValue), minDelay, maxDelay); document.getElementById('delay-value').textContent = `${delay} ms`; } hexToRgb(hex) { const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex); return result ? { r: parseInt(result[1], 16), g: parseInt(result[2], 16), b: parseInt(result[3], 16) } : { r: 0, g: 0, b: 0 }; } rgbToHex(r, g, b) { return `#${[r, g, b].map(x => { const hex = x.toString(16); return hex.length === 1 ? '0' + hex : hex; }).join('')}`; } showAddTabModal() { document.getElementById('new-tab-name').value = ''; document.getElementById('new-tab-ids').value = '1'; document.getElementById('add-tab-modal').classList.add('active'); } async createTab() { const name = document.getElementById('new-tab-name').value.trim(); const idsStr = document.getElementById('new-tab-ids').value.trim(); const ids = idsStr.split(',').map(id => id.trim()).filter(id => id); if (!name) { alert('Tab name cannot be empty'); return; } try { const response = await fetch('/tabs', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name, ids }) }); if (response.ok) { await this.loadState(); this.renderTabs(); this.selectTab(name); this.hideModal('add-tab-modal'); } else { const error = await response.json(); alert(error.error || 'Failed to create tab'); } } catch (error) { console.error('Failed to create tab:', error); alert('Failed to create tab'); } } showEditTabModal() { if (!this.currentTab) { alert('Please select a tab first'); return; } const light = this.state.lights[this.currentTab]; document.getElementById('edit-tab-name').value = this.currentTab; document.getElementById('edit-tab-ids').value = light.names.join(', '); document.getElementById('edit-tab-modal').classList.add('active'); } async updateTab() { const newName = document.getElementById('edit-tab-name').value.trim(); const idsStr = document.getElementById('edit-tab-ids').value.trim(); const ids = idsStr.split(',').map(id => id.trim()).filter(id => id); if (!newName) { alert('Tab name cannot be empty'); return; } try { const response = await fetch(`/tabs/${this.currentTab}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: newName, ids }) }); if (response.ok) { await this.loadState(); this.renderTabs(); this.selectTab(newName); this.hideModal('edit-tab-modal'); } else { const error = await response.json(); alert(error.error || 'Failed to update tab'); } } catch (error) { console.error('Failed to update tab:', error); alert('Failed to update tab'); } } async deleteCurrentTab() { if (!this.currentTab) { alert('Please select a tab first'); return; } if (!confirm(`Are you sure you want to delete the tab '${this.currentTab}'?`)) { return; } try { const response = await fetch(`/tabs/${this.currentTab}`, { method: 'DELETE' }); if (response.ok) { await this.loadState(); this.renderTabs(); if (this.state.tab_order.length > 0) { this.selectTab(this.state.tab_order[0]); } else { this.currentTab = null; document.getElementById('tab-content').innerHTML = '

No tabs available. Create a new tab to get started.

'; } } } catch (error) { console.error('Failed to delete tab:', error); alert('Failed to delete tab'); } } async addColorToPalette() { if (!this.currentTab) return; const pattern = this.state.lights[this.currentTab].settings.pattern; const patternSettings = this.getPatternSettings(this.currentTab, pattern); patternSettings.colors.push('#000000'); this.selectedColorIndex = patternSettings.colors.length - 1; this.renderColorPalette(this.currentTab, patternSettings.colors); } async removeSelectedColor() { if (!this.currentTab) return; const pattern = this.state.lights[this.currentTab].settings.pattern; const patternSettings = this.getPatternSettings(this.currentTab, pattern); if (patternSettings.colors.length <= 1) { alert('There must be at least one color in the palette'); return; } patternSettings.colors.splice(this.selectedColorIndex, 1); if (this.selectedColorIndex >= patternSettings.colors.length) { this.selectedColorIndex = patternSettings.colors.length - 1; } this.renderColorPalette(this.currentTab, patternSettings.colors); } async showColorPalette() { const modal = document.getElementById('color-palette-modal'); modal.classList.add('active'); // Update current profile display in palette modal const profileNameDisplay = document.getElementById('palette-current-profile-name'); if (profileNameDisplay) { profileNameDisplay.textContent = this.state.current_profile || 'None'; } await this.loadProfilePalette(); } async showProfiles() { const modal = document.getElementById('profiles-modal'); modal.classList.add('active'); await this.loadProfiles(); await this.loadProfilePalette(); } async loadProfiles() { try { const response = await fetch('/profiles'); const profiles = await response.json(); const profilesList = document.getElementById('profiles-list'); profilesList.innerHTML = ''; // Controllers return dict with IDs as keys, convert to array of names const profileNames = Object.keys(profiles || {}); const profileData = Object.values(profiles || {}); // Find current profile (first one or from settings) const currentProfile = profileNames.length > 0 ? profileNames[0] : ''; this.state.current_profile = currentProfile; if (currentProfile && profiles[currentProfile]) { this.state.color_palette = profiles[currentProfile].color_palette || []; } this.updateCurrentProfileDisplay(); if (profileNames.length === 0) { profilesList.innerHTML = '

No profiles found

'; } else { profileNames.forEach(profileId => { const profile = profiles[profileId]; const profileName = profile.name || profileId; const profileItem = document.createElement('div'); profileItem.style.cssText = 'display: flex; align-items: center; justify-content: space-between; padding: 0.75rem; background-color: #3a3a3a; border-radius: 4px; margin-bottom: 0.5rem;'; const profileLabel = document.createElement('span'); profileLabel.textContent = profileName; if (profileId === currentProfile || profileName === currentProfile) { profileLabel.textContent = `✓ ${profileName}`; profileLabel.style.fontWeight = 'bold'; profileLabel.style.color = '#FFD700'; } const actionsContainer = document.createElement('div'); actionsContainer.style.cssText = 'display: flex; gap: 0.5rem;'; const loadButton = document.createElement('button'); loadButton.className = 'btn btn-small'; loadButton.textContent = 'Load'; loadButton.addEventListener('click', () => this.loadProfile(profileId)); const deleteButton = document.createElement('button'); deleteButton.className = 'btn btn-small btn-danger'; deleteButton.textContent = 'Delete'; deleteButton.addEventListener('click', () => this.deleteProfile(profileId)); actionsContainer.appendChild(loadButton); actionsContainer.appendChild(deleteButton); profileItem.appendChild(profileLabel); profileItem.appendChild(actionsContainer); profilesList.appendChild(profileItem); }); } } catch (error) { console.error('Failed to load profiles:', error); alert('Failed to load profiles'); } } async deleteProfile(profileName) { if (!confirm(`Delete profile '${profileName}'? This cannot be undone.`)) { return; } try { const response = await fetch(`/profiles/${profileName}`, { method: 'DELETE' }); if (response.ok) { await this.loadProfiles(); // If the current profile was deleted, clear current state tabs if (this.state.current_profile === profileName) { this.state.current_profile = ''; this.state.lights = {}; this.state.tab_order = []; this.renderTabs(); document.getElementById('tab-content').innerHTML = '

No tabs available. Create a new tab to get started.

'; this.updateCurrentProfileDisplay(); } } else { const error = await response.json(); alert(error.error || 'Failed to delete profile'); } } catch (error) { console.error('Failed to delete profile:', error); alert('Failed to delete profile'); } } async loadProfile(profileName) { try { const response = await fetch(`/profiles/${profileName}`, { method: 'POST' }); if (response.ok) { await this.loadState(); this.renderTabs(); if (this.state.tab_order.length > 0) { this.selectTab(this.state.tab_order[0]); } else { this.currentTab = null; } await this.loadProfiles(); // Refresh the profiles list } else { const error = await response.json(); alert(error.error || 'Failed to load profile'); } } catch (error) { console.error('Failed to load profile:', error); alert('Failed to load profile'); } } async createProfile() { const nameInput = document.getElementById('new-profile-name'); const profileName = nameInput.value.trim(); if (!profileName) { alert('Profile name cannot be empty'); return; } try { const response = await fetch('/profiles', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ name: profileName }) }); if (response.ok) { nameInput.value = ''; await this.loadProfiles(); alert(`Profile '${profileName}' created successfully`); } else { const error = await response.json(); alert(error.error || 'Failed to create profile'); } } catch (error) { console.error('Failed to create profile:', error); alert('Failed to create profile'); } } async loadProfilePalette() { const currentProfile = this.state.current_profile; if (!currentProfile) { this.renderProfilePalette([]); return; } try { // TODO: /profiles//palette endpoint doesn't exist, load profile and extract palette const profileResponse = await fetch(`/profiles/${currentProfile}`); const profile = await profileResponse.json(); const response = { json: async () => ({ color_palette: profile.color_palette || [] }) }; if (response.ok) { const data = await response.json(); this.renderProfilePalette(data.color_palette || []); } else { this.renderProfilePalette([]); } } catch (error) { console.error('Failed to load profile palette:', error); this.renderProfilePalette([]); } } renderProfilePalette(colors) { // Render in profiles modal const container = document.getElementById('profile-palette-container'); if (container) { container.innerHTML = ''; colors.forEach((color, index) => { const swatch = this.createPaletteSwatch(color, index); container.appendChild(swatch); }); } // Render in color palette modal const paletteContainer = document.getElementById('palette-container'); if (paletteContainer) { paletteContainer.innerHTML = ''; colors.forEach((color, index) => { const swatch = this.createPaletteSwatch(color, index); paletteContainer.appendChild(swatch); }); } } createPaletteSwatch(color, index) { const swatch = document.createElement('div'); swatch.style.cssText = 'width: 40px; height: 40px; background-color: ' + color + '; border: 2px solid #4a4a4a; border-radius: 4px; cursor: pointer; position: relative;'; swatch.title = `Click to apply ${color} to selected color`; // Click to apply color to currently selected color in active tab swatch.addEventListener('click', (e) => { // Only apply if not clicking the remove button if (e.target === swatch || !e.target.closest('button')) { this.applyPaletteColorToSelected(color); } }); const removeBtn = document.createElement('button'); removeBtn.textContent = '×'; removeBtn.style.cssText = 'position: absolute; top: -8px; right: -8px; width: 20px; height: 20px; border-radius: 50%; background-color: #ff4444; color: white; border: none; cursor: pointer; font-size: 14px; line-height: 1; display: flex; align-items: center; justify-content: center; z-index: 10;'; removeBtn.addEventListener('click', (e) => { e.stopPropagation(); this.removePaletteColor(index); }); swatch.appendChild(removeBtn); return swatch; } applyPaletteColorToSelected(paletteColor) { if (!this.currentTab) { alert('No tab selected. Please select a tab first.'); return; } const pattern = this.state.lights[this.currentTab].settings.pattern; const patternSettings = this.getPatternSettings(this.currentTab, pattern); if (patternSettings.colors.length === 0) { alert('No colors in the current pattern. Add a color first.'); return; } // Apply the palette color to the currently selected color index const selectedIndex = this.selectedColorIndex; if (selectedIndex >= 0 && selectedIndex < patternSettings.colors.length) { this.updateColorInPalette(this.currentTab, selectedIndex, paletteColor); } else { // If no color is selected, apply to the first color this.updateColorInPalette(this.currentTab, 0, paletteColor); } } async addPaletteColor() { const colorInput = document.getElementById('new-palette-color'); if (!colorInput) return; await this.addPaletteColorFromInput(colorInput); } async addPaletteColorFromModal() { const colorInput = document.getElementById('palette-new-color'); if (!colorInput) return; await this.addPaletteColorFromInput(colorInput); } async addPaletteColorFromInput(colorInput) { const color = colorInput.value; const currentProfile = this.state.current_profile; if (!currentProfile) { alert('No profile selected'); return; } try { const currentPalette = this.state.color_palette || []; if (currentPalette.includes(color)) { alert('Color already in palette'); return; } const newPalette = [...currentPalette, color]; // TODO: /profiles//palette endpoint doesn't exist, update profile directly const profileResponse = await fetch(`/profiles/${currentProfile}`); const profile = await profileResponse.json(); profile.color_palette = colorPalette; const response = await fetch(`/profiles/${currentProfile}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ color_palette: newPalette }) }); if (response.ok) { this.state.color_palette = newPalette; await this.loadProfilePalette(); } else { alert('Failed to add color to palette'); } } catch (error) { console.error('Failed to add palette color:', error); alert('Failed to add color to palette'); } } async removePaletteColor(index) { const currentProfile = this.state.current_profile; if (!currentProfile) { alert('No profile selected'); return; } try { const currentPalette = this.state.color_palette || []; const newPalette = currentPalette.filter((_, i) => i !== index); // TODO: /profiles//palette endpoint doesn't exist, update profile directly const profileResponse = await fetch(`/profiles/${currentProfile}`); const profile = await profileResponse.json(); profile.color_palette = colorPalette; const response = await fetch(`/profiles/${currentProfile}`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ color_palette: newPalette }) }); if (response.ok) { this.state.color_palette = newPalette; await this.loadProfilePalette(); } else { alert('Failed to remove color from palette'); } } catch (error) { console.error('Failed to remove palette color:', error); alert('Failed to remove color from palette'); } } showPaletteQuickSelect(wrapper, tabName, colorIndex, colorPickerInput) { const palette = this.state.color_palette || []; if (palette.length === 0) { // No palette colors, allow native picker to open return; } // Store context for the modal this.quickPaletteContext = { tabName: tabName, colorIndex: colorIndex, colorPickerInput: colorPickerInput }; // Show the modal const modal = document.getElementById('quick-palette-modal'); modal.classList.add('active'); // Render palette colors in the modal this.renderQuickPalette(palette); } renderQuickPalette(palette) { const container = document.getElementById('quick-palette-container'); if (!container) return; container.innerHTML = ''; if (palette.length === 0) { container.innerHTML = '

No colors in palette

'; return; } palette.forEach((color) => { const colorBtn = document.createElement('div'); colorBtn.style.cssText = `width: 80px; height: 80px; background-color: ${color}; border: 3px solid #4a4a4a; border-radius: 8px; cursor: pointer; flex-shrink: 0; position: relative; transition: transform 0.2s, border-color 0.2s;`; colorBtn.title = color; // Add color hex label const label = document.createElement('div'); label.style.cssText = 'position: absolute; bottom: -20px; left: 0; right: 0; text-align: center; font-size: 0.7rem; color: #aaa;'; label.textContent = color; colorBtn.appendChild(label); colorBtn.addEventListener('mouseenter', () => { colorBtn.style.transform = 'scale(1.1)'; colorBtn.style.borderColor = '#6a5acd'; }); colorBtn.addEventListener('mouseleave', () => { colorBtn.style.transform = 'scale(1)'; colorBtn.style.borderColor = '#4a4a4a'; }); colorBtn.addEventListener('click', () => { if (this.quickPaletteContext) { const { tabName, colorIndex, colorPickerInput } = this.quickPaletteContext; this.updateColorInPalette(tabName, colorIndex, color); colorPickerInput.value = color; } this.hideQuickPaletteModal(); }); container.appendChild(colorBtn); }); } hideQuickPaletteModal() { const modal = document.getElementById('quick-palette-modal'); modal.classList.remove('active'); this.quickPaletteContext = null; } useColorPickerFromQuickPalette() { if (this.quickPaletteContext && this.quickPaletteContext.colorPickerInput) { const colorPickerInput = this.quickPaletteContext.colorPickerInput; // Mark that we want to allow native picker colorPickerInput.dataset.allowNative = 'true'; // Temporarily remove the click handler if (colorPickerInput._clickHandler) { colorPickerInput.removeEventListener('click', colorPickerInput._clickHandler); } this.hideQuickPaletteModal(); // Trigger native color picker after closing modal setTimeout(() => { // Try using showPicker() if available (modern browsers) if (colorPickerInput.showPicker) { colorPickerInput.showPicker().catch(() => { // Fallback to click if showPicker fails colorPickerInput.click(); }); } else { // Fallback to click for older browsers colorPickerInput.click(); } // Restore the click handler after a moment setTimeout(() => { if (colorPickerInput._clickHandler) { colorPickerInput.addEventListener('click', colorPickerInput._clickHandler); } delete colorPickerInput.dataset.allowNative; }, 500); }, 200); } } async showPresets() { const modal = document.getElementById('presets-modal'); modal.classList.add('active'); await this.loadPresets(); } async loadPresets() { try { const response = await fetch('/presets'); const data = await response.json(); // Controllers return the full dict, so use it directly this.state.presets = data || {}; const presetsList = document.getElementById('presets-list'); presetsList.innerHTML = ''; const presetNames = Object.keys(this.state.presets); if (presetNames.length === 0) { presetsList.innerHTML = '

No presets found. Create one to get started.

'; } else { presetNames.forEach(presetName => { const preset = this.state.presets[presetName]; const presetItem = document.createElement('div'); presetItem.style.cssText = 'display: flex; align-items: center; justify-content: space-between; padding: 0.75rem; background-color: #3a3a3a; border-radius: 4px; margin-bottom: 0.5rem;'; const presetInfo = document.createElement('div'); presetInfo.style.cssText = 'display: flex; flex-direction: column; gap: 0.25rem; flex: 1;'; const presetNameLabel = document.createElement('span'); presetNameLabel.textContent = presetName; presetNameLabel.style.fontWeight = 'bold'; const presetDetails = document.createElement('span'); presetDetails.style.fontSize = '0.9em'; presetDetails.style.color = '#aaa'; presetDetails.textContent = `Pattern: ${preset.pattern} | Brightness: ${preset.brightness} | Delay: ${preset.delay}ms | Colors: ${preset.colors.length}`; presetInfo.appendChild(presetNameLabel); presetInfo.appendChild(presetDetails); const actionsContainer = document.createElement('div'); actionsContainer.style.cssText = 'display: flex; gap: 0.5rem;'; const applyButton = document.createElement('button'); applyButton.className = 'btn btn-small btn-primary'; applyButton.textContent = 'Apply'; applyButton.addEventListener('click', () => this.applyPreset(presetName)); const editButton = document.createElement('button'); editButton.className = 'btn btn-small'; editButton.textContent = 'Edit'; editButton.addEventListener('click', () => this.showPresetEditor(presetName)); const deleteButton = document.createElement('button'); deleteButton.className = 'btn btn-small btn-danger'; deleteButton.textContent = 'Delete'; deleteButton.addEventListener('click', () => this.deletePreset(presetName)); actionsContainer.appendChild(applyButton); actionsContainer.appendChild(editButton); actionsContainer.appendChild(deleteButton); presetItem.appendChild(presetInfo); presetItem.appendChild(actionsContainer); presetsList.appendChild(presetItem); }); } } catch (error) { console.error('Failed to load presets:', error); alert('Failed to load presets'); } } async applyPreset(presetName) { if (!this.currentTab) { alert('Please select a tab first'); return; } try { // TODO: /presets//apply endpoint doesn't exist, using pattern/parameters directly const preset = this.state.presets[presetName]; if (!preset) { alert('Preset not found'); return; } // Apply preset using the apply endpoint const response = await fetch(`/api/presets/${presetName}/apply`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ tab_name: this.currentTab }) }); if (response.ok) { await this.loadState(); await this.loadTabContent(this.currentTab); this.hideModal('presets-modal'); } else { const error = await response.json(); alert(`Failed to apply preset: ${error.error || 'Unknown error'}`); } } catch (error) { console.error('Failed to apply preset:', error); alert('Failed to apply preset'); } } async deletePreset(presetName) { if (!confirm(`Delete preset '${presetName}'? This cannot be undone.`)) { return; } try { const response = await fetch(`/presets/${presetName}`, { method: 'DELETE' }); if (response.ok) { await this.loadPresets(); } else { const error = await response.json(); alert(`Failed to delete preset: ${error.error || 'Unknown error'}`); } } catch (error) { console.error('Failed to delete preset:', error); alert('Failed to delete preset'); } } showPresetEditor(presetName = null) { const modal = document.getElementById('preset-editor-modal'); const title = document.getElementById('preset-editor-title'); const nameInput = document.getElementById('preset-name-input'); const patternSelect = document.getElementById('preset-pattern-select'); const brightnessSlider = document.getElementById('preset-brightness-slider'); const brightnessValue = document.getElementById('preset-brightness-value'); const delayInput = document.getElementById('preset-delay-input'); const n1Input = document.getElementById('preset-n1-input'); const n2Input = document.getElementById('preset-n2-input'); const n3Input = document.getElementById('preset-n3-input'); const n4Input = document.getElementById('preset-n4-input'); // Store editing preset name this.editingPresetName = presetName; // Populate pattern select patternSelect.innerHTML = ''; const patterns = Object.keys(this.state.patterns || {}); patterns.forEach(pattern => { const option = document.createElement('option'); option.value = pattern; option.textContent = pattern; patternSelect.appendChild(option); }); if (presetName && this.state.presets[presetName]) { // Editing existing preset title.textContent = 'Edit Preset'; const preset = this.state.presets[presetName]; nameInput.value = presetName; nameInput.disabled = false; patternSelect.value = preset.pattern; brightnessSlider.value = preset.brightness; brightnessValue.textContent = preset.brightness; delayInput.value = preset.delay; n1Input.value = preset.n1; n2Input.value = preset.n2; n3Input.value = preset.n3; n4Input.value = preset.n4; this.renderPresetColors(preset.colors); } else { // Creating new preset title.textContent = 'Create Preset'; nameInput.value = ''; nameInput.disabled = false; patternSelect.value = patterns[0] || 'on'; brightnessSlider.value = 127; brightnessValue.textContent = 127; delayInput.value = 100; n1Input.value = 10; n2Input.value = 10; n3Input.value = 10; n4Input.value = 10; this.renderPresetColors(['#000000']); } this.selectedPresetColorIndex = 0; modal.classList.add('active'); } renderPresetColors(colors) { const container = document.getElementById('preset-colors-container'); container.innerHTML = ''; colors.forEach((color, index) => { const colorWrapper = document.createElement('div'); colorWrapper.style.cssText = 'position: relative;'; const colorSwatch = document.createElement('div'); colorSwatch.style.cssText = `width: 50px; height: 50px; background-color: ${color}; border: 2px solid ${index === this.selectedPresetColorIndex ? '#FFD700' : '#4a4a4a'}; border-radius: 4px; cursor: pointer; position: relative;`; colorSwatch.dataset.index = index; colorSwatch.addEventListener('click', () => { this.selectedPresetColorIndex = index; this.renderPresetColors(colors); }); const colorPicker = document.createElement('input'); colorPicker.type = 'color'; colorPicker.value = color; colorPicker.style.cssText = 'position: absolute; top: 0; left: 0; width: 50px; height: 50px; opacity: 0; cursor: pointer;'; colorPicker.addEventListener('change', (e) => { colors[index] = e.target.value; this.renderPresetColors(colors); }); colorWrapper.appendChild(colorSwatch); colorWrapper.appendChild(colorPicker); container.appendChild(colorWrapper); }); this.presetColors = colors; } addPresetColor() { const colorInput = document.getElementById('preset-new-color'); const color = colorInput.value; if (!this.presetColors) { this.presetColors = []; } this.presetColors.push(color); this.selectedPresetColorIndex = this.presetColors.length - 1; this.renderPresetColors(this.presetColors); } removePresetColor() { if (!this.presetColors || this.presetColors.length === 0) { return; } if (this.selectedPresetColorIndex >= 0 && this.selectedPresetColorIndex < this.presetColors.length) { this.presetColors.splice(this.selectedPresetColorIndex, 1); if (this.selectedPresetColorIndex >= this.presetColors.length) { this.selectedPresetColorIndex = Math.max(0, this.presetColors.length - 1); } this.renderPresetColors(this.presetColors); } } loadCurrentTabToPresetEditor() { if (!this.currentTab || !this.state.lights[this.currentTab]) { alert('Please select a tab first'); return; } const light = this.state.lights[this.currentTab]; const settings = light.settings; const pattern = settings.pattern || 'on'; // Get pattern-specific settings const patternSettings = this.getPatternSettings(this.currentTab, pattern); const patternSelect = document.getElementById('preset-pattern-select'); const brightnessSlider = document.getElementById('preset-brightness-slider'); const brightnessValue = document.getElementById('preset-brightness-value'); const delayInput = document.getElementById('preset-delay-input'); const n1Input = document.getElementById('preset-n1-input'); const n2Input = document.getElementById('preset-n2-input'); const n3Input = document.getElementById('preset-n3-input'); const n4Input = document.getElementById('preset-n4-input'); patternSelect.value = pattern; brightnessSlider.value = settings.brightness || 127; brightnessValue.textContent = settings.brightness || 127; delayInput.value = patternSettings.delay || 100; n1Input.value = patternSettings.n1 || 10; n2Input.value = patternSettings.n2 || 10; n3Input.value = patternSettings.n3 || 10; n4Input.value = patternSettings.n4 || 10; this.renderPresetColors(patternSettings.colors || ['#000000']); } async savePreset() { const nameInput = document.getElementById('preset-name-input'); const patternSelect = document.getElementById('preset-pattern-select'); const brightnessSlider = document.getElementById('preset-brightness-slider'); const delayInput = document.getElementById('preset-delay-input'); const n1Input = document.getElementById('preset-n1-input'); const n2Input = document.getElementById('preset-n2-input'); const n3Input = document.getElementById('preset-n3-input'); const n4Input = document.getElementById('preset-n4-input'); const presetName = nameInput.value.trim(); if (!presetName) { alert('Please enter a preset name'); return; } if (!this.presetColors || this.presetColors.length === 0) { alert('Please add at least one color'); return; } const presetData = { name: presetName, pattern: patternSelect.value, brightness: parseInt(brightnessSlider.value), delay: parseInt(delayInput.value), n1: parseInt(n1Input.value), n2: parseInt(n2Input.value), n3: parseInt(n3Input.value), n4: parseInt(n4Input.value), colors: this.presetColors }; try { let response; if (this.editingPresetName) { // Update existing preset response = await fetch(`/presets/${this.editingPresetName}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(presetData) }); } else { // Create new preset response = await fetch('/presets', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(presetData) }); } if (response.ok) { await this.loadPresets(); this.hideModal('preset-editor-modal'); this.editingPresetName = null; } else { const error = await response.json(); alert(`Failed to save preset: ${error.error || 'Unknown error'}`); } } catch (error) { console.error('Failed to save preset:', error); alert('Failed to save preset'); } } hideModal(modalId) { document.getElementById(modalId).classList.remove('active'); } } // Initialize app when DOM is ready document.addEventListener('DOMContentLoaded', () => { new LightingController(); });