Files
led-controller/src/static/presets.js
Jimmy 6cbb728d9a feat(patterns): add new pattern suite and improve mobile controls
Add a broad set of LED patterns with metadata/tests and update zone/profile preset seeding, while refining mobile/desktop UI behavior for scrolling, brightness controls, and bulk pattern sending.
2026-04-23 20:07:55 +12:00

2046 lines
72 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// Shared WebSocket for ESPNow messages (presets + selects)
let espnowSocket = null;
let espnowSocketReady = false;
let espnowPendingMessages = [];
let currentProfileIdCache = null;
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);
}),
);
};
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)
: [];
}
async function postDriverSequence(sequence, targetMacs, delayS) {
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 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');
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)
// 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-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: 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;
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 : [];
const paletteRefs = Array.isArray(preset.palette_refs) ? preset.palette_refs : [];
renderPresetColors(colors, paletteRefs);
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 = '';
}
// 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;
}
});
}
// 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);
updatePresetEditorTabActionsVisibility();
};
const clearForm = () => {
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,
});
// 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 = () => {
if (!presetRemoveFromTabButton) return;
const show = Boolean(currentEditTabId && currentEditId);
presetRemoveFromTabButton.hidden = !show;
};
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');
}
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 || [],
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,
};
// 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 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 = '<option value="">Pattern</option>';
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 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') {
patternConfig = patternConfig.parameter_mappings;
}
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 hasPatternMeta =
patternConfig && typeof patternConfig === 'object' && Object.keys(patternConfig).length > 0;
const hasAnyNLabel = visibleNKeys.size > 0;
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';
}
};
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 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();
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);
}
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;
}
// 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.id = 'add-preset-to-zone-modal';
modal.innerHTML = `
<div class="modal-content">
<h2>Add Preset to Zone</h2>
<div id="add-preset-list" class="profiles-list" style="max-height: 400px; overflow-y: auto;"></div>
<div class="modal-actions">
<button class="btn btn-secondary" id="add-preset-to-zone-close-btn">Close</button>
</div>
</div>
`;
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 = '<p class="muted-text">No presets to add. All presets are already in this zone, or create a preset first.</p>';
} 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();
// 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);
});
}
// 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.innerHTML = `
<div class="modal-content">
<h2>Pick Palette Color</h2>
<div id="pick-palette-list" class="profiles-list" style="max-height: 300px; overflow-y: auto;"></div>
<div class="modal-actions">
<button class="btn btn-secondary" id="pick-palette-close-btn">Close</button>
</div>
</div>
`;
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 = `
<div style="width:28px;height:28px;border-radius:4px;border:1px solid #555;background:${color};"></div>
<span style="flex:1">${color}</span>
<button class="btn btn-primary btn-small" type="button">Use</button>
`;
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;
if (currentPresetColors.includes(color) && currentPresetPaletteRefs.includes(ref)) {
alert('That palette color is already linked.');
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 colors.');
}
});
}
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 and then select on all devices in the current zone (if any)
const section = document.querySelector('.presets-section[data-zone-id]');
const deviceNames = tabDeviceNamesFromSection(section);
// Work out the preset ID: for existing presets use currentEditId, otherwise fall back to name
const presetId = currentEditId || payload.name;
// Try sends preset first, then select; never persist on device.
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 section = document.querySelector('.presets-section[data-zone-id]');
const deviceNames = tabDeviceNamesFromSection(section);
const presetId = currentEditId || payload.name;
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: zone tab supplies names and selection without persistence.
const section = document.querySelector('.presets-section[data-zone-id]');
const deviceNames = tabDeviceNamesFromSection(section);
// 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();
});
const 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;
};
// 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,
) => {
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 presetMessage = {
v: '1',
presets: {
[wirePresetId]: {
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: coercePresetInt(preset.n1),
n2: coercePresetInt(preset.n2),
n3: coercePresetInt(preset.n3),
n4: coercePresetInt(preset.n4),
n5: coercePresetInt(preset.n5),
n6: coercePresetInt(preset.n6),
},
},
};
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];
if (names.length > 0) {
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);
} 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.');
}
};
// 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
const selectedPresets = {};
// 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();
// 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
const renderTabPresets = async (zoneId) => {
const presetsList = document.getElementById('presets-list-zone');
if (!presetsList) return;
try {
// Get zone data to see which presets are associated
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();
// 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 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);
}
});
}
// Get the currently selected preset for this zone
const selectedPresetId = selectedPresets[zoneId];
// 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 zone 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 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) {
const isSelected = presetId === selectedPresetId;
const displayPreset = {
...preset,
colors: resolveColorsWithPaletteRefs(preset.colors, preset.palette_refs, paletteColors),
};
const wrapper = createPresetButton(presetId, displayPreset, zoneId, isSelected);
presetsList.appendChild(wrapper);
}
});
}
} catch (error) {
console.error('Failed to render zone presets:', error);
presetsList.innerHTML = '<p class="muted-text">Failed to load presets.</p>';
}
};
const createPresetButton = (presetId, preset, zoneId, isSelected = false) => {
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 isRainbow = (preset.pattern || '').toLowerCase() === 'rainbow';
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);
button.addEventListener('click', () => {
if (isDraggingPreset) return;
const presetsListEl = document.getElementById('presets-list-zone');
if (presetsListEl) {
presetsListEl.querySelectorAll('.pattern-button').forEach((btn) => btn.classList.remove('active'));
}
button.classList.add('active');
selectedPresets[zoneId] = presetId;
const section = row.closest('.presets-section');
const deviceNames = tabDeviceNamesFromSection(section);
sendPresetViaEspNow(presetId, preset, deviceNames, false, false, '2').catch((err) => {
console.error(err);
});
});
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);
});
}
row.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);
row.appendChild(actions);
}
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();
// 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) {}
// 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');
const leftPanel = document.querySelector('.presets-section[data-zone-id]');
if (leftPanel) {
renderTabPresets(leftPanel.dataset.zoneId);
}
});
});
});