Files
lighting-controller/static/app.js
Jimmy 90be198483 Add presets system and convert back to Flask
- Convert from Microdot back to Flask
- Add presets system with CRUD operations
- Store presets in presets.json file
- Replace patterns section with presets grid
- Add preset editor with full configuration
- Add collapse/expand functionality to left panel
- Always show on/off presets in presets list
- Highlight active preset matching current tab settings
- Add 'Create from Current' button in preset editor
2026-01-08 21:45:55 +13:00

1651 lines
66 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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 {
const response = await fetch('/api/state');
const data = await response.json();
this.state = data;
// Ensure presets is always an object
if (!this.state.presets) {
this.state.presets = {};
}
// 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 <= 4; 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());
// Close modals on outside click
document.getElementById('add-tab-modal').addEventListener('click', (e) => {
if (e.target.id === 'add-tab-modal') this.hideModal('add-tab-modal');
});
document.getElementById('edit-tab-modal').addEventListener('click', (e) => {
if (e.target.id === 'edit-tab-modal') this.hideModal('edit-tab-modal');
});
document.getElementById('profiles-modal').addEventListener('click', (e) => {
if (e.target.id === 'profiles-modal') this.hideModal('profiles-modal');
});
document.getElementById('presets-modal').addEventListener('click', (e) => {
if (e.target.id === 'presets-modal') this.hideModal('presets-modal');
});
document.getElementById('preset-editor-modal').addEventListener('click', (e) => {
if (e.target.id === 'preset-editor-modal') this.hideModal('preset-editor-modal');
});
}
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 <= 4; i++) {
document.getElementById(`n${i}-input`).value = patternSettings[`n${i}`] || 10;
}
// Render presets
this.renderPresets(tabName);
}
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
},
'off': {
pattern: 'off',
colors: ['#000000'],
brightness: 0,
delay: 100,
n1: 10,
n2: 10,
n3: 10,
n4: 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
})
});
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
};
}
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 <= 4; 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 response = await fetch(`/api/presets/${presetName}/apply`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
tab_name: tabName
})
});
if (response.ok) {
// Reload state and tab content to reflect changes
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 };
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
};
}
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
})
});
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 <= 4; 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('/api/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(`/api/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(`/api/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 = '<p>No tabs available. Create a new tab to get started.</p>';
}
}
} 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('/api/profiles');
const data = await response.json();
const profilesList = document.getElementById('profiles-list');
profilesList.innerHTML = '';
const currentProfile = data.current_profile || '';
this.state.current_profile = currentProfile;
this.state.color_palette = data.color_palette || [];
this.updateCurrentProfileDisplay();
if (data.profiles.length === 0) {
profilesList.innerHTML = '<p style="text-align: center; color: #888;">No profiles found</p>';
} else {
data.profiles.forEach(profileName => {
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 (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(profileName));
const deleteButton = document.createElement('button');
deleteButton.className = 'btn btn-small btn-danger';
deleteButton.textContent = 'Delete';
deleteButton.addEventListener('click', () => this.deleteProfile(profileName));
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(`/api/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 = '<p>No tabs available. Create a new tab to get started.</p>';
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(`/api/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('/api/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 {
const response = await fetch(`/api/profiles/${currentProfile}/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];
const response = await fetch(`/api/profiles/${currentProfile}/palette`, {
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);
const response = await fetch(`/api/profiles/${currentProfile}/palette`, {
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 = '<p style="text-align: center; color: #888; width: 100%;">No colors in palette</p>';
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('/api/presets');
const data = await response.json();
this.state.presets = data.presets || {};
const presetsList = document.getElementById('presets-list');
presetsList.innerHTML = '';
const presetNames = Object.keys(this.state.presets);
if (presetNames.length === 0) {
presetsList.innerHTML = '<p style="text-align: center; color: #888;">No presets found. Create one to get started.</p>';
} 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 {
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(`/api/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(`/api/presets/${this.editingPresetName}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(presetData)
});
} else {
// Create new preset
response = await fetch('/api/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();
});