Files
led-controller/src/static/presets.js

2834 lines
100 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;
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 = '<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, 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 = `
<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();
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 = `
<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;
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 = `
<div class="modal-content">
<h2>Pick background colour</h2>
<div id="pick-bg-palette-list" class="profiles-list" style="max-height: 300px; overflow-y: auto;"></div>
<div class="modal-actions">
<button class="btn btn-secondary" id="pick-bg-palette-close-btn">Close</button>
</div>
</div>
`;
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 = `
<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-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 = '<p class="muted-text">Failed to load presets.</p>';
}
};
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).
});
});
});