- 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
1651 lines
66 KiB
JavaScript
1651 lines
66 KiB
JavaScript
// 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();
|
||
});
|
||
|