Files
led-controller/src/static/presets.js
jimmy 1576383d09 Update tab UI, presets interactions, and help
Refine tab presets selection and editing, add per-tab removal, improve layout, and provide an in-app help modal.
2026-01-28 04:44:30 +13:00

1678 lines
54 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 = [];
const getEspnowSocket = () => {
if (espnowSocket && (espnowSocket.readyState === WebSocket.OPEN || espnowSocket.readyState === WebSocket.CONNECTING)) {
return espnowSocket;
}
const wsUrl = `ws://${window.location.host}/ws`;
espnowSocket = new WebSocket(wsUrl);
espnowSocketReady = false;
espnowSocket.onopen = () => {
espnowSocketReady = true;
// Flush any queued messages
espnowPendingMessages.forEach((msg) => {
try {
espnowSocket.send(msg);
} catch (err) {
console.error('Failed to send queued ESPNow message:', err);
}
});
espnowPendingMessages = [];
};
espnowSocket.onclose = () => {
espnowSocketReady = false;
espnowSocket = null;
};
espnowSocket.onerror = (err) => {
console.error('ESPNow WebSocket error:', err);
};
return espnowSocket;
};
const sendEspnowMessage = (obj) => {
const json = JSON.stringify(obj);
const ws = getEspnowSocket();
if (espnowSocketReady && ws.readyState === WebSocket.OPEN) {
try {
ws.send(json);
} catch (err) {
console.error('Failed to send ESPNow message:', err);
}
} else {
// Queue until connection is open
espnowPendingMessages.push(json);
}
};
// Send a select message for a preset to all device names in the current tab.
const sendSelectForCurrentTabDevices = (presetName, sectionEl) => {
const section = sectionEl || document.querySelector('.presets-section[data-tab-id]');
if (!section || !presetName) {
return;
}
const namesAttr = section.getAttribute('data-device-names');
const deviceNames = namesAttr
? namesAttr.split(',').map((n) => n.trim()).filter((n) => n.length > 0)
: [];
if (!deviceNames.length) {
return;
}
const select = {};
deviceNames.forEach((name) => {
select[name] = [presetName];
});
const message = {
v: '1',
select,
};
sendEspnowMessage(message);
};
document.addEventListener('DOMContentLoaded', () => {
const presetsButton = document.getElementById('presets-btn');
const presetsModal = document.getElementById('presets-modal');
const presetsCloseButton = document.getElementById('presets-close-btn');
const presetsList = document.getElementById('presets-list');
const presetsAddButton = document.getElementById('preset-add-btn');
const presetEditorModal = document.getElementById('preset-editor-modal');
const presetEditorCloseButton = document.getElementById('preset-editor-close-btn');
const presetNameInput = document.getElementById('preset-name-input');
const presetPatternInput = document.getElementById('preset-pattern-input');
const presetColorsContainer = document.getElementById('preset-colors-container');
const presetNewColorInput = document.getElementById('preset-new-color');
const presetAddColorButton = document.getElementById('preset-add-color-btn');
const presetBrightnessInput = document.getElementById('preset-brightness-input');
const presetDelayInput = document.getElementById('preset-delay-input');
const presetSaveButton = document.getElementById('preset-save-btn');
const presetClearButton = document.getElementById('preset-clear-btn');
const presetAddFromPaletteButton = document.getElementById('preset-add-from-palette-btn');
const presetRemoveFromTabButton = document.getElementById('preset-remove-from-tab-btn');
if (!presetsButton || !presetsModal || !presetsList || !presetSaveButton || !presetClearButton) {
return;
}
let currentEditId = null;
let currentEditTabId = null;
let cachedPresets = {};
let cachedPatterns = {};
let currentPresetColors = []; // Track colors for the current preset
// Function to get max colors for current pattern
const getMaxColors = () => {
if (!presetPatternInput || !presetPatternInput.value) {
return Infinity; // No pattern selected, no limit
}
const patternName = presetPatternInput.value.trim();
const patternConfig = cachedPatterns && cachedPatterns[patternName];
if (patternConfig && typeof patternConfig === 'object' && patternConfig.max_colors !== undefined) {
return patternConfig.max_colors;
}
return Infinity; // No limit if not specified
};
// Function to show/hide color section based on max_colors
const updateColorSectionVisibility = () => {
const maxColors = getMaxColors();
const shouldShow = maxColors > 0;
// Find the color label (the label before the container)
if (presetColorsContainer) {
let prev = presetColorsContainer.previousElementSibling;
while (prev) {
if (prev.tagName === 'LABEL' && prev.textContent.trim().toLowerCase().includes('color')) {
prev.style.display = shouldShow ? '' : 'none';
break;
}
prev = prev.previousElementSibling;
}
// Hide/show the container
presetColorsContainer.style.display = shouldShow ? '' : 'none';
// Hide/show the actions (color picker and buttons)
const colorActions = presetColorsContainer.nextElementSibling;
if (colorActions && (colorActions.querySelector('#preset-add-color-btn') || colorActions.querySelector('#preset-new-color'))) {
colorActions.style.display = shouldShow ? '' : 'none';
}
}
};
const getNumberInput = (id) => {
const input = document.getElementById(id);
if (!input) {
return 0;
}
return parseInt(input.value, 10) || 0;
};
const renderPresetColors = (colors) => {
if (!presetColorsContainer) return;
presetColorsContainer.innerHTML = '';
currentPresetColors = colors || [];
// Get max colors for current pattern
const maxColors = getMaxColors();
const maxColorsText = maxColors !== Infinity ? ` (max ${maxColors})` : '';
if (currentPresetColors.length === 0) {
const empty = document.createElement('p');
empty.className = 'muted-text';
empty.textContent = `No colors added. Click "Add Color" to add colors.${maxColorsText}`;
presetColorsContainer.appendChild(empty);
return;
}
// Show max colors info if limit exists and reached
if (maxColors !== Infinity && currentPresetColors.length >= maxColors) {
const info = document.createElement('p');
info.className = 'muted-text';
info.style.cssText = 'font-size: 0.85em; margin-bottom: 0.5rem; color: #ffa500;';
info.textContent = `Maximum ${maxColors} color${maxColors !== 1 ? 's' : ''} reached for this pattern.`;
presetColorsContainer.appendChild(info);
}
const swatchContainer = document.createElement('div');
swatchContainer.style.cssText = 'display: flex; flex-wrap: wrap; gap: 0.5rem;';
swatchContainer.classList.add('color-swatches-container');
currentPresetColors.forEach((color, index) => {
const swatchWrapper = document.createElement('div');
swatchWrapper.style.cssText = 'position: relative; display: inline-block;';
swatchWrapper.draggable = true;
swatchWrapper.dataset.colorIndex = index;
swatchWrapper.classList.add('draggable-color-swatch');
const swatch = document.createElement('div');
swatch.style.cssText = `
width: 64px;
height: 64px;
border-radius: 8px;
background-color: ${color};
border: 2px solid #4a4a4a;
cursor: move;
box-shadow: 0 2px 4px rgba(0,0,0,0.3);
transition: opacity 0.2s, transform 0.2s;
`;
swatch.title = `${color} - Drag to reorder`;
// Color picker overlay
const colorPicker = document.createElement('input');
colorPicker.type = 'color';
colorPicker.value = color;
colorPicker.style.cssText = `
position: absolute;
top: 0;
left: 0;
width: 64px;
height: 64px;
opacity: 0;
cursor: pointer;
z-index: 5;
`;
colorPicker.addEventListener('change', (e) => {
currentPresetColors[index] = e.target.value;
renderPresetColors(currentPresetColors);
});
// Prevent color picker from interfering with drag
colorPicker.addEventListener('mousedown', (e) => {
e.stopPropagation();
});
// Remove button
const removeBtn = document.createElement('button');
removeBtn.textContent = '×';
removeBtn.style.cssText = `
position: absolute;
top: -8px;
right: -8px;
width: 24px;
height: 24px;
border-radius: 50%;
background-color: #ff4444;
color: white;
border: none;
cursor: pointer;
font-size: 18px;
line-height: 1;
display: flex;
align-items: center;
justify-content: center;
z-index: 10;
padding: 0;
`;
removeBtn.addEventListener('click', (e) => {
e.stopPropagation();
currentPresetColors.splice(index, 1);
renderPresetColors(currentPresetColors);
});
// Prevent remove button from interfering with drag
removeBtn.addEventListener('mousedown', (e) => {
e.stopPropagation();
});
// Drag event handlers for reordering
swatchWrapper.addEventListener('dragstart', (e) => {
swatchWrapper.classList.add('dragging-color');
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', index.toString());
swatch.style.opacity = '0.5';
});
swatchWrapper.addEventListener('dragend', (e) => {
swatchWrapper.classList.remove('dragging-color');
swatch.style.opacity = '1';
// Remove drag-over classes from siblings
document.querySelectorAll('.draggable-color-swatch').forEach(el => {
el.classList.remove('drag-over-color');
});
});
swatchWrapper.appendChild(swatch);
swatchWrapper.appendChild(colorPicker);
swatchWrapper.appendChild(removeBtn);
swatchContainer.appendChild(swatchWrapper);
});
// Add drag and drop handlers to the container
swatchContainer.addEventListener('dragover', (e) => {
e.preventDefault();
const dragging = swatchContainer.querySelector('.dragging-color');
if (!dragging) return;
const afterElement = getDragAfterElementForColors(swatchContainer, e.clientX);
if (afterElement == null) {
swatchContainer.appendChild(dragging);
} else {
swatchContainer.insertBefore(dragging, afterElement);
}
});
swatchContainer.addEventListener('drop', (e) => {
e.preventDefault();
const dragging = swatchContainer.querySelector('.dragging-color');
if (!dragging) return;
// Get new order of colors from DOM
const colorElements = [...swatchContainer.querySelectorAll('.draggable-color-swatch')];
const newColorOrder = colorElements.map(el => {
const colorPicker = el.querySelector('input[type="color"]');
return colorPicker ? colorPicker.value : null;
}).filter(color => color !== null);
// Update current colors array
currentPresetColors = newColorOrder;
// Re-render to update indices
renderPresetColors(currentPresetColors);
});
presetColorsContainer.appendChild(swatchContainer);
};
// Function to get drag after element for colors (horizontal layout)
const getDragAfterElementForColors = (container, x) => {
const draggableElements = [...container.querySelectorAll('.draggable-color-swatch:not(.dragging-color)')];
return draggableElements.reduce((closest, child) => {
const box = child.getBoundingClientRect();
const offset = x - box.left - box.width / 2;
if (offset < 0 && offset > closest.offset) {
return { offset: offset, element: child };
} else {
return closest;
}
}, { offset: Number.NEGATIVE_INFINITY }).element;
};
const setFormValues = (preset) => {
if (!presetNameInput || !presetPatternInput || !presetBrightnessInput || !presetDelayInput) {
return;
}
presetNameInput.value = preset.name || '';
const patternName = preset.pattern || '';
presetPatternInput.value = patternName;
const colors = Array.isArray(preset.colors) ? preset.colors : [];
renderPresetColors(colors);
presetBrightnessInput.value = preset.brightness || 0;
presetDelayInput.value = preset.delay || 0;
// Update color section visibility based on pattern
updateColorSectionVisibility();
// Make name and pattern read-only when editing (not when creating new)
const isEditing = currentEditId !== null;
presetNameInput.disabled = isEditing;
presetPatternInput.disabled = isEditing;
if (isEditing) {
presetNameInput.style.backgroundColor = '#2a2a2a';
presetNameInput.style.cursor = 'not-allowed';
presetPatternInput.style.backgroundColor = '#2a2a2a';
presetPatternInput.style.cursor = 'not-allowed';
} else {
presetNameInput.style.backgroundColor = '';
presetNameInput.style.cursor = '';
presetPatternInput.style.backgroundColor = '';
presetPatternInput.style.cursor = '';
}
// Update labels and visibility based on pattern
updatePresetNLabels(patternName);
// Get pattern config to map descriptive names back to n keys
const patternConfig = cachedPatterns && cachedPatterns[patternName];
const nToLabel = {};
if (patternConfig && typeof patternConfig === 'object') {
// Now n keys are keys, labels are values
Object.entries(patternConfig).forEach(([nKey, label]) => {
if (typeof nKey === 'string' && nKey.startsWith('n') && typeof label === 'string') {
nToLabel[nKey] = label;
}
});
}
// Set n values, checking both n keys and descriptive names
for (let i = 1; i <= 8; i++) {
const nKey = `n${i}`;
const inputEl = document.getElementById(`preset-${nKey}-input`);
if (inputEl) {
// First check if preset has n key directly
if (preset[nKey] !== undefined) {
inputEl.value = preset[nKey] || 0;
} else {
// Check if preset has descriptive name (from pattern.json mapping)
const label = nToLabel[nKey];
if (label && preset[label] !== undefined) {
inputEl.value = preset[label] || 0;
} else {
inputEl.value = 0;
}
}
}
}
};
const clearForm = () => {
currentEditId = null;
currentEditTabId = null;
currentPresetColors = [];
setFormValues({
name: '',
pattern: '',
colors: [],
brightness: 0,
delay: 0,
n1: 0,
n2: 0,
n3: 0,
n4: 0,
n5: 0,
n6: 0,
n7: 0,
n8: 0,
});
// Re-enable name and pattern when clearing (for new preset)
if (presetNameInput) {
presetNameInput.disabled = false;
presetNameInput.style.backgroundColor = '';
presetNameInput.style.cursor = '';
}
if (presetPatternInput) {
presetPatternInput.disabled = false;
presetPatternInput.style.backgroundColor = '';
presetPatternInput.style.cursor = '';
}
};
const openEditor = () => {
if (presetEditorModal) {
presetEditorModal.classList.add('active');
}
loadPatterns().then(() => {
updatePresetNLabels(presetPatternInput ? presetPatternInput.value : '');
updateColorSectionVisibility();
});
};
const closeEditor = () => {
if (presetEditorModal) {
presetEditorModal.classList.remove('active');
}
};
const buildPresetPayload = () => {
const payload = {
name: presetNameInput ? presetNameInput.value.trim() : '',
pattern: presetPatternInput ? presetPatternInput.value.trim() : '',
colors: currentPresetColors || [],
// Use canonical field names expected by the device / API
brightness: presetBrightnessInput ? parseInt(presetBrightnessInput.value, 10) || 0 : 0,
delay: presetDelayInput ? parseInt(presetDelayInput.value, 10) || 0 : 0,
};
// Always store numeric parameters as n1..n8.
for (let i = 1; i <= 8; i++) {
const nKey = `n${i}`;
payload[nKey] = getNumberInput(`preset-${nKey}-input`);
}
return payload;
};
const loadPatterns = async () => {
if (!presetPatternInput) {
return;
}
try {
// Load pattern definitions from pattern.json
const response = await fetch('/patterns/definitions', {
headers: { Accept: 'application/json' },
});
if (!response.ok) {
return;
}
const patterns = await response.json();
cachedPatterns = patterns || {};
const entries = Object.keys(cachedPatterns);
const desiredPattern = presetPatternInput.value;
presetPatternInput.innerHTML = '<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', () => {
currentEditId = presetId;
setFormValues(preset || {});
openEditor();
});
const sendButton = document.createElement('button');
sendButton.className = 'btn btn-primary btn-small';
sendButton.textContent = 'Send';
sendButton.title = 'Send this preset via ESPNow';
sendButton.addEventListener('click', () => {
// Just send the definition; selection happens when user clicks the preset.
sendPresetViaEspNow(presetId, preset || {});
});
const deleteButton = document.createElement('button');
deleteButton.className = 'btn btn-danger btn-small';
deleteButton.textContent = 'Delete';
deleteButton.addEventListener('click', async () => {
const confirmed = confirm(`Delete preset "${label.textContent}"?`);
if (!confirmed) {
return;
}
try {
const response = await fetch(`/presets/${presetId}`, {
method: 'DELETE',
headers: { Accept: 'application/json' },
});
if (!response.ok) {
throw new Error('Failed to delete preset');
}
await loadPresets();
if (currentEditId === presetId) {
clearForm();
}
} catch (error) {
console.error('Delete preset failed:', error);
alert('Failed to delete preset.');
}
});
row.appendChild(label);
row.appendChild(details);
row.appendChild(editButton);
row.appendChild(sendButton);
row.appendChild(deleteButton);
presetsList.appendChild(row);
});
};
const loadPresets = async () => {
presetsList.innerHTML = '';
const loading = document.createElement('p');
loading.className = 'muted-text';
loading.textContent = 'Loading presets...';
presetsList.appendChild(loading);
try {
const response = await fetch('/presets', {
headers: { Accept: 'application/json' },
});
if (!response.ok) {
throw new Error('Failed to load presets');
}
const presets = await response.json();
renderPresets(presets);
} catch (error) {
console.error('Load presets failed:', error);
presetsList.innerHTML = '';
const errorMessage = document.createElement('p');
errorMessage.className = 'muted-text';
errorMessage.textContent = 'Failed to load presets.';
presetsList.appendChild(errorMessage);
}
};
const openModal = () => {
presetsModal.classList.add('active');
loadPresets();
};
const closeModal = () => {
presetsModal.classList.remove('active');
};
presetsButton.addEventListener('click', openModal);
if (presetsCloseButton) {
presetsCloseButton.addEventListener('click', closeModal);
}
if (presetsAddButton) {
presetsAddButton.addEventListener('click', () => {
clearForm();
openEditor();
});
}
// Handle "Add Preset" button in tab area (dynamically loaded)
document.addEventListener('click', async (e) => {
if (e.target && e.target.id === 'preset-add-btn-tab') {
await showAddPresetToTabModal();
}
});
const showAddPresetToTabModal = async () => {
// Get current tab ID from the left-panel
const leftPanel = document.querySelector('.presets-section[data-tab-id]');
let tabId = leftPanel ? leftPanel.dataset.tabId : null;
if (!tabId) {
// Fallback: try to get from URL
const pathParts = window.location.pathname.split('/');
const tabIndex = pathParts.indexOf('tabs');
if (tabIndex !== -1 && tabIndex + 1 < pathParts.length) {
tabId = pathParts[tabIndex + 1];
}
}
if (!tabId) {
alert('Could not determine current tab.');
return;
}
// Load all presets
try {
const response = await fetch('/presets', {
headers: { Accept: 'application/json' },
});
if (!response.ok) {
throw new Error('Failed to load presets');
}
const allPresets = await response.json();
// Get current tab's presets to exclude already added ones
let currentTabPresets = [];
if (tabId) {
try {
const tabResponse = await fetch(`/tabs/${tabId}`, {
headers: { Accept: 'application/json' },
});
if (tabResponse.ok) {
const tabData = await tabResponse.json();
currentTabPresets = tabData.presets || [];
}
} catch (e) {
console.warn('Could not load current tab presets:', e);
}
}
// Create modal
const modal = document.createElement('div');
modal.className = 'modal active';
modal.id = 'add-preset-to-tab-modal';
modal.innerHTML = `
<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);
if (presetNames.length === 0) {
listContainer.innerHTML = '<p class="muted-text">No presets available. Create a preset first.</p>';
} else {
presetNames.forEach(presetId => {
const preset = allPresets[presetId];
const isAlreadyAdded = currentTabPresets.includes(presetId);
const row = document.createElement('div');
row.className = 'profiles-row';
const label = document.createElement('span');
label.textContent = preset.name || presetId;
const details = document.createElement('span');
details.style.color = '#aaa';
details.style.fontSize = '0.85em';
details.textContent = preset.pattern || '-';
const actionButton = document.createElement('button');
if (isAlreadyAdded) {
actionButton.className = 'btn btn-danger btn-small';
actionButton.textContent = 'Remove';
actionButton.addEventListener('click', async (e) => {
e.stopPropagation();
if (confirm(`Remove preset "${preset.name || presetId}" from this tab?`)) {
await removePresetFromTab(presetId, tabId);
modal.remove();
}
});
} else {
actionButton.className = 'btn btn-primary btn-small';
actionButton.textContent = 'Add';
actionButton.addEventListener('click', async () => {
await addPresetToTab(presetId, tabId);
modal.remove();
});
}
row.appendChild(label);
row.appendChild(details);
row.appendChild(actionButton);
listContainer.appendChild(row);
});
}
// Close button handler
document.getElementById('add-preset-to-tab-close-btn').addEventListener('click', () => {
modal.remove();
});
// Close on outside click
modal.addEventListener('click', (e) => {
if (e.target === modal) {
modal.remove();
}
});
} catch (error) {
console.error('Failed to show add preset modal:', error);
alert('Failed to load presets.');
}
};
const addPresetToTab = async (presetId, tabId) => {
if (!tabId) {
// Try to get tab ID from the left-panel
const leftPanel = document.querySelector('.presets-section[data-tab-id]');
tabId = leftPanel ? leftPanel.dataset.tabId : null;
if (!tabId) {
// Fallback: try to get from URL
const pathParts = window.location.pathname.split('/');
const tabIndex = pathParts.indexOf('tabs');
if (tabIndex !== -1 && tabIndex + 1 < pathParts.length) {
tabId = pathParts[tabIndex + 1];
}
}
}
if (!tabId) {
alert('Could not determine current tab.');
return;
}
try {
// Get current tab data
const tabResponse = await fetch(`/tabs/${tabId}`, {
headers: { Accept: 'application/json' },
});
if (!tabResponse.ok) {
throw new Error('Failed to load tab');
}
const tabData = await tabResponse.json();
// Add preset to tab's presets array if not already present
const presets = tabData.presets || [];
if (!presets.includes(presetId)) {
presets.push(presetId);
// Update tab
const updateResponse = await fetch(`/tabs/${tabId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...tabData, presets }),
});
if (!updateResponse.ok) {
throw new Error('Failed to update tab');
}
// Reload the tab content to show the new preset
if (window.htmx) {
htmx.ajax('GET', `/tabs/${tabId}/content-fragment`, {
target: '#tab-content',
swap: 'innerHTML'
});
// The htmx:afterSwap event listener will call renderTabPresets
} else {
// Fallback: reload the page
window.location.reload();
}
} else {
alert('Preset is already added to this tab.');
}
} catch (error) {
console.error('Failed to add preset to tab:', error);
alert('Failed to add preset to tab.');
}
};
if (presetEditorCloseButton) {
presetEditorCloseButton.addEventListener('click', closeEditor);
}
presetClearButton.addEventListener('click', clearForm);
if (presetPatternInput) {
presetPatternInput.addEventListener('change', () => {
updatePresetNLabels(presetPatternInput.value);
// Update color section visibility
updateColorSectionVisibility();
// Re-render colors to show updated max colors limit
renderPresetColors(currentPresetColors);
});
}
// Add Color button handler
if (presetAddColorButton && presetNewColorInput) {
presetAddColorButton.addEventListener('click', () => {
const color = presetNewColorInput.value;
if (!color) return;
if (currentPresetColors.includes(color)) {
alert('This color is already in the list.');
return;
}
const maxColors = getMaxColors();
if (currentPresetColors.length >= maxColors) {
alert(`This pattern allows a maximum of ${maxColors} color${maxColors !== 1 ? 's' : ''}.`);
return;
}
currentPresetColors.push(color);
renderPresetColors(currentPresetColors);
});
}
// Add from Palette button handler
if (presetAddFromPaletteButton) {
presetAddFromPaletteButton.addEventListener('click', () => {
const openButton = document.getElementById('color-palette-btn');
if (openButton) {
openButton.click();
}
const modal = document.getElementById('color-palette-modal');
const modalList = document.getElementById('palette-container');
if (modal) {
modal.classList.add('active');
}
if (!modalList) {
return;
}
const handlePick = (event) => {
const row = event.target.closest('[data-color]');
if (!row) {
return;
}
const picked = row.dataset.color;
if (!picked) {
return;
}
if (currentPresetColors.includes(picked)) {
alert('This color is already in the list.');
return;
}
const maxColors = getMaxColors();
if (currentPresetColors.length >= maxColors) {
alert(`This pattern allows a maximum of ${maxColors} color${maxColors !== 1 ? 's' : ''}.`);
if (modal) {
modal.classList.remove('active');
}
modalList.removeEventListener('click', handlePick);
return;
}
currentPresetColors.push(picked);
renderPresetColors(currentPresetColors);
if (modal) {
modal.classList.remove('active');
}
modalList.removeEventListener('click', handlePick);
};
modalList.addEventListener('click', handlePick);
});
}
const presetSendButton = document.getElementById('preset-send-btn');
if (presetSendButton) {
presetSendButton.addEventListener('click', () => {
const payload = buildPresetPayload();
if (!payload.name) {
alert('Preset name is required to send.');
return;
}
// Send current editor values and select on all devices in the current tab (if any)
const section = document.querySelector('.presets-section[data-tab-id]');
const namesAttr = section && section.getAttribute('data-device-names');
const deviceNames = namesAttr
? namesAttr.split(',').map((n) => n.trim()).filter((n) => n.length > 0)
: [];
sendPresetViaEspNow(payload.name, payload, deviceNames);
});
}
presetSaveButton.addEventListener('click', async () => {
const payload = buildPresetPayload();
if (!payload.name) {
alert('Preset name is required.');
return;
}
try {
const url = currentEditId ? `/presets/${currentEditId}` : '/presets';
const method = currentEditId ? 'PUT' : 'POST';
const response = await fetch(url, {
method,
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
});
if (!response.ok) {
throw new Error('Failed to save preset');
}
// Determine device names from current tab (if any)
let deviceNames = [];
const section = document.querySelector('.presets-section[data-tab-id]');
if (section) {
const namesAttr = section.getAttribute('data-device-names');
deviceNames = namesAttr
? namesAttr.split(',').map((n) => n.trim()).filter((n) => n.length > 0)
: [];
}
// Use saved preset from server response for sending
const saved = await response.json().catch(() => null);
if (saved && typeof saved === 'object') {
if (currentEditId) {
// PUT returns the preset object directly
sendPresetViaEspNow(payload.name, saved, deviceNames);
} else {
// POST returns { id: preset }
const entries = Object.entries(saved);
if (entries.length > 0) {
const [newId, presetData] = entries[0];
sendPresetViaEspNow(newId, presetData, deviceNames);
}
}
} else {
// Fallback: send what we just built
sendPresetViaEspNow(payload.name, payload, deviceNames);
}
await loadPresets();
clearForm();
closeEditor();
// Reload tab presets if we're in a tab view
const leftPanel = document.querySelector('.presets-section[data-tab-id]');
if (leftPanel) {
const tabId = leftPanel.dataset.tabId;
if (tabId && typeof renderTabPresets !== 'undefined') {
renderTabPresets(tabId);
}
}
} catch (error) {
console.error('Save preset failed:', error);
alert('Failed to save preset.');
}
});
// Listen for edit preset events from tab preset buttons
document.addEventListener('editPreset', async (event) => {
const { presetId, preset, tabId } = event.detail;
currentEditId = presetId;
currentEditTabId = tabId || null;
await loadPatterns();
setFormValues(preset);
openEditor();
});
if (presetRemoveFromTabButton) {
presetRemoveFromTabButton.addEventListener('click', async () => {
if (!currentEditId) {
alert('No preset loaded to remove.');
return;
}
try {
await removePresetFromTab(currentEditTabId, currentEditId);
closeEditor();
} catch (e) {
// removePresetFromTab already logs and alerts on error
}
});
}
presetsModal.addEventListener('click', (event) => {
if (event.target === presetsModal) {
closeModal();
}
});
if (presetEditorModal) {
presetEditorModal.addEventListener('click', (event) => {
if (event.target === presetEditorModal) {
closeEditor();
}
});
}
clearForm();
});
// Build an ESPNow preset message for a single preset and optionally include a select
// for the given device names, then send it via WebSocket.
const sendPresetViaEspNow = (presetId, preset, deviceNames) => {
try {
const presetName = preset.name || presetId;
if (!presetName) {
alert('Preset has no name and cannot be sent.');
return;
}
const colors = Array.isArray(preset.colors) && preset.colors.length
? preset.colors
: ['#FFFFFF'];
const message = {
v: '1',
presets: {
[presetName]: {
pattern: preset.pattern || 'off',
colors,
delay: typeof preset.delay === 'number' ? preset.delay : 100,
brightness: typeof preset.brightness === 'number'
? preset.brightness
: (typeof preset.br === 'number' ? preset.br : 127),
auto: typeof preset.auto === 'boolean' ? preset.auto : true,
n1: typeof preset.n1 === 'number' ? preset.n1 : 0,
n2: typeof preset.n2 === 'number' ? preset.n2 : 0,
n3: typeof preset.n3 === 'number' ? preset.n3 : 0,
n4: typeof preset.n4 === 'number' ? preset.n4 : 0,
n5: typeof preset.n5 === 'number' ? preset.n5 : 0,
n6: typeof preset.n6 === 'number' ? preset.n6 : 0,
},
},
};
// Optionally include a select section for specific devices
if (Array.isArray(deviceNames) && deviceNames.length > 0) {
const select = {};
deviceNames.forEach((name) => {
if (name) {
select[name] = [presetName];
}
});
if (Object.keys(select).length > 0) {
message.select = select;
}
}
sendEspnowMessage(message);
} catch (error) {
console.error('Failed to send preset via ESPNow:', error);
alert('Failed to send preset via ESPNow.');
}
};
// Expose for other scripts (tabs.js) so they can reuse the shared WebSocket.
try {
window.sendPresetViaEspNow = sendPresetViaEspNow;
} catch (e) {
// window may not exist in some environments; ignore.
}
// Store selected preset per tab
const selectedPresets = {};
// Track if we're currently dragging a preset
let isDraggingPreset = false;
// Context menu for tab presets
let presetContextMenu = null;
let presetContextTarget = null;
const ensurePresetContextMenu = () => {
if (presetContextMenu) {
return presetContextMenu;
}
const menu = document.createElement('div');
menu.id = 'preset-context-menu';
menu.style.cssText = `
position: fixed;
z-index: 2000;
background: #2e2e2e;
border: 1px solid #4a4a4a;
border-radius: 4px;
box-shadow: 0 2px 6px rgba(0,0,0,0.6);
padding: 0.25rem 0;
min-width: 160px;
display: none;
`;
const addItem = (label, action) => {
const item = document.createElement('button');
item.type = 'button';
item.textContent = label;
item.dataset.action = action;
item.style.cssText = `
display: block;
width: 100%;
padding: 0.4rem 0.75rem;
background: transparent;
color: #eee;
border: none;
text-align: left;
cursor: pointer;
font-size: 0.9rem;
`;
if (action === 'remove') {
// Visually emphasize and align remove to the right
item.style.textAlign = 'right';
item.style.color = '#ff8080';
}
item.addEventListener('mouseover', () => {
item.style.backgroundColor = '#3a3a3a';
});
item.addEventListener('mouseout', () => {
item.style.backgroundColor = 'transparent';
});
menu.appendChild(item);
};
addItem('Edit preset…', 'edit');
addItem('Remove', 'remove');
menu.addEventListener('click', async (e) => {
const btn = e.target.closest('button[data-action]');
if (!btn || !presetContextTarget) {
return;
}
const { tabId, presetId } = presetContextTarget;
const action = btn.dataset.action;
hidePresetContextMenu();
if (action === 'edit') {
await editPresetFromTab(presetId);
} else if (action === 'remove') {
await removePresetFromTab(tabId, presetId);
}
});
document.body.appendChild(menu);
presetContextMenu = menu;
// Hide on outside click
document.addEventListener('click', (e) => {
if (!presetContextMenu) return;
if (e.target.closest('#preset-context-menu')) return;
hidePresetContextMenu();
});
return menu;
};
const showPresetContextMenu = (x, y, tabId, presetId, preset) => {
const menu = ensurePresetContextMenu();
presetContextTarget = { tabId, presetId, preset };
menu.style.left = `${x}px`;
menu.style.top = `${y}px`;
menu.style.display = 'block';
};
const hidePresetContextMenu = () => {
if (presetContextMenu) {
presetContextMenu.style.display = 'none';
}
presetContextTarget = null;
};
// Function to convert 2D grid to flat array (for backward compatibility)
const gridToArray = (presetsGrid) => {
if (!presetsGrid || !Array.isArray(presetsGrid)) {
return [];
}
// If it's already a flat array (old format), return it
if (presetsGrid.length > 0 && typeof presetsGrid[0] === 'string') {
return presetsGrid;
}
// If it's a 2D grid, flatten it
if (Array.isArray(presetsGrid[0])) {
return presetsGrid.flat();
}
// If it's an array of objects with positions, convert to grid then flatten
if (presetsGrid.length > 0 && typeof presetsGrid[0] === 'object' && presetsGrid[0].id) {
// Find max row and col
let maxRow = 0, maxCol = 0;
presetsGrid.forEach(p => {
if (p.row > maxRow) maxRow = p.row;
if (p.col > maxCol) maxCol = p.col;
});
// Create grid
const grid = Array(maxRow + 1).fill(null).map(() => Array(maxCol + 1).fill(null));
presetsGrid.forEach(p => {
grid[p.row][p.col] = p.id;
});
return grid.flat().filter(id => id !== null);
}
return [];
};
// Function to convert flat array to 2D grid
const arrayToGrid = (presetIds, columns = 3) => {
if (!presetIds || !Array.isArray(presetIds)) {
return [];
}
const grid = [];
for (let i = 0; i < presetIds.length; i += columns) {
grid.push(presetIds.slice(i, i + columns));
}
return grid;
};
// Function to save preset grid for a tab
const savePresetGrid = async (tabId, presetGrid) => {
try {
// Get current tab data
const tabResponse = await fetch(`/tabs/${tabId}`, {
headers: { Accept: 'application/json' },
});
if (!tabResponse.ok) {
throw new Error('Failed to load tab');
}
const tabData = await tabResponse.json();
// Store as 2D grid
tabData.presets = presetGrid;
// Also store as flat array for backward compatibility
tabData.presets_flat = presetGrid.flat();
// Save updated tab
const updateResponse = await fetch(`/tabs/${tabId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(tabData),
});
if (!updateResponse.ok) {
throw new Error('Failed to save preset grid');
}
} catch (error) {
console.error('Failed to save preset grid:', error);
throw error;
}
};
// Function to get drop target in 2D grid
const getDropTarget = (container, x, y) => {
const draggableElements = [...container.querySelectorAll('.draggable-preset:not(.dragging)')];
return draggableElements.reduce((closest, child) => {
const box = child.getBoundingClientRect();
const centerX = box.left + box.width / 2;
const centerY = box.top + box.height / 2;
const distanceX = Math.abs(x - centerX);
const distanceY = Math.abs(y - centerY);
const distance = Math.sqrt(distanceX * distanceX + distanceY * distanceY);
if (distance < closest.distance) {
return { distance: distance, element: child };
} else {
return closest;
}
}, { distance: Infinity }).element;
};
// Function to render presets for a specific tab in 2D grid
const renderTabPresets = async (tabId) => {
const presetsList = document.getElementById('presets-list-tab');
if (!presetsList) return;
try {
// Get tab data to see which presets are associated
const tabResponse = await fetch(`/tabs/${tabId}`, {
headers: { Accept: 'application/json' },
});
if (!tabResponse.ok) {
throw new Error('Failed to load tab');
}
const tabData = await tabResponse.json();
// Get presets - support both 2D grid and flat array (for backward compatibility)
let presetGrid = tabData.presets;
if (!presetGrid || !Array.isArray(presetGrid)) {
// Try to get from flat array or convert old format
const flatArray = tabData.presets_flat || tabData.presets || [];
presetGrid = arrayToGrid(flatArray, 3); // Default to 3 columns
} else if (presetGrid.length > 0 && typeof presetGrid[0] === 'string') {
// It's a flat array, convert to grid
presetGrid = arrayToGrid(presetGrid, 3);
}
// Get all presets
const presetsResponse = await fetch('/presets', {
headers: { Accept: 'application/json' },
});
if (!presetsResponse.ok) {
throw new Error('Failed to load presets');
}
const allPresets = await presetsResponse.json();
presetsList.innerHTML = '';
// Add drag and drop handlers to the container
presetsList.addEventListener('dragover', (e) => {
e.preventDefault();
const dragging = presetsList.querySelector('.dragging');
if (!dragging) return;
const dropTarget = getDropTarget(presetsList, e.clientX, e.clientY);
if (dropTarget && dropTarget !== dragging) {
// Insert before or after based on position
const rect = dropTarget.getBoundingClientRect();
const draggingRect = dragging.getBoundingClientRect();
if (e.clientX < rect.left + rect.width / 2) {
// Insert before
presetsList.insertBefore(dragging, dropTarget);
} else {
// Insert after
presetsList.insertBefore(dragging, dropTarget.nextSibling);
}
}
});
presetsList.addEventListener('drop', async (e) => {
e.preventDefault();
const dragging = presetsList.querySelector('.dragging');
if (!dragging) return;
// Get new grid layout from DOM
const presetElements = [...presetsList.querySelectorAll('.draggable-preset')];
const presetIds = presetElements.map(el => el.dataset.presetId);
// Convert to 2D grid (3 columns)
const newGrid = arrayToGrid(presetIds, 3);
// Save new grid
try {
await savePresetGrid(tabId, newGrid);
// Re-render to ensure consistency
await renderTabPresets(tabId);
} catch (error) {
console.error('Failed to save preset grid:', error);
alert('Failed to save preset order. Please try again.');
// Re-render to restore original order
await renderTabPresets(tabId);
}
});
// Get the currently selected preset for this tab
const selectedPresetId = selectedPresets[tabId];
// Render presets in grid layout
// Flatten the grid and render all presets (grid CSS will handle layout)
const flatPresets = presetGrid.flat().filter(id => id);
if (flatPresets.length === 0) {
// Show empty message if this tab has no presets
const empty = document.createElement('p');
empty.className = 'muted-text';
empty.style.gridColumn = '1 / -1'; // Span all columns
empty.textContent = 'No presets added to this tab. Click "Add Preset" to add one.';
presetsList.appendChild(empty);
} else {
flatPresets.forEach((presetId) => {
const preset = allPresets[presetId];
if (preset) {
const isSelected = presetId === selectedPresetId;
const wrapper = createPresetButton(presetId, preset, tabId, isSelected);
presetsList.appendChild(wrapper);
}
});
}
} catch (error) {
console.error('Failed to render tab presets:', error);
presetsList.innerHTML = '<p class="muted-text">Failed to load presets.</p>';
}
};
const createPresetButton = (presetId, preset, tabId, isSelected = false) => {
// Create wrapper div for button and edit button
const wrapper = document.createElement('div');
wrapper.style.cssText = 'display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.5rem;';
wrapper.draggable = true;
wrapper.dataset.presetId = presetId;
wrapper.classList.add('draggable-preset');
// Create preset button
const button = document.createElement('button');
button.className = 'pattern-button';
button.style.flex = '1';
if (isSelected) {
button.classList.add('active');
}
button.dataset.presetId = presetId;
const presetInfo = document.createElement('div');
presetInfo.style.cssText = 'display: flex; flex-direction: column; align-items: flex-start; width: 100%;';
const presetNameLabel = document.createElement('span');
presetNameLabel.textContent = preset.name || presetId;
presetNameLabel.style.fontWeight = 'bold';
presetNameLabel.style.marginBottom = '0.25rem';
const presetDetails = document.createElement('span');
presetDetails.style.fontSize = '0.85em';
presetDetails.style.color = '#aaa';
const colors = Array.isArray(preset.colors) ? preset.colors : [];
presetDetails.textContent = `${preset.pattern || '-'}${colors.length} color${colors.length !== 1 ? 's' : ''}`;
presetInfo.appendChild(presetNameLabel);
presetInfo.appendChild(presetDetails);
button.appendChild(presetInfo);
// Left-click selects preset, right-click opens editor
button.addEventListener('click', (e) => {
if (isDraggingPreset) {
return;
}
// Remove active class from all presets in this tab
const presetsList = document.getElementById('presets-list-tab');
if (presetsList) {
presetsList.querySelectorAll('.pattern-button').forEach(btn => {
btn.classList.remove('active');
});
}
// Add active class to clicked preset
button.classList.add('active');
// Store selected preset for this tab
selectedPresets[tabId] = presetId;
// Build and send a select message via WebSocket for all device names in this tab.
const presetName = preset.name || presetId;
const section = button.closest('.presets-section');
sendSelectForCurrentTabDevices(presetName, section);
});
button.addEventListener('contextmenu', async (e) => {
e.preventDefault();
if (isDraggingPreset) {
return;
}
// Right-click: directly open the preset editor using data we already have
await editPresetFromTab(presetId, tabId, preset);
});
wrapper.appendChild(button);
// Add drag event handlers
wrapper.addEventListener('dragstart', (e) => {
isDraggingPreset = true;
wrapper.classList.add('dragging');
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', presetId);
});
wrapper.addEventListener('dragend', (e) => {
wrapper.classList.remove('dragging');
// Remove any drag-over classes from siblings
document.querySelectorAll('.draggable-preset').forEach(el => {
el.classList.remove('drag-over');
});
// Reset dragging flag after a short delay to allow click event to check it
setTimeout(() => {
isDraggingPreset = false;
}, 100);
});
return wrapper;
};
const editPresetFromTab = async (presetId, tabId, existingPreset) => {
try {
let preset = existingPreset;
if (!preset) {
// Fallback: load the preset data from the server if we weren't given it
const response = await fetch(`/presets/${presetId}`, {
headers: { Accept: 'application/json' },
});
if (!response.ok) {
throw new Error('Failed to load preset');
}
preset = await response.json();
}
// Dispatch a custom event to trigger the edit in the DOMContentLoaded scope
const editEvent = new CustomEvent('editPreset', {
detail: { presetId, preset, tabId }
});
document.dispatchEvent(editEvent);
} catch (error) {
console.error('Failed to load preset for editing:', error);
alert('Failed to load preset for editing.');
}
};
// Remove a preset from a specific tab (does not delete the preset itself)
// Expected call style: removePresetFromTab(tabId, presetId)
const removePresetFromTab = async (tabId, presetId) => {
if (!tabId) {
// Try to get tab ID from the left-panel
const leftPanel = document.querySelector('.presets-section[data-tab-id]');
tabId = leftPanel ? leftPanel.dataset.tabId : null;
if (!tabId) {
// Fallback: try to get from URL
const pathParts = window.location.pathname.split('/');
const tabIndex = pathParts.indexOf('tabs');
if (tabIndex !== -1 && tabIndex + 1 < pathParts.length) {
tabId = pathParts[tabIndex + 1];
}
}
}
if (!tabId) {
alert('Could not determine current tab.');
return;
}
try {
// Get current tab data
const tabResponse = await fetch(`/tabs/${tabId}`, {
headers: { Accept: 'application/json' },
});
if (!tabResponse.ok) {
throw new Error('Failed to load tab');
}
const tabData = await tabResponse.json();
// Normalize to flat array
let flat = [];
if (Array.isArray(tabData.presets_flat)) {
flat = tabData.presets_flat.slice();
} else if (Array.isArray(tabData.presets)) {
if (tabData.presets.length && typeof tabData.presets[0] === 'string') {
flat = tabData.presets.slice();
} else if (Array.isArray(tabData.presets[0])) {
flat = tabData.presets.flat();
}
}
const beforeLen = flat.length;
flat = flat.filter(id => String(id) !== String(presetId));
if (flat.length === beforeLen) {
alert('Preset is not in this tab.');
return;
}
const newGrid = arrayToGrid(flat, 3);
tabData.presets = newGrid;
tabData.presets_flat = flat;
const updateResponse = await fetch(`/tabs/${tabId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(tabData),
});
if (!updateResponse.ok) {
throw new Error('Failed to update tab presets');
}
await renderTabPresets(tabId);
} catch (error) {
console.error('Failed to remove preset from tab:', error);
alert('Failed to remove preset from tab.');
}
};
// Listen for HTMX swaps to render presets
document.body.addEventListener('htmx:afterSwap', (event) => {
if (event.target && event.target.id === 'tab-content') {
// Get tab ID from the left-panel
const leftPanel = document.querySelector('.presets-section[data-tab-id]');
if (leftPanel) {
const tabId = leftPanel.dataset.tabId;
if (tabId) {
renderTabPresets(tabId);
}
}
}
});