// Shared WebSocket for ESPNow messages (presets + selects) let espnowSocket = null; let espnowSocketReady = false; let espnowPendingMessages = []; const getEspnowSocket = () => { if (espnowSocket && (espnowSocket.readyState === WebSocket.OPEN || espnowSocket.readyState === WebSocket.CONNECTING)) { return espnowSocket; } const wsUrl = `ws://${window.location.host}/ws`; espnowSocket = new WebSocket(wsUrl); espnowSocketReady = false; espnowSocket.onopen = () => { espnowSocketReady = true; // Flush any queued messages espnowPendingMessages.forEach((msg) => { try { espnowSocket.send(msg); } catch (err) { console.error('Failed to send queued ESPNow message:', err); } }); espnowPendingMessages = []; }; espnowSocket.onclose = () => { espnowSocketReady = false; espnowSocket = null; }; espnowSocket.onerror = (err) => { console.error('ESPNow WebSocket error:', err); }; return espnowSocket; }; const sendEspnowMessage = (obj) => { const json = JSON.stringify(obj); const ws = getEspnowSocket(); if (espnowSocketReady && ws.readyState === WebSocket.OPEN) { try { ws.send(json); } catch (err) { console.error('Failed to send ESPNow message:', err); } } else { // Queue until connection is open espnowPendingMessages.push(json); } }; // Send a select message for a preset to all device names in the current tab. const sendSelectForCurrentTabDevices = (presetName, sectionEl) => { const section = sectionEl || document.querySelector('.presets-section[data-tab-id]'); if (!section || !presetName) { return; } const namesAttr = section.getAttribute('data-device-names'); const deviceNames = namesAttr ? namesAttr.split(',').map((n) => n.trim()).filter((n) => n.length > 0) : []; if (!deviceNames.length) { return; } const select = {}; deviceNames.forEach((name) => { select[name] = [presetName]; }); const message = { v: '1', select, }; sendEspnowMessage(message); }; 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 presetColorsContainer = document.getElementById('preset-colors-container'); const presetNewColorInput = document.getElementById('preset-new-color'); const presetAddColorButton = document.getElementById('preset-add-color-btn'); 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'); const presetRemoveFromTabButton = document.getElementById('preset-remove-from-tab-btn'); if (!presetsButton || !presetsModal || !presetsList || !presetSaveButton || !presetClearButton) { return; } let currentEditId = null; let currentEditTabId = null; let cachedPresets = {}; let cachedPatterns = {}; let currentPresetColors = []; // Track colors for the current preset // Function to get max colors for current pattern const getMaxColors = () => { if (!presetPatternInput || !presetPatternInput.value) { return Infinity; // No pattern selected, no limit } const patternName = presetPatternInput.value.trim(); const patternConfig = cachedPatterns && cachedPatterns[patternName]; if (patternConfig && typeof patternConfig === 'object' && patternConfig.max_colors !== undefined) { return patternConfig.max_colors; } return Infinity; // No limit if not specified }; // Function to show/hide color section based on max_colors const updateColorSectionVisibility = () => { const maxColors = getMaxColors(); const shouldShow = maxColors > 0; // Find the color label (the label before the container) if (presetColorsContainer) { let prev = presetColorsContainer.previousElementSibling; while (prev) { if (prev.tagName === 'LABEL' && prev.textContent.trim().toLowerCase().includes('color')) { prev.style.display = shouldShow ? '' : 'none'; break; } prev = prev.previousElementSibling; } // Hide/show the container presetColorsContainer.style.display = shouldShow ? '' : 'none'; // Hide/show the actions (color picker and buttons) const colorActions = presetColorsContainer.nextElementSibling; if (colorActions && (colorActions.querySelector('#preset-add-color-btn') || colorActions.querySelector('#preset-new-color'))) { colorActions.style.display = shouldShow ? '' : 'none'; } } }; const getNumberInput = (id) => { const input = document.getElementById(id); if (!input) { return 0; } return parseInt(input.value, 10) || 0; }; const renderPresetColors = (colors) => { if (!presetColorsContainer) return; presetColorsContainer.innerHTML = ''; currentPresetColors = colors || []; // Get max colors for current pattern const maxColors = getMaxColors(); const maxColorsText = maxColors !== Infinity ? ` (max ${maxColors})` : ''; if (currentPresetColors.length === 0) { const empty = document.createElement('p'); empty.className = 'muted-text'; empty.textContent = `No colors added. Click "Add Color" to add colors.${maxColorsText}`; presetColorsContainer.appendChild(empty); return; } // Show max colors info if limit exists and reached if (maxColors !== Infinity && currentPresetColors.length >= maxColors) { const info = document.createElement('p'); info.className = 'muted-text'; info.style.cssText = 'font-size: 0.85em; margin-bottom: 0.5rem; color: #ffa500;'; info.textContent = `Maximum ${maxColors} color${maxColors !== 1 ? 's' : ''} reached for this pattern.`; presetColorsContainer.appendChild(info); } const swatchContainer = document.createElement('div'); swatchContainer.style.cssText = 'display: flex; flex-wrap: wrap; gap: 0.5rem;'; swatchContainer.classList.add('color-swatches-container'); currentPresetColors.forEach((color, index) => { const swatchWrapper = document.createElement('div'); swatchWrapper.style.cssText = 'position: relative; display: inline-block;'; swatchWrapper.draggable = true; swatchWrapper.dataset.colorIndex = index; swatchWrapper.classList.add('draggable-color-swatch'); const swatch = document.createElement('div'); swatch.style.cssText = ` width: 64px; height: 64px; border-radius: 8px; background-color: ${color}; border: 2px solid #4a4a4a; cursor: move; box-shadow: 0 2px 4px rgba(0,0,0,0.3); transition: opacity 0.2s, transform 0.2s; `; swatch.title = `${color} - Drag to reorder`; // Color picker overlay const colorPicker = document.createElement('input'); colorPicker.type = 'color'; colorPicker.value = color; colorPicker.style.cssText = ` position: absolute; top: 0; left: 0; width: 64px; height: 64px; opacity: 0; cursor: pointer; z-index: 5; `; colorPicker.addEventListener('change', (e) => { currentPresetColors[index] = e.target.value; renderPresetColors(currentPresetColors); }); // Prevent color picker from interfering with drag colorPicker.addEventListener('mousedown', (e) => { e.stopPropagation(); }); // Remove button const removeBtn = document.createElement('button'); removeBtn.textContent = '×'; removeBtn.style.cssText = ` position: absolute; top: -8px; right: -8px; width: 24px; height: 24px; border-radius: 50%; background-color: #ff4444; color: white; border: none; cursor: pointer; font-size: 18px; line-height: 1; display: flex; align-items: center; justify-content: center; z-index: 10; padding: 0; `; removeBtn.addEventListener('click', (e) => { e.stopPropagation(); currentPresetColors.splice(index, 1); renderPresetColors(currentPresetColors); }); // Prevent remove button from interfering with drag removeBtn.addEventListener('mousedown', (e) => { e.stopPropagation(); }); // Drag event handlers for reordering swatchWrapper.addEventListener('dragstart', (e) => { swatchWrapper.classList.add('dragging-color'); e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', index.toString()); swatch.style.opacity = '0.5'; }); swatchWrapper.addEventListener('dragend', (e) => { swatchWrapper.classList.remove('dragging-color'); swatch.style.opacity = '1'; // Remove drag-over classes from siblings document.querySelectorAll('.draggable-color-swatch').forEach(el => { el.classList.remove('drag-over-color'); }); }); swatchWrapper.appendChild(swatch); swatchWrapper.appendChild(colorPicker); swatchWrapper.appendChild(removeBtn); swatchContainer.appendChild(swatchWrapper); }); // Add drag and drop handlers to the container swatchContainer.addEventListener('dragover', (e) => { e.preventDefault(); const dragging = swatchContainer.querySelector('.dragging-color'); if (!dragging) return; const afterElement = getDragAfterElementForColors(swatchContainer, e.clientX); if (afterElement == null) { swatchContainer.appendChild(dragging); } else { swatchContainer.insertBefore(dragging, afterElement); } }); swatchContainer.addEventListener('drop', (e) => { e.preventDefault(); const dragging = swatchContainer.querySelector('.dragging-color'); if (!dragging) return; // Get new order of colors from DOM const colorElements = [...swatchContainer.querySelectorAll('.draggable-color-swatch')]; const newColorOrder = colorElements.map(el => { const colorPicker = el.querySelector('input[type="color"]'); return colorPicker ? colorPicker.value : null; }).filter(color => color !== null); // Update current colors array currentPresetColors = newColorOrder; // Re-render to update indices renderPresetColors(currentPresetColors); }); presetColorsContainer.appendChild(swatchContainer); }; // Function to get drag after element for colors (horizontal layout) const getDragAfterElementForColors = (container, x) => { const draggableElements = [...container.querySelectorAll('.draggable-color-swatch:not(.dragging-color)')]; return draggableElements.reduce((closest, child) => { const box = child.getBoundingClientRect(); const offset = x - box.left - box.width / 2; if (offset < 0 && offset > closest.offset) { return { offset: offset, element: child }; } else { return closest; } }, { offset: Number.NEGATIVE_INFINITY }).element; }; const setFormValues = (preset) => { if (!presetNameInput || !presetPatternInput || !presetBrightnessInput || !presetDelayInput) { return; } presetNameInput.value = preset.name || ''; const patternName = preset.pattern || ''; presetPatternInput.value = patternName; const colors = Array.isArray(preset.colors) ? preset.colors : []; renderPresetColors(colors); presetBrightnessInput.value = preset.brightness || 0; presetDelayInput.value = preset.delay || 0; // Update color section visibility based on pattern updateColorSectionVisibility(); // Make name and pattern read-only when editing (not when creating new) const isEditing = currentEditId !== null; presetNameInput.disabled = isEditing; presetPatternInput.disabled = isEditing; if (isEditing) { presetNameInput.style.backgroundColor = '#2a2a2a'; presetNameInput.style.cursor = 'not-allowed'; presetPatternInput.style.backgroundColor = '#2a2a2a'; presetPatternInput.style.cursor = 'not-allowed'; } else { presetNameInput.style.backgroundColor = ''; presetNameInput.style.cursor = ''; presetPatternInput.style.backgroundColor = ''; presetPatternInput.style.cursor = ''; } // Update labels and visibility based on pattern updatePresetNLabels(patternName); // Get pattern config to map descriptive names back to n keys const patternConfig = cachedPatterns && cachedPatterns[patternName]; const nToLabel = {}; if (patternConfig && typeof patternConfig === 'object') { // Now n keys are keys, labels are values Object.entries(patternConfig).forEach(([nKey, label]) => { if (typeof nKey === 'string' && nKey.startsWith('n') && typeof label === 'string') { nToLabel[nKey] = label; } }); } // Set n values, checking both n keys and descriptive names for (let i = 1; i <= 8; i++) { const nKey = `n${i}`; const inputEl = document.getElementById(`preset-${nKey}-input`); if (inputEl) { // First check if preset has n key directly if (preset[nKey] !== undefined) { inputEl.value = preset[nKey] || 0; } else { // Check if preset has descriptive name (from pattern.json mapping) const label = nToLabel[nKey]; if (label && preset[label] !== undefined) { inputEl.value = preset[label] || 0; } else { inputEl.value = 0; } } } } }; const clearForm = () => { currentEditId = null; currentEditTabId = null; currentPresetColors = []; setFormValues({ name: '', pattern: '', colors: [], brightness: 0, delay: 0, n1: 0, n2: 0, n3: 0, n4: 0, n5: 0, n6: 0, n7: 0, n8: 0, }); // Re-enable name and pattern when clearing (for new preset) if (presetNameInput) { presetNameInput.disabled = false; presetNameInput.style.backgroundColor = ''; presetNameInput.style.cursor = ''; } if (presetPatternInput) { presetPatternInput.disabled = false; presetPatternInput.style.backgroundColor = ''; presetPatternInput.style.cursor = ''; } }; const openEditor = () => { if (presetEditorModal) { presetEditorModal.classList.add('active'); } loadPatterns().then(() => { updatePresetNLabels(presetPatternInput ? presetPatternInput.value : ''); updateColorSectionVisibility(); }); }; const closeEditor = () => { if (presetEditorModal) { presetEditorModal.classList.remove('active'); } }; const buildPresetPayload = () => { const payload = { name: presetNameInput ? presetNameInput.value.trim() : '', pattern: presetPatternInput ? presetPatternInput.value.trim() : '', colors: currentPresetColors || [], // Use canonical field names expected by the device / API brightness: presetBrightnessInput ? parseInt(presetBrightnessInput.value, 10) || 0 : 0, delay: presetDelayInput ? parseInt(presetDelayInput.value, 10) || 0 : 0, }; // Always store numeric parameters as n1..n8. for (let i = 1; i <= 8; i++) { const nKey = `n${i}`; payload[nKey] = getNumberInput(`preset-${nKey}-input`); } return payload; }; const loadPatterns = async () => { if (!presetPatternInput) { return; } try { // Load pattern definitions from pattern.json const response = await fetch('/patterns/definitions', { 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.keys(config).some((key) => { return typeof key === 'string' && key.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 = {}; const visibleNKeys = new Set(); // Initialize all labels with default n1:, n2:, etc. for (let i = 1; i <= 8; i++) { labels[`n${i}`] = `n${i}:`; } const patternConfig = cachedPatterns && cachedPatterns[patternName]; if (patternConfig && typeof patternConfig === 'object') { // Now n values are keys and descriptive names are values Object.entries(patternConfig).forEach(([key, label]) => { if (typeof key === 'string' && key.startsWith('n') && typeof label === 'string') { labels[key] = `${label}:`; visibleNKeys.add(key); // Mark this n key as visible } }); } // Update labels and show/hide input groups for (let i = 1; i <= 8; i++) { const nKey = `n${i}`; const labelEl = document.getElementById(`preset-${nKey}-label`); const inputEl = document.getElementById(`preset-${nKey}-input`); const groupEl = labelEl ? labelEl.closest('.n-param-group') : null; if (labelEl) { labelEl.textContent = labels[nKey]; } // Show or hide the entire group based on whether it has a mapping if (groupEl) { if (visibleNKeys.has(nKey)) { groupEl.style.display = ''; // Show } else { groupEl.style.display = 'none'; // Hide } } } }; 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 sendButton = document.createElement('button'); sendButton.className = 'btn btn-primary btn-small'; sendButton.textContent = 'Send'; sendButton.title = 'Send this preset via ESPNow'; sendButton.addEventListener('click', () => { // Just send the definition; selection happens when user clicks the preset. sendPresetViaEspNow(presetId, preset || {}); }); 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(sendButton); 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(); }); } // Handle "Add Preset" button in tab area (dynamically loaded) document.addEventListener('click', async (e) => { if (e.target && e.target.id === 'preset-add-btn-tab') { await showAddPresetToTabModal(); } }); const showAddPresetToTabModal = async () => { // Get current tab ID from the left-panel const leftPanel = document.querySelector('.presets-section[data-tab-id]'); let tabId = leftPanel ? leftPanel.dataset.tabId : null; if (!tabId) { // Fallback: try to get from URL const pathParts = window.location.pathname.split('/'); const tabIndex = pathParts.indexOf('tabs'); if (tabIndex !== -1 && tabIndex + 1 < pathParts.length) { tabId = pathParts[tabIndex + 1]; } } if (!tabId) { alert('Could not determine current tab.'); return; } // Load all presets try { const response = await fetch('/presets', { headers: { Accept: 'application/json' }, }); if (!response.ok) { throw new Error('Failed to load presets'); } const allPresets = await response.json(); // Get current tab's presets to exclude already added ones let currentTabPresets = []; if (tabId) { try { const tabResponse = await fetch(`/tabs/${tabId}`, { headers: { Accept: 'application/json' }, }); if (tabResponse.ok) { const tabData = await tabResponse.json(); currentTabPresets = tabData.presets || []; } } catch (e) { console.warn('Could not load current tab presets:', e); } } // Create modal const modal = document.createElement('div'); modal.className = 'modal active'; modal.id = 'add-preset-to-tab-modal'; modal.innerHTML = ` `; document.body.appendChild(modal); const listContainer = document.getElementById('add-preset-list'); const presetNames = Object.keys(allPresets); if (presetNames.length === 0) { listContainer.innerHTML = '

No presets available. Create a preset first.

'; } else { presetNames.forEach(presetId => { const preset = allPresets[presetId]; const isAlreadyAdded = currentTabPresets.includes(presetId); const row = document.createElement('div'); row.className = 'profiles-row'; const label = document.createElement('span'); label.textContent = preset.name || presetId; const details = document.createElement('span'); details.style.color = '#aaa'; details.style.fontSize = '0.85em'; details.textContent = preset.pattern || '-'; const actionButton = document.createElement('button'); if (isAlreadyAdded) { actionButton.className = 'btn btn-danger btn-small'; actionButton.textContent = 'Remove'; actionButton.addEventListener('click', async (e) => { e.stopPropagation(); if (confirm(`Remove preset "${preset.name || presetId}" from this tab?`)) { await removePresetFromTab(presetId, tabId); modal.remove(); } }); } else { actionButton.className = 'btn btn-primary btn-small'; actionButton.textContent = 'Add'; actionButton.addEventListener('click', async () => { await addPresetToTab(presetId, tabId); modal.remove(); }); } row.appendChild(label); row.appendChild(details); row.appendChild(actionButton); listContainer.appendChild(row); }); } // Close button handler document.getElementById('add-preset-to-tab-close-btn').addEventListener('click', () => { modal.remove(); }); // Close on outside click modal.addEventListener('click', (e) => { if (e.target === modal) { modal.remove(); } }); } catch (error) { console.error('Failed to show add preset modal:', error); alert('Failed to load presets.'); } }; const addPresetToTab = async (presetId, tabId) => { if (!tabId) { // Try to get tab ID from the left-panel const leftPanel = document.querySelector('.presets-section[data-tab-id]'); tabId = leftPanel ? leftPanel.dataset.tabId : null; if (!tabId) { // Fallback: try to get from URL const pathParts = window.location.pathname.split('/'); const tabIndex = pathParts.indexOf('tabs'); if (tabIndex !== -1 && tabIndex + 1 < pathParts.length) { tabId = pathParts[tabIndex + 1]; } } } if (!tabId) { alert('Could not determine current tab.'); return; } try { // Get current tab data const tabResponse = await fetch(`/tabs/${tabId}`, { headers: { Accept: 'application/json' }, }); if (!tabResponse.ok) { throw new Error('Failed to load tab'); } const tabData = await tabResponse.json(); // Add preset to tab's presets array if not already present const presets = tabData.presets || []; if (!presets.includes(presetId)) { presets.push(presetId); // Update tab const updateResponse = await fetch(`/tabs/${tabId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ ...tabData, presets }), }); if (!updateResponse.ok) { throw new Error('Failed to update tab'); } // Reload the tab content to show the new preset if (window.htmx) { htmx.ajax('GET', `/tabs/${tabId}/content-fragment`, { target: '#tab-content', swap: 'innerHTML' }); // The htmx:afterSwap event listener will call renderTabPresets } else { // Fallback: reload the page window.location.reload(); } } else { alert('Preset is already added to this tab.'); } } catch (error) { console.error('Failed to add preset to tab:', error); alert('Failed to add preset to tab.'); } }; if (presetEditorCloseButton) { presetEditorCloseButton.addEventListener('click', closeEditor); } presetClearButton.addEventListener('click', clearForm); if (presetPatternInput) { presetPatternInput.addEventListener('change', () => { updatePresetNLabels(presetPatternInput.value); // Update color section visibility updateColorSectionVisibility(); // Re-render colors to show updated max colors limit renderPresetColors(currentPresetColors); }); } // Add Color button handler if (presetAddColorButton && presetNewColorInput) { presetAddColorButton.addEventListener('click', () => { const color = presetNewColorInput.value; if (!color) return; if (currentPresetColors.includes(color)) { alert('This color is already in the list.'); return; } const maxColors = getMaxColors(); if (currentPresetColors.length >= maxColors) { alert(`This pattern allows a maximum of ${maxColors} color${maxColors !== 1 ? 's' : ''}.`); return; } currentPresetColors.push(color); renderPresetColors(currentPresetColors); }); } // Add from Palette button handler 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) { return; } const handlePick = (event) => { const row = event.target.closest('[data-color]'); if (!row) { return; } const picked = row.dataset.color; if (!picked) { return; } if (currentPresetColors.includes(picked)) { alert('This color is already in the list.'); return; } const maxColors = getMaxColors(); if (currentPresetColors.length >= maxColors) { alert(`This pattern allows a maximum of ${maxColors} color${maxColors !== 1 ? 's' : ''}.`); if (modal) { modal.classList.remove('active'); } modalList.removeEventListener('click', handlePick); return; } currentPresetColors.push(picked); renderPresetColors(currentPresetColors); if (modal) { modal.classList.remove('active'); } modalList.removeEventListener('click', handlePick); }; modalList.addEventListener('click', handlePick); }); } const presetSendButton = document.getElementById('preset-send-btn'); if (presetSendButton) { presetSendButton.addEventListener('click', () => { const payload = buildPresetPayload(); if (!payload.name) { alert('Preset name is required to send.'); return; } // Send current editor values and select on all devices in the current tab (if any) const section = document.querySelector('.presets-section[data-tab-id]'); const namesAttr = section && section.getAttribute('data-device-names'); const deviceNames = namesAttr ? namesAttr.split(',').map((n) => n.trim()).filter((n) => n.length > 0) : []; sendPresetViaEspNow(payload.name, payload, deviceNames); }); } 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'); } // Determine device names from current tab (if any) let deviceNames = []; const section = document.querySelector('.presets-section[data-tab-id]'); if (section) { const namesAttr = section.getAttribute('data-device-names'); deviceNames = namesAttr ? namesAttr.split(',').map((n) => n.trim()).filter((n) => n.length > 0) : []; } // Use saved preset from server response for sending const saved = await response.json().catch(() => null); if (saved && typeof saved === 'object') { if (currentEditId) { // PUT returns the preset object directly sendPresetViaEspNow(payload.name, saved, deviceNames); } else { // POST returns { id: preset } const entries = Object.entries(saved); if (entries.length > 0) { const [newId, presetData] = entries[0]; sendPresetViaEspNow(newId, presetData, deviceNames); } } } else { // Fallback: send what we just built sendPresetViaEspNow(payload.name, payload, deviceNames); } await loadPresets(); clearForm(); closeEditor(); // Reload tab presets if we're in a tab view const leftPanel = document.querySelector('.presets-section[data-tab-id]'); if (leftPanel) { const tabId = leftPanel.dataset.tabId; if (tabId && typeof renderTabPresets !== 'undefined') { renderTabPresets(tabId); } } } catch (error) { console.error('Save preset failed:', error); alert('Failed to save preset.'); } }); // Listen for edit preset events from tab preset buttons document.addEventListener('editPreset', async (event) => { const { presetId, preset, tabId } = event.detail; currentEditId = presetId; currentEditTabId = tabId || null; await loadPatterns(); setFormValues(preset); openEditor(); }); if (presetRemoveFromTabButton) { presetRemoveFromTabButton.addEventListener('click', async () => { if (!currentEditId) { alert('No preset loaded to remove.'); return; } try { await removePresetFromTab(currentEditTabId, currentEditId); closeEditor(); } catch (e) { // removePresetFromTab already logs and alerts on error } }); } presetsModal.addEventListener('click', (event) => { if (event.target === presetsModal) { closeModal(); } }); if (presetEditorModal) { presetEditorModal.addEventListener('click', (event) => { if (event.target === presetEditorModal) { closeEditor(); } }); } clearForm(); }); // Build an ESPNow preset message for a single preset and optionally include a select // for the given device names, then send it via WebSocket. const sendPresetViaEspNow = (presetId, preset, deviceNames) => { try { const presetName = preset.name || presetId; if (!presetName) { alert('Preset has no name and cannot be sent.'); return; } const colors = Array.isArray(preset.colors) && preset.colors.length ? preset.colors : ['#FFFFFF']; const message = { v: '1', presets: { [presetName]: { pattern: preset.pattern || 'off', colors, delay: typeof preset.delay === 'number' ? preset.delay : 100, brightness: typeof preset.brightness === 'number' ? preset.brightness : (typeof preset.br === 'number' ? preset.br : 127), auto: typeof preset.auto === 'boolean' ? preset.auto : true, n1: typeof preset.n1 === 'number' ? preset.n1 : 0, n2: typeof preset.n2 === 'number' ? preset.n2 : 0, n3: typeof preset.n3 === 'number' ? preset.n3 : 0, n4: typeof preset.n4 === 'number' ? preset.n4 : 0, n5: typeof preset.n5 === 'number' ? preset.n5 : 0, n6: typeof preset.n6 === 'number' ? preset.n6 : 0, }, }, }; // Optionally include a select section for specific devices if (Array.isArray(deviceNames) && deviceNames.length > 0) { const select = {}; deviceNames.forEach((name) => { if (name) { select[name] = [presetName]; } }); if (Object.keys(select).length > 0) { message.select = select; } } sendEspnowMessage(message); } catch (error) { console.error('Failed to send preset via ESPNow:', error); alert('Failed to send preset via ESPNow.'); } }; // Expose for other scripts (tabs.js) so they can reuse the shared WebSocket. try { window.sendPresetViaEspNow = sendPresetViaEspNow; } catch (e) { // window may not exist in some environments; ignore. } // Store selected preset per tab const selectedPresets = {}; // Track if we're currently dragging a preset let isDraggingPreset = false; // Context menu for tab presets let presetContextMenu = null; let presetContextTarget = null; const ensurePresetContextMenu = () => { if (presetContextMenu) { return presetContextMenu; } const menu = document.createElement('div'); menu.id = 'preset-context-menu'; menu.style.cssText = ` position: fixed; z-index: 2000; background: #2e2e2e; border: 1px solid #4a4a4a; border-radius: 4px; box-shadow: 0 2px 6px rgba(0,0,0,0.6); padding: 0.25rem 0; min-width: 160px; display: none; `; const addItem = (label, action) => { const item = document.createElement('button'); item.type = 'button'; item.textContent = label; item.dataset.action = action; item.style.cssText = ` display: block; width: 100%; padding: 0.4rem 0.75rem; background: transparent; color: #eee; border: none; text-align: left; cursor: pointer; font-size: 0.9rem; `; if (action === 'remove') { // Visually emphasize and align remove to the right item.style.textAlign = 'right'; item.style.color = '#ff8080'; } item.addEventListener('mouseover', () => { item.style.backgroundColor = '#3a3a3a'; }); item.addEventListener('mouseout', () => { item.style.backgroundColor = 'transparent'; }); menu.appendChild(item); }; addItem('Edit preset…', 'edit'); addItem('Remove', 'remove'); menu.addEventListener('click', async (e) => { const btn = e.target.closest('button[data-action]'); if (!btn || !presetContextTarget) { return; } const { tabId, presetId } = presetContextTarget; const action = btn.dataset.action; hidePresetContextMenu(); if (action === 'edit') { await editPresetFromTab(presetId); } else if (action === 'remove') { await removePresetFromTab(tabId, presetId); } }); document.body.appendChild(menu); presetContextMenu = menu; // Hide on outside click document.addEventListener('click', (e) => { if (!presetContextMenu) return; if (e.target.closest('#preset-context-menu')) return; hidePresetContextMenu(); }); return menu; }; const showPresetContextMenu = (x, y, tabId, presetId, preset) => { const menu = ensurePresetContextMenu(); presetContextTarget = { tabId, presetId, preset }; menu.style.left = `${x}px`; menu.style.top = `${y}px`; menu.style.display = 'block'; }; const hidePresetContextMenu = () => { if (presetContextMenu) { presetContextMenu.style.display = 'none'; } presetContextTarget = null; }; // Function to convert 2D grid to flat array (for backward compatibility) const gridToArray = (presetsGrid) => { if (!presetsGrid || !Array.isArray(presetsGrid)) { return []; } // If it's already a flat array (old format), return it if (presetsGrid.length > 0 && typeof presetsGrid[0] === 'string') { return presetsGrid; } // If it's a 2D grid, flatten it if (Array.isArray(presetsGrid[0])) { return presetsGrid.flat(); } // If it's an array of objects with positions, convert to grid then flatten if (presetsGrid.length > 0 && typeof presetsGrid[0] === 'object' && presetsGrid[0].id) { // Find max row and col let maxRow = 0, maxCol = 0; presetsGrid.forEach(p => { if (p.row > maxRow) maxRow = p.row; if (p.col > maxCol) maxCol = p.col; }); // Create grid const grid = Array(maxRow + 1).fill(null).map(() => Array(maxCol + 1).fill(null)); presetsGrid.forEach(p => { grid[p.row][p.col] = p.id; }); return grid.flat().filter(id => id !== null); } return []; }; // Function to convert flat array to 2D grid const arrayToGrid = (presetIds, columns = 3) => { if (!presetIds || !Array.isArray(presetIds)) { return []; } const grid = []; for (let i = 0; i < presetIds.length; i += columns) { grid.push(presetIds.slice(i, i + columns)); } return grid; }; // Function to save preset grid for a tab const savePresetGrid = async (tabId, presetGrid) => { try { // Get current tab data const tabResponse = await fetch(`/tabs/${tabId}`, { headers: { Accept: 'application/json' }, }); if (!tabResponse.ok) { throw new Error('Failed to load tab'); } const tabData = await tabResponse.json(); // Store as 2D grid tabData.presets = presetGrid; // Also store as flat array for backward compatibility tabData.presets_flat = presetGrid.flat(); // Save updated tab const updateResponse = await fetch(`/tabs/${tabId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(tabData), }); if (!updateResponse.ok) { throw new Error('Failed to save preset grid'); } } catch (error) { console.error('Failed to save preset grid:', error); throw error; } }; // Function to get drop target in 2D grid const getDropTarget = (container, x, y) => { const draggableElements = [...container.querySelectorAll('.draggable-preset:not(.dragging)')]; return draggableElements.reduce((closest, child) => { const box = child.getBoundingClientRect(); const centerX = box.left + box.width / 2; const centerY = box.top + box.height / 2; const distanceX = Math.abs(x - centerX); const distanceY = Math.abs(y - centerY); const distance = Math.sqrt(distanceX * distanceX + distanceY * distanceY); if (distance < closest.distance) { return { distance: distance, element: child }; } else { return closest; } }, { distance: Infinity }).element; }; // Function to render presets for a specific tab in 2D grid const renderTabPresets = async (tabId) => { const presetsList = document.getElementById('presets-list-tab'); if (!presetsList) return; try { // Get tab data to see which presets are associated const tabResponse = await fetch(`/tabs/${tabId}`, { headers: { Accept: 'application/json' }, }); if (!tabResponse.ok) { throw new Error('Failed to load tab'); } const tabData = await tabResponse.json(); // Get presets - support both 2D grid and flat array (for backward compatibility) let presetGrid = tabData.presets; if (!presetGrid || !Array.isArray(presetGrid)) { // Try to get from flat array or convert old format const flatArray = tabData.presets_flat || tabData.presets || []; presetGrid = arrayToGrid(flatArray, 3); // Default to 3 columns } else if (presetGrid.length > 0 && typeof presetGrid[0] === 'string') { // It's a flat array, convert to grid presetGrid = arrayToGrid(presetGrid, 3); } // Get all presets const presetsResponse = await fetch('/presets', { headers: { Accept: 'application/json' }, }); if (!presetsResponse.ok) { throw new Error('Failed to load presets'); } const allPresets = await presetsResponse.json(); presetsList.innerHTML = ''; // Add drag and drop handlers to the container presetsList.addEventListener('dragover', (e) => { e.preventDefault(); const dragging = presetsList.querySelector('.dragging'); if (!dragging) return; const dropTarget = getDropTarget(presetsList, e.clientX, e.clientY); if (dropTarget && dropTarget !== dragging) { // Insert before or after based on position const rect = dropTarget.getBoundingClientRect(); const draggingRect = dragging.getBoundingClientRect(); if (e.clientX < rect.left + rect.width / 2) { // Insert before presetsList.insertBefore(dragging, dropTarget); } else { // Insert after presetsList.insertBefore(dragging, dropTarget.nextSibling); } } }); presetsList.addEventListener('drop', async (e) => { e.preventDefault(); const dragging = presetsList.querySelector('.dragging'); if (!dragging) return; // Get new grid layout from DOM const presetElements = [...presetsList.querySelectorAll('.draggable-preset')]; const presetIds = presetElements.map(el => el.dataset.presetId); // Convert to 2D grid (3 columns) const newGrid = arrayToGrid(presetIds, 3); // Save new grid try { await savePresetGrid(tabId, newGrid); // Re-render to ensure consistency await renderTabPresets(tabId); } catch (error) { console.error('Failed to save preset grid:', error); alert('Failed to save preset order. Please try again.'); // Re-render to restore original order await renderTabPresets(tabId); } }); // Get the currently selected preset for this tab const selectedPresetId = selectedPresets[tabId]; // Render presets in grid layout // Flatten the grid and render all presets (grid CSS will handle layout) const flatPresets = presetGrid.flat().filter(id => id); if (flatPresets.length === 0) { // Show empty message if this tab has no presets const empty = document.createElement('p'); empty.className = 'muted-text'; empty.style.gridColumn = '1 / -1'; // Span all columns empty.textContent = 'No presets added to this tab. Click "Add Preset" to add one.'; presetsList.appendChild(empty); } else { flatPresets.forEach((presetId) => { const preset = allPresets[presetId]; if (preset) { const isSelected = presetId === selectedPresetId; const wrapper = createPresetButton(presetId, preset, tabId, isSelected); presetsList.appendChild(wrapper); } }); } } catch (error) { console.error('Failed to render tab presets:', error); presetsList.innerHTML = '

Failed to load presets.

'; } }; const createPresetButton = (presetId, preset, tabId, isSelected = false) => { // Create wrapper div for button and edit button const wrapper = document.createElement('div'); wrapper.style.cssText = 'display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.5rem;'; wrapper.draggable = true; wrapper.dataset.presetId = presetId; wrapper.classList.add('draggable-preset'); // Create preset button const button = document.createElement('button'); button.className = 'pattern-button'; button.style.flex = '1'; if (isSelected) { button.classList.add('active'); } button.dataset.presetId = presetId; 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 = preset.name || presetId; presetNameLabel.style.fontWeight = 'bold'; presetNameLabel.style.marginBottom = '0.25rem'; const presetDetails = document.createElement('span'); presetDetails.style.fontSize = '0.85em'; presetDetails.style.color = '#aaa'; const colors = Array.isArray(preset.colors) ? preset.colors : []; presetDetails.textContent = `${preset.pattern || '-'} • ${colors.length} color${colors.length !== 1 ? 's' : ''}`; presetInfo.appendChild(presetNameLabel); presetInfo.appendChild(presetDetails); button.appendChild(presetInfo); // Left-click selects preset, right-click opens editor button.addEventListener('click', (e) => { if (isDraggingPreset) { return; } // Remove active class from all presets in this tab const presetsList = document.getElementById('presets-list-tab'); if (presetsList) { presetsList.querySelectorAll('.pattern-button').forEach(btn => { btn.classList.remove('active'); }); } // Add active class to clicked preset button.classList.add('active'); // Store selected preset for this tab selectedPresets[tabId] = presetId; // Build and send a select message via WebSocket for all device names in this tab. const presetName = preset.name || presetId; const section = button.closest('.presets-section'); sendSelectForCurrentTabDevices(presetName, section); }); button.addEventListener('contextmenu', async (e) => { e.preventDefault(); if (isDraggingPreset) { return; } // Right-click: directly open the preset editor using data we already have await editPresetFromTab(presetId, tabId, preset); }); wrapper.appendChild(button); // Add drag event handlers wrapper.addEventListener('dragstart', (e) => { isDraggingPreset = true; wrapper.classList.add('dragging'); e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', presetId); }); wrapper.addEventListener('dragend', (e) => { wrapper.classList.remove('dragging'); // Remove any drag-over classes from siblings document.querySelectorAll('.draggable-preset').forEach(el => { el.classList.remove('drag-over'); }); // Reset dragging flag after a short delay to allow click event to check it setTimeout(() => { isDraggingPreset = false; }, 100); }); return wrapper; }; const editPresetFromTab = async (presetId, tabId, existingPreset) => { try { let preset = existingPreset; if (!preset) { // Fallback: load the preset data from the server if we weren't given it const response = await fetch(`/presets/${presetId}`, { headers: { Accept: 'application/json' }, }); if (!response.ok) { throw new Error('Failed to load preset'); } preset = await response.json(); } // Dispatch a custom event to trigger the edit in the DOMContentLoaded scope const editEvent = new CustomEvent('editPreset', { detail: { presetId, preset, tabId } }); document.dispatchEvent(editEvent); } catch (error) { console.error('Failed to load preset for editing:', error); alert('Failed to load preset for editing.'); } }; // Remove a preset from a specific tab (does not delete the preset itself) // Expected call style: removePresetFromTab(tabId, presetId) const removePresetFromTab = async (tabId, presetId) => { if (!tabId) { // Try to get tab ID from the left-panel const leftPanel = document.querySelector('.presets-section[data-tab-id]'); tabId = leftPanel ? leftPanel.dataset.tabId : null; if (!tabId) { // Fallback: try to get from URL const pathParts = window.location.pathname.split('/'); const tabIndex = pathParts.indexOf('tabs'); if (tabIndex !== -1 && tabIndex + 1 < pathParts.length) { tabId = pathParts[tabIndex + 1]; } } } if (!tabId) { alert('Could not determine current tab.'); return; } try { // Get current tab data const tabResponse = await fetch(`/tabs/${tabId}`, { headers: { Accept: 'application/json' }, }); if (!tabResponse.ok) { throw new Error('Failed to load tab'); } const tabData = await tabResponse.json(); // Normalize to flat array let flat = []; if (Array.isArray(tabData.presets_flat)) { flat = tabData.presets_flat.slice(); } else if (Array.isArray(tabData.presets)) { if (tabData.presets.length && typeof tabData.presets[0] === 'string') { flat = tabData.presets.slice(); } else if (Array.isArray(tabData.presets[0])) { flat = tabData.presets.flat(); } } const beforeLen = flat.length; flat = flat.filter(id => String(id) !== String(presetId)); if (flat.length === beforeLen) { alert('Preset is not in this tab.'); return; } const newGrid = arrayToGrid(flat, 3); tabData.presets = newGrid; tabData.presets_flat = flat; const updateResponse = await fetch(`/tabs/${tabId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(tabData), }); if (!updateResponse.ok) { throw new Error('Failed to update tab presets'); } await renderTabPresets(tabId); } catch (error) { console.error('Failed to remove preset from tab:', error); alert('Failed to remove preset from tab.'); } }; // Listen for HTMX swaps to render presets document.body.addEventListener('htmx:afterSwap', (event) => { if (event.target && event.target.id === 'tab-content') { // Get tab ID from the left-panel const leftPanel = document.querySelector('.presets-section[data-tab-id]'); if (leftPanel) { const tabId = leftPanel.dataset.tabId; if (tabId) { renderTabPresets(tabId); } } } });