Refine tab presets selection and editing, add per-tab removal, improve layout, and provide an in-app help modal.
1678 lines
54 KiB
JavaScript
1678 lines
54 KiB
JavaScript
// 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);
|
||
}
|
||
}
|
||
}
|
||
});
|