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

1938 lines
67 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 wsUrl = `ws://${window.location.host}/ws`;
espnowSocket = new WebSocket(wsUrl);
espnowSocketReady = false;
espnowSocket.onopen = () => {
espnowSocketReady = true;
// Flush any queued messages
espnowPendingMessages.forEach((msg) => {
try {
espnowSocket.send(msg);
} catch (err) {
console.error('Failed to send queued ESPNow message:', err);
}
});
espnowPendingMessages = [];
};
espnowSocket.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
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);
}
};
// Send a select message for a preset to all device names in the current tab.
// Uses the preset ID as the select key.
const sendSelectForCurrentTabDevices = (presetId, sectionEl) => {
const section = sectionEl || document.querySelector('.presets-section[data-tab-id]');
if (!section || !presetId) {
return;
}
const namesAttr = section.getAttribute('data-device-names');
const deviceNames = namesAttr
? namesAttr.split(',').map((n) => n.trim()).filter((n) => n.length > 0)
: [];
if (!deviceNames.length) {
return;
}
const select = {};
deviceNames.forEach((name) => {
select[name] = [presetId];
});
const message = {
v: '1',
select,
};
sendEspnowMessage(message);
};
document.addEventListener('DOMContentLoaded', () => {
const presetsButton = document.getElementById('presets-btn');
const presetsModal = document.getElementById('presets-modal');
const presetsCloseButton = document.getElementById('presets-close-btn');
const presetsList = document.getElementById('presets-list');
const presetsAddButton = document.getElementById('preset-add-btn');
const presetEditorModal = document.getElementById('preset-editor-modal');
const presetEditorCloseButton = document.getElementById('preset-editor-close-btn');
const presetNameInput = document.getElementById('preset-name-input');
const presetPatternInput = document.getElementById('preset-pattern-input');
const presetColorsContainer = document.getElementById('preset-colors-container');
const presetNewColorInput = document.getElementById('preset-new-color');
const 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-tab-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;
}
return parseInt(input.value, 10) || 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 = '';
}
// Update labels and visibility based on pattern
updatePresetNLabels(patternName);
// Get pattern config to map descriptive names back to n keys
const patternConfig = cachedPatterns && cachedPatterns[patternName];
const nToLabel = {};
if (patternConfig && typeof patternConfig === 'object') {
// Now n keys are keys, labels are values
Object.entries(patternConfig).forEach(([nKey, label]) => {
if (typeof nKey === 'string' && nKey.startsWith('n') && typeof label === 'string') {
nToLabel[nKey] = label;
}
});
}
// Set n values, checking both n keys and descriptive names
for (let i = 1; i <= 8; i++) {
const nKey = `n${i}`;
const inputEl = document.getElementById(`preset-${nKey}-input`);
if (inputEl) {
// First check if preset has n key directly
if (preset[nKey] !== undefined) {
inputEl.value = preset[nKey] || 0;
} else {
// Check if preset has descriptive name (from pattern.json mapping)
const label = nToLabel[nKey];
if (label && preset[label] !== undefined) {
inputEl.value = preset[label] || 0;
} else {
inputEl.value = 0;
}
}
}
}
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-tab-id]');
return section ? section.dataset.tabId : null;
};
const updatePresetEditorTabActionsVisibility = () => {
if (!presetRemoveFromTabButton) return;
const show = Boolean(currentEditTabId && currentEditId);
presetRemoveFromTabButton.hidden = !show;
};
const updateTabDefaultPreset = async (presetId) => {
const tabId = getActiveTabId();
if (!tabId) {
return;
}
try {
const tabResponse = await fetch(`/tabs/${tabId}`, {
headers: { Accept: 'application/json' },
});
if (!tabResponse.ok) {
return;
}
const tabData = await tabResponse.json();
tabData.default_preset = presetId;
await fetch(`/tabs/${tabId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(tabData),
});
} catch (error) {
console.warn('Failed to save tab 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', {
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', {
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 labels = {};
const visibleNKeys = new Set();
// Initialize all labels with default n1:, n2:, etc.
for (let i = 1; i <= 8; i++) {
labels[`n${i}`] = `n${i}:`;
}
const patternConfig = cachedPatterns && cachedPatterns[patternName];
if (patternConfig && typeof patternConfig === 'object') {
// Now n values are keys and descriptive names are values
Object.entries(patternConfig).forEach(([key, label]) => {
if (typeof key === 'string' && key.startsWith('n') && typeof label === 'string') {
labels[key] = `${label}:`;
visibleNKeys.add(key); // Mark this n key as visible
}
});
}
// Update labels and show/hide input groups
for (let i = 1; i <= 8; i++) {
const nKey = `n${i}`;
const labelEl = document.getElementById(`preset-${nKey}-label`);
const inputEl = document.getElementById(`preset-${nKey}-input`);
const groupEl = labelEl ? labelEl.closest('.n-param-group') : null;
if (labelEl) {
labelEl.textContent = labels[nKey];
}
// Show or hide the entire group based on whether it has a mapping
if (groupEl) {
if (visibleNKeys.has(nKey)) {
groupEl.style.display = ''; // Show
} else {
groupEl.style.display = 'none'; // Hide
}
}
}
};
const renderPresets = (presets) => {
presetsList.innerHTML = '';
cachedPresets = presets || {};
const entries = Object.entries(cachedPresets);
if (!entries.length) {
const empty = document.createElement('p');
empty.className = 'muted-text';
empty.textContent = 'No presets found.';
presetsList.appendChild(empty);
return;
}
entries.forEach(([presetId, preset]) => {
const row = document.createElement('div');
row.className = 'profiles-row';
const label = document.createElement('span');
label.textContent = (preset && preset.name) || presetId;
const details = document.createElement('span');
const pattern = preset && preset.pattern ? preset.pattern : '-';
details.textContent = pattern;
details.style.color = '#aaa';
details.style.fontSize = '0.85em';
const editButton = document.createElement('button');
editButton.className = 'btn btn-secondary btn-small';
editButton.textContent = 'Edit';
editButton.addEventListener('click', async () => {
currentEditId = presetId;
currentEditTabId = null;
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 via ESPNow';
sendButton.addEventListener('click', () => {
// Just send the definition; selection happens when user clicks the preset.
sendPresetViaEspNow(presetId, preset || {});
});
const deleteButton = document.createElement('button');
deleteButton.className = 'btn btn-danger btn-small';
deleteButton.textContent = 'Delete';
deleteButton.addEventListener('click', async () => {
const confirmed = confirm(`Delete preset "${label.textContent}"?`);
if (!confirmed) {
return;
}
try {
const response = await fetch(`/presets/${presetId}`, {
method: 'DELETE',
headers: { Accept: 'application/json' },
});
if (!response.ok) {
throw new Error('Failed to delete preset');
}
await loadPresets();
if (currentEditId === presetId) {
clearForm();
}
} catch (error) {
console.error('Delete preset failed:', error);
alert('Failed to delete preset.');
}
});
row.appendChild(label);
row.appendChild(details);
row.appendChild(editButton);
row.appendChild(sendButton);
row.appendChild(deleteButton);
presetsList.appendChild(row);
});
};
const loadPresets = async () => {
presetsList.innerHTML = '';
const loading = document.createElement('p');
loading.className = 'muted-text';
loading.textContent = 'Loading presets...';
presetsList.appendChild(loading);
try {
const response = await fetch('/presets', {
headers: { Accept: 'application/json' },
});
if (!response.ok) {
throw new Error('Failed to load presets');
}
const presets = await response.json();
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();
});
}
const showAddPresetToTabModal = async (optionalTabId) => {
let tabId = optionalTabId;
if (!tabId) {
// Get current tab ID from the presets section
const leftPanel = document.querySelector('.presets-section[data-tab-id]');
tabId = leftPanel ? leftPanel.dataset.tabId : null;
}
if (!tabId) {
// Fallback: try to get from URL
const pathParts = window.location.pathname.split('/');
const tabIndex = pathParts.indexOf('tabs');
if (tabIndex !== -1 && tabIndex + 1 < pathParts.length) {
tabId = pathParts[tabIndex + 1];
}
}
if (!tabId) {
alert('Could not determine current tab.');
return;
}
// 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 tab's presets so we can avoid duplicates within this tab.
let currentTabPresets = [];
try {
const tabResponse = await fetch(`/tabs/${tabId}`, {
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 tab presets:', e);
}
// Create modal
const modal = document.createElement('div');
modal.className = 'modal active';
modal.id = 'add-preset-to-tab-modal';
modal.innerHTML = `
<div class="modal-content">
<h2>Add Preset to Tab</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-tab-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 tab, 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, tabId);
modal.remove();
});
row.appendChild(label);
row.appendChild(details);
row.appendChild(addButton);
listContainer.appendChild(row);
});
}
// Close button handler
document.getElementById('add-preset-to-tab-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, tabId) => {
if (!tabId) {
// Try to get tab ID from the left-panel
const leftPanel = document.querySelector('.presets-section[data-tab-id]');
tabId = leftPanel ? leftPanel.dataset.tabId : null;
if (!tabId) {
// Fallback: try to get from URL
const pathParts = window.location.pathname.split('/');
const tabIndex = pathParts.indexOf('tabs');
if (tabIndex !== -1 && tabIndex + 1 < pathParts.length) {
tabId = pathParts[tabIndex + 1];
}
}
}
if (!tabId) {
alert('Could not determine current tab.');
return;
}
try {
// Get current tab data
const tabResponse = await fetch(`/tabs/${tabId}`, {
headers: { Accept: 'application/json' },
});
if (!tabResponse.ok) {
throw new Error('Failed to load tab');
}
const tabData = await tabResponse.json();
// 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 tab.');
return;
}
flat.push(presetId);
const newGrid = arrayToGrid(flat, 3);
tabData.presets = newGrid;
tabData.presets_flat = flat;
// Update tab
const updateResponse = await fetch(`/tabs/${tabId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(tabData),
});
if (!updateResponse.ok) {
throw new Error('Failed to update tab');
}
// Reload the tab content to show the new preset
if (typeof renderTabPresets === 'function') {
await renderTabPresets(tabId);
} else if (window.htmx) {
htmx.ajax('GET', `/tabs/${tabId}/content-fragment`, {
target: '#tab-content',
swap: 'innerHTML'
});
} else {
// Fallback: reload the page
window.location.reload();
}
} catch (error) {
console.error('Failed to add preset to tab:', error);
alert('Failed to add preset to tab.');
}
};
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 tab (if any)
const section = document.querySelector('.presets-section[data-tab-id]');
const namesAttr = section && section.getAttribute('data-device-names');
const deviceNames = namesAttr
? namesAttr.split(',').map((n) => n.trim()).filter((n) => n.length > 0)
: [];
// 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);
});
}
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-tab-id]');
const namesAttr = section && section.getAttribute('data-device-names');
const deviceNames = namesAttr
? namesAttr.split(',').map((n) => n.trim()).filter((n) => n.length > 0)
: [];
const presetId = currentEditId || payload.name;
await updateTabDefaultPreset(presetId);
sendDefaultPreset(presetId, deviceNames);
});
}
if (presetRemoveFromTabButton) {
presetRemoveFromTabButton.addEventListener('click', async () => {
if (!currentEditTabId || !currentEditId) return;
if (!window.confirm('Remove this preset from this tab?')) 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');
}
// 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
// Save & Send should not force-select the preset on devices.
sendPresetViaEspNow(currentEditId, saved, [], true, false);
} else {
// POST returns { id: preset }
const entries = Object.entries(saved);
if (entries.length > 0) {
const [newId, presetData] = entries[0];
// Save & Send should not force-select the preset on devices.
sendPresetViaEspNow(newId, presetData, [], true, false);
}
}
} else {
// Fallback: send what we just built
// Save & Send should not force-select the preset on devices.
sendPresetViaEspNow(payload.name, payload, [], true, false);
}
await loadPresets();
clearForm();
closeEditor();
// Reload tab presets if we're in a tab view
const leftPanel = document.querySelector('.presets-section[data-tab-id]');
if (leftPanel) {
const tabId = leftPanel.dataset.tabId;
if (tabId && typeof renderTabPresets !== 'undefined') {
renderTabPresets(tabId);
}
}
} catch (error) {
console.error('Save preset failed:', error);
alert('Failed to save preset.');
}
});
// Listen for edit preset events from tab preset buttons
document.addEventListener('editPreset', async (event) => {
const { presetId, preset, tabId } = event.detail;
currentEditId = presetId;
currentEditTabId = tabId || null;
await loadPatterns();
const paletteColors = await getCurrentProfilePaletteColors();
setFormValues({
...(preset || {}),
colors: resolveColorsWithPaletteRefs(
(preset && preset.colors) || [],
(preset && preset.palette_refs) || [],
paletteColors,
),
});
openEditor();
});
clearForm();
});
// Build ESPNow messages for a single preset.
// 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) => {
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 presetMessage = {
v: '1',
presets: {
[presetId]: {
pattern: preset.pattern || 'off',
colors,
delay: typeof preset.delay === 'number' ? preset.delay : 100,
brightness: typeof preset.brightness === 'number'
? preset.brightness
: (typeof preset.br === 'number' ? preset.br : 127),
auto: typeof preset.auto === 'boolean' ? preset.auto : true,
n1: typeof preset.n1 === 'number' ? preset.n1 : 0,
n2: typeof preset.n2 === 'number' ? preset.n2 : 0,
n3: typeof preset.n3 === 'number' ? preset.n3 : 0,
n4: typeof preset.n4 === 'number' ? preset.n4 : 0,
n5: typeof preset.n5 === 'number' ? preset.n5 : 0,
n6: typeof preset.n6 === 'number' ? preset.n6 : 0,
},
},
};
if (saveToDevice) {
presetMessage.save = true;
}
if (setDefault) {
presetMessage.default = presetId;
}
// 1) Send presets first, without save.
sendEspnowMessage(presetMessage);
// Optionally send a separate select message for specific devices.
if (Array.isArray(deviceNames) && deviceNames.length > 0) {
const select = {};
deviceNames.forEach((name) => {
if (name) {
select[name] = [presetId];
}
});
if (Object.keys(select).length > 0) {
// Small gap helps slower receivers process preset update before select.
await new Promise((resolve) => setTimeout(resolve, 30));
sendEspnowMessage({ v: '1', select });
}
}
} catch (error) {
console.error('Failed to send preset via ESPNow:', error);
alert('Failed to send preset via ESPNow.');
}
};
const sendDefaultPreset = (presetId, deviceNames) => {
if (!presetId) {
alert('Select a preset to set as default.');
return;
}
// Default should only set startup preset, not trigger live selection.
// Save is attached to default messages.
// When device names are provided, scope the default update to those devices.
const targets = Array.isArray(deviceNames)
? deviceNames.map((n) => (n || '').trim()).filter((n) => n.length > 0)
: [];
const message = { v: '1', default: presetId };
message.save = true;
if (targets.length > 0) {
message.targets = targets;
}
sendEspnowMessage(message);
};
// Expose for other scripts (tabs.js) so they can reuse the shared WebSocket.
try {
window.sendPresetViaEspNow = sendPresetViaEspNow;
// Expose a generic ESPNow sender so other scripts (tabs.js) can send
// non-preset messages such as global brightness.
window.sendEspnowRaw = sendEspnowMessage;
} catch (e) {
// window may not exist in some environments; ignore.
}
// Store selected preset per tab
const selectedPresets = {};
// Run vs Edit for tab 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 tab
const savePresetGrid = async (tabId, presetGrid) => {
try {
// Get current tab data
const tabResponse = await fetch(`/tabs/${tabId}`, {
headers: { Accept: 'application/json' },
});
if (!tabResponse.ok) {
throw new Error('Failed to load tab');
}
const tabData = await tabResponse.json();
// Store as 2D grid
tabData.presets = presetGrid;
// Also store as flat array for backward compatibility
tabData.presets_flat = presetGrid.flat();
// Save updated tab
const updateResponse = await fetch(`/tabs/${tabId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(tabData),
});
if (!updateResponse.ok) {
throw new Error('Failed to save preset grid');
}
} catch (error) {
console.error('Failed to save preset grid:', error);
throw error;
}
};
// Function to get drop target: 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 tab in 2D grid
const renderTabPresets = async (tabId) => {
const presetsList = document.getElementById('presets-list-tab');
if (!presetsList) return;
try {
// Get tab data to see which presets are associated
const tabResponse = await fetch(`/tabs/${tabId}`, {
headers: { Accept: 'application/json' },
});
if (!tabResponse.ok) {
throw new Error('Failed to load tab');
}
const tabData = await tabResponse.json();
// Get presets - support both 2D grid and flat array (for backward compatibility)
let presetGrid = tabData.presets;
if (!presetGrid || !Array.isArray(presetGrid)) {
// Try to get from flat array or convert old format
const flatArray = tabData.presets_flat || tabData.presets || [];
presetGrid = arrayToGrid(flatArray, 3); // Default to 3 columns
} else if (presetGrid.length > 0 && typeof presetGrid[0] === 'string') {
// It's a flat array, convert to grid
presetGrid = arrayToGrid(presetGrid, 3);
}
// Get all presets
const presetsResponse = await fetch('/presets', {
headers: { Accept: 'application/json' },
});
if (!presetsResponse.ok) {
throw new Error('Failed to load presets');
}
const allPresetsRaw = await presetsResponse.json();
const allPresets = await filterPresetsForCurrentProfile(allPresetsRaw);
const paletteColors = await getCurrentProfilePaletteColors();
presetsList.innerHTML = '';
presetsList.dataset.reorderTabId = tabId;
// 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 tab 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 tab
const selectedPresetId = selectedPresets[tabId];
// Render presets in grid layout
// Flatten the grid and render all presets (grid CSS will handle layout)
const flatPresets = presetGrid.flat().filter(id => id);
if (flatPresets.length === 0) {
// Show empty message if this tab has no presets
const empty = document.createElement('p');
empty.className = 'muted-text';
empty.style.gridColumn = '1 / -1'; // Span all columns
empty.textContent = 'No presets added to this tab. Open the tab\'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, tabId, isSelected);
presetsList.appendChild(wrapper);
}
});
}
} catch (error) {
console.error('Failed to render tab presets:', error);
presetsList.innerHTML = '<p class="muted-text">Failed to load presets.</p>';
}
};
const createPresetButton = (presetId, preset, tabId, 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-tab');
if (presetsListEl) {
presetsListEl.querySelectorAll('.pattern-button').forEach((btn) => btn.classList.remove('active'));
}
button.classList.add('active');
selectedPresets[tabId] = presetId;
const section = row.closest('.presets-section');
sendSelectForCurrentTabDevices(presetId, section);
});
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-tab');
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, tabId, preset);
});
actions.appendChild(editBtn);
row.appendChild(actions);
}
return row;
};
const editPresetFromTab = async (presetId, tabId, existingPreset) => {
try {
let preset = existingPreset;
if (!preset) {
// Fallback: load the preset data from the server if we weren't given it
const response = await fetch(`/presets/${presetId}`, {
headers: { Accept: 'application/json' },
});
if (!response.ok) {
throw new Error('Failed to load preset');
}
preset = await response.json();
}
// Dispatch a custom event to trigger the edit in the DOMContentLoaded scope
const editEvent = new CustomEvent('editPreset', {
detail: { presetId, preset, tabId }
});
document.dispatchEvent(editEvent);
} catch (error) {
console.error('Failed to load preset for editing:', error);
alert('Failed to load preset for editing.');
}
};
// Remove a preset from a specific tab (does not delete the preset itself)
// Expected call style: removePresetFromTab(tabId, presetId)
const removePresetFromTab = async (tabId, presetId) => {
if (!tabId) {
// Try to get tab ID from the left-panel
const leftPanel = document.querySelector('.presets-section[data-tab-id]');
tabId = leftPanel ? leftPanel.dataset.tabId : null;
if (!tabId) {
// Fallback: try to get from URL
const pathParts = window.location.pathname.split('/');
const tabIndex = pathParts.indexOf('tabs');
if (tabIndex !== -1 && tabIndex + 1 < pathParts.length) {
tabId = pathParts[tabIndex + 1];
}
}
}
if (!tabId) {
alert('Could not determine current tab.');
return;
}
try {
// Get current tab data
const tabResponse = await fetch(`/tabs/${tabId}`, {
headers: { Accept: 'application/json' },
});
if (!tabResponse.ok) {
throw new Error('Failed to load tab');
}
const tabData = await tabResponse.json();
// Normalize to flat array
let flat = [];
if (Array.isArray(tabData.presets_flat)) {
flat = tabData.presets_flat.slice();
} else if (Array.isArray(tabData.presets)) {
if (tabData.presets.length && typeof tabData.presets[0] === 'string') {
flat = tabData.presets.slice();
} else if (Array.isArray(tabData.presets[0])) {
flat = tabData.presets.flat();
}
}
const beforeLen = flat.length;
flat = flat.filter(id => String(id) !== String(presetId));
if (flat.length === beforeLen) {
alert('Preset is not in this tab.');
return;
}
const newGrid = arrayToGrid(flat, 3);
tabData.presets = newGrid;
tabData.presets_flat = flat;
const updateResponse = await fetch(`/tabs/${tabId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(tabData),
});
if (!updateResponse.ok) {
throw new Error('Failed to update tab presets');
}
await renderTabPresets(tabId);
} catch (error) {
console.error('Failed to remove preset from tab:', error);
alert('Failed to remove preset from tab.');
}
};
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 === 'tab-content') {
// Get tab ID from the left-panel
const leftPanel = document.querySelector('.presets-section[data-tab-id]');
if (leftPanel) {
const tabId = leftPanel.dataset.tabId;
if (tabId) {
renderTabPresets(tabId);
}
}
}
});
document.addEventListener('DOMContentLoaded', () => {
updateUiModeToggleButtons();
document.querySelectorAll('.ui-mode-toggle').forEach((btn) => {
btn.addEventListener('click', () => {
const next = getPresetUiMode() === 'edit' ? 'run' : 'edit';
setPresetUiMode(next);
updateUiModeToggleButtons();
const mainMenu = document.getElementById('main-menu-dropdown');
if (mainMenu) mainMenu.classList.remove('open');
const leftPanel = document.querySelector('.presets-section[data-tab-id]');
if (leftPanel) {
renderTabPresets(leftPanel.dataset.tabId);
}
});
});
});