diff --git a/src/static/app.js b/src/static/app.js
new file mode 100644
index 0000000..ed62bc5
--- /dev/null
+++ b/src/static/app.js
@@ -0,0 +1,1750 @@
+// Lighting Controller Web App
+class LightingController {
+ constructor() {
+ this.currentTab = null;
+ this.state = {
+ lights: {},
+ patterns: {},
+ tab_order: [],
+ presets: {}
+ };
+ this.selectedColorIndex = 0;
+ this.updateTimeouts = {};
+ this.quickPaletteContext = null;
+
+ this.init();
+ }
+
+ async init() {
+ await this.loadState();
+ this.setupEventListeners();
+ this.renderTabs();
+ if (this.state.tab_order.length > 0) {
+ this.selectTab(this.state.tab_order[0]);
+ }
+ }
+
+ async loadState() {
+ try {
+ // Load presets and profiles separately since /api/state doesn't exist
+ const presetsResponse = await fetch('/presets');
+ const presets = await presetsResponse.json();
+ this.state.presets = presets || {};
+
+ const patternsResponse = await fetch('/patterns');
+ if (patternsResponse.ok) {
+ const patterns = await patternsResponse.json();
+ this.state.patterns = patterns || {};
+ }
+
+ // Load profiles
+ const profilesResponse = await fetch('/profiles');
+ const profiles = await profilesResponse.json();
+
+ // Update current profile display
+ this.updateCurrentProfileDisplay();
+ // Update current profile display if profiles modal is open
+ if (document.getElementById('profiles-modal').classList.contains('active')) {
+ await this.loadProfiles();
+ await this.loadProfilePalette();
+ }
+ } catch (error) {
+ console.error('Failed to load state:', error);
+ }
+ }
+
+ updateCurrentProfileDisplay() {
+ const currentProfileName = document.getElementById('current-profile-name');
+ if (currentProfileName) {
+ const profile = this.state.current_profile || 'None';
+ currentProfileName.textContent = profile;
+ }
+ }
+
+ setupEventListeners() {
+ // Tab management
+ document.getElementById('add-tab-btn').addEventListener('click', () => this.showAddTabModal());
+ document.getElementById('edit-tab-btn').addEventListener('click', () => this.showEditTabModal());
+ document.getElementById('delete-tab-btn').addEventListener('click', () => this.deleteCurrentTab());
+ document.getElementById('color-palette-btn').addEventListener('click', () => this.showColorPalette());
+ document.getElementById('presets-btn').addEventListener('click', () => this.showPresets());
+ document.getElementById('profiles-btn').addEventListener('click', () => this.showProfiles());
+
+ // Modal actions
+ document.getElementById('add-tab-confirm').addEventListener('click', () => this.createTab());
+ document.getElementById('add-tab-cancel').addEventListener('click', () => this.hideModal('add-tab-modal'));
+ document.getElementById('edit-tab-confirm').addEventListener('click', () => this.updateTab());
+ document.getElementById('edit-tab-cancel').addEventListener('click', () => this.hideModal('edit-tab-modal'));
+ document.getElementById('profiles-close-btn').addEventListener('click', () => this.hideModal('profiles-modal'));
+ document.getElementById('color-palette-close-btn').addEventListener('click', () => this.hideModal('color-palette-modal'));
+ document.getElementById('presets-close-btn').addEventListener('click', () => this.hideModal('presets-modal'));
+ document.getElementById('create-preset-btn').addEventListener('click', () => this.showPresetEditor());
+ document.getElementById('preset-editor-save-btn').addEventListener('click', () => this.savePreset());
+ document.getElementById('preset-editor-cancel-btn').addEventListener('click', () => this.hideModal('preset-editor-modal'));
+ document.getElementById('preset-add-color-btn').addEventListener('click', () => this.addPresetColor());
+ document.getElementById('preset-remove-color-btn').addEventListener('click', () => this.removePresetColor());
+ document.getElementById('preset-editor-from-current-btn').addEventListener('click', () => this.loadCurrentTabToPresetEditor());
+ document.getElementById('preset-brightness-slider').addEventListener('input', (e) => {
+ document.getElementById('preset-brightness-value').textContent = e.target.value;
+ });
+ document.getElementById('quick-palette-close-btn').addEventListener('click', () => this.hideQuickPaletteModal());
+ document.getElementById('quick-palette-use-picker-btn').addEventListener('click', () => this.useColorPickerFromQuickPalette());
+ document.getElementById('create-profile-btn').addEventListener('click', () => this.createProfile());
+ document.getElementById('add-palette-color-btn').addEventListener('click', () => this.addPaletteColor());
+ document.getElementById('palette-add-color-btn').addEventListener('click', () => this.addPaletteColorFromModal());
+ document.getElementById('toggle-left-panel').addEventListener('click', () => this.toggleLeftPanel());
+
+ // Enter key for new profile name
+ document.getElementById('new-profile-name').addEventListener('keypress', (e) => {
+ if (e.key === 'Enter') {
+ this.createProfile();
+ }
+ });
+
+ // Brightness and delay sliders
+ document.getElementById('brightness-slider').addEventListener('input', (e) => {
+ document.getElementById('brightness-value').textContent = e.target.value;
+ this.debounceUpdate('brightness', () => this.updateBrightness());
+ });
+ document.getElementById('delay-slider').addEventListener('input', (e) => {
+ this.updateDelayValue(e.target.value);
+ this.debounceUpdate('delay', () => this.updateDelay());
+ });
+
+ // N parameters
+ for (let i = 1; i <= 8; i++) {
+ document.getElementById(`n${i}-input`).addEventListener('input', (e) => {
+ this.debounceUpdate('nparams', () => this.updateNParams());
+ });
+ }
+
+ // Color palette
+ document.getElementById('add-color-btn').addEventListener('click', () => this.addColorToPalette());
+ document.getElementById('remove-color-btn').addEventListener('click', () => this.removeSelectedColor());
+
+ // 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 <= 8; i++) {
+ document.getElementById(`n${i}-input`).value = patternSettings[`n${i}`] || 10;
+ }
+ this.updateNParamLabels(pattern);
+
+ // Render presets
+ this.renderPresets(tabName);
+ }
+
+ updateNParamLabels(patternName) {
+ const defaultLabels = {};
+ for (let i = 1; i <= 8; i++) {
+ defaultLabels[`n${i}`] = `n${i}:`;
+ }
+ const patternConfig = this.state.patterns ? this.state.patterns[patternName] : null;
+ if (patternConfig && typeof patternConfig === 'object') {
+ Object.entries(patternConfig).forEach(([label, key]) => {
+ if (typeof key === 'string' && key.startsWith('n')) {
+ defaultLabels[key] = `${label}:`;
+ }
+ });
+ }
+ for (let i = 1; i <= 8; i++) {
+ const labelEl = document.getElementById(`n${i}-label`);
+ if (labelEl) {
+ labelEl.textContent = defaultLabels[`n${i}`];
+ }
+ }
+ }
+
+ renderPresets(tabName) {
+ const presetsList = document.getElementById('presets-list-tab');
+ presetsList.innerHTML = '';
+
+ const presets = this.state.presets || {};
+ const presetNames = Object.keys(presets);
+
+ // Get current tab's settings for comparison
+ const currentSettings = this.getCurrentTabSettings(tabName);
+
+ // Always include "on" and "off" presets
+ const defaultPresets = {
+ 'on': {
+ pattern: 'on',
+ colors: ['#FFFFFF'],
+ brightness: 255,
+ delay: 100,
+ n1: 10,
+ n2: 10,
+ n3: 10,
+ n4: 10,
+ n5: 10,
+ n6: 10,
+ n7: 10,
+ n8: 10
+ },
+ 'off': {
+ pattern: 'off',
+ colors: ['#000000'],
+ brightness: 0,
+ delay: 100,
+ n1: 10,
+ n2: 10,
+ n3: 10,
+ n4: 10,
+ n5: 10,
+ n6: 10,
+ n7: 10,
+ n8: 10
+ }
+ };
+
+ // Create a combined list with default presets first, then user presets
+ const allPresets = { ...defaultPresets, ...presets };
+ const allPresetNames = ['on', 'off', ...presetNames.filter(name => name !== 'on' && name !== 'off')];
+
+ allPresetNames.forEach(presetName => {
+ const preset = allPresets[presetName];
+ const presetButton = document.createElement('button');
+ presetButton.className = 'pattern-button';
+
+ // Check if this preset matches the current tab's settings
+ const isActive = this.presetMatchesSettings(preset, currentSettings);
+ if (isActive) {
+ presetButton.classList.add('active');
+ }
+
+ // Mark default presets (on/off) with a special indicator
+ const isDefault = presetName === 'on' || presetName === 'off';
+ if (isDefault) {
+ presetButton.classList.add('default-preset');
+ }
+
+ // Create preset info display
+ const presetInfo = document.createElement('div');
+ presetInfo.style.cssText = 'display: flex; flex-direction: column; align-items: flex-start; width: 100%;';
+
+ const presetNameLabel = document.createElement('span');
+ presetNameLabel.textContent = presetName;
+ presetNameLabel.style.fontWeight = 'bold';
+ presetNameLabel.style.marginBottom = '0.25rem';
+
+ const presetDetails = document.createElement('span');
+ presetDetails.style.fontSize = '0.85em';
+ presetDetails.style.color = '#aaa';
+ presetDetails.textContent = `${preset.pattern} • ${preset.colors.length} color${preset.colors.length !== 1 ? 's' : ''}`;
+
+ presetInfo.appendChild(presetNameLabel);
+ presetInfo.appendChild(presetDetails);
+ presetButton.appendChild(presetInfo);
+
+ presetButton.addEventListener('click', () => {
+ if (isDefault && !presets[presetName]) {
+ // Apply default preset directly
+ this.applyDefaultPreset(tabName, presetName, preset);
+ } else {
+ // Apply regular preset
+ this.applyPresetToTab(tabName, presetName);
+ }
+ });
+ presetsList.appendChild(presetButton);
+ });
+ }
+
+ async applyDefaultPreset(tabName, presetName, preset) {
+ // Apply default preset by setting pattern and parameters directly
+ try {
+ // First set the pattern
+ const patternResponse = await fetch('/api/pattern', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ tab_name: tabName,
+ pattern: preset.pattern,
+ delay: preset.delay,
+ colors: preset.colors,
+ n1: preset.n1,
+ n2: preset.n2,
+ n3: preset.n3,
+ n4: preset.n4,
+ n5: preset.n5,
+ n6: preset.n6,
+ n7: preset.n7,
+ n8: preset.n8
+ })
+ });
+
+ if (patternResponse.ok) {
+ // Then update brightness
+ await fetch('/api/parameters', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ tab_name: tabName,
+ brightness: preset.brightness
+ })
+ });
+
+ // Reload state and tab content
+ await this.loadState();
+ await this.loadTabContent(tabName);
+ } else {
+ const error = await patternResponse.json();
+ alert(`Failed to apply preset: ${error.error || 'Unknown error'}`);
+ }
+ } catch (error) {
+ console.error('Failed to apply default preset:', error);
+ alert('Failed to apply preset');
+ }
+ }
+
+ getCurrentTabSettings(tabName) {
+ if (!this.state.lights[tabName]) {
+ return null;
+ }
+
+ const light = this.state.lights[tabName];
+ const settings = light.settings;
+ const pattern = settings.pattern || 'on';
+ const patternSettings = this.getPatternSettings(tabName, pattern);
+
+ return {
+ pattern: pattern,
+ brightness: settings.brightness || 127,
+ delay: patternSettings.delay || 100,
+ colors: patternSettings.colors || ['#000000'],
+ n1: patternSettings.n1 || 10,
+ n2: patternSettings.n2 || 10,
+ n3: patternSettings.n3 || 10,
+ n4: patternSettings.n4 || 10,
+ n5: patternSettings.n5 || 10,
+ n6: patternSettings.n6 || 10,
+ n7: patternSettings.n7 || 10,
+ n8: patternSettings.n8 || 10
+ };
+ }
+
+ presetMatchesSettings(preset, currentSettings) {
+ if (!currentSettings) {
+ return false;
+ }
+
+ // Compare all settings
+ if (preset.pattern !== currentSettings.pattern) {
+ return false;
+ }
+
+ if (preset.brightness !== currentSettings.brightness) {
+ return false;
+ }
+
+ if (preset.delay !== currentSettings.delay) {
+ return false;
+ }
+
+ // Compare n values
+ for (let i = 1; i <= 8; i++) {
+ if (preset[`n${i}`] !== currentSettings[`n${i}`]) {
+ return false;
+ }
+ }
+
+ // Compare colors (order matters for presets)
+ if (preset.colors.length !== currentSettings.colors.length) {
+ return false;
+ }
+
+ for (let i = 0; i < preset.colors.length; i++) {
+ // Normalize color format (uppercase, no spaces)
+ const presetColor = preset.colors[i].toUpperCase().trim();
+ const currentColor = currentSettings.colors[i].toUpperCase().trim();
+ if (presetColor !== currentColor) {
+ return false;
+ }
+ }
+
+ return true;
+ }
+
+ async applyPresetToTab(tabName, presetName) {
+ try {
+ const preset = this.state.presets[presetName];
+ if (!preset) {
+ alert('Preset not found');
+ return;
+ }
+
+ // Apply preset using the apply endpoint
+ const response = await fetch(`/api/presets/${presetName}/apply`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json'
+ },
+ body: JSON.stringify({
+ tab_name: tabName
+ })
+ });
+
+ if (response.ok) {
+ await this.loadState();
+ await this.loadTabContent(tabName);
+ } else {
+ const error = await response.json();
+ alert(`Failed to apply preset: ${error.error || 'Unknown error'}`);
+ }
+ } catch (error) {
+ console.error('Failed to apply preset:', error);
+ alert('Failed to apply preset');
+ }
+ }
+
+ renderColorPalette(tabName, colors) {
+ const palette = document.getElementById('color-palette');
+ if (!palette) {
+ return;
+ }
+ palette.innerHTML = '';
+
+ colors.forEach((hexColor, index) => {
+ const swatch = document.createElement('div');
+ swatch.className = 'color-swatch';
+ if (index === this.selectedColorIndex) {
+ swatch.classList.add('selected');
+ }
+
+ const preview = document.createElement('div');
+ preview.className = 'color-swatch-preview';
+ preview.style.backgroundColor = hexColor;
+
+ const label = document.createElement('span');
+ label.className = 'color-swatch-label';
+ label.textContent = `Color ${index + 1}`;
+
+ // Color picker input with palette quick-select
+ const colorPickerWrapper = document.createElement('div');
+ colorPickerWrapper.style.cssText = 'position: relative; display: inline-block;';
+
+ const colorPicker = document.createElement('input');
+ colorPicker.type = 'color';
+ colorPicker.value = hexColor;
+ colorPicker.className = 'color-picker-input';
+ colorPicker.dataset.tabName = tabName;
+ colorPicker.dataset.colorIndex = index;
+ colorPicker.addEventListener('change', (e) => {
+ const newColor = e.target.value;
+ this.updateColorInPalette(tabName, index, newColor);
+ });
+
+ // Show quick palette on click, prevent native picker if palette has colors
+ const clickHandler = (e) => {
+ e.stopPropagation();
+ const palette = this.state.color_palette || [];
+ // Check if we're allowing native picker (set by "Use Color Picker" button)
+ if (palette.length > 0 && !colorPicker.dataset.allowNative) {
+ e.preventDefault();
+ this.showPaletteQuickSelect(colorPickerWrapper, tabName, index, colorPicker);
+ }
+ // If no palette colors or allowNative is set, let native picker open
+ };
+ colorPicker.addEventListener('click', clickHandler);
+ // Store handler reference for later removal if needed
+ colorPicker._clickHandler = clickHandler;
+
+ colorPickerWrapper.appendChild(colorPicker);
+
+ swatch.appendChild(preview);
+ swatch.appendChild(label);
+ swatch.appendChild(colorPickerWrapper);
+ swatch.addEventListener('click', (e) => {
+ // Don't trigger selection if clicking on the color picker
+ if (e.target !== colorPicker && !colorPicker.contains(e.target)) {
+ this.selectColor(tabName, index, hexColor);
+ }
+ });
+
+ palette.appendChild(swatch);
+ });
+ }
+
+ getPatternSettings(tabName, patternName) {
+ const light = this.state.lights[tabName];
+ if (!light) return {
+ colors: ['#000000'],
+ delay: 100,
+ n1: 10,
+ n2: 10,
+ n3: 10,
+ n4: 10,
+ n5: 10,
+ n6: 10,
+ n7: 10,
+ n8: 10
+ };
+
+ const lightSettings = light.settings;
+ if (!lightSettings.patterns) lightSettings.patterns = {};
+ if (!lightSettings.patterns[patternName]) lightSettings.patterns[patternName] = {};
+
+ const patternSettings = lightSettings.patterns[patternName];
+ // Fall back to global colors if pattern-specific colors don't exist
+ const globalColors = lightSettings.colors || ['#000000'];
+ return {
+ colors: patternSettings.colors || globalColors,
+ delay: patternSettings.delay || lightSettings.delay || 100,
+ n1: patternSettings.n1 || lightSettings.n1 || 10,
+ n2: patternSettings.n2 || lightSettings.n2 || 10,
+ n3: patternSettings.n3 || lightSettings.n3 || 10,
+ n4: patternSettings.n4 || lightSettings.n4 || 10,
+ n5: patternSettings.n5 || lightSettings.n5 || 10,
+ n6: patternSettings.n6 || lightSettings.n6 || 10,
+ n7: patternSettings.n7 || lightSettings.n7 || 10,
+ n8: patternSettings.n8 || lightSettings.n8 || 10
+ };
+ }
+
+ async setPattern(tabName, patternName) {
+ const patternSettings = this.getPatternSettings(tabName, patternName);
+
+ try {
+ const response = await fetch('/api/pattern', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ tab_name: tabName,
+ pattern: patternName,
+ delay: patternSettings.delay,
+ colors: patternSettings.colors,
+ n1: patternSettings.n1,
+ n2: patternSettings.n2,
+ n3: patternSettings.n3,
+ n4: patternSettings.n4,
+ n5: patternSettings.n5,
+ n6: patternSettings.n6,
+ n7: patternSettings.n7,
+ n8: patternSettings.n8
+ })
+ });
+
+ if (response.ok) {
+ // Update the pattern immediately in the state
+ if (this.state.lights[tabName]) {
+ this.state.lights[tabName].settings.pattern = patternName;
+ }
+ // Reload state from server to ensure consistency
+ await this.loadState();
+ // Reload tab content to update UI
+ await this.loadTabContent(tabName);
+ } else {
+ const errorText = await response.text();
+ console.error('Failed to set pattern:', errorText);
+ alert(`Failed to set pattern: ${errorText}`);
+ }
+ } catch (error) {
+ console.error('Failed to set pattern:', error);
+ }
+ }
+
+ selectColor(tabName, index, hexColor) {
+ this.selectedColorIndex = index;
+ if (this.state.lights[tabName]) {
+ const pattern = this.state.lights[tabName].settings.pattern;
+ const patternSettings = this.getPatternSettings(tabName, pattern);
+ this.renderColorPalette(tabName, patternSettings.colors);
+ }
+ }
+
+ async updateColorInPalette(tabName, index, hexColor) {
+ if (!this.currentTab || tabName !== this.currentTab) return;
+
+ const pattern = this.state.lights[tabName].settings.pattern;
+ const patternSettings = this.getPatternSettings(tabName, pattern);
+ patternSettings.colors[index] = hexColor;
+
+ // Update the preview
+ const palette = document.getElementById('color-palette');
+ if (palette && palette.children[index]) {
+ const preview = palette.children[index].querySelector('.color-swatch-preview');
+ if (preview) {
+ preview.style.backgroundColor = hexColor;
+ }
+ }
+
+ // Send update to backend to persist changes
+ const rgb = this.hexToRgb(hexColor);
+ try {
+ const response = await fetch('/api/parameters', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ tab_name: this.currentTab,
+ red: rgb.r,
+ green: rgb.g,
+ blue: rgb.b,
+ color_index: index
+ })
+ });
+
+ if (response.ok) {
+ // Reload state to ensure persistence
+ await this.loadState();
+ // Re-render palette to reflect persisted state
+ const updatedPatternSettings = this.getPatternSettings(tabName, pattern);
+ this.renderColorPalette(tabName, updatedPatternSettings.colors);
+ }
+ } catch (error) {
+ console.error('Failed to update color:', error);
+ }
+ }
+
+
+ async updateBrightness() {
+ if (!this.currentTab) return;
+
+ const brightness = parseInt(document.getElementById('brightness-slider').value) || 0;
+
+ try {
+ await fetch('/api/parameters', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ tab_name: this.currentTab,
+ brightness: brightness
+ })
+ });
+ } catch (error) {
+ console.error('Failed to update brightness:', error);
+ }
+ }
+
+ async updateDelay() {
+ if (!this.currentTab) return;
+
+ const sliderValue = parseInt(document.getElementById('delay-slider').value);
+ const pattern = this.state.lights[this.currentTab].settings.pattern;
+ const patternConfig = this.state.patterns[pattern] || {};
+ const minDelay = patternConfig.min_delay || 10;
+ const maxDelay = patternConfig.max_delay || 10000;
+
+ try {
+ await fetch('/api/parameters', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ tab_name: this.currentTab,
+ delay_slider: sliderValue
+ })
+ });
+ } catch (error) {
+ console.error('Failed to update delay:', error);
+ }
+ }
+
+ async updateNParams() {
+ if (!this.currentTab) return;
+
+ const nParams = {};
+ for (let i = 1; i <= 8; i++) {
+ nParams[`n${i}`] = parseInt(document.getElementById(`n${i}-input`).value) || 0;
+ }
+
+ try {
+ await fetch('/api/parameters', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ tab_name: this.currentTab,
+ ...nParams
+ })
+ });
+ } catch (error) {
+ console.error('Failed to update n params:', error);
+ }
+ }
+
+ debounceUpdate(key, callback) {
+ if (this.updateTimeouts[key]) {
+ clearTimeout(this.updateTimeouts[key]);
+ }
+ this.updateTimeouts[key] = setTimeout(callback, 100);
+ }
+
+ delayToSlider(delayMs, minDelay = 10, maxDelay = 10000) {
+ if (delayMs <= minDelay) return 0;
+ if (delayMs >= maxDelay) return 1000;
+ if (minDelay === maxDelay) return 0;
+ return Math.floor(1000 * Math.log(delayMs / minDelay) / Math.log(maxDelay / minDelay));
+ }
+
+ sliderToDelay(sliderValue, minDelay = 10, maxDelay = 10000) {
+ if (sliderValue <= 0) return minDelay;
+ if (sliderValue >= 1000) return maxDelay;
+ if (minDelay === maxDelay) return minDelay;
+ return Math.floor(minDelay * Math.pow(maxDelay / minDelay, sliderValue / 1000));
+ }
+
+ updateDelayValue(sliderValue, minDelay, maxDelay) {
+ if (!minDelay || !maxDelay) {
+ const pattern = this.currentTab ? this.state.lights[this.currentTab]?.settings?.pattern : 'on';
+ const patternConfig = this.state.patterns[pattern] || {};
+ minDelay = patternConfig.min_delay || 10;
+ maxDelay = patternConfig.max_delay || 10000;
+ }
+ const delay = this.sliderToDelay(parseInt(sliderValue), minDelay, maxDelay);
+ document.getElementById('delay-value').textContent = `${delay} ms`;
+ }
+
+ hexToRgb(hex) {
+ const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
+ return result ? {
+ r: parseInt(result[1], 16),
+ g: parseInt(result[2], 16),
+ b: parseInt(result[3], 16)
+ } : { r: 0, g: 0, b: 0 };
+ }
+
+ rgbToHex(r, g, b) {
+ return `#${[r, g, b].map(x => {
+ const hex = x.toString(16);
+ return hex.length === 1 ? '0' + hex : hex;
+ }).join('')}`;
+ }
+
+ showAddTabModal() {
+ document.getElementById('new-tab-name').value = '';
+ document.getElementById('new-tab-ids').value = '1';
+ document.getElementById('add-tab-modal').classList.add('active');
+ }
+
+ async createTab() {
+ const name = document.getElementById('new-tab-name').value.trim();
+ const idsStr = document.getElementById('new-tab-ids').value.trim();
+ const ids = idsStr.split(',').map(id => id.trim()).filter(id => id);
+
+ if (!name) {
+ alert('Tab name cannot be empty');
+ return;
+ }
+
+ try {
+ const response = await fetch('/tabs', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ name, ids })
+ });
+
+ if (response.ok) {
+ await this.loadState();
+ this.renderTabs();
+ this.selectTab(name);
+ this.hideModal('add-tab-modal');
+ } else {
+ const error = await response.json();
+ alert(error.error || 'Failed to create tab');
+ }
+ } catch (error) {
+ console.error('Failed to create tab:', error);
+ alert('Failed to create tab');
+ }
+ }
+
+ showEditTabModal() {
+ if (!this.currentTab) {
+ alert('Please select a tab first');
+ return;
+ }
+
+ const light = this.state.lights[this.currentTab];
+ document.getElementById('edit-tab-name').value = this.currentTab;
+ document.getElementById('edit-tab-ids').value = light.names.join(', ');
+ document.getElementById('edit-tab-modal').classList.add('active');
+ }
+
+ async updateTab() {
+ const newName = document.getElementById('edit-tab-name').value.trim();
+ const idsStr = document.getElementById('edit-tab-ids').value.trim();
+ const ids = idsStr.split(',').map(id => id.trim()).filter(id => id);
+
+ if (!newName) {
+ alert('Tab name cannot be empty');
+ return;
+ }
+
+ try {
+ const response = await fetch(`/tabs/${this.currentTab}`, {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ name: newName, ids })
+ });
+
+ if (response.ok) {
+ await this.loadState();
+ this.renderTabs();
+ this.selectTab(newName);
+ this.hideModal('edit-tab-modal');
+ } else {
+ const error = await response.json();
+ alert(error.error || 'Failed to update tab');
+ }
+ } catch (error) {
+ console.error('Failed to update tab:', error);
+ alert('Failed to update tab');
+ }
+ }
+
+ async deleteCurrentTab() {
+ if (!this.currentTab) {
+ alert('Please select a tab first');
+ return;
+ }
+
+ if (!confirm(`Are you sure you want to delete the tab '${this.currentTab}'?`)) {
+ return;
+ }
+
+ try {
+ const response = await fetch(`/tabs/${this.currentTab}`, {
+ method: 'DELETE'
+ });
+
+ if (response.ok) {
+ await this.loadState();
+ this.renderTabs();
+ if (this.state.tab_order.length > 0) {
+ this.selectTab(this.state.tab_order[0]);
+ } else {
+ this.currentTab = null;
+ document.getElementById('tab-content').innerHTML = '
No tabs available. Create a new tab to get started.
';
+ }
+ }
+ } catch (error) {
+ console.error('Failed to delete tab:', error);
+ alert('Failed to delete tab');
+ }
+ }
+
+ async addColorToPalette() {
+ if (!this.currentTab) return;
+
+ const pattern = this.state.lights[this.currentTab].settings.pattern;
+ const patternSettings = this.getPatternSettings(this.currentTab, pattern);
+ patternSettings.colors.push('#000000');
+ this.selectedColorIndex = patternSettings.colors.length - 1;
+ this.renderColorPalette(this.currentTab, patternSettings.colors);
+ }
+
+ async removeSelectedColor() {
+ if (!this.currentTab) return;
+
+ const pattern = this.state.lights[this.currentTab].settings.pattern;
+ const patternSettings = this.getPatternSettings(this.currentTab, pattern);
+
+ if (patternSettings.colors.length <= 1) {
+ alert('There must be at least one color in the palette');
+ return;
+ }
+
+ patternSettings.colors.splice(this.selectedColorIndex, 1);
+ if (this.selectedColorIndex >= patternSettings.colors.length) {
+ this.selectedColorIndex = patternSettings.colors.length - 1;
+ }
+ this.renderColorPalette(this.currentTab, patternSettings.colors);
+ }
+
+ async showColorPalette() {
+ const modal = document.getElementById('color-palette-modal');
+ modal.classList.add('active');
+ // Update current profile display in palette modal
+ const profileNameDisplay = document.getElementById('palette-current-profile-name');
+ if (profileNameDisplay) {
+ profileNameDisplay.textContent = this.state.current_profile || 'None';
+ }
+ await this.loadProfilePalette();
+ }
+
+ async showProfiles() {
+ const modal = document.getElementById('profiles-modal');
+ modal.classList.add('active');
+
+ await this.loadProfiles();
+ await this.loadProfilePalette();
+ }
+
+ async loadProfiles() {
+ try {
+ const response = await fetch('/profiles');
+ const profiles = await response.json();
+
+ const profilesList = document.getElementById('profiles-list');
+ profilesList.innerHTML = '';
+
+ // Controllers return dict with IDs as keys, convert to array of names
+ const profileNames = Object.keys(profiles || {});
+ const profileData = Object.values(profiles || {});
+
+ // Find current profile (first one or from settings)
+ const currentProfile = profileNames.length > 0 ? profileNames[0] : '';
+ this.state.current_profile = currentProfile;
+ if (currentProfile && profiles[currentProfile]) {
+ this.state.color_palette = profiles[currentProfile].color_palette || [];
+ }
+ this.updateCurrentProfileDisplay();
+
+ if (profileNames.length === 0) {
+ profilesList.innerHTML = 'No profiles found
';
+ } else {
+ profileNames.forEach(profileId => {
+ const profile = profiles[profileId];
+ const profileName = profile.name || profileId;
+ const profileItem = document.createElement('div');
+ profileItem.style.cssText = 'display: flex; align-items: center; justify-content: space-between; padding: 0.75rem; background-color: #3a3a3a; border-radius: 4px; margin-bottom: 0.5rem;';
+
+ const profileLabel = document.createElement('span');
+ profileLabel.textContent = profileName;
+ if (profileId === currentProfile || profileName === currentProfile) {
+ profileLabel.textContent = `✓ ${profileName}`;
+ profileLabel.style.fontWeight = 'bold';
+ profileLabel.style.color = '#FFD700';
+ }
+
+ const actionsContainer = document.createElement('div');
+ actionsContainer.style.cssText = 'display: flex; gap: 0.5rem;';
+
+ const loadButton = document.createElement('button');
+ loadButton.className = 'btn btn-small';
+ loadButton.textContent = 'Load';
+ loadButton.addEventListener('click', () => this.loadProfile(profileId));
+
+ const deleteButton = document.createElement('button');
+ deleteButton.className = 'btn btn-small btn-danger';
+ deleteButton.textContent = 'Delete';
+ deleteButton.addEventListener('click', () => this.deleteProfile(profileId));
+
+ actionsContainer.appendChild(loadButton);
+ actionsContainer.appendChild(deleteButton);
+
+ profileItem.appendChild(profileLabel);
+ profileItem.appendChild(actionsContainer);
+ profilesList.appendChild(profileItem);
+ });
+ }
+ } catch (error) {
+ console.error('Failed to load profiles:', error);
+ alert('Failed to load profiles');
+ }
+ }
+
+ async deleteProfile(profileName) {
+ if (!confirm(`Delete profile '${profileName}'? This cannot be undone.`)) {
+ return;
+ }
+
+ try {
+ const response = await fetch(`/profiles/${profileName}`, {
+ method: 'DELETE'
+ });
+
+ if (response.ok) {
+ await this.loadProfiles();
+ // If the current profile was deleted, clear current state tabs
+ if (this.state.current_profile === profileName) {
+ this.state.current_profile = '';
+ this.state.lights = {};
+ this.state.tab_order = [];
+ this.renderTabs();
+ document.getElementById('tab-content').innerHTML = 'No tabs available. Create a new tab to get started.
';
+ this.updateCurrentProfileDisplay();
+ }
+ } else {
+ const error = await response.json();
+ alert(error.error || 'Failed to delete profile');
+ }
+ } catch (error) {
+ console.error('Failed to delete profile:', error);
+ alert('Failed to delete profile');
+ }
+ }
+
+ async loadProfile(profileName) {
+ try {
+ const response = await fetch(`/profiles/${profileName}`, {
+ method: 'POST'
+ });
+
+ if (response.ok) {
+ await this.loadState();
+ this.renderTabs();
+ if (this.state.tab_order.length > 0) {
+ this.selectTab(this.state.tab_order[0]);
+ } else {
+ this.currentTab = null;
+ }
+ await this.loadProfiles(); // Refresh the profiles list
+ } else {
+ const error = await response.json();
+ alert(error.error || 'Failed to load profile');
+ }
+ } catch (error) {
+ console.error('Failed to load profile:', error);
+ alert('Failed to load profile');
+ }
+ }
+
+ async createProfile() {
+ const nameInput = document.getElementById('new-profile-name');
+ const profileName = nameInput.value.trim();
+
+ if (!profileName) {
+ alert('Profile name cannot be empty');
+ return;
+ }
+
+ try {
+ const response = await fetch('/profiles', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ name: profileName })
+ });
+
+ if (response.ok) {
+ nameInput.value = '';
+ await this.loadProfiles();
+ alert(`Profile '${profileName}' created successfully`);
+ } else {
+ const error = await response.json();
+ alert(error.error || 'Failed to create profile');
+ }
+ } catch (error) {
+ console.error('Failed to create profile:', error);
+ alert('Failed to create profile');
+ }
+ }
+
+ async loadProfilePalette() {
+ const currentProfile = this.state.current_profile;
+ if (!currentProfile) {
+ this.renderProfilePalette([]);
+ return;
+ }
+
+ try {
+ // TODO: /profiles//palette endpoint doesn't exist, load profile and extract palette
+ const profileResponse = await fetch(`/profiles/${currentProfile}`);
+ const profile = await profileResponse.json();
+ const response = { json: async () => ({ color_palette: profile.color_palette || [] }) };
+ if (response.ok) {
+ const data = await response.json();
+ this.renderProfilePalette(data.color_palette || []);
+ } else {
+ this.renderProfilePalette([]);
+ }
+ } catch (error) {
+ console.error('Failed to load profile palette:', error);
+ this.renderProfilePalette([]);
+ }
+ }
+
+ renderProfilePalette(colors) {
+ // Render in profiles modal
+ const container = document.getElementById('profile-palette-container');
+ if (container) {
+ container.innerHTML = '';
+ colors.forEach((color, index) => {
+ const swatch = this.createPaletteSwatch(color, index);
+ container.appendChild(swatch);
+ });
+ }
+
+ // Render in color palette modal
+ const paletteContainer = document.getElementById('palette-container');
+ if (paletteContainer) {
+ paletteContainer.innerHTML = '';
+ colors.forEach((color, index) => {
+ const swatch = this.createPaletteSwatch(color, index);
+ paletteContainer.appendChild(swatch);
+ });
+ }
+ }
+
+ createPaletteSwatch(color, index) {
+ const swatch = document.createElement('div');
+ swatch.style.cssText = 'width: 40px; height: 40px; background-color: ' + color + '; border: 2px solid #4a4a4a; border-radius: 4px; cursor: pointer; position: relative;';
+ swatch.title = `Click to apply ${color} to selected color`;
+
+ // Click to apply color to currently selected color in active tab
+ swatch.addEventListener('click', (e) => {
+ // Only apply if not clicking the remove button
+ if (e.target === swatch || !e.target.closest('button')) {
+ this.applyPaletteColorToSelected(color);
+ }
+ });
+
+ const removeBtn = document.createElement('button');
+ removeBtn.textContent = '×';
+ removeBtn.style.cssText = 'position: absolute; top: -8px; right: -8px; width: 20px; height: 20px; border-radius: 50%; background-color: #ff4444; color: white; border: none; cursor: pointer; font-size: 14px; line-height: 1; display: flex; align-items: center; justify-content: center; z-index: 10;';
+ removeBtn.addEventListener('click', (e) => {
+ e.stopPropagation();
+ this.removePaletteColor(index);
+ });
+
+ swatch.appendChild(removeBtn);
+ return swatch;
+ }
+
+ applyPaletteColorToSelected(paletteColor) {
+ if (!this.currentTab) {
+ alert('No tab selected. Please select a tab first.');
+ return;
+ }
+
+ const pattern = this.state.lights[this.currentTab].settings.pattern;
+ const patternSettings = this.getPatternSettings(this.currentTab, pattern);
+
+ if (patternSettings.colors.length === 0) {
+ alert('No colors in the current pattern. Add a color first.');
+ return;
+ }
+
+ // Apply the palette color to the currently selected color index
+ const selectedIndex = this.selectedColorIndex;
+ if (selectedIndex >= 0 && selectedIndex < patternSettings.colors.length) {
+ this.updateColorInPalette(this.currentTab, selectedIndex, paletteColor);
+ } else {
+ // If no color is selected, apply to the first color
+ this.updateColorInPalette(this.currentTab, 0, paletteColor);
+ }
+ }
+
+ async addPaletteColor() {
+ const colorInput = document.getElementById('new-palette-color');
+ if (!colorInput) return;
+ await this.addPaletteColorFromInput(colorInput);
+ }
+
+ async addPaletteColorFromModal() {
+ const colorInput = document.getElementById('palette-new-color');
+ if (!colorInput) return;
+ await this.addPaletteColorFromInput(colorInput);
+ }
+
+ async addPaletteColorFromInput(colorInput) {
+ const color = colorInput.value;
+ const currentProfile = this.state.current_profile;
+
+ if (!currentProfile) {
+ alert('No profile selected');
+ return;
+ }
+
+ try {
+ const currentPalette = this.state.color_palette || [];
+ if (currentPalette.includes(color)) {
+ alert('Color already in palette');
+ return;
+ }
+
+ const newPalette = [...currentPalette, color];
+ // TODO: /profiles//palette endpoint doesn't exist, update profile directly
+ const profileResponse = await fetch(`/profiles/${currentProfile}`);
+ const profile = await profileResponse.json();
+ profile.color_palette = colorPalette;
+ const response = await fetch(`/profiles/${currentProfile}`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ color_palette: newPalette })
+ });
+
+ if (response.ok) {
+ this.state.color_palette = newPalette;
+ await this.loadProfilePalette();
+ } else {
+ alert('Failed to add color to palette');
+ }
+ } catch (error) {
+ console.error('Failed to add palette color:', error);
+ alert('Failed to add color to palette');
+ }
+ }
+
+ async removePaletteColor(index) {
+ const currentProfile = this.state.current_profile;
+
+ if (!currentProfile) {
+ alert('No profile selected');
+ return;
+ }
+
+ try {
+ const currentPalette = this.state.color_palette || [];
+ const newPalette = currentPalette.filter((_, i) => i !== index);
+
+ // TODO: /profiles//palette endpoint doesn't exist, update profile directly
+ const profileResponse = await fetch(`/profiles/${currentProfile}`);
+ const profile = await profileResponse.json();
+ profile.color_palette = colorPalette;
+ const response = await fetch(`/profiles/${currentProfile}`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ color_palette: newPalette })
+ });
+
+ if (response.ok) {
+ this.state.color_palette = newPalette;
+ await this.loadProfilePalette();
+ } else {
+ alert('Failed to remove color from palette');
+ }
+ } catch (error) {
+ console.error('Failed to remove palette color:', error);
+ alert('Failed to remove color from palette');
+ }
+ }
+
+ showPaletteQuickSelect(wrapper, tabName, colorIndex, colorPickerInput) {
+ const palette = this.state.color_palette || [];
+ if (palette.length === 0) {
+ // No palette colors, allow native picker to open
+ return;
+ }
+
+ // Store context for the modal
+ this.quickPaletteContext = {
+ tabName: tabName,
+ colorIndex: colorIndex,
+ colorPickerInput: colorPickerInput
+ };
+
+ // Show the modal
+ const modal = document.getElementById('quick-palette-modal');
+ modal.classList.add('active');
+
+ // Render palette colors in the modal
+ this.renderQuickPalette(palette);
+ }
+
+ renderQuickPalette(palette) {
+ const container = document.getElementById('quick-palette-container');
+ if (!container) return;
+
+ container.innerHTML = '';
+
+ if (palette.length === 0) {
+ container.innerHTML = 'No colors in palette
';
+ return;
+ }
+
+ palette.forEach((color) => {
+ const colorBtn = document.createElement('div');
+ colorBtn.style.cssText = `width: 80px; height: 80px; background-color: ${color}; border: 3px solid #4a4a4a; border-radius: 8px; cursor: pointer; flex-shrink: 0; position: relative; transition: transform 0.2s, border-color 0.2s;`;
+ colorBtn.title = color;
+
+ // Add color hex label
+ const label = document.createElement('div');
+ label.style.cssText = 'position: absolute; bottom: -20px; left: 0; right: 0; text-align: center; font-size: 0.7rem; color: #aaa;';
+ label.textContent = color;
+ colorBtn.appendChild(label);
+
+ colorBtn.addEventListener('mouseenter', () => {
+ colorBtn.style.transform = 'scale(1.1)';
+ colorBtn.style.borderColor = '#6a5acd';
+ });
+ colorBtn.addEventListener('mouseleave', () => {
+ colorBtn.style.transform = 'scale(1)';
+ colorBtn.style.borderColor = '#4a4a4a';
+ });
+
+ colorBtn.addEventListener('click', () => {
+ if (this.quickPaletteContext) {
+ const { tabName, colorIndex, colorPickerInput } = this.quickPaletteContext;
+ this.updateColorInPalette(tabName, colorIndex, color);
+ colorPickerInput.value = color;
+ }
+ this.hideQuickPaletteModal();
+ });
+
+ container.appendChild(colorBtn);
+ });
+ }
+
+ hideQuickPaletteModal() {
+ const modal = document.getElementById('quick-palette-modal');
+ modal.classList.remove('active');
+ this.quickPaletteContext = null;
+ }
+
+ useColorPickerFromQuickPalette() {
+ if (this.quickPaletteContext && this.quickPaletteContext.colorPickerInput) {
+ const colorPickerInput = this.quickPaletteContext.colorPickerInput;
+ // Mark that we want to allow native picker
+ colorPickerInput.dataset.allowNative = 'true';
+ // Temporarily remove the click handler
+ if (colorPickerInput._clickHandler) {
+ colorPickerInput.removeEventListener('click', colorPickerInput._clickHandler);
+ }
+ this.hideQuickPaletteModal();
+ // Trigger native color picker after closing modal
+ setTimeout(() => {
+ // Try using showPicker() if available (modern browsers)
+ if (colorPickerInput.showPicker) {
+ colorPickerInput.showPicker().catch(() => {
+ // Fallback to click if showPicker fails
+ colorPickerInput.click();
+ });
+ } else {
+ // Fallback to click for older browsers
+ colorPickerInput.click();
+ }
+ // Restore the click handler after a moment
+ setTimeout(() => {
+ if (colorPickerInput._clickHandler) {
+ colorPickerInput.addEventListener('click', colorPickerInput._clickHandler);
+ }
+ delete colorPickerInput.dataset.allowNative;
+ }, 500);
+ }, 200);
+ }
+ }
+
+ async showPresets() {
+ const modal = document.getElementById('presets-modal');
+ modal.classList.add('active');
+ await this.loadPresets();
+ }
+
+ async loadPresets() {
+ try {
+ const response = await fetch('/presets');
+ const data = await response.json();
+
+ // Controllers return the full dict, so use it directly
+ this.state.presets = data || {};
+
+ const presetsList = document.getElementById('presets-list');
+ presetsList.innerHTML = '';
+
+ const presetNames = Object.keys(this.state.presets);
+ if (presetNames.length === 0) {
+ presetsList.innerHTML = 'No presets found. Create one to get started.
';
+ } else {
+ presetNames.forEach(presetName => {
+ const preset = this.state.presets[presetName];
+ const presetItem = document.createElement('div');
+ presetItem.style.cssText = 'display: flex; align-items: center; justify-content: space-between; padding: 0.75rem; background-color: #3a3a3a; border-radius: 4px; margin-bottom: 0.5rem;';
+
+ const presetInfo = document.createElement('div');
+ presetInfo.style.cssText = 'display: flex; flex-direction: column; gap: 0.25rem; flex: 1;';
+
+ const presetNameLabel = document.createElement('span');
+ presetNameLabel.textContent = presetName;
+ presetNameLabel.style.fontWeight = 'bold';
+
+ const presetDetails = document.createElement('span');
+ presetDetails.style.fontSize = '0.9em';
+ presetDetails.style.color = '#aaa';
+ presetDetails.textContent = `Pattern: ${preset.pattern} | Brightness: ${preset.brightness} | Delay: ${preset.delay}ms | Colors: ${preset.colors.length}`;
+
+ presetInfo.appendChild(presetNameLabel);
+ presetInfo.appendChild(presetDetails);
+
+ const actionsContainer = document.createElement('div');
+ actionsContainer.style.cssText = 'display: flex; gap: 0.5rem;';
+
+ const applyButton = document.createElement('button');
+ applyButton.className = 'btn btn-small btn-primary';
+ applyButton.textContent = 'Apply';
+ applyButton.addEventListener('click', () => this.applyPreset(presetName));
+
+ const editButton = document.createElement('button');
+ editButton.className = 'btn btn-small';
+ editButton.textContent = 'Edit';
+ editButton.addEventListener('click', () => this.showPresetEditor(presetName));
+
+ const deleteButton = document.createElement('button');
+ deleteButton.className = 'btn btn-small btn-danger';
+ deleteButton.textContent = 'Delete';
+ deleteButton.addEventListener('click', () => this.deletePreset(presetName));
+
+ actionsContainer.appendChild(applyButton);
+ actionsContainer.appendChild(editButton);
+ actionsContainer.appendChild(deleteButton);
+
+ presetItem.appendChild(presetInfo);
+ presetItem.appendChild(actionsContainer);
+ presetsList.appendChild(presetItem);
+ });
+ }
+ } catch (error) {
+ console.error('Failed to load presets:', error);
+ alert('Failed to load presets');
+ }
+ }
+
+ async applyPreset(presetName) {
+ if (!this.currentTab) {
+ alert('Please select a tab first');
+ return;
+ }
+
+ try {
+ // TODO: /presets//apply endpoint doesn't exist, using pattern/parameters directly
+ const preset = this.state.presets[presetName];
+ if (!preset) {
+ alert('Preset not found');
+ return;
+ }
+
+ // Apply preset using the apply endpoint
+ const response = await fetch(`/api/presets/${presetName}/apply`, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json'
+ },
+ body: JSON.stringify({
+ tab_name: this.currentTab
+ })
+ });
+
+ if (response.ok) {
+ await this.loadState();
+ await this.loadTabContent(this.currentTab);
+ this.hideModal('presets-modal');
+ } else {
+ const error = await response.json();
+ alert(`Failed to apply preset: ${error.error || 'Unknown error'}`);
+ }
+ } catch (error) {
+ console.error('Failed to apply preset:', error);
+ alert('Failed to apply preset');
+ }
+ }
+
+ async deletePreset(presetName) {
+ if (!confirm(`Delete preset '${presetName}'? This cannot be undone.`)) {
+ return;
+ }
+
+ try {
+ const response = await fetch(`/presets/${presetName}`, {
+ method: 'DELETE'
+ });
+
+ if (response.ok) {
+ await this.loadPresets();
+ } else {
+ const error = await response.json();
+ alert(`Failed to delete preset: ${error.error || 'Unknown error'}`);
+ }
+ } catch (error) {
+ console.error('Failed to delete preset:', error);
+ alert('Failed to delete preset');
+ }
+ }
+
+ showPresetEditor(presetName = null) {
+ const modal = document.getElementById('preset-editor-modal');
+ const title = document.getElementById('preset-editor-title');
+ const nameInput = document.getElementById('preset-name-input');
+ const patternSelect = document.getElementById('preset-pattern-select');
+ const brightnessSlider = document.getElementById('preset-brightness-slider');
+ const brightnessValue = document.getElementById('preset-brightness-value');
+ const delayInput = document.getElementById('preset-delay-input');
+ const n1Input = document.getElementById('preset-n1-input');
+ const n2Input = document.getElementById('preset-n2-input');
+ const n3Input = document.getElementById('preset-n3-input');
+ const n4Input = document.getElementById('preset-n4-input');
+
+ // Store editing preset name
+ this.editingPresetName = presetName;
+
+ // Populate pattern select
+ patternSelect.innerHTML = '';
+ const patterns = Object.keys(this.state.patterns || {});
+ patterns.forEach(pattern => {
+ const option = document.createElement('option');
+ option.value = pattern;
+ option.textContent = pattern;
+ patternSelect.appendChild(option);
+ });
+
+ if (presetName && this.state.presets[presetName]) {
+ // Editing existing preset
+ title.textContent = 'Edit Preset';
+ const preset = this.state.presets[presetName];
+ nameInput.value = presetName;
+ nameInput.disabled = false;
+ patternSelect.value = preset.pattern;
+ brightnessSlider.value = preset.brightness;
+ brightnessValue.textContent = preset.brightness;
+ delayInput.value = preset.delay;
+ n1Input.value = preset.n1;
+ n2Input.value = preset.n2;
+ n3Input.value = preset.n3;
+ n4Input.value = preset.n4;
+ this.renderPresetColors(preset.colors);
+ } else {
+ // Creating new preset
+ title.textContent = 'Create Preset';
+ nameInput.value = '';
+ nameInput.disabled = false;
+ patternSelect.value = patterns[0] || 'on';
+ brightnessSlider.value = 127;
+ brightnessValue.textContent = 127;
+ delayInput.value = 100;
+ n1Input.value = 10;
+ n2Input.value = 10;
+ n3Input.value = 10;
+ n4Input.value = 10;
+ this.renderPresetColors(['#000000']);
+ }
+
+ this.selectedPresetColorIndex = 0;
+ modal.classList.add('active');
+ }
+
+ renderPresetColors(colors) {
+ const container = document.getElementById('preset-colors-container');
+ container.innerHTML = '';
+
+ colors.forEach((color, index) => {
+ const colorWrapper = document.createElement('div');
+ colorWrapper.style.cssText = 'position: relative;';
+
+ const colorSwatch = document.createElement('div');
+ colorSwatch.style.cssText = `width: 50px; height: 50px; background-color: ${color}; border: 2px solid ${index === this.selectedPresetColorIndex ? '#FFD700' : '#4a4a4a'}; border-radius: 4px; cursor: pointer; position: relative;`;
+ colorSwatch.dataset.index = index;
+ colorSwatch.addEventListener('click', () => {
+ this.selectedPresetColorIndex = index;
+ this.renderPresetColors(colors);
+ });
+
+ const colorPicker = document.createElement('input');
+ colorPicker.type = 'color';
+ colorPicker.value = color;
+ colorPicker.style.cssText = 'position: absolute; top: 0; left: 0; width: 50px; height: 50px; opacity: 0; cursor: pointer;';
+ colorPicker.addEventListener('change', (e) => {
+ colors[index] = e.target.value;
+ this.renderPresetColors(colors);
+ });
+
+ colorWrapper.appendChild(colorSwatch);
+ colorWrapper.appendChild(colorPicker);
+ container.appendChild(colorWrapper);
+ });
+
+ this.presetColors = colors;
+ }
+
+ addPresetColor() {
+ const colorInput = document.getElementById('preset-new-color');
+ const color = colorInput.value;
+
+ if (!this.presetColors) {
+ this.presetColors = [];
+ }
+
+ this.presetColors.push(color);
+ this.selectedPresetColorIndex = this.presetColors.length - 1;
+ this.renderPresetColors(this.presetColors);
+ }
+
+ removePresetColor() {
+ if (!this.presetColors || this.presetColors.length === 0) {
+ return;
+ }
+
+ if (this.selectedPresetColorIndex >= 0 && this.selectedPresetColorIndex < this.presetColors.length) {
+ this.presetColors.splice(this.selectedPresetColorIndex, 1);
+ if (this.selectedPresetColorIndex >= this.presetColors.length) {
+ this.selectedPresetColorIndex = Math.max(0, this.presetColors.length - 1);
+ }
+ this.renderPresetColors(this.presetColors);
+ }
+ }
+
+ loadCurrentTabToPresetEditor() {
+ if (!this.currentTab || !this.state.lights[this.currentTab]) {
+ alert('Please select a tab first');
+ return;
+ }
+
+ const light = this.state.lights[this.currentTab];
+ const settings = light.settings;
+ const pattern = settings.pattern || 'on';
+
+ // Get pattern-specific settings
+ const patternSettings = this.getPatternSettings(this.currentTab, pattern);
+
+ const patternSelect = document.getElementById('preset-pattern-select');
+ const brightnessSlider = document.getElementById('preset-brightness-slider');
+ const brightnessValue = document.getElementById('preset-brightness-value');
+ const delayInput = document.getElementById('preset-delay-input');
+ const n1Input = document.getElementById('preset-n1-input');
+ const n2Input = document.getElementById('preset-n2-input');
+ const n3Input = document.getElementById('preset-n3-input');
+ const n4Input = document.getElementById('preset-n4-input');
+
+ patternSelect.value = pattern;
+ brightnessSlider.value = settings.brightness || 127;
+ brightnessValue.textContent = settings.brightness || 127;
+ delayInput.value = patternSettings.delay || 100;
+ n1Input.value = patternSettings.n1 || 10;
+ n2Input.value = patternSettings.n2 || 10;
+ n3Input.value = patternSettings.n3 || 10;
+ n4Input.value = patternSettings.n4 || 10;
+
+ this.renderPresetColors(patternSettings.colors || ['#000000']);
+ }
+
+ async savePreset() {
+ const nameInput = document.getElementById('preset-name-input');
+ const patternSelect = document.getElementById('preset-pattern-select');
+ const brightnessSlider = document.getElementById('preset-brightness-slider');
+ const delayInput = document.getElementById('preset-delay-input');
+ const n1Input = document.getElementById('preset-n1-input');
+ const n2Input = document.getElementById('preset-n2-input');
+ const n3Input = document.getElementById('preset-n3-input');
+ const n4Input = document.getElementById('preset-n4-input');
+
+ const presetName = nameInput.value.trim();
+ if (!presetName) {
+ alert('Please enter a preset name');
+ return;
+ }
+
+ if (!this.presetColors || this.presetColors.length === 0) {
+ alert('Please add at least one color');
+ return;
+ }
+
+ const presetData = {
+ name: presetName,
+ pattern: patternSelect.value,
+ brightness: parseInt(brightnessSlider.value),
+ delay: parseInt(delayInput.value),
+ n1: parseInt(n1Input.value),
+ n2: parseInt(n2Input.value),
+ n3: parseInt(n3Input.value),
+ n4: parseInt(n4Input.value),
+ colors: this.presetColors
+ };
+
+ try {
+ let response;
+ if (this.editingPresetName) {
+ // Update existing preset
+ response = await fetch(`/presets/${this.editingPresetName}`, {
+ method: 'PUT',
+ headers: {
+ 'Content-Type': 'application/json'
+ },
+ body: JSON.stringify(presetData)
+ });
+ } else {
+ // Create new preset
+ response = await fetch('/presets', {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json'
+ },
+ body: JSON.stringify(presetData)
+ });
+ }
+
+ if (response.ok) {
+ await this.loadPresets();
+ this.hideModal('preset-editor-modal');
+ this.editingPresetName = null;
+ } else {
+ const error = await response.json();
+ alert(`Failed to save preset: ${error.error || 'Unknown error'}`);
+ }
+ } catch (error) {
+ console.error('Failed to save preset:', error);
+ alert('Failed to save preset');
+ }
+ }
+
+ hideModal(modalId) {
+ document.getElementById(modalId).classList.remove('active');
+ }
+}
+
+// Initialize app when DOM is ready
+document.addEventListener('DOMContentLoaded', () => {
+ new LightingController();
+});
+
diff --git a/src/static/color_palette.js b/src/static/color_palette.js
new file mode 100644
index 0000000..697d9b0
--- /dev/null
+++ b/src/static/color_palette.js
@@ -0,0 +1,144 @@
+document.addEventListener('DOMContentLoaded', () => {
+ const paletteButton = document.getElementById('color-palette-btn');
+ const paletteModal = document.getElementById('color-palette-modal');
+ const closeButton = document.getElementById('color-palette-close-btn');
+ const paletteContainer = document.getElementById('palette-container');
+ const paletteNewColor = document.getElementById('palette-new-color');
+ const paletteAddButton = document.getElementById('palette-add-color-btn');
+ const profileNameDisplay = document.getElementById('palette-current-profile-name');
+
+ if (!paletteButton || !paletteModal || !paletteContainer) {
+ return;
+ }
+
+ let currentProfileId = null;
+ let currentPalette = [];
+ let currentProfileName = null;
+
+ const renderPalette = () => {
+ paletteContainer.innerHTML = '';
+ if (!currentPalette.length) {
+ const empty = document.createElement('p');
+ empty.className = 'muted-text';
+ empty.textContent = 'No colors in palette.';
+ paletteContainer.appendChild(empty);
+ return;
+ }
+ currentPalette.forEach((color, index) => {
+ const row = document.createElement('div');
+ row.className = 'profiles-row';
+ row.dataset.color = color;
+
+ const swatch = document.createElement('div');
+ swatch.style.width = '28px';
+ swatch.style.height = '28px';
+ swatch.style.borderRadius = '4px';
+ swatch.style.backgroundColor = color;
+ swatch.style.border = '1px solid #4a4a4a';
+
+ const label = document.createElement('span');
+ label.textContent = color;
+
+ const removeButton = document.createElement('button');
+ removeButton.className = 'btn btn-danger btn-small';
+ removeButton.textContent = 'Remove';
+ removeButton.addEventListener('click', async () => {
+ const updated = currentPalette.filter((_, i) => i !== index);
+ await savePalette(updated);
+ });
+
+ row.appendChild(swatch);
+ row.appendChild(label);
+ row.appendChild(removeButton);
+ paletteContainer.appendChild(row);
+ });
+ };
+
+ const loadPalette = async () => {
+ try {
+ const currentResponse = await fetch('/profiles/current', {
+ headers: { Accept: 'application/json' },
+ });
+ if (!currentResponse.ok) {
+ throw new Error('Failed to load current profile');
+ }
+ const currentData = await currentResponse.json();
+ currentProfileId = currentData.id || null;
+ const profile = currentData.profile || null;
+ currentProfileName = profile ? profile.name : null;
+ if (profileNameDisplay) {
+ profileNameDisplay.textContent = currentProfileName || currentProfileId || 'None';
+ }
+
+ if (!currentProfileId || !profile) {
+ currentPalette = [];
+ renderPalette();
+ return;
+ }
+
+ currentPalette = profile.palette || profile.color_palette || [];
+ renderPalette();
+ } catch (error) {
+ console.error('Failed to load palette:', error);
+ currentPalette = [];
+ renderPalette();
+ }
+ };
+
+ const savePalette = async (newPalette) => {
+ if (!currentProfileId) {
+ alert('No profile selected.');
+ return;
+ }
+ try {
+ const response = await fetch('/profiles/current', {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ palette: newPalette,
+ color_palette: newPalette,
+ }),
+ });
+ if (!response.ok) {
+ throw new Error('Failed to save palette');
+ }
+ currentPalette = newPalette;
+ renderPalette();
+ } catch (error) {
+ console.error('Failed to save palette:', error);
+ alert('Failed to save palette.');
+ }
+ };
+
+ const openModal = () => {
+ paletteModal.classList.add('active');
+ loadPalette();
+ };
+
+ const closeModal = () => {
+ paletteModal.classList.remove('active');
+ };
+
+ paletteButton.addEventListener('click', openModal);
+ if (closeButton) {
+ closeButton.addEventListener('click', closeModal);
+ }
+ if (paletteAddButton && paletteNewColor) {
+ paletteAddButton.addEventListener('click', async () => {
+ const color = paletteNewColor.value;
+ if (!color) {
+ return;
+ }
+ if (currentPalette.includes(color)) {
+ alert('Color already in palette.');
+ return;
+ }
+ await savePalette([...currentPalette, color]);
+ });
+ }
+ paletteModal.addEventListener('click', (event) => {
+ if (event.target === paletteModal) {
+ closeModal();
+ }
+ });
+});
diff --git a/src/static/patterns.js b/src/static/patterns.js
new file mode 100644
index 0000000..3b964a1
--- /dev/null
+++ b/src/static/patterns.js
@@ -0,0 +1,86 @@
+document.addEventListener('DOMContentLoaded', () => {
+ const patternsButton = document.getElementById('patterns-btn');
+ const patternsModal = document.getElementById('patterns-modal');
+ const patternsCloseButton = document.getElementById('patterns-close-btn');
+ const patternsList = document.getElementById('patterns-list');
+
+ if (!patternsButton || !patternsModal || !patternsList) {
+ return;
+ }
+
+ const renderPatterns = (patterns) => {
+ patternsList.innerHTML = '';
+ const entries = Object.entries(patterns || {});
+ if (!entries.length) {
+ const empty = document.createElement('p');
+ empty.className = 'muted-text';
+ empty.textContent = 'No patterns found.';
+ patternsList.appendChild(empty);
+ return;
+ }
+ entries.forEach(([patternName, data]) => {
+ const row = document.createElement('div');
+ row.className = 'profiles-row';
+
+ const label = document.createElement('span');
+ label.textContent = patternName;
+
+ const details = document.createElement('span');
+ const minDelay = data && data.min_delay !== undefined ? data.min_delay : '-';
+ const maxDelay = data && data.max_delay !== undefined ? data.max_delay : '-';
+ details.textContent = `${minDelay}–${maxDelay} ms`;
+ details.style.color = '#aaa';
+ details.style.fontSize = '0.85em';
+
+ row.appendChild(label);
+ row.appendChild(details);
+ patternsList.appendChild(row);
+ });
+ };
+
+ const loadPatterns = async () => {
+ patternsList.innerHTML = '';
+ const loading = document.createElement('p');
+ loading.className = 'muted-text';
+ loading.textContent = 'Loading patterns...';
+ patternsList.appendChild(loading);
+
+ try {
+ const response = await fetch('/patterns', {
+ headers: { Accept: 'application/json' },
+ });
+ if (!response.ok) {
+ throw new Error('Failed to load patterns');
+ }
+ const patterns = await response.json();
+ renderPatterns(patterns);
+ } catch (error) {
+ console.error('Load patterns failed:', error);
+ patternsList.innerHTML = '';
+ const errorMessage = document.createElement('p');
+ errorMessage.className = 'muted-text';
+ errorMessage.textContent = 'Failed to load patterns.';
+ patternsList.appendChild(errorMessage);
+ }
+ };
+
+ const openModal = () => {
+ patternsModal.classList.add('active');
+ loadPatterns();
+ };
+
+ const closeModal = () => {
+ patternsModal.classList.remove('active');
+ };
+
+ patternsButton.addEventListener('click', openModal);
+ if (patternsCloseButton) {
+ patternsCloseButton.addEventListener('click', closeModal);
+ }
+
+ patternsModal.addEventListener('click', (event) => {
+ if (event.target === patternsModal) {
+ closeModal();
+ }
+ });
+});
diff --git a/src/static/presets.js b/src/static/presets.js
new file mode 100644
index 0000000..0219f17
--- /dev/null
+++ b/src/static/presets.js
@@ -0,0 +1,381 @@
+document.addEventListener('DOMContentLoaded', () => {
+ const presetsButton = document.getElementById('presets-btn');
+ const presetsModal = document.getElementById('presets-modal');
+ const presetsCloseButton = document.getElementById('presets-close-btn');
+ const presetsList = document.getElementById('presets-list');
+ const presetsAddButton = document.getElementById('preset-add-btn');
+ const presetEditorModal = document.getElementById('preset-editor-modal');
+ const presetEditorCloseButton = document.getElementById('preset-editor-close-btn');
+ const presetNameInput = document.getElementById('preset-name-input');
+ const presetPatternInput = document.getElementById('preset-pattern-input');
+ const presetColorsInput = document.getElementById('preset-colors-input');
+ const presetBrightnessInput = document.getElementById('preset-brightness-input');
+ const presetDelayInput = document.getElementById('preset-delay-input');
+ const presetSaveButton = document.getElementById('preset-save-btn');
+ const presetClearButton = document.getElementById('preset-clear-btn');
+ const presetAddFromPaletteButton = document.getElementById('preset-add-from-palette-btn');
+
+ if (!presetsButton || !presetsModal || !presetsList || !presetSaveButton || !presetClearButton) {
+ return;
+ }
+
+ let currentEditId = null;
+ let cachedPresets = {};
+ let cachedPatterns = {};
+
+ const getNumberInput = (id) => {
+ const input = document.getElementById(id);
+ if (!input) {
+ return 0;
+ }
+ return parseInt(input.value, 10) || 0;
+ };
+
+ const parseColors = (value) => {
+ if (!value) {
+ return [];
+ }
+ return value
+ .split(',')
+ .map((color) => color.trim())
+ .filter((color) => color.length > 0)
+ .map((color) => (color.startsWith('#') ? color : `#${color}`));
+ };
+
+ const setFormValues = (preset) => {
+ if (!presetNameInput || !presetPatternInput || !presetColorsInput || !presetBrightnessInput || !presetDelayInput) {
+ return;
+ }
+ presetNameInput.value = preset.name || '';
+ presetPatternInput.value = preset.pattern || '';
+ presetColorsInput.value = Array.isArray(preset.colors) ? preset.colors.join(',') : '';
+ presetBrightnessInput.value = preset.brightness || 0;
+ presetDelayInput.value = preset.delay || 0;
+ document.getElementById('preset-n1-input').value = preset.n1 || 0;
+ document.getElementById('preset-n2-input').value = preset.n2 || 0;
+ document.getElementById('preset-n3-input').value = preset.n3 || 0;
+ document.getElementById('preset-n4-input').value = preset.n4 || 0;
+ document.getElementById('preset-n5-input').value = preset.n5 || 0;
+ document.getElementById('preset-n6-input').value = preset.n6 || 0;
+ document.getElementById('preset-n7-input').value = preset.n7 || 0;
+ document.getElementById('preset-n8-input').value = preset.n8 || 0;
+ };
+
+ const clearForm = () => {
+ currentEditId = null;
+ setFormValues({
+ name: '',
+ pattern: '',
+ colors: [],
+ brightness: 0,
+ delay: 0,
+ n1: 0,
+ n2: 0,
+ n3: 0,
+ n4: 0,
+ n5: 0,
+ n6: 0,
+ n7: 0,
+ n8: 0,
+ });
+ };
+
+ const openEditor = () => {
+ if (presetEditorModal) {
+ presetEditorModal.classList.add('active');
+ }
+ loadPatterns().then(() => {
+ updatePresetNLabels(presetPatternInput ? presetPatternInput.value : '');
+ });
+ };
+
+ const closeEditor = () => {
+ if (presetEditorModal) {
+ presetEditorModal.classList.remove('active');
+ }
+ };
+
+ const buildPresetPayload = () => {
+ return {
+ name: presetNameInput ? presetNameInput.value.trim() : '',
+ pattern: presetPatternInput ? presetPatternInput.value.trim() : '',
+ colors: parseColors(presetColorsInput ? presetColorsInput.value : ''),
+ brightness: presetBrightnessInput ? parseInt(presetBrightnessInput.value, 10) || 0 : 0,
+ delay: presetDelayInput ? parseInt(presetDelayInput.value, 10) || 0 : 0,
+ n1: getNumberInput('preset-n1-input'),
+ n2: getNumberInput('preset-n2-input'),
+ n3: getNumberInput('preset-n3-input'),
+ n4: getNumberInput('preset-n4-input'),
+ n5: getNumberInput('preset-n5-input'),
+ n6: getNumberInput('preset-n6-input'),
+ n7: getNumberInput('preset-n7-input'),
+ n8: getNumberInput('preset-n8-input'),
+ };
+ };
+
+ const loadPatterns = async () => {
+ if (!presetPatternInput) {
+ return;
+ }
+ try {
+ const response = await fetch('/patterns', {
+ headers: { Accept: 'application/json' },
+ });
+ if (!response.ok) {
+ return;
+ }
+ const patterns = await response.json();
+ cachedPatterns = patterns || {};
+ const entries = Object.keys(cachedPatterns);
+ const desiredPattern = presetPatternInput.value;
+ presetPatternInput.innerHTML = '';
+ entries.forEach((patternName) => {
+ const option = document.createElement('option');
+ option.value = patternName;
+ option.textContent = patternName;
+ presetPatternInput.appendChild(option);
+ });
+ if (desiredPattern && cachedPatterns[desiredPattern]) {
+ presetPatternInput.value = desiredPattern;
+ } else if (entries.length > 0) {
+ let defaultPattern = entries[0];
+ for (const patternName of entries) {
+ const config = cachedPatterns[patternName];
+ const hasMapping = config && Object.values(config).some((value) => {
+ return typeof value === 'string' && value.startsWith('n');
+ });
+ if (hasMapping) {
+ defaultPattern = patternName;
+ break;
+ }
+ }
+ presetPatternInput.value = defaultPattern;
+ }
+ updatePresetNLabels(presetPatternInput.value);
+ } catch (error) {
+ console.warn('Failed to load patterns:', error);
+ }
+ };
+
+ const updatePresetNLabels = (patternName) => {
+ const labels = {};
+ for (let i = 1; i <= 8; i++) {
+ labels[`n${i}`] = `n${i}:`;
+ }
+ const patternConfig = cachedPatterns && cachedPatterns[patternName];
+ if (patternConfig && typeof patternConfig === 'object') {
+ Object.entries(patternConfig).forEach(([label, key]) => {
+ if (typeof key === 'string' && key.startsWith('n')) {
+ labels[key] = `${label}:`;
+ }
+ });
+ }
+ for (let i = 1; i <= 8; i++) {
+ const labelEl = document.getElementById(`preset-n${i}-label`);
+ if (labelEl) {
+ labelEl.textContent = labels[`n${i}`];
+ }
+ }
+ };
+
+ const renderPresets = (presets) => {
+ presetsList.innerHTML = '';
+ cachedPresets = presets || {};
+ const entries = Object.entries(cachedPresets);
+ if (!entries.length) {
+ const empty = document.createElement('p');
+ empty.className = 'muted-text';
+ empty.textContent = 'No presets found.';
+ presetsList.appendChild(empty);
+ return;
+ }
+ entries.forEach(([presetId, preset]) => {
+ const row = document.createElement('div');
+ row.className = 'profiles-row';
+
+ const label = document.createElement('span');
+ label.textContent = (preset && preset.name) || presetId;
+
+ const details = document.createElement('span');
+ const pattern = preset && preset.pattern ? preset.pattern : '-';
+ details.textContent = pattern;
+ details.style.color = '#aaa';
+ details.style.fontSize = '0.85em';
+
+ const editButton = document.createElement('button');
+ editButton.className = 'btn btn-secondary btn-small';
+ editButton.textContent = 'Edit';
+ editButton.addEventListener('click', () => {
+ currentEditId = presetId;
+ setFormValues(preset || {});
+ openEditor();
+ });
+
+ const deleteButton = document.createElement('button');
+ deleteButton.className = 'btn btn-danger btn-small';
+ deleteButton.textContent = 'Delete';
+ deleteButton.addEventListener('click', async () => {
+ const confirmed = confirm(`Delete preset "${label.textContent}"?`);
+ if (!confirmed) {
+ return;
+ }
+ try {
+ const response = await fetch(`/presets/${presetId}`, {
+ method: 'DELETE',
+ headers: { Accept: 'application/json' },
+ });
+ if (!response.ok) {
+ throw new Error('Failed to delete preset');
+ }
+ await loadPresets();
+ if (currentEditId === presetId) {
+ clearForm();
+ }
+ } catch (error) {
+ console.error('Delete preset failed:', error);
+ alert('Failed to delete preset.');
+ }
+ });
+
+ row.appendChild(label);
+ row.appendChild(details);
+ row.appendChild(editButton);
+ row.appendChild(deleteButton);
+ presetsList.appendChild(row);
+ });
+ };
+
+ const loadPresets = async () => {
+ presetsList.innerHTML = '';
+ const loading = document.createElement('p');
+ loading.className = 'muted-text';
+ loading.textContent = 'Loading presets...';
+ presetsList.appendChild(loading);
+
+ try {
+ const response = await fetch('/presets', {
+ headers: { Accept: 'application/json' },
+ });
+ if (!response.ok) {
+ throw new Error('Failed to load presets');
+ }
+ const presets = await response.json();
+ renderPresets(presets);
+ } catch (error) {
+ console.error('Load presets failed:', error);
+ presetsList.innerHTML = '';
+ const errorMessage = document.createElement('p');
+ errorMessage.className = 'muted-text';
+ errorMessage.textContent = 'Failed to load presets.';
+ presetsList.appendChild(errorMessage);
+ }
+ };
+
+ const openModal = () => {
+ presetsModal.classList.add('active');
+ loadPresets();
+ };
+
+ const closeModal = () => {
+ presetsModal.classList.remove('active');
+ };
+
+ presetsButton.addEventListener('click', openModal);
+ if (presetsCloseButton) {
+ presetsCloseButton.addEventListener('click', closeModal);
+ }
+ if (presetsAddButton) {
+ presetsAddButton.addEventListener('click', () => {
+ clearForm();
+ openEditor();
+ });
+ }
+ if (presetEditorCloseButton) {
+ presetEditorCloseButton.addEventListener('click', closeEditor);
+ }
+ presetClearButton.addEventListener('click', clearForm);
+ if (presetPatternInput) {
+ presetPatternInput.addEventListener('change', () => {
+ updatePresetNLabels(presetPatternInput.value);
+ });
+ }
+ if (presetAddFromPaletteButton) {
+ presetAddFromPaletteButton.addEventListener('click', () => {
+ const openButton = document.getElementById('color-palette-btn');
+ if (openButton) {
+ openButton.click();
+ }
+ const modal = document.getElementById('color-palette-modal');
+ const modalList = document.getElementById('palette-container');
+ if (modal) {
+ modal.classList.add('active');
+ }
+ if (!modalList || !presetColorsInput) {
+ return;
+ }
+
+ const handlePick = (event) => {
+ const row = event.target.closest('[data-color]');
+ if (!row) {
+ return;
+ }
+ const picked = row.dataset.color;
+ if (!picked) {
+ return;
+ }
+ const currentColors = parseColors(presetColorsInput.value);
+ if (!currentColors.includes(picked)) {
+ currentColors.push(picked);
+ presetColorsInput.value = currentColors.join(',');
+ }
+ if (modal) {
+ modal.classList.remove('active');
+ }
+ modalList.removeEventListener('click', handlePick);
+ };
+
+ modalList.addEventListener('click', handlePick);
+ });
+ }
+ presetSaveButton.addEventListener('click', async () => {
+ const payload = buildPresetPayload();
+ if (!payload.name) {
+ alert('Preset name is required.');
+ return;
+ }
+ try {
+ const url = currentEditId ? `/presets/${currentEditId}` : '/presets';
+ const method = currentEditId ? 'PUT' : 'POST';
+ const response = await fetch(url, {
+ method,
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(payload),
+ });
+ if (!response.ok) {
+ throw new Error('Failed to save preset');
+ }
+ await loadPresets();
+ clearForm();
+ closeEditor();
+ } catch (error) {
+ console.error('Save preset failed:', error);
+ alert('Failed to save preset.');
+ }
+ });
+
+ presetsModal.addEventListener('click', (event) => {
+ if (event.target === presetsModal) {
+ closeModal();
+ }
+ });
+
+ if (presetEditorModal) {
+ presetEditorModal.addEventListener('click', (event) => {
+ if (event.target === presetEditorModal) {
+ closeEditor();
+ }
+ });
+ }
+
+ clearForm();
+});
diff --git a/src/static/profiles.js b/src/static/profiles.js
new file mode 100644
index 0000000..4b514c0
--- /dev/null
+++ b/src/static/profiles.js
@@ -0,0 +1,186 @@
+document.addEventListener("DOMContentLoaded", () => {
+ const profilesButton = document.getElementById("profiles-btn");
+ const profilesModal = document.getElementById("profiles-modal");
+ const profilesCloseButton = document.getElementById("profiles-close-btn");
+ const profilesList = document.getElementById("profiles-list");
+ const newProfileInput = document.getElementById("new-profile-name");
+ const createProfileButton = document.getElementById("create-profile-btn");
+
+ if (!profilesButton || !profilesModal || !profilesList) {
+ return;
+ }
+
+ const openModal = () => {
+ profilesModal.classList.add("active");
+ loadProfiles();
+ };
+
+ const closeModal = () => {
+ profilesModal.classList.remove("active");
+ };
+
+ const renderProfiles = (profiles, currentProfileId) => {
+ profilesList.innerHTML = "";
+ let entries = [];
+
+ if (Array.isArray(profiles)) {
+ entries = profiles.map((profileId) => [profileId, {}]);
+ } else if (profiles && typeof profiles === "object") {
+ entries = Object.entries(profiles);
+ }
+
+ if (entries.length === 0) {
+ const empty = document.createElement("p");
+ empty.className = "muted-text";
+ empty.textContent = "No profiles found.";
+ profilesList.appendChild(empty);
+ return;
+ }
+
+ entries.forEach(([profileId, profile]) => {
+ const row = document.createElement("div");
+ row.className = "profiles-row";
+
+ const label = document.createElement("span");
+ label.textContent = (profile && profile.name) || profileId;
+ if (String(profileId) === String(currentProfileId)) {
+ label.textContent = `✓ ${label.textContent}`;
+ label.style.fontWeight = "bold";
+ label.style.color = "#FFD700";
+ }
+
+ const applyButton = document.createElement("button");
+ applyButton.className = "btn btn-secondary btn-small profiles-apply-btn";
+ applyButton.textContent = "Apply";
+ applyButton.addEventListener("click", async () => {
+ try {
+ const response = await fetch(`/profiles/${profileId}/apply`, {
+ method: "POST",
+ headers: { Accept: "application/json" },
+ });
+ if (!response.ok) {
+ throw new Error("Failed to apply profile");
+ }
+ await loadProfiles();
+ document.body.dispatchEvent(new Event("tabs-updated"));
+ } catch (error) {
+ console.error("Apply profile failed:", error);
+ alert("Failed to apply profile.");
+ }
+ });
+
+ const deleteButton = document.createElement("button");
+ deleteButton.className = "btn btn-danger btn-small";
+ deleteButton.textContent = "Delete";
+ deleteButton.addEventListener("click", async () => {
+ const confirmed = confirm(`Delete profile "${label.textContent}"?`);
+ if (!confirmed) {
+ return;
+ }
+ try {
+ const response = await fetch(`/profiles/${profileId}`, {
+ method: "DELETE",
+ headers: { Accept: "application/json" },
+ });
+ if (!response.ok) {
+ throw new Error("Failed to delete profile");
+ }
+ await loadProfiles();
+ } catch (error) {
+ console.error("Delete profile failed:", error);
+ alert("Failed to delete profile.");
+ }
+ });
+
+ row.appendChild(label);
+ row.appendChild(applyButton);
+ row.appendChild(deleteButton);
+ profilesList.appendChild(row);
+ });
+ };
+
+ const loadProfiles = async () => {
+ profilesList.innerHTML = "";
+ const loading = document.createElement("p");
+ loading.className = "muted-text";
+ loading.textContent = "Loading profiles...";
+ profilesList.appendChild(loading);
+
+ try {
+ const response = await fetch("/profiles", {
+ headers: { Accept: "application/json" },
+ });
+ if (!response.ok) {
+ throw new Error("Failed to load profiles");
+ }
+ const profiles = await response.json();
+ let currentProfileId = null;
+ try {
+ const currentResponse = await fetch("/profiles/current", {
+ headers: { Accept: "application/json" },
+ });
+ if (currentResponse.ok) {
+ const currentData = await currentResponse.json();
+ currentProfileId = currentData.id || null;
+ }
+ } catch (error) {
+ console.warn("Failed to load current profile:", error);
+ }
+ renderProfiles(profiles, currentProfileId);
+ } catch (error) {
+ console.error("Load profiles failed:", error);
+ profilesList.innerHTML = "";
+ const errorMessage = document.createElement("p");
+ errorMessage.className = "muted-text";
+ errorMessage.textContent = "Failed to load profiles.";
+ profilesList.appendChild(errorMessage);
+ }
+ };
+
+ const createProfile = async () => {
+ if (!newProfileInput) {
+ return;
+ }
+ const name = newProfileInput.value.trim();
+ if (!name) {
+ alert("Profile name cannot be empty.");
+ return;
+ }
+ try {
+ const response = await fetch("/profiles", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ name }),
+ });
+ if (!response.ok) {
+ throw new Error("Failed to create profile");
+ }
+ newProfileInput.value = "";
+ await loadProfiles();
+ } catch (error) {
+ console.error("Create profile failed:", error);
+ alert("Failed to create profile.");
+ }
+ };
+
+ profilesButton.addEventListener("click", openModal);
+ if (profilesCloseButton) {
+ profilesCloseButton.addEventListener("click", closeModal);
+ }
+ if (createProfileButton) {
+ createProfileButton.addEventListener("click", createProfile);
+ }
+ if (newProfileInput) {
+ newProfileInput.addEventListener("keypress", (event) => {
+ if (event.key === "Enter") {
+ createProfile();
+ }
+ });
+ }
+
+ profilesModal.addEventListener("click", (event) => {
+ if (event.target === profilesModal) {
+ closeModal();
+ }
+ });
+});
diff --git a/src/static/style.css b/src/static/style.css
new file mode 100644
index 0000000..9822e4a
--- /dev/null
+++ b/src/static/style.css
@@ -0,0 +1,548 @@
+* {
+ margin: 0;
+ padding: 0;
+ box-sizing: border-box;
+}
+
+body {
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
+ background-color: #2e2e2e;
+ color: white;
+ height: 100vh;
+ overflow: hidden;
+}
+
+.app-container {
+ display: flex;
+ flex-direction: column;
+ height: 100vh;
+}
+
+header {
+ background-color: #1a1a1a;
+ padding: 1rem 2rem;
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ border-bottom: 2px solid #4a4a4a;
+}
+
+header h1 {
+ font-size: 1.5rem;
+ font-weight: 600;
+}
+
+.header-actions {
+ display: flex;
+ gap: 0.5rem;
+}
+
+.btn {
+ padding: 0.5rem 1rem;
+ border: none;
+ border-radius: 4px;
+ cursor: pointer;
+ font-size: 0.9rem;
+ font-weight: 500;
+ transition: background-color 0.2s;
+}
+
+.btn-primary {
+ background-color: #4a4a4a;
+ color: white;
+}
+
+.btn-primary:hover {
+ background-color: #5a5a5a;
+}
+
+.btn-secondary {
+ background-color: #3a3a3a;
+ color: white;
+}
+
+.btn-secondary:hover {
+ background-color: #4a4a4a;
+}
+
+.btn-danger {
+ background-color: #d32f2f;
+ color: white;
+}
+
+.btn-danger:hover {
+ background-color: #c62828;
+}
+
+.btn-small {
+ padding: 0.25rem 0.5rem;
+ font-size: 0.8rem;
+}
+
+.main-content {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ overflow: hidden;
+}
+
+.tabs-container {
+ background-color: #1a1a1a;
+ border-bottom: 2px solid #4a4a4a;
+ padding: 0.5rem 1rem;
+}
+
+.tabs-list {
+ display: flex;
+ gap: 0.5rem;
+ overflow-x: auto;
+}
+
+.tab-button {
+ padding: 0.5rem 1rem;
+ background-color: #3a3a3a;
+ color: white;
+ border: none;
+ border-radius: 4px 4px 0 0;
+ cursor: pointer;
+ font-size: 0.9rem;
+ white-space: nowrap;
+ transition: background-color 0.2s;
+}
+
+.tab-button:hover {
+ background-color: #4a4a4a;
+}
+
+.tab-button.active {
+ background-color: #6a5acd;
+ color: white;
+}
+
+.tab-content {
+ flex: 1;
+ display: flex;
+ overflow: hidden;
+ padding: 1rem;
+ gap: 1rem;
+}
+
+.left-panel {
+ flex: 0 0 50%;
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+ overflow-y: auto;
+ border-right: 2px solid #4a4a4a;
+ padding-right: 1rem;
+}
+
+.right-panel {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+ overflow-y: auto;
+ padding-left: 1rem;
+}
+
+.ids-display {
+ padding: 0.5rem;
+ background-color: #3a3a3a;
+ border-radius: 4px;
+ font-size: 0.9rem;
+}
+
+.left-panel-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.5rem;
+}
+
+.left-panel-toggle {
+ padding: 0.25rem 0.5rem;
+ min-width: 32px;
+}
+
+.left-panel-body {
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+}
+
+.left-panel.collapsed {
+ flex: 0 0 48px;
+ padding-right: 0.5rem;
+}
+
+.left-panel.collapsed .left-panel-body {
+ display: none;
+}
+
+.left-panel.collapsed .left-panel-toggle {
+ transform: rotate(180deg);
+}
+
+.controls-section {
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+}
+
+.control-group {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+}
+
+.control-group label {
+ min-width: 100px;
+ font-weight: 500;
+}
+
+.slider {
+ flex: 1;
+ height: 8px;
+ background-color: #3a3a3a;
+ border-radius: 4px;
+ outline: none;
+ -webkit-appearance: none;
+ margin: 0 0.5rem;
+}
+
+.slider::-webkit-slider-thumb {
+ -webkit-appearance: none;
+ appearance: none;
+ width: 20px;
+ height: 20px;
+ background-color: #6a5acd;
+ border-radius: 50%;
+ cursor: pointer;
+ transition: background-color 0.2s;
+}
+
+.slider::-webkit-slider-thumb:hover {
+ background-color: #7a6add;
+}
+
+.slider::-moz-range-thumb {
+ width: 20px;
+ height: 20px;
+ background-color: #6a5acd;
+ border-radius: 50%;
+ cursor: pointer;
+ border: none;
+ transition: background-color 0.2s;
+}
+
+.slider::-moz-range-thumb:hover {
+ background-color: #7a6add;
+}
+
+/* Red slider */
+#red-slider {
+ accent-color: #ff0000;
+}
+
+#red-slider::-webkit-slider-thumb {
+ background-color: #ff0000;
+}
+
+#red-slider::-moz-range-thumb {
+ background-color: #ff0000;
+}
+
+/* Green slider */
+#green-slider {
+ accent-color: #00ff00;
+}
+
+#green-slider::-webkit-slider-thumb {
+ background-color: #00ff00;
+}
+
+#green-slider::-moz-range-thumb {
+ background-color: #00ff00;
+}
+
+/* Blue slider */
+#blue-slider {
+ accent-color: #0000ff;
+}
+
+#blue-slider::-webkit-slider-thumb {
+ background-color: #0000ff;
+}
+
+#blue-slider::-moz-range-thumb {
+ background-color: #0000ff;
+}
+
+/* Brightness slider */
+#brightness-slider {
+ accent-color: #ffff00;
+}
+
+#brightness-slider::-webkit-slider-thumb {
+ background-color: #ffff00;
+}
+
+#brightness-slider::-moz-range-thumb {
+ background-color: #ffff00;
+}
+
+.slider-value {
+ min-width: 50px;
+ text-align: right;
+ font-weight: 500;
+ font-size: 0.9rem;
+}
+
+.n-params-section {
+ margin-top: 1rem;
+}
+
+.n-params-section h3 {
+ margin-bottom: 0.5rem;
+ font-size: 1rem;
+}
+
+.n-params-grid {
+ display: grid;
+ grid-template-columns: 1fr 1fr;
+ gap: 1rem;
+}
+
+.n-param-group {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+}
+
+.n-param-group label {
+ min-width: 40px;
+ font-weight: 500;
+}
+
+.n-input {
+ flex: 1;
+ padding: 0.5rem;
+ background-color: #3a3a3a;
+ color: white;
+ border: 1px solid #4a4a4a;
+ border-radius: 4px;
+ font-size: 1rem;
+}
+
+.n-input:focus {
+ outline: none;
+ border-color: #6a5acd;
+}
+
+.patterns-section,
+.presets-section,
+.color-palette-section {
+ background-color: #1a1a1a;
+ border: 2px solid #4a4a4a;
+ border-radius: 4px;
+ padding: 1rem;
+}
+
+.patterns-section h3,
+.presets-section h3,
+.color-palette-section h3 {
+ margin-bottom: 1rem;
+ font-size: 1.1rem;
+}
+
+.patterns-list {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+}
+
+.presets-list {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
+ gap: 0.75rem;
+}
+
+.pattern-button {
+ padding: 0.75rem;
+ background-color: #3a3a3a;
+ color: white;
+ border: none;
+ border-radius: 4px;
+ cursor: pointer;
+ font-size: 0.9rem;
+ text-align: left;
+ transition: background-color 0.2s;
+}
+
+.pattern-button:hover {
+ background-color: #4a4a4a;
+}
+
+.pattern-button.active {
+ background-color: #6a5acd;
+ color: white;
+}
+
+.pattern-button.default-preset {
+ border: 2px solid #6a5acd;
+}
+
+.color-palette {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+ margin-bottom: 1rem;
+ max-height: 300px;
+ overflow-y: auto;
+}
+
+.color-swatch {
+ display: flex;
+ align-items: center;
+ padding: 0.5rem;
+ background-color: #3a3a3a;
+ border: 2px solid transparent;
+ border-radius: 4px;
+ cursor: pointer;
+ transition: border-color 0.2s;
+ gap: 0.5rem;
+}
+
+.color-swatch:hover {
+ border-color: #6a5acd;
+}
+
+.color-swatch.selected {
+ border-color: #FFD700;
+ border-width: 3px;
+}
+
+.color-swatch-preview {
+ width: 40px;
+ height: 40px;
+ border-radius: 4px;
+ border: 1px solid #4a4a4a;
+ flex-shrink: 0;
+}
+
+.color-swatch-label {
+ flex: 1;
+ font-size: 0.9rem;
+ min-width: 80px;
+}
+
+.color-picker-input {
+ width: 60px;
+ height: 40px;
+ border: 1px solid #4a4a4a;
+ border-radius: 4px;
+ cursor: pointer;
+ background: none;
+ padding: 0;
+ flex-shrink: 0;
+}
+
+.color-picker-input::-webkit-color-swatch-wrapper {
+ padding: 0;
+}
+
+.color-picker-input::-webkit-color-swatch {
+ border: none;
+ border-radius: 4px;
+}
+
+.color-picker-input::-moz-color-swatch {
+ border: none;
+ border-radius: 4px;
+}
+
+.palette-actions {
+ display: flex;
+ gap: 0.5rem;
+ margin-top: 0.5rem;
+}
+
+.modal {
+ display: none;
+ position: fixed;
+ top: 0;
+ left: 0;
+ width: 100%;
+ height: 100%;
+ background-color: rgba(0, 0, 0, 0.7);
+ z-index: 1000;
+ align-items: center;
+ justify-content: center;
+}
+
+.modal.active {
+ display: flex;
+}
+
+.modal-content {
+ background-color: #2e2e2e;
+ padding: 2rem;
+ border-radius: 8px;
+ min-width: 400px;
+ max-width: 500px;
+}
+
+.modal-content h2 {
+ margin-bottom: 1rem;
+ font-size: 1.3rem;
+}
+
+.modal-content label {
+ display: block;
+ margin-top: 1rem;
+ margin-bottom: 0.5rem;
+ font-weight: 500;
+}
+
+.modal-content input {
+ width: 100%;
+ padding: 0.5rem;
+ background-color: #3a3a3a;
+ color: white;
+ border: 1px solid #4a4a4a;
+ border-radius: 4px;
+ font-size: 1rem;
+}
+
+.modal-content input:focus {
+ outline: none;
+ border-color: #6a5acd;
+}
+
+.modal-actions {
+ display: flex;
+ gap: 0.5rem;
+ margin-top: 1.5rem;
+ justify-content: flex-end;
+}
+
+/* Scrollbar styling */
+::-webkit-scrollbar {
+ width: 8px;
+ height: 8px;
+}
+
+::-webkit-scrollbar-track {
+ background: #1a1a1a;
+}
+
+::-webkit-scrollbar-thumb {
+ background: #4a4a4a;
+ border-radius: 4px;
+}
+
+::-webkit-scrollbar-thumb:hover {
+ background: #5a5a5a;
+}
+
diff --git a/src/static/tab_palette.js b/src/static/tab_palette.js
new file mode 100644
index 0000000..ea9cc9d
--- /dev/null
+++ b/src/static/tab_palette.js
@@ -0,0 +1,262 @@
+document.addEventListener('DOMContentLoaded', () => {
+ let selectedIndex = null;
+
+ const getTab = async (tabId) => {
+ const response = await fetch(`/tabs/${tabId}`, {
+ headers: { Accept: 'application/json' },
+ });
+ if (!response.ok) {
+ throw new Error('No tab found');
+ }
+ return response.json();
+ };
+
+ const saveTabColors = async (tabId, colors) => {
+ const response = await fetch(`/tabs/${tabId}`, {
+ method: 'PUT',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ colors }),
+ });
+ if (!response.ok) {
+ throw new Error('Failed to save tab colors');
+ }
+ return response.json();
+ };
+
+ const renderPalette = (paletteContainer, colors, onColorChange, onRemoveColor, onReorder) => {
+ paletteContainer.innerHTML = '';
+ if (!colors.length) {
+ const empty = document.createElement('div');
+ empty.className = 'muted-text';
+ empty.textContent = 'No colors in palette.';
+ paletteContainer.appendChild(empty);
+ return;
+ }
+
+ colors.forEach((color, index) => {
+ const swatch = document.createElement('div');
+ swatch.className = 'color-swatch';
+ swatch.draggable = true;
+ swatch.dataset.index = String(index);
+ if (index === selectedIndex) {
+ swatch.classList.add('selected');
+ }
+
+ const preview = document.createElement('div');
+ preview.className = 'color-swatch-preview';
+ preview.style.backgroundColor = color;
+
+ const label = document.createElement('span');
+ label.className = 'color-swatch-label';
+ label.textContent = color;
+
+ const colorPicker = document.createElement('input');
+ colorPicker.type = 'color';
+ colorPicker.className = 'color-picker-input';
+ colorPicker.value = color;
+ colorPicker.addEventListener('change', async (event) => {
+ const newColor = event.target.value;
+ await onColorChange(index, newColor);
+ });
+
+ const removeButton = document.createElement('button');
+ removeButton.className = 'btn btn-danger btn-small';
+ removeButton.textContent = 'Remove';
+ removeButton.addEventListener('click', async (event) => {
+ event.stopPropagation();
+ await onRemoveColor(index);
+ });
+
+ swatch.addEventListener('dragstart', (event) => {
+ event.dataTransfer.setData('text/plain', String(index));
+ event.dataTransfer.effectAllowed = 'move';
+ });
+ swatch.addEventListener('dragover', (event) => {
+ event.preventDefault();
+ event.dataTransfer.dropEffect = 'move';
+ });
+ swatch.addEventListener('drop', async (event) => {
+ event.preventDefault();
+ const fromIndex = parseInt(event.dataTransfer.getData('text/plain'), 10);
+ const toIndex = parseInt(swatch.dataset.index || '-1', 10);
+ if (Number.isNaN(fromIndex) || Number.isNaN(toIndex) || fromIndex === toIndex) {
+ return;
+ }
+ await onReorder(fromIndex, toIndex);
+ });
+
+ swatch.appendChild(preview);
+ swatch.appendChild(label);
+ swatch.appendChild(colorPicker);
+ swatch.appendChild(removeButton);
+ swatch.addEventListener('click', () => {
+ selectedIndex = index;
+ renderPalette(paletteContainer, colors, onColorChange, onRemoveColor, onReorder);
+ colorPicker.click();
+ });
+
+ paletteContainer.appendChild(swatch);
+ });
+ };
+
+ const initTabPalette = async () => {
+ const paletteContainer = document.getElementById('color-palette');
+ const addButton = document.getElementById('tab-color-add-btn');
+ const addFromPaletteButton = document.getElementById('tab-color-add-from-palette-btn');
+ const colorInput = document.getElementById('tab-color-input');
+
+ if (!paletteContainer || !addButton || !colorInput) {
+ return;
+ }
+
+ const tabId = paletteContainer.dataset.tabId;
+ if (!tabId) {
+ renderPalette(paletteContainer, []);
+ return;
+ }
+
+ let tabData;
+ try {
+ tabData = await getTab(tabId);
+ } catch (error) {
+ renderPalette(paletteContainer, []);
+ return;
+ }
+
+ let colors = tabData.colors || [];
+ if (!Array.isArray(colors)) {
+ colors = [];
+ }
+ const onRemoveColor = async (index) => {
+ if (index === null || index < 0 || index >= colors.length) {
+ alert('Select a color to remove.');
+ return;
+ }
+ try {
+ const updated = colors.filter((_, i) => i !== index);
+ const saved = await saveTabColors(tabId, updated);
+ colors = saved.colors || updated;
+ selectedIndex = null;
+ renderPalette(paletteContainer, colors, onColorChange, onRemoveColor, onReorder);
+ } catch (error) {
+ console.error('Failed to remove color:', error);
+ alert('Failed to remove color.');
+ }
+ };
+
+ const onReorder = async (fromIndex, toIndex) => {
+ if (fromIndex < 0 || fromIndex >= colors.length || toIndex < 0 || toIndex >= colors.length) {
+ return;
+ }
+ try {
+ const updated = [...colors];
+ const [moved] = updated.splice(fromIndex, 1);
+ updated.splice(toIndex, 0, moved);
+ const saved = await saveTabColors(tabId, updated);
+ colors = saved.colors || updated;
+ selectedIndex = toIndex;
+ renderPalette(paletteContainer, colors, onColorChange, onRemoveColor, onReorder);
+ } catch (error) {
+ console.error('Failed to reorder colors:', error);
+ alert('Failed to reorder colors.');
+ }
+ };
+
+ const onColorChange = async (index, newColor) => {
+ if (!newColor || index < 0 || index >= colors.length) {
+ return;
+ }
+ try {
+ const updated = [...colors];
+ updated[index] = newColor;
+ const saved = await saveTabColors(tabId, updated);
+ colors = saved.colors || updated;
+ selectedIndex = index;
+ renderPalette(paletteContainer, colors, onColorChange, onRemoveColor, onReorder);
+ } catch (error) {
+ console.error('Failed to update color:', error);
+ alert('Failed to update color.');
+ }
+ };
+
+ renderPalette(paletteContainer, colors, onColorChange, onRemoveColor, onReorder);
+
+ addButton.onclick = async () => {
+ const newColor = colorInput.value;
+ if (!newColor) {
+ return;
+ }
+ if (colors.includes(newColor)) {
+ alert('Color already in palette.');
+ return;
+ }
+ try {
+ const updated = [...colors, newColor];
+ const saved = await saveTabColors(tabId, updated);
+ colors = saved.colors || updated;
+ selectedIndex = colors.length - 1;
+ renderPalette(paletteContainer, colors, onColorChange, onRemoveColor, onReorder);
+ } catch (error) {
+ console.error('Failed to add color:', error);
+ alert('Failed to add color.');
+ }
+ };
+
+ if (addFromPaletteButton) {
+ addFromPaletteButton.onclick = () => {
+ const openButton = document.getElementById('color-palette-btn');
+ if (openButton) {
+ openButton.click();
+ }
+ const modal = document.getElementById('color-palette-modal');
+ const modalList = document.getElementById('palette-container');
+ if (modal) {
+ modal.classList.add('active');
+ }
+ if (!modalList) {
+ return;
+ }
+
+ const handlePick = async (event) => {
+ const row = event.target.closest('[data-color]');
+ if (!row) {
+ return;
+ }
+ const picked = row.dataset.color;
+ if (!picked) {
+ return;
+ }
+ try {
+ if (!colors.includes(picked)) {
+ const updated = [...colors, picked];
+ const saved = await saveTabColors(tabId, updated);
+ colors = saved.colors || updated;
+ selectedIndex = colors.indexOf(picked);
+ renderPalette(paletteContainer, colors, onColorChange, onRemoveColor, onReorder);
+ }
+ if (modal) {
+ modal.classList.remove('active');
+ }
+ } catch (error) {
+ console.error('Failed to add palette color:', error);
+ alert('Failed to add palette color.');
+ } finally {
+ modalList.removeEventListener('click', handlePick);
+ }
+ };
+
+ modalList.addEventListener('click', handlePick);
+ };
+ }
+
+ };
+
+ document.body.addEventListener('htmx:afterSwap', (event) => {
+ if (event.target && event.target.id === 'tab-content') {
+ selectedIndex = null;
+ initTabPalette();
+ }
+ });
+
+ initTabPalette();
+});
diff --git a/src/templates/index.html b/src/templates/index.html
index 5431954..82fd08e 100644
--- a/src/templates/index.html
+++ b/src/templates/index.html
@@ -1,14 +1,295 @@
-
+
-
-
- RGB Slider Tabs
-
-
-
-
-
+
+
+
+ LED Controller - Tab Mode
+
+
+
+
+
+
+ LED Controller - Tab Mode
+
+
-
-
+
+
+
+
+
+ Select a tab to get started
+
+
+
+
+
+
+
+
+
+
+
+
Edit Tab
+
Edit functionality coming soon...
+
+
+
+
+
+
+
+
+
+
Profiles
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Presets
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Preset
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Patterns
+
+
+
+
+
+
+
+
+
+
+
Color Palette
+
Profile: None
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+