// Shared WebSocket for ESPNow messages (presets + selects) let espnowSocket = null; let espnowSocketReady = false; let espnowPendingMessages = []; let currentProfileIdCache = null; function coercePresetInt(v, def = 0) { if (typeof v === 'number' && Number.isFinite(v)) { return v; } const t = parseInt(String(v), 10); return Number.isFinite(t) ? t : def; } /** Style variant for wire ``n6``; presets may store ``mode`` or legacy ``n6``. */ function presetWireN6(preset, def = 0) { if (!preset || typeof preset !== 'object') { return def; } if (preset.mode !== undefined && preset.mode !== null && preset.mode !== '') { return coercePresetInt(preset.mode, def); } return coercePresetInt(preset.n6, def); } const getCurrentProfileId = async () => { try { const res = await fetch('/profiles/current', { headers: { Accept: 'application/json' } }); if (!res.ok) return currentProfileIdCache ? String(currentProfileIdCache) : null; const data = await res.json(); const id = data && (data.id || (data.profile && data.profile.id)); currentProfileIdCache = id ? String(id) : null; return currentProfileIdCache; } catch (_) { return currentProfileIdCache ? String(currentProfileIdCache) : null; } }; const filterPresetsForCurrentProfile = async (presetsObj) => { const scoped = presetsObj && typeof presetsObj === 'object' ? presetsObj : {}; const currentProfileId = await getCurrentProfileId(); if (!currentProfileId) return scoped; return Object.fromEntries( Object.entries(scoped).filter(([, preset]) => { if (!preset || typeof preset !== 'object') return false; if (!('profile_id' in preset)) return true; // Legacy records return String(preset.profile_id) === String(currentProfileId); }), ); }; try { window.filterPresetsForCurrentProfile = filterPresetsForCurrentProfile; } catch (e) {} const getCurrentProfileData = async () => { try { const res = await fetch('/profiles/current', { headers: { Accept: 'application/json' } }); if (!res.ok) return null; return await res.json(); } catch (_) { return null; } }; const getCurrentProfilePaletteColors = async () => { const profileData = await getCurrentProfileData(); const profile = profileData && profileData.profile; const paletteId = profile && (profile.palette_id || profile.paletteId); if (!paletteId) return []; try { const res = await fetch(`/palettes/${paletteId}`, { headers: { Accept: 'application/json' } }); if (!res.ok) return []; const pal = await res.json(); return Array.isArray(pal.colors) ? pal.colors : []; } catch (_) { return []; } }; const resolveColorsWithPaletteRefs = (colors, paletteRefs, paletteColors) => { const baseColors = Array.isArray(colors) ? colors : []; const refs = Array.isArray(paletteRefs) ? paletteRefs : []; const pal = Array.isArray(paletteColors) ? paletteColors : []; return baseColors.map((color, idx) => { const refRaw = refs[idx]; const ref = Number.isInteger(refRaw) ? refRaw : parseInt(refRaw, 10); if (Number.isInteger(ref) && ref >= 0 && ref < pal.length && pal[ref]) { return pal[ref]; } return color; }); }; const getEspnowSocket = () => { if (espnowSocket && (espnowSocket.readyState === WebSocket.OPEN || espnowSocket.readyState === WebSocket.CONNECTING)) { return espnowSocket; } const wsScheme = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; const wsUrl = `${wsScheme}//${window.location.host}/ws`; espnowSocket = new WebSocket(wsUrl); espnowSocketReady = false; espnowSocket.onopen = () => { espnowSocketReady = true; window.dispatchEvent(new CustomEvent('deviceTcpWsOpen')); // Flush any queued messages espnowPendingMessages.forEach((msg) => { try { espnowSocket.send(msg); } catch (err) { console.error('Failed to send queued ESPNow message:', err); } }); espnowPendingMessages = []; }; espnowSocket.onmessage = (event) => { try { const data = JSON.parse(event.data); if (data && data.type === 'device_tcp' && typeof data.connected === 'boolean' && data.ip) { window.dispatchEvent( new CustomEvent('deviceTcpStatus', { detail: { ip: data.ip, connected: data.connected } }), ); return; } if (data && data.type === 'device_tcp_snapshot' && Array.isArray(data.connected_ips)) { window.dispatchEvent( new CustomEvent('deviceTcpSnapshot', { detail: { connectedIps: data.connected_ips } }), ); return; } if (data && data.error) { console.error('ESP-NOW:', data.error); alert('ESP-NOW send failed. ' + (data.error === 'ESP-NOW send failed' ? 'Check device WiFi/interface.' : data.error)); } } catch (_) { // Ignore non-JSON or non-error messages } }; 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); } }; function tabDeviceNamesFromSection(section) { if (typeof window.parseTabDeviceNames === 'function') { return window.parseTabDeviceNames(section); } const namesAttr = section && section.getAttribute('data-device-names'); return namesAttr ? namesAttr.split(',').map((n) => n.trim()).filter((n) => n.length > 0) : []; } /** Device names for ``presetId`` on the current zone tab (zone ``group_ids`` for presets, else tab devices). */ async function deviceNamesForPresetOnCurrentZone(presetId) { const section = document.querySelector('.presets-section[data-zone-id]'); const fallback = tabDeviceNamesFromSection(section); if (!section || !presetId) return fallback; const zm = window.zonesManager; if (!zm || typeof zm.resolveDeviceNamesForZonePreset !== 'function') return fallback; const zoneId = section.dataset.zoneId; try { const res = await fetch(`/zones/${zoneId}`, { headers: { Accept: 'application/json' } }); if (!res.ok) return fallback; const zd = await res.json(); const names = await zm.resolveDeviceNamesForZonePreset(zd, String(presetId)); return names.length ? names : fallback; } catch (_) { return fallback; } } function formatPresetTargetGroupsLine(zoneDoc, groupsMap) { const zm = window.zonesManager; const gids = zm && typeof zm.effectiveGroupIdsForZonePreset === 'function' ? zm.effectiveGroupIdsForZonePreset(zoneDoc || {}) : Array.isArray(zoneDoc && zoneDoc.group_ids) ? zoneDoc.group_ids.map((x) => String(x).trim()).filter((x) => x.length > 0) : []; const parts = (gids || []) .map((id) => { const g = groupsMap && groupsMap[id]; const gn = g && g.name ? String(g.name).trim() : ''; return gn; }) .filter(Boolean); return parts.length ? parts.join(', ') : ''; } async function postDriverSequence(sequence, targetMacs, delayS, pushOptions) { const body = { sequence, targets: Array.isArray(targetMacs) && targetMacs.length ? [...new Set(targetMacs)] : undefined, }; if (delayS != null && delayS >= 0) { body.delay_s = delayS; } const res = await fetch('/presets/push', { method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, credentials: 'same-origin', body: JSON.stringify(body), }); if (!res.ok) { const err = await res.json().catch(() => ({})); throw new Error((err && err.error) || res.statusText || 'Send failed'); } return res.json().catch(() => ({})); } 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 presetClearDeviceButton = document.getElementById('preset-clear-device-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 presetBrightnessInput = document.getElementById('preset-brightness-input'); const presetDelayInput = document.getElementById('preset-delay-input'); const presetDelayField = presetDelayInput ? presetDelayInput.closest('.preset-editor-field') : null; const presetBackgroundInput = document.getElementById('preset-background-input'); const presetBackgroundButton = document.getElementById('preset-background-btn'); const presetManualModeInput = document.getElementById('preset-manual-mode-input'); const presetManualModeHint = document.getElementById('preset-manual-mode-hint'); const presetManualModeLabel = document.getElementById('preset-manual-mode-label'); const presetManualBeatNWrap = document.getElementById('preset-manual-beat-n-wrap'); const presetManualBeatNInput = document.getElementById('preset-manual-beat-n-input'); const presetDefaultButton = document.getElementById('preset-default-btn'); const presetRemoveFromTabButton = document.getElementById('preset-remove-from-zone-btn'); const presetSaveButton = document.getElementById('preset-save-btn'); const presetAddFromPaletteButton = document.getElementById('preset-add-from-palette-btn'); const presetBackgroundFromPaletteButton = document.getElementById('preset-background-from-palette-btn'); const presetModeInput = document.getElementById('preset-mode-input'); const presetModeGroup = document.getElementById('preset-mode-group'); const presetReverseInput = document.getElementById('preset-reverse-input'); const presetReverseGroup = document.getElementById('preset-reverse-group'); if (!presetsButton || !presetsModal || !presetsList || !presetSaveButton) { return; } let currentEditId = null; let currentEditTabId = null; let cachedPresets = {}; let cachedPatterns = {}; let currentPresetColors = []; // Track colors for the current preset let currentPresetPaletteRefs = []; // Palette index refs per color (null for direct colors) let currentBackgroundPaletteRef = null; let bgPaletteResolveGen = 0; // 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 }; const resolvePatternConfig = (patternName) => { const rawPatternName = String(patternName || '').trim(); const normalizedPatternName = rawPatternName.endsWith('.py') ? rawPatternName.slice(0, -3) : rawPatternName; let patternConfig = (cachedPatterns && cachedPatterns[rawPatternName]) || (cachedPatterns && cachedPatterns[normalizedPatternName]) || null; if (!patternConfig && cachedPatterns && typeof cachedPatterns === 'object') { const lower = normalizedPatternName.toLowerCase(); const matchedKey = Object.keys(cachedPatterns).find( (k) => String(k).toLowerCase() === lower, ); if (matchedKey) { patternConfig = cachedPatterns[matchedKey]; } } if (patternConfig && typeof patternConfig === 'object' && patternConfig.data && typeof patternConfig.data === 'object') { patternConfig = patternConfig.data; } if ( patternConfig && typeof patternConfig === 'object' && patternConfig.parameter_mappings && typeof patternConfig.parameter_mappings === 'object' ) { const { parameter_mappings: pm, data: _data, ...rest } = patternConfig; patternConfig = { ...rest, ...pm }; } return patternConfig && typeof patternConfig === 'object' ? patternConfig : null; }; /** From db/pattern.json; missing key means pattern allows manual / beat (backward compatible). */ const patternSupportsManual = (patternName) => { const cfg = resolvePatternConfig(patternName); if (!cfg) { return true; } return cfg.supports_manual !== false; }; const getPatternModeOptions = (patternName) => { const cfg = resolvePatternConfig(patternName); if (!cfg || typeof cfg.mode !== 'object' || cfg.mode === null || Array.isArray(cfg.mode)) { return null; } const entries = Object.entries(cfg.mode).filter( ([, label]) => typeof label === 'string' && label.trim(), ); if (entries.length < 2) { return null; } entries.sort((a, b) => parseInt(a[0], 10) - parseInt(b[0], 10)); return entries; }; const patternSupportsModes = (patternName) => getPatternModeOptions(patternName) !== null; const patternSupportsReverse = (patternName) => { const cfg = resolvePatternConfig(patternName); return !!(cfg && cfg.supports_reverse); }; const setPresetReverseFieldVisible = (show) => { if (!presetReverseGroup) { return; } presetReverseGroup.hidden = !show; presetReverseGroup.style.display = show ? '' : 'none'; if (!show && presetReverseInput) { presetReverseInput.checked = false; } }; const setPresetModeFieldVisible = (show) => { if (!presetModeGroup) { return; } presetModeGroup.hidden = !show; presetModeGroup.style.display = show ? '' : 'none'; if (!show && presetModeInput) { presetModeInput.innerHTML = ''; } }; const presetStoredMode = (preset) => { if (!preset || typeof preset !== 'object') { return 0; } if (preset.mode !== undefined && preset.mode !== null && preset.mode !== '') { const m = parseInt(String(preset.mode), 10); return Number.isFinite(m) ? m : 0; } const n6 = parseInt(String(preset.n6), 10); return Number.isFinite(n6) ? n6 : 0; }; const updateManualBeatNVisibility = () => { if (!presetManualBeatNWrap) { return; } const manualOn = presetManualModeInput && presetManualModeInput.checked; const patternName = presetPatternInput ? presetPatternInput.value.trim() : ''; const ok = !patternName || patternSupportsManual(patternName); presetManualBeatNWrap.style.display = manualOn && ok ? '' : 'none'; }; const updatePresetBackgroundButton = () => { if (!presetBackgroundButton || !presetBackgroundInput) return; const color = coercePresetBackground({ background: presetBackgroundInput.value }); presetBackgroundInput.value = color; presetBackgroundButton.textContent = color; presetBackgroundButton.style.backgroundColor = color; presetBackgroundButton.style.color = '#fff'; presetBackgroundButton.style.borderColor = 'rgba(255, 255, 255, 0.6)'; presetBackgroundButton.title = currentBackgroundPaletteRef != null ? `Background from profile palette (index ${currentBackgroundPaletteRef}); click to pick a custom colour` : 'Choose background colour'; }; const updateDelayVisibilityForManualMode = () => { if (!presetDelayField) return; const manualOn = presetManualModeInput && presetManualModeInput.checked; presetDelayField.style.display = manualOn ? 'none' : ''; }; const updateManualModeAvailability = () => { if (!presetManualModeInput) { return; } const patternName = presetPatternInput ? presetPatternInput.value.trim() : ''; const ok = !patternName || patternSupportsManual(patternName); presetManualModeInput.disabled = !ok; if (presetManualModeLabel) { presetManualModeLabel.style.opacity = ok ? '' : '0.55'; } if (presetManualModeHint) { if (!patternName || ok) { presetManualModeHint.style.display = 'none'; presetManualModeHint.textContent = ''; } else { presetManualModeHint.style.display = ''; presetManualModeHint.textContent = 'This pattern is a poor fit for manual mode or audio beat triggers; use auto mode for best results.'; } } if (!ok) { presetManualModeInput.checked = false; } updateManualBeatNVisibility(); updateDelayVisibilityForManualMode(); }; // 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-new-color')) { colorActions.style.display = shouldShow ? '' : 'none'; } } }; const getNumberInput = (id) => { const input = document.getElementById(id); if (!input) { return 0; } const n = parseInt(String(input.value).trim(), 10); return Number.isFinite(n) ? n : 0; }; const renderPresetColors = (colors, paletteRefs) => { if (!presetColorsContainer) return; presetColorsContainer.innerHTML = ''; currentPresetColors = Array.isArray(colors) ? colors.slice() : []; if (Array.isArray(paletteRefs)) { currentPresetPaletteRefs = currentPresetColors.map((_, i) => { const refRaw = paletteRefs[i]; const ref = Number.isInteger(refRaw) ? refRaw : parseInt(refRaw, 10); return Number.isInteger(ref) ? ref : null; }); } else { currentPresetPaletteRefs = currentPresetColors.map((_, i) => { const refRaw = currentPresetPaletteRefs[i]; const ref = Number.isInteger(refRaw) ? refRaw : parseInt(refRaw, 10); return Number.isInteger(ref) ? ref : null; }); } // 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. Use the color picker 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: nowrap; gap: 0.5rem; align-items: flex-start; overflow-x: auto;'; 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; const refAtIndex = currentPresetPaletteRefs[index]; swatchWrapper.dataset.paletteRef = Number.isInteger(refAtIndex) ? String(refAtIndex) : ''; 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`; if (Number.isInteger(refAtIndex)) { const linkedBadge = document.createElement('span'); linkedBadge.textContent = 'P'; linkedBadge.title = `Linked to palette color #${refAtIndex + 1}`; linkedBadge.style.cssText = ` position: absolute; left: -6px; top: -6px; min-width: 18px; height: 18px; border-radius: 9px; background: #3f51b5; color: #fff; font-size: 11px; font-weight: 700; display: flex; align-items: center; justify-content: center; z-index: 11; border: 1px solid rgba(255,255,255,0.35); box-shadow: 0 1px 3px rgba(0,0,0,0.35); `; swatchWrapper.appendChild(linkedBadge); } // 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; // Manual picker edit breaks palette linkage for this slot. currentPresetPaletteRefs[index] = null; renderPresetColors(currentPresetColors, currentPresetPaletteRefs); }); // 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); currentPresetPaletteRefs.splice(index, 1); renderPresetColors(currentPresetColors, currentPresetPaletteRefs); }); // 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); const newRefOrder = colorElements.map((el) => { const refRaw = el.dataset.paletteRef; const ref = Number.isInteger(refRaw) ? refRaw : parseInt(refRaw, 10); return Number.isInteger(ref) ? ref : null; }); // Update current colors array currentPresetColors = newColorOrder; currentPresetPaletteRefs = newRefOrder; // Re-render to update indices renderPresetColors(currentPresetColors, currentPresetPaletteRefs); }); 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.slice() : []; const paletteRefs = Array.isArray(preset.palette_refs) ? preset.palette_refs.slice() : []; renderPresetColors(colors, paletteRefs); presetBrightnessInput.value = preset.brightness || 0; presetDelayInput.value = preset.delay || 0; if (presetBackgroundInput) { const rawBgRef = preset.background_palette_ref ?? preset.backgroundPaletteRef; let bgRef = null; if (rawBgRef != null && rawBgRef !== '') { const n = typeof rawBgRef === 'number' ? rawBgRef : parseInt(String(rawBgRef), 10); if (Number.isInteger(n) && n >= 0) { bgRef = n; } } currentBackgroundPaletteRef = bgRef; presetBackgroundInput.value = coercePresetBackground(preset); updatePresetBackgroundButton(); const gen = ++bgPaletteResolveGen; void getCurrentProfilePaletteColors().then((pal) => { if (gen !== bgPaletteResolveGen || !presetBackgroundInput) { return; } presetBackgroundInput.value = resolvePresetBackgroundHex(preset, pal); updatePresetBackgroundButton(); }); } else { updatePresetBackgroundButton(); } if (presetManualModeInput) { const autoVal = typeof preset.auto === 'boolean' ? preset.auto : true; presetManualModeInput.checked = !autoVal; } if (presetManualBeatNInput) { const raw = preset.manual_beat_n; let n = typeof raw === 'number' ? raw : parseInt(String(raw != null ? raw : '1'), 10); if (!Number.isFinite(n)) n = 1; n = Math.max(1, Math.min(64, n)); presetManualBeatNInput.value = String(n); } // 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 = ''; } // Get pattern config to map descriptive names back to n keys const patternConfig = cachedPatterns && cachedPatterns[patternName]; const nToLabel = {}; if (patternConfig && typeof patternConfig === 'object') { Object.entries(patternConfig).forEach(([nKey, label]) => { if (typeof nKey === 'string' && nKey.startsWith('n') && typeof label === 'string') { nToLabel[nKey] = label; } }); } if (presetReverseInput) { const n5raw = preset.n5; const n5 = typeof n5raw === 'number' ? n5raw : parseInt(String(n5raw != null ? n5raw : '0'), 10); presetReverseInput.checked = Number.isFinite(n5) && n5 > 0; } // 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) { if (preset[nKey] !== undefined && preset[nKey] !== null) { const raw = preset[nKey]; const n = typeof raw === 'number' ? raw : parseInt(String(raw), 10); inputEl.value = String(Number.isFinite(n) ? n : 0); } else { const label = nToLabel[nKey]; if (label && preset[label] !== undefined && preset[label] !== null) { const rawL = preset[label]; const nL = typeof rawL === 'number' ? rawL : parseInt(String(rawL), 10); inputEl.value = String(Number.isFinite(nL) ? nL : 0); } else { inputEl.value = '0'; } } } } // After values: show only mapped n params with labels from pattern.json; clear hidden inputs updatePresetNLabels(patternName, preset); updateManualModeAvailability(); updatePresetEditorTabActionsVisibility(); }; const clearForm = () => { bgPaletteResolveGen += 1; currentEditId = null; currentEditTabId = null; currentPresetColors = []; currentPresetPaletteRefs = []; setFormValues({ name: '', pattern: '', colors: [], brightness: 0, delay: 0, n1: 0, n2: 0, n3: 0, n4: 0, n5: 0, n6: 0, n7: 0, n8: 0, background: '#000000', auto: true, manual_beat_n: 1, }); if (presetManualModeInput) { presetManualModeInput.checked = false; } if (presetReverseInput) { presetReverseInput.checked = false; } setPresetReverseFieldVisible(false); if (presetManualBeatNInput) { presetManualBeatNInput.value = '1'; } updatePresetBackgroundButton(); updateManualModeAvailability(); // 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 = ''; } updatePresetEditorTabActionsVisibility(); }; const getActiveTabId = () => { if (currentEditTabId) { return currentEditTabId; } const section = document.querySelector('.presets-section[data-zone-id]'); return section ? section.dataset.zoneId : null; }; const updatePresetEditorTabActionsVisibility = async () => { if (!presetRemoveFromTabButton) return; if (!currentEditTabId || !currentEditId) { presetRemoveFromTabButton.hidden = true; return; } try { const tabRes = await fetch(`/zones/${currentEditTabId}`, { headers: { Accept: 'application/json' }, }); if (!tabRes.ok) { presetRemoveFromTabButton.hidden = false; return; } const tabData = await tabRes.json(); const allowed = typeof window.zoneAllowsPresets === 'function' ? window.zoneAllowsPresets(tabData, currentEditTabId) : true; presetRemoveFromTabButton.hidden = !allowed; } catch (e) { presetRemoveFromTabButton.hidden = false; } }; const updateTabDefaultPreset = async (presetId) => { const zoneId = getActiveTabId(); if (!zoneId) { return; } try { const tabResponse = await fetch(`/zones/${zoneId}`, { headers: { Accept: 'application/json' }, }); if (!tabResponse.ok) { return; } const tabData = await tabResponse.json(); tabData.default_preset = presetId; await fetch(`/zones/${zoneId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(tabData), }); } catch (error) { console.warn('Failed to save zone default preset:', error); } }; const openEditor = () => { if (presetEditorModal) { presetEditorModal.classList.add('active'); } const patternName = presetPatternInput ? presetPatternInput.value : ''; const modeBefore = patternSupportsModes(patternName) ? presetStoredMode({ mode: presetModeInput ? presetModeInput.value : undefined, n6: getNumberInput('preset-n6-input'), }) : 0; loadPatterns().then(() => { updatePresetNLabels(patternName, { mode: modeBefore, n6: modeBefore }); 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 || [], palette_refs: currentPresetPaletteRefs || [], // 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, background: presetBackgroundInput ? presetBackgroundInput.value : '#000000', background_palette_ref: currentBackgroundPaletteRef != null ? currentBackgroundPaletteRef : null, auto: presetManualModeInput ? !presetManualModeInput.checked : true, manual_beat_n: (() => { if (!presetManualBeatNInput) return 1; let n = parseInt(presetManualBeatNInput.value, 10); if (!Number.isFinite(n)) n = 1; return Math.max(1, Math.min(64, n)); })(), }; // Always store numeric parameters as n1..n8 (except n6 when pattern uses mode). const modeEntries = patternSupportsModes(payload.pattern) ? getPatternModeOptions(payload.pattern) : null; const reverseField = patternSupportsReverse(payload.pattern); for (let i = 1; i <= 8; i++) { const nKey = `n${i}`; if (modeEntries && nKey === 'n6') { continue; } if (reverseField && nKey === 'n5') { continue; } payload[nKey] = getNumberInput(`preset-${nKey}-input`); } if (reverseField) { payload.n5 = presetReverseInput && presetReverseInput.checked ? 1 : 0; } if (modeEntries && presetModeInput) { payload.mode = parseInt(presetModeInput.value, 10) || 0; } return payload; }; const normalizePatternMap = (raw) => { if (!raw) return {}; if (typeof raw === 'object' && !Array.isArray(raw)) { // Support wrapped payloads like { patterns: {...} }. if (raw.patterns && typeof raw.patterns === 'object' && !Array.isArray(raw.patterns)) { return raw.patterns; } return raw; } if (Array.isArray(raw)) { // Support list payloads like [{name: "blink", ...}, ...]. return raw.reduce((acc, item, idx) => { if (item && typeof item === 'object') { const name = item.name || item.id || String(idx); acc[String(name)] = item; } return acc; }, {}); } return {}; }; const loadPatterns = async () => { if (!presetPatternInput) { return; } try { // Load pattern definitions from pattern.json let patternsPayload = null; let response = await fetch('/patterns/definitions', { cache: 'no-store', headers: { Accept: 'application/json' }, }); if (response.ok) { patternsPayload = await response.json(); } let normalized = normalizePatternMap(patternsPayload); if (!Object.keys(normalized).length) { // Fallback when definitions route is unavailable or returns an empty map. response = await fetch('/patterns', { cache: 'no-store', headers: { Accept: 'application/json' }, }); if (!response.ok) { return; } patternsPayload = await response.json(); normalized = normalizePatternMap(patternsPayload); } cachedPatterns = normalized; 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, presetForMode = null) => { const patternConfig = resolvePatternConfig(patternName); const labels = {}; const visibleNKeys = new Set(); if (patternConfig && typeof patternConfig === 'object') { Object.entries(patternConfig).forEach(([key, label]) => { if (typeof key === 'string' && key.startsWith('n') && typeof label === 'string') { const text = label.trim(); if (text) { labels[key] = `${text}:`; visibleNKeys.add(key); } } }); } const modeEntries = patternSupportsModes(patternName) ? getPatternModeOptions(patternName) : null; const reverseField = patternSupportsReverse(patternName); if (modeEntries) { visibleNKeys.delete('n6'); } if (reverseField) { visibleNKeys.delete('n5'); } setPresetReverseFieldVisible(reverseField); if (reverseField && presetReverseInput) { const n5raw = presetForMode && presetForMode.n5 !== undefined ? presetForMode.n5 : 0; const n5 = typeof n5raw === 'number' ? n5raw : parseInt(String(n5raw), 10); presetReverseInput.checked = Number.isFinite(n5) && n5 > 0; } if (presetModeInput) { if (modeEntries) { setPresetModeFieldVisible(true); presetModeInput.innerHTML = ''; modeEntries.forEach(([val, label]) => { const opt = document.createElement('option'); opt.value = val; opt.textContent = label.trim(); presetModeInput.appendChild(opt); }); const modeVal = presetForMode ? presetStoredMode(presetForMode) : 0; const modeStr = String(modeVal); if ([...presetModeInput.options].some((o) => o.value === modeStr)) { presetModeInput.value = modeStr; } else if (presetModeInput.options.length) { presetModeInput.selectedIndex = 0; } } else { setPresetModeFieldVisible(false); } } const hasPatternMeta = patternConfig && typeof patternConfig === 'object' && Object.keys(patternConfig).length > 0; const hasAnyNLabel = visibleNKeys.size > 0 || Boolean(modeEntries); for (let i = 1; i <= 8; i++) { const nKey = `n${i}`; const labelEl = document.getElementById(`preset-${nKey}-label`); const groupEl = labelEl ? labelEl.closest('.n-param-group') : null; const show = visibleNKeys.has(nKey); const inputEl = document.getElementById(`preset-${nKey}-input`); if (labelEl) { labelEl.textContent = show ? labels[nKey] : ''; } if (groupEl) { groupEl.style.display = show ? '' : 'none'; } // Only clear hidden n inputs when we know this pattern's metadata (avoids wiping n3..n4 // while definitions are still loading, or when twinkle exists only as a driver file). if (inputEl && !show && (hasAnyNLabel || hasPatternMeta)) { inputEl.value = '0'; } } const nGrid = presetEditorModal && presetEditorModal.querySelector('.n-params-grid'); if (nGrid) { nGrid.style.display = visibleNKeys.size > 0 ? '' : 'none'; } updateManualModeAvailability(); }; 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', async () => { currentEditId = presetId; currentEditTabId = null; await loadPatterns(); const paletteColors = await getCurrentProfilePaletteColors(); const presetForEditor = { ...(preset || {}), colors: resolveColorsWithPaletteRefs( (preset && preset.colors) || [], (preset && preset.palette_refs) || [], paletteColors, ), }; setFormValues(presetForEditor); openEditor(); }); const sendButton = document.createElement('button'); sendButton.className = 'btn btn-primary btn-small'; sendButton.textContent = 'Send'; sendButton.title = 'Send this preset to drivers'; sendButton.addEventListener('click', () => { // Just send the definition; selection happens when user clicks the preset. void sendPresetViaEspNow(presetId, preset || {}, []); }); const exportButton = document.createElement('button'); exportButton.className = 'btn btn-secondary btn-small'; exportButton.textContent = 'Export'; exportButton.addEventListener('click', async () => { try { const response = await fetch(`/presets/${presetId}/export`, { headers: { Accept: 'application/json' }, }); if (!response.ok) { throw new Error('Export failed'); } const bundle = await response.json(); const safeName = ((preset && preset.name) || presetId).replace(/[^\w.-]+/g, '_'); window.downloadJsonFile(`preset-${safeName}.json`, bundle); } catch (error) { console.error('Export preset failed:', error); alert('Failed to export 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(exportButton); 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(); const filtered = await filterPresetsForCurrentProfile(presets); renderPresets(filtered); } 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); } const importPresetBtn = document.getElementById('import-preset-btn'); if (importPresetBtn) { importPresetBtn.addEventListener('click', async () => { const text = await window.pickJsonFile(); if (!text) return; const bundle = window.parseJsonFileText(text); if (!bundle || bundle.kind !== 'preset') { alert('Invalid preset bundle file.'); return; } try { const response = await fetch('/presets/import', { method: 'POST', headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, body: JSON.stringify({ bundle }), }); if (!response.ok) { const err = await response.json().catch(() => ({})); throw new Error(err.error || 'Import failed'); } await loadPresets(); } catch (error) { console.error('Import preset failed:', error); alert(error.message || 'Failed to import preset.'); } }); } if (presetsAddButton) { presetsAddButton.addEventListener('click', () => { clearForm(); openEditor(); }); } if (presetClearDeviceButton) { presetClearDeviceButton.addEventListener('click', async () => { const section = document.querySelector('.presets-section[data-zone-id]'); const deviceNames = tabDeviceNamesFromSection(section); if (!deviceNames.length) { alert('No devices found in the current zone.'); return; } if (!window.confirm('Clear all presets on current zone devices?')) { return; } try { const targetMacs = typeof window.tabsManager !== 'undefined' && typeof window.tabsManager.resolveTabDeviceMacs === 'function' ? await window.tabsManager.resolveTabDeviceMacs(deviceNames) : []; await postDriverSequence([{ v: '1', clear_presets: true, save: true }], targetMacs); } catch (error) { console.error('Clear device presets failed:', error); alert('Failed to clear presets on devices.'); } }); } const showAddPresetToTabModal = async (optionalTabId) => { let zoneId = optionalTabId; if (!zoneId) { // Get current zone ID from the presets section const leftPanel = document.querySelector('.presets-section[data-zone-id]'); zoneId = leftPanel ? leftPanel.dataset.zoneId : null; } if (!zoneId) { // Fallback: try to get from URL const pathParts = window.location.pathname.split('/'); const tabIndex = pathParts.indexOf('zones'); if (tabIndex !== -1 && tabIndex + 1 < pathParts.length) { zoneId = pathParts[tabIndex + 1]; } } if (!zoneId) { alert('Could not determine current zone.'); return; } try { const zoneCheck = await fetch(`/zones/${zoneId}`, { headers: { Accept: 'application/json' } }); if (zoneCheck.ok) { const zoneDoc = await zoneCheck.json(); if ( typeof window.zoneAllowsPresets === 'function' && !window.zoneAllowsPresets(zoneDoc, zoneId) ) { alert('This zone is for sequences only. Add a sequence from the zone Edit menu instead.'); return; } } } catch (e) { console.warn('Could not verify zone content kind:', e); } // Load all presets try { const response = await fetch('/presets', { headers: { Accept: 'application/json' }, }); if (!response.ok) { throw new Error('Failed to load presets'); } const allPresetsRaw = await response.json(); const allPresets = await filterPresetsForCurrentProfile(allPresetsRaw); // Load only the current zone's presets so we can avoid duplicates within this zone. let currentTabPresets = []; try { const tabResponse = await fetch(`/zones/${zoneId}`, { headers: { Accept: 'application/json' }, }); if (tabResponse.ok) { const tabData = await tabResponse.json(); if (Array.isArray(tabData.presets_flat)) { currentTabPresets = tabData.presets_flat.slice(); } else if (Array.isArray(tabData.presets)) { if (tabData.presets.length && typeof tabData.presets[0] === 'string') { currentTabPresets = tabData.presets.slice(); } else if (Array.isArray(tabData.presets[0])) { currentTabPresets = tabData.presets.flat(); } } } } catch (e) { console.warn('Could not load current zone presets:', e); } // Create modal const modal = document.createElement('div'); modal.className = 'modal active modal-child-overlay'; modal.id = 'add-preset-to-zone-modal'; modal.innerHTML = ` `; document.body.appendChild(modal); const listContainer = document.getElementById('add-preset-list'); const presetNames = Object.keys(allPresets); const availableToAdd = presetNames.filter(presetId => !currentTabPresets.includes(presetId)); if (availableToAdd.length === 0) { listContainer.innerHTML = '

No presets to add. All presets are already in this zone, or create a preset first.

'; } else { availableToAdd.forEach(presetId => { const preset = allPresets[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 addButton = document.createElement('button'); addButton.className = 'btn btn-primary btn-small'; addButton.textContent = 'Add'; addButton.addEventListener('click', async () => { await addPresetToTab(presetId, zoneId); modal.remove(); }); row.appendChild(label); row.appendChild(details); row.appendChild(addButton); listContainer.appendChild(row); }); } // Close button handler document.getElementById('add-preset-to-zone-close-btn').addEventListener('click', () => { modal.remove(); }); } catch (error) { console.error('Failed to show add preset modal:', error); alert('Failed to load presets.'); } }; try { window.showAddPresetToTabModal = showAddPresetToTabModal; } catch (e) {} const addPresetToTab = async (presetId, zoneId) => { if (!zoneId) { // Try to get zone ID from the left-panel const leftPanel = document.querySelector('.presets-section[data-zone-id]'); zoneId = leftPanel ? leftPanel.dataset.zoneId : null; if (!zoneId) { // Fallback: try to get from URL const pathParts = window.location.pathname.split('/'); const tabIndex = pathParts.indexOf('zones'); if (tabIndex !== -1 && tabIndex + 1 < pathParts.length) { zoneId = pathParts[tabIndex + 1]; } } } if (!zoneId) { alert('Could not determine current zone.'); return; } try { // Get current zone data const tabResponse = await fetch(`/zones/${zoneId}`, { headers: { Accept: 'application/json' }, }); if (!tabResponse.ok) { throw new Error('Failed to load zone'); } const tabData = await tabResponse.json(); if ( typeof window.zoneAllowsPresets === 'function' && !window.zoneAllowsPresets(tabData, zoneId) ) { alert('This zone is for sequences only. Add a sequence from the zone Edit menu instead.'); return; } // Normalize to flat array to check and update usage 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(); } } if (flat.includes(presetId)) { alert('Preset is already added to this zone.'); return; } flat.push(presetId); const newGrid = arrayToGrid(flat, 3); tabData.presets = newGrid; tabData.presets_flat = flat; // Update zone const updateResponse = await fetch(`/zones/${zoneId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(tabData), }); if (!updateResponse.ok) { throw new Error('Failed to update zone'); } // Reload the zone content to show the new preset if (typeof renderTabPresets === 'function') { await renderTabPresets(zoneId); } else if (window.htmx) { htmx.ajax('GET', `/zones/${zoneId}/content-fragment`, { target: '#zone-content', swap: 'innerHTML' }); } else { // Fallback: reload the page window.location.reload(); } } catch (error) { console.error('Failed to add preset to zone:', error); alert('Failed to add preset to zone.'); } }; try { window.addPresetToTab = addPresetToTab; } catch (e) {} if (presetEditorCloseButton) { presetEditorCloseButton.addEventListener('click', closeEditor); } if (presetPatternInput) { presetPatternInput.addEventListener('change', () => { updatePresetNLabels(presetPatternInput.value); // Update color section visibility updateColorSectionVisibility(); // Re-render colors to show updated max colors limit renderPresetColors(currentPresetColors, currentPresetPaletteRefs); updateManualModeAvailability(); }); } if (presetManualModeInput) { presetManualModeInput.addEventListener('change', () => { updateManualBeatNVisibility(); updateDelayVisibilityForManualMode(); }); } if (presetBackgroundButton && presetBackgroundInput) { presetBackgroundButton.addEventListener('click', () => { presetBackgroundInput.click(); }); presetBackgroundInput.addEventListener('input', () => { currentBackgroundPaletteRef = null; updatePresetBackgroundButton(); }); } // Color picker auto-add handler if (presetNewColorInput) { const tryAddSelectedColor = () => { 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); currentPresetPaletteRefs.push(null); renderPresetColors(currentPresetColors, currentPresetPaletteRefs); }; // Add when the picker closes (user confirms selection). presetNewColorInput.addEventListener('change', tryAddSelectedColor); } if (presetAddFromPaletteButton) { presetAddFromPaletteButton.addEventListener('click', async () => { try { const paletteColors = await getCurrentProfilePaletteColors(); if (!Array.isArray(paletteColors) || paletteColors.length === 0) { alert('No profile palette colors available.'); return; } const modal = document.createElement('div'); modal.className = 'modal active modal-child-overlay'; modal.innerHTML = ` `; document.body.appendChild(modal); const list = modal.querySelector('#pick-palette-list'); paletteColors.forEach((color, idx) => { const row = document.createElement('div'); row.className = 'profiles-row'; row.style.display = 'flex'; row.style.alignItems = 'center'; row.style.gap = '0.75rem'; row.dataset.paletteIndex = String(idx); row.dataset.paletteColor = color; row.innerHTML = `
${color} `; list.appendChild(row); }); const close = () => modal.remove(); modal.querySelector('#pick-palette-close-btn').addEventListener('click', close); list.addEventListener('click', (e) => { const btn = e.target.closest('button'); if (!btn) return; const row = e.target.closest('[data-palette-index]'); if (!row) return; const color = row.dataset.paletteColor; const ref = parseInt(row.dataset.paletteIndex, 10); if (!color || !Number.isInteger(ref)) return; const maxColors = getMaxColors(); if (currentPresetColors.length >= maxColors) { alert(`This pattern allows a maximum of ${maxColors} color${maxColors !== 1 ? 's' : ''}.`); return; } currentPresetColors.push(color); currentPresetPaletteRefs.push(ref); renderPresetColors(currentPresetColors, currentPresetPaletteRefs); close(); }); } catch (err) { console.error('Failed to add from palette:', err); alert('Failed to load palette colours.'); } }); } if (presetBackgroundFromPaletteButton) { presetBackgroundFromPaletteButton.addEventListener('click', async () => { try { const paletteColors = await getCurrentProfilePaletteColors(); if (!Array.isArray(paletteColors) || paletteColors.length === 0) { alert('No profile palette colours available.'); return; } const modal = document.createElement('div'); modal.className = 'modal active modal-child-overlay'; modal.innerHTML = ` `; document.body.appendChild(modal); const list = modal.querySelector('#pick-bg-palette-list'); paletteColors.forEach((color, idx) => { const row = document.createElement('div'); row.className = 'profiles-row'; row.style.display = 'flex'; row.style.alignItems = 'center'; row.style.gap = '0.75rem'; row.dataset.paletteIndex = String(idx); row.dataset.paletteColor = color; row.innerHTML = `
${color} `; list.appendChild(row); }); const close = () => modal.remove(); modal.querySelector('#pick-bg-palette-close-btn').addEventListener('click', close); list.addEventListener('click', (e) => { const btn = e.target.closest('button'); if (!btn) return; const row = e.target.closest('[data-palette-index]'); if (!row) return; const color = row.dataset.paletteColor; const ref = parseInt(row.dataset.paletteIndex, 10); if (!color || !Number.isInteger(ref)) return; currentBackgroundPaletteRef = ref; if (presetBackgroundInput) { presetBackgroundInput.value = color; } updatePresetBackgroundButton(); close(); }); } catch (err) { console.error('Failed to pick background from palette:', err); alert('Failed to load palette colours.'); } }); } const presetSendButton = document.getElementById('preset-send-btn'); if (presetSendButton) { presetSendButton.addEventListener('click', async () => { const payload = buildPresetPayload(); if (!payload.name) { alert('Preset name is required to send.'); return; } // Send current editor values to zone devices (if any); never persist on device. const presetId = currentEditId || payload.name; const deviceNames = await deviceNamesForPresetOnCurrentZone(presetId); // Auto: load + immediate select. Manual: load only; first advance on the next audio beat. await sendPresetViaEspNow(presetId, payload, deviceNames, false, false, '2'); }); } if (presetDefaultButton) { presetDefaultButton.addEventListener('click', async () => { const payload = buildPresetPayload(); if (!payload.name) { alert('Preset name is required.'); return; } const presetId = currentEditId || payload.name; const deviceNames = await deviceNamesForPresetOnCurrentZone(presetId); await sendPresetViaEspNow(presetId, payload, deviceNames, true, true, '1'); await updateTabDefaultPreset(presetId); await sendDefaultPreset('1', deviceNames); }); } if (presetRemoveFromTabButton) { presetRemoveFromTabButton.addEventListener('click', async () => { if (!currentEditTabId || !currentEditId) return; if (!window.confirm('Remove this preset from this zone?')) return; await removePresetFromTab(currentEditTabId, currentEditId); clearForm(); closeEditor(); }); } 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'); } // Same device targeting as Try: per-preset zone groups when in a zone tab. const presetIdForSend = currentEditId || payload.name; const deviceNames = await deviceNamesForPresetOnCurrentZone(presetIdForSend); // 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; use the existing ID await sendPresetViaEspNow(currentEditId, saved, deviceNames, false, false, '2'); } else { // POST returns { id: preset } const entries = Object.entries(saved); if (entries.length > 0) { const [newId, presetData] = entries[0]; await sendPresetViaEspNow(newId, presetData, deviceNames, false, false, '2'); } } } else { // Fallback: send what we just built await sendPresetViaEspNow(currentEditId || payload.name, payload, deviceNames, false, false, '2'); } await loadPresets(); clearForm(); closeEditor(); // Reload zone presets if we're in a zone view const leftPanel = document.querySelector('.presets-section[data-zone-id]'); if (leftPanel) { const zoneId = leftPanel.dataset.zoneId; if (zoneId && typeof renderTabPresets !== 'undefined') { renderTabPresets(zoneId); } } } catch (error) { console.error('Save preset failed:', error); alert('Failed to save preset.'); } }); // Listen for edit preset events from zone preset buttons document.addEventListener('editPreset', async (event) => { const { presetId, preset, zoneId } = event.detail; currentEditId = presetId; currentEditTabId = zoneId || null; await loadPatterns(); const paletteColors = await getCurrentProfilePaletteColors(); setFormValues({ ...(preset || {}), colors: resolveColorsWithPaletteRefs( (preset && preset.colors) || [], (preset && preset.palette_refs) || [], paletteColors, ), }); openEditor(); }); clearForm(); }); /** Device field ``a`` / API ``auto``; missing → auto-run (matches server build_preset_dict). */ const coercePresetAuto = (preset) => { if (!preset || typeof preset !== 'object') { return true; } const v = preset.auto !== undefined && preset.auto !== null ? preset.auto : preset.a; if (typeof v === 'boolean') { return v; } if (v === 0 || v === '0') { return false; } if (v === 1 || v === '1') { return true; } if (typeof v === 'string') { const l = v.trim().toLowerCase(); if (['false', '0', 'no', 'off'].includes(l)) { return false; } if (['true', '1', 'yes', 'on'].includes(l)) { return true; } } return true; }; /** Preset background colour; accepts #RRGGBB or [r,g,b]. */ const coercePresetBackground = (preset) => { if (!preset || typeof preset !== 'object') { return '#000000'; } const raw = preset.background !== undefined && preset.background !== null ? preset.background : preset.bg; if (typeof raw === 'string') { const s = raw.trim(); if (/^#[0-9a-fA-F]{6}$/.test(s)) { return s.toUpperCase(); } } if (Array.isArray(raw) && raw.length === 3) { const r = coercePresetInt(raw[0], 0); const g = coercePresetInt(raw[1], 0); const b = coercePresetInt(raw[2], 0); const clamp = (n) => Math.max(0, Math.min(255, n)); return `#${clamp(r).toString(16).padStart(2, '0')}${clamp(g).toString(16).padStart(2, '0')}${clamp(b).toString(16).padStart(2, '0')}`.toUpperCase(); } return '#000000'; }; /** Resolved background hex; uses ``background_palette_ref`` when set and palette is available. */ const resolvePresetBackgroundHex = (preset, paletteColors) => { if (!preset || typeof preset !== 'object') { return coercePresetBackground(preset); } const rawRef = preset.background_palette_ref !== undefined && preset.background_palette_ref !== null ? preset.background_palette_ref : preset.backgroundPaletteRef; const ref = typeof rawRef === 'number' ? rawRef : parseInt(String(rawRef != null ? rawRef : ''), 10); const pal = Array.isArray(paletteColors) ? paletteColors : []; if (Number.isInteger(ref) && ref >= 0 && ref < pal.length && pal[ref]) { const c = String(pal[ref]).trim(); if (/^#[0-9a-fA-F]{6}$/i.test(c)) { return c.toUpperCase(); } } return coercePresetBackground(preset); }; /** Audio beat stride for manual presets (led-controller only; firmware ignores this key). */ const coerceManualBeatN = (preset) => { if (!preset || typeof preset !== 'object') return 1; const raw = preset.manual_beat_n; let n = typeof raw === 'number' ? raw : parseInt(String(raw != null ? raw : '1'), 10); if (!Number.isFinite(n)) n = 1; return Math.max(1, Math.min(64, n)); }; // Build driver messages for a single preset; deliver via /presets/push (ESP-NOW + TCP). // Send order: // 1) preset payload (optionally with save) // 2) optional select for device names (never with save) // saveToDevice defaults to true. const sendPresetViaEspNow = async ( presetId, preset, deviceNames, saveToDevice = true, setDefault = false, devicePresetId = null, pushOptions = null, ) => { try { const baseColors = Array.isArray(preset.colors) && preset.colors.length ? preset.colors : ['#FFFFFF']; const paletteColors = await getCurrentProfilePaletteColors(); const colors = resolveColorsWithPaletteRefs(baseColors, preset.palette_refs, paletteColors); const wirePresetId = devicePresetId != null ? String(devicePresetId) : String(presetId); const presetAuto = coercePresetAuto(preset); const presetBackground = resolvePresetBackgroundHex(preset, paletteColors); const presetMessage = { v: '1', presets: { [wirePresetId]: { pattern: preset.pattern || 'off', colors, bg: presetBackground, delay: typeof preset.delay === 'number' ? preset.delay : 100, brightness: typeof preset.brightness === 'number' ? preset.brightness : (typeof preset.br === 'number' ? preset.br : 127), auto: presetAuto, a: presetAuto, n1: coercePresetInt(preset.n1), n2: coercePresetInt(preset.n2), n3: coercePresetInt(preset.n3), n4: coercePresetInt(preset.n4), n5: coercePresetInt(preset.n5), n6: presetWireN6(preset), manual_beat_n: coerceManualBeatN(preset), }, }, }; if (saveToDevice) { presetMessage.save = true; } if (setDefault) { presetMessage.default = wirePresetId; } const names = Array.isArray(deviceNames) ? deviceNames : []; const targetMacs = names.length > 0 && typeof window.tabsManager !== 'undefined' && typeof window.tabsManager.resolveTabDeviceMacs === 'function' ? await window.tabsManager.resolveTabDeviceMacs(names) : []; const sequence = [presetMessage]; // Auto: apply preset immediately via select. Manual: load definition only — first step is on the next audio beat. if (names.length > 0 && presetAuto) { const select = {}; names.forEach((name) => { if (name) { select[name] = [wirePresetId]; } }); if (Object.keys(select).length > 0) { sequence.push({ v: '1', select }); } } await postDriverSequence(sequence, targetMacs, 0.05, pushOptions); } catch (error) { console.error('Failed to send preset to devices:', error); alert('Failed to send preset to devices.'); } }; const sendDefaultPreset = async (presetId, deviceNames) => { if (!presetId) { alert('Select a preset to set as default.'); return; } const nameTargets = Array.isArray(deviceNames) ? deviceNames.map((n) => (n || '').trim()).filter((n) => n.length > 0) : []; const message = { v: '1', default: presetId }; message.save = true; if (nameTargets.length > 0) { message.targets = nameTargets; } const macTargets = nameTargets.length > 0 && typeof window.tabsManager !== 'undefined' && typeof window.tabsManager.resolveTabDeviceMacs === 'function' ? await window.tabsManager.resolveTabDeviceMacs(nameTargets) : []; try { await postDriverSequence([message], macTargets); } catch (e) { console.error('sendDefaultPreset:', e); alert('Failed to send default preset to devices.'); } }; const sendPresetSelectViaEspNow = async (presetId, deviceNames) => { if (!presetId) { return; } const nameTargets = Array.isArray(deviceNames) ? deviceNames.map((n) => (n || '').trim()).filter((n) => n.length > 0) : []; if (!nameTargets.length) { return; } const select = {}; nameTargets.forEach((name) => { select[name] = [String(presetId)]; }); const macTargets = nameTargets.length > 0 && typeof window.tabsManager !== 'undefined' && typeof window.tabsManager.resolveTabDeviceMacs === 'function' ? await window.tabsManager.resolveTabDeviceMacs(nameTargets) : []; await postDriverSequence([{ v: '1', select }], macTargets); }; // Expose for other scripts (zones.js) so they can reuse the shared WebSocket. try { window.sendPresetViaEspNow = sendPresetViaEspNow; window.postDriverSequence = postDriverSequence; // Expose a generic ESPNow sender so other scripts (zones.js) can send // non-preset messages such as global brightness. window.sendEspnowRaw = sendEspnowMessage; window.getEspnowSocket = getEspnowSocket; } catch (e) { // window may not exist in some environments; ignore. } // Store selected preset per zone (single-select; one tile active, one driver push per click). const zoneSelectedPresetIds = {}; const zonePresetSelectionOrder = {}; function ensureZonePresetSelection(zoneId) { const z = String(zoneId); if (!zoneSelectedPresetIds[z]) zoneSelectedPresetIds[z] = new Set(); if (!zonePresetSelectionOrder[z]) zonePresetSelectionOrder[z] = []; } function pruneZonePresetSelection(zoneId, validIdSet) { const z = String(zoneId); ensureZonePresetSelection(z); const set = zoneSelectedPresetIds[z]; for (const id of [...set]) { if (!validIdSet.has(String(id))) set.delete(id); } zonePresetSelectionOrder[z] = (zonePresetSelectionOrder[z] || []).filter((id) => set.has(String(id))); } function getOrderedZonePresetSelection(zoneId) { const z = String(zoneId); ensureZonePresetSelection(z); const set = zoneSelectedPresetIds[z]; return (zonePresetSelectionOrder[z] || []).filter((id) => set.has(String(id))); } /** Preset id that should show the tile outline (last click in selection order). */ function getLastZonePresetSelectionId(zoneId) { const order = getOrderedZonePresetSelection(zoneId); return order.length ? String(order[order.length - 1]) : null; } async function sendZonePresetSelection(zoneId, tabData, presetId, preset, allPresets) { const pid = String(presetId); const body = (allPresets && allPresets[pid]) || preset; if (!body) return; const names = window.zonesManager && typeof window.zonesManager.resolveDeviceNamesForZonePreset === 'function' ? await window.zonesManager.resolveDeviceNamesForZonePreset(tabData, pid) : []; await sendPresetViaEspNow(pid, body, names, false, false, '2'); } // Store selected preset per zone const selectedPresets = {}; // Store selected preset payload per zone for beat-trigger reliability. const selectedPresetPayloads = {}; // Run vs Edit for zone preset strip (in-memory only — each full page load starts in run mode) let presetUiMode = 'run'; const getPresetUiMode = () => (presetUiMode === 'edit' ? 'edit' : 'run'); const setPresetUiMode = (mode) => { presetUiMode = mode === 'edit' ? 'edit' : 'run'; }; const updateUiModeToggleButtons = () => { const mode = getPresetUiMode(); // Label is the mode you switch *to* (opposite of current) const label = mode === 'edit' ? 'Run mode' : 'Edit mode'; document.querySelectorAll('.ui-mode-toggle').forEach((btn) => { btn.textContent = label; btn.setAttribute('aria-pressed', mode === 'edit' ? 'true' : 'false'); btn.classList.toggle('ui-mode-toggle--edit', mode === 'edit'); }); document.body.classList.toggle('preset-ui-edit', mode === 'edit'); document.body.classList.toggle('preset-ui-run', mode === 'run'); }; // Track if we're currently dragging a preset let isDraggingPreset = false; // 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 zone const savePresetGrid = async (zoneId, presetGrid) => { try { // Get current zone data const tabResponse = await fetch(`/zones/${zoneId}`, { headers: { Accept: 'application/json' }, }); if (!tabResponse.ok) { throw new Error('Failed to load zone'); } const tabData = await tabResponse.json(); if ( typeof window.zoneAllowsPresets === 'function' && !window.zoneAllowsPresets(tabData, zoneId) ) { throw new Error('This zone is for sequences only.'); } // Store as 2D grid tabData.presets = presetGrid; // Also store as flat array for backward compatibility tabData.presets_flat = presetGrid.flat(); // Save updated zone const updateResponse = await fetch(`/zones/${zoneId}`, { 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: the cell that contains the cursor (or closest if in a gap) const getDropTarget = (container, x, y) => { const draggableElements = [...container.querySelectorAll('.draggable-preset:not(.dragging)')]; // First try: find the element whose rect contains the cursor const containing = draggableElements.find((child) => { const box = child.getBoundingClientRect(); return x >= box.left && x <= box.right && y >= box.top && y <= box.bottom; }); if (containing) return containing; // Fallback: closest element by distance to center const closest = draggableElements.reduce((best, child) => { const box = child.getBoundingClientRect(); const cx = box.left + box.width / 2; const cy = box.top + box.height / 2; const d = Math.hypot(x - cx, y - cy); return d < best.distance ? { distance: d, element: child } : best; }, { distance: Infinity }); return closest.element; }; /** * Move dragged tile onto the drop target's slot. * When moving down the list (fromIdx < toIdx), insertBefore(dragging, dropTarget) lands one index * too early; use the next element sibling so the item occupies the target slot. */ const insertDraggingOntoTarget = (presetsList, dragging, dropTarget) => { const siblings = [...presetsList.querySelectorAll('.draggable-preset')]; const fromIdx = siblings.indexOf(dragging); const toIdx = siblings.indexOf(dropTarget); if (fromIdx === -1 || toIdx === -1) return; if (fromIdx < toIdx) { const next = dropTarget.nextElementSibling; presetsList.insertBefore(dragging, next); } else { presetsList.insertBefore(dragging, dropTarget); } }; // Function to render presets for a specific zone in 2D grid /** * @param {string} zoneId * @param {{ stopSequencePlayback?: boolean }} [options] - pass `{ stopSequencePlayback: true }` only when * the UI action should stop server zone sequence playback (default: do not POST /sequences/stop). */ const renderTabPresets = async (zoneId, options = {}) => { const presetsList = document.getElementById('presets-list-zone'); if (!presetsList) return; const stopSeq = options.stopSequencePlayback === true; if (stopSeq && typeof window.stopZoneSequencePlayback === 'function') { // Pass false: an earlier render's stop() can finish after this pass rebuilds the DOM and // would otherwise clear .active from new sequence tiles (breaks edit/run selection). await window.stopZoneSequencePlayback(false); } try { const [tabResponse, groupsStripRes, presetsResponse] = await Promise.all([ fetch(`/zones/${zoneId}`, { headers: { Accept: 'application/json' }, }), fetch('/groups', { headers: { Accept: 'application/json' } }), fetch('/presets', { headers: { Accept: 'application/json' }, }), ]); if (!tabResponse.ok) { throw new Error('Failed to load zone'); } const tabData = await tabResponse.json(); const groupsMapStrip = groupsStripRes.ok ? await groupsStripRes.json() : {}; const ck = typeof window.effectiveZoneContentKind === 'function' ? window.effectiveZoneContentKind(tabData) : typeof window.normalizeZoneContentKind === 'function' ? window.normalizeZoneContentKind(tabData) : 'presets'; // 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); } if (ck === 'sequences') { presetGrid = []; } if (!presetsResponse.ok) { throw new Error('Failed to load presets'); } const allPresetsRaw = await presetsResponse.json(); const allPresets = await filterPresetsForCurrentProfile(allPresetsRaw); const paletteColors = await getCurrentProfilePaletteColors(); presetsList.innerHTML = ''; presetsList.dataset.reorderTabId = zoneId; // Drag-and-drop on the list (wire once — re-render would duplicate listeners otherwise) if (!presetsList.dataset.dragWired) { presetsList.dataset.dragWired = '1'; // dragenter + dropEffect tell the browser this zone accepts a move (avoids ⊘ cursor) presetsList.addEventListener('dragenter', (e) => { if (getPresetUiMode() !== 'edit') return; e.preventDefault(); }); presetsList.addEventListener('dragover', (e) => { if (getPresetUiMode() !== 'edit') return; e.preventDefault(); try { e.dataTransfer.dropEffect = 'move'; } catch (_) {} const dragging = presetsList.querySelector('.dragging'); if (!dragging) return; const dropTarget = getDropTarget(presetsList, e.clientX, e.clientY); // Keep dragover side-effect free; commit placement only on drop. if (!dropTarget || dropTarget === dragging) { delete presetsList.dataset.dropTargetId; return; } presetsList.dataset.dropTargetId = dropTarget.dataset.presetId || ''; }); presetsList.addEventListener('drop', async (e) => { if (getPresetUiMode() !== 'edit') return; e.preventDefault(); const dragging = presetsList.querySelector('.dragging'); if (!dragging) return; const targetId = presetsList.dataset.dropTargetId; if (targetId) { const dropTarget = presetsList.querySelector(`.draggable-preset[data-preset-id="${targetId}"]:not(.dragging)`); if (dropTarget) { insertDraggingOntoTarget(presetsList, dragging, dropTarget); } } delete presetsList.dataset.dropTargetId; const saveId = presetsList.dataset.reorderTabId; const presetElements = [...presetsList.querySelectorAll('.draggable-preset')]; const presetIds = presetElements.map((el) => el.dataset.presetId); const newGrid = arrayToGrid(presetIds, 3); try { if (!saveId) { console.warn('No zone id for preset reorder save'); return; } await savePresetGrid(saveId, newGrid); await renderTabPresets(saveId); } catch (error) { console.error('Failed to save preset grid:', error); alert('Failed to save preset order. Please try again.'); const fallbackId = presetsList.dataset.reorderTabId; if (fallbackId) await renderTabPresets(fallbackId); } }); } const flatPresets = presetGrid.flat().filter(id => id); const validIdSet = new Set(flatPresets.map((id) => String(id))); pruneZonePresetSelection(zoneId, validIdSet); const hasSeq = Array.isArray(tabData.sequence_ids) && tabData.sequence_ids.some((x) => x != null && String(x).trim()); if (flatPresets.length === 0) { const empty = document.createElement('p'); empty.className = 'muted-text'; empty.style.gridColumn = '1 / -1'; // Span all columns if (ck === 'sequences') { if (!hasSeq) { empty.textContent = "No sequences on this zone yet. Open the zone's Edit menu to add one."; presetsList.appendChild(empty); } } else { empty.textContent = 'No presets added to this zone. Open the zone\'s Edit menu and click "Add Preset" to add one.'; presetsList.appendChild(empty); } } else { flatPresets.forEach((presetId) => { const preset = allPresets[presetId]; if (preset) { ensureZonePresetSelection(zoneId); const lastSelectedId = getLastZonePresetSelectionId(zoneId); const isSelected = lastSelectedId !== null && lastSelectedId === String(presetId); const displayPreset = { ...preset, colors: resolveColorsWithPaletteRefs(preset.colors, preset.palette_refs, paletteColors), background: resolvePresetBackgroundHex(preset, paletteColors), }; const wrapper = createPresetButton( presetId, displayPreset, zoneId, isSelected, tabData, groupsMapStrip, allPresets, ); presetsList.appendChild(wrapper); } }); } if ( typeof window.appendZoneSequenceTiles === 'function' && (typeof window.zoneAllowsSequences !== 'function' || window.zoneAllowsSequences(tabData, zoneId)) ) { await window.appendZoneSequenceTiles(zoneId, tabData, allPresets, paletteColors, presetsList); } } catch (error) { console.error('Failed to render zone presets:', error); presetsList.innerHTML = '

Failed to load presets.

'; } }; const createPresetButton = (presetId, preset, zoneId, isSelected, tabData, groupsMap, allPresets) => { const uiMode = getPresetUiMode(); const row = document.createElement('div'); const canDrag = uiMode === 'edit'; row.className = `preset-tile-row preset-tile-row--${uiMode}${canDrag ? ' draggable-preset' : ''}`; row.draggable = canDrag; row.dataset.presetId = presetId; const button = document.createElement('button'); button.type = 'button'; button.className = 'pattern-button preset-tile-main'; if (isSelected) { button.classList.add('active'); } const colors = Array.isArray(preset.colors) ? preset.colors.filter((c) => c) : []; const pat = (preset.pattern || '').toLowerCase(); const mode = presetWireN6(preset); const isRainbow = pat === 'rainbow' || (pat === 'colour_cycle' && mode === 1); const barColors = isRainbow ? ['#FF0000', '#FF7F00', '#FFFF00', '#00FF00', '#0000FF', '#4B0082', '#8F00FF'] : colors; if (barColors.length > 0) { const n = barColors.length; const stops = barColors.flatMap((c, i) => { const start = (100 * i / n).toFixed(2); const end = (100 * (i + 1) / n).toFixed(2); return [`${c} ${start}%`, `${c} ${end}%`]; }).join(', '); button.style.backgroundImage = `linear-gradient(rgba(0,0,0,0.4), rgba(0,0,0,0.4)), linear-gradient(to right, ${stops})`; } const presetNameLabel = document.createElement('span'); presetNameLabel.textContent = preset.name || presetId; presetNameLabel.style.fontWeight = 'bold'; presetNameLabel.className = 'pattern-button-label'; button.appendChild(presetNameLabel); const groupsText = formatPresetTargetGroupsLine(tabData || {}, groupsMap || {}); if (groupsText) { const groupsSpan = document.createElement('span'); groupsSpan.className = 'preset-tile-groups'; groupsSpan.textContent = groupsText; button.appendChild(groupsSpan); } const bgSwatch = document.createElement('span'); const bgColor = coercePresetBackground(preset); bgSwatch.title = `Background: ${bgColor}`; bgSwatch.style.cssText = ` position: absolute; left: 4px; bottom: 4px; width: 12px; height: 12px; border-radius: 2px; background: ${bgColor}; border: 1px solid rgba(255, 255, 255, 0.7); box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.5); pointer-events: none; z-index: 2; `; button.appendChild(bgSwatch); const isManualPreset = preset && !coercePresetAuto(preset); if (isManualPreset) { const manualBadge = document.createElement('span'); manualBadge.textContent = '1'; manualBadge.title = 'Manual preset'; manualBadge.style.cssText = ` position: absolute; right: 4px; bottom: 4px; min-width: 16px; height: 16px; border-radius: 8px; background: rgba(0, 0, 0, 0.72); color: #fff; border: 1px solid rgba(255, 255, 255, 0.5); font-size: 11px; font-weight: 700; line-height: 14px; text-align: center; pointer-events: none; z-index: 2; `; button.appendChild(manualBadge); } button.addEventListener('click', () => { if (isDraggingPreset) return; console.info('Preset button pressed', { zoneId, presetId, name: (preset && preset.name) || presetId }); const presetsListEl = document.getElementById('presets-list-zone'); ensureZonePresetSelection(zoneId); const z = String(zoneId); const set = zoneSelectedPresetIds[z]; const idStr = String(presetId); const wasSelected = set.has(idStr); set.clear(); zonePresetSelectionOrder[z] = []; if (!wasSelected) { set.add(idStr); zonePresetSelectionOrder[z] = [idStr]; } const outlinePresetId = getLastZonePresetSelectionId(zoneId); if (presetsListEl) { presetsListEl.querySelectorAll('.preset-tile-row:not(.sequence-tile-row)').forEach((rw) => { const pid = rw.dataset.presetId; const btnEl = rw.querySelector('.preset-tile-main'); if (!btnEl || !pid) return; if (outlinePresetId && String(pid) === outlinePresetId) btnEl.classList.add('active'); else btnEl.classList.remove('active'); }); } if (!wasSelected) { selectedPresets[zoneId] = idStr; selectedPresetPayloads[zoneId] = (allPresets && allPresets[idStr]) || preset; void sendZonePresetSelection(zoneId, tabData, idStr, preset, allPresets); } else { delete selectedPresets[zoneId]; delete selectedPresetPayloads[zoneId]; } }); if (canDrag) { row.addEventListener('dragstart', (e) => { isDraggingPreset = true; row.classList.add('dragging'); e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', presetId); }); row.addEventListener('dragend', () => { row.classList.remove('dragging'); const presetsListEl = document.getElementById('presets-list-zone'); if (presetsListEl) { delete presetsListEl.dataset.dropTargetId; } document.querySelectorAll('.draggable-preset').forEach((el) => el.classList.remove('drag-over')); setTimeout(() => { isDraggingPreset = false; }, 100); }); } const top = document.createElement('div'); top.className = 'preset-tile-row-top'; top.appendChild(button); if (uiMode === 'edit') { const actions = document.createElement('div'); actions.className = 'preset-tile-actions'; const editBtn = document.createElement('button'); editBtn.type = 'button'; editBtn.className = 'btn btn-secondary btn-small'; editBtn.textContent = 'Edit'; editBtn.title = 'Edit preset'; editBtn.addEventListener('click', (e) => { e.preventDefault(); e.stopPropagation(); if (isDraggingPreset) return; editPresetFromTab(presetId, zoneId, preset); }); actions.appendChild(editBtn); top.appendChild(actions); } row.appendChild(top); return row; }; const editPresetFromTab = async (presetId, zoneId, 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, zoneId } }); 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 zone (does not delete the preset itself) // Expected call style: removePresetFromTab(zoneId, presetId) const removePresetFromTab = async (zoneId, presetId) => { if (!zoneId) { // Try to get zone ID from the left-panel const leftPanel = document.querySelector('.presets-section[data-zone-id]'); zoneId = leftPanel ? leftPanel.dataset.zoneId : null; if (!zoneId) { // Fallback: try to get from URL const pathParts = window.location.pathname.split('/'); const tabIndex = pathParts.indexOf('zones'); if (tabIndex !== -1 && tabIndex + 1 < pathParts.length) { zoneId = pathParts[tabIndex + 1]; } } } if (!zoneId) { alert('Could not determine current zone.'); return; } try { // Get current zone data const tabResponse = await fetch(`/zones/${zoneId}`, { headers: { Accept: 'application/json' }, }); if (!tabResponse.ok) { throw new Error('Failed to load zone'); } const tabData = await tabResponse.json(); if ( typeof window.zoneAllowsPresets === 'function' && !window.zoneAllowsPresets(tabData, zoneId) ) { alert('This zone is for sequences only.'); return; } // 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 zone.'); return; } const newGrid = arrayToGrid(flat, 3); tabData.presets = newGrid; tabData.presets_flat = flat; const updateResponse = await fetch(`/zones/${zoneId}`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(tabData), }); if (!updateResponse.ok) { throw new Error('Failed to update zone presets'); } await renderTabPresets(zoneId); } catch (error) { console.error('Failed to remove preset from zone:', error); alert('Failed to remove preset from zone.'); } }; try { window.removePresetFromTab = removePresetFromTab; } catch (e) {} try { window.renderTabPresets = renderTabPresets; window.getPresetUiMode = getPresetUiMode; } catch (e) {} // Listen for HTMX swaps to render presets document.body.addEventListener('htmx:afterSwap', (event) => { if (event.target && event.target.id === 'zone-content') { // Get zone ID from the left-panel const leftPanel = document.querySelector('.presets-section[data-zone-id]'); if (leftPanel) { const zoneId = leftPanel.dataset.zoneId; if (zoneId) { renderTabPresets(zoneId); } } } }); document.addEventListener('DOMContentLoaded', () => { updateUiModeToggleButtons(); document.querySelectorAll('.ui-mode-toggle').forEach((btn) => { btn.addEventListener('click', () => { const next = getPresetUiMode() === 'edit' ? 'run' : 'edit'; setPresetUiMode(next); updateUiModeToggleButtons(); if (next === 'run') { ['devices-modal', 'edit-device-modal'].forEach((id) => { const el = document.getElementById(id); if (el) el.classList.remove('active'); }); } const mainMenu = document.getElementById('main-menu-dropdown'); if (mainMenu) mainMenu.classList.remove('open'); // Preset strip re-renders from `zones.js` after `loadZones()` (no driver/playback side effects). }); }); });