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.
This commit is contained in:
2026-01-28 04:44:30 +13:00
parent 8503315bef
commit 1576383d09
5 changed files with 569 additions and 127 deletions

27
src/static/help.js Normal file
View File

@@ -0,0 +1,27 @@
document.addEventListener('DOMContentLoaded', () => {
const helpBtn = document.getElementById('help-btn');
const helpModal = document.getElementById('help-modal');
const helpCloseBtn = document.getElementById('help-close-btn');
if (helpBtn && helpModal) {
helpBtn.addEventListener('click', () => {
helpModal.classList.add('active');
});
}
if (helpCloseBtn && helpModal) {
helpCloseBtn.addEventListener('click', () => {
helpModal.classList.remove('active');
});
}
if (helpModal) {
helpModal.addEventListener('click', (event) => {
if (event.target === helpModal) {
helpModal.classList.remove('active');
}
});
}
}
)

View File

@@ -1,3 +1,85 @@
// 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', () => { document.addEventListener('DOMContentLoaded', () => {
const presetsButton = document.getElementById('presets-btn'); const presetsButton = document.getElementById('presets-btn');
const presetsModal = document.getElementById('presets-modal'); const presetsModal = document.getElementById('presets-modal');
@@ -16,12 +98,14 @@ document.addEventListener('DOMContentLoaded', () => {
const presetSaveButton = document.getElementById('preset-save-btn'); const presetSaveButton = document.getElementById('preset-save-btn');
const presetClearButton = document.getElementById('preset-clear-btn'); const presetClearButton = document.getElementById('preset-clear-btn');
const presetAddFromPaletteButton = document.getElementById('preset-add-from-palette-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) { if (!presetsButton || !presetsModal || !presetsList || !presetSaveButton || !presetClearButton) {
return; return;
} }
let currentEditId = null; let currentEditId = null;
let currentEditTabId = null;
let cachedPresets = {}; let cachedPresets = {};
let cachedPatterns = {}; let cachedPatterns = {};
let currentPresetColors = []; // Track colors for the current preset let currentPresetColors = []; // Track colors for the current preset
@@ -324,6 +408,7 @@ document.addEventListener('DOMContentLoaded', () => {
const clearForm = () => { const clearForm = () => {
currentEditId = null; currentEditId = null;
currentEditTabId = null;
currentPresetColors = []; currentPresetColors = [];
setFormValues({ setFormValues({
name: '', name: '',
@@ -374,47 +459,15 @@ document.addEventListener('DOMContentLoaded', () => {
name: presetNameInput ? presetNameInput.value.trim() : '', name: presetNameInput ? presetNameInput.value.trim() : '',
pattern: presetPatternInput ? presetPatternInput.value.trim() : '', pattern: presetPatternInput ? presetPatternInput.value.trim() : '',
colors: currentPresetColors || [], colors: currentPresetColors || [],
// Use canonical field names expected by the device / API
brightness: presetBrightnessInput ? parseInt(presetBrightnessInput.value, 10) || 0 : 0, brightness: presetBrightnessInput ? parseInt(presetBrightnessInput.value, 10) || 0 : 0,
delay: presetDelayInput ? parseInt(presetDelayInput.value, 10) || 0 : 0, delay: presetDelayInput ? parseInt(presetDelayInput.value, 10) || 0 : 0,
}; };
// Get pattern config to map n keys to their descriptive names // Always store numeric parameters as n1..n8.
const patternName = presetPatternInput ? presetPatternInput.value.trim() : ''; for (let i = 1; i <= 8; i++) {
const patternConfig = cachedPatterns && cachedPatterns[patternName]; const nKey = `n${i}`;
payload[nKey] = getNumberInput(`preset-${nKey}-input`);
// Substitute n keys with their values from pattern.json
if (patternConfig && typeof patternConfig === 'object') {
// Build a mapping: n1 -> "Step Rate", etc. (n keys are now keys, labels are values)
const nToLabel = {};
Object.entries(patternConfig).forEach(([nKey, label]) => {
if (typeof nKey === 'string' && nKey.startsWith('n') && typeof label === 'string') {
nToLabel[nKey] = label;
}
});
// Add n values using their descriptive names as keys
for (let i = 1; i <= 8; i++) {
const nKey = `n${i}`;
const value = getNumberInput(`preset-${nKey}-input`);
const label = nToLabel[nKey];
if (label) {
// Use the descriptive label as the key
payload[label] = value;
} else {
// Keep n key if no mapping found
payload[nKey] = value;
}
}
} else {
// No pattern config, use n keys directly
payload.n1 = getNumberInput('preset-n1-input');
payload.n2 = getNumberInput('preset-n2-input');
payload.n3 = getNumberInput('preset-n3-input');
payload.n4 = getNumberInput('preset-n4-input');
payload.n5 = getNumberInput('preset-n5-input');
payload.n6 = getNumberInput('preset-n6-input');
payload.n7 = getNumberInput('preset-n7-input');
payload.n8 = getNumberInput('preset-n8-input');
} }
return payload; return payload;
@@ -540,6 +593,15 @@ document.addEventListener('DOMContentLoaded', () => {
openEditor(); 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'); const deleteButton = document.createElement('button');
deleteButton.className = 'btn btn-danger btn-small'; deleteButton.className = 'btn btn-danger btn-small';
deleteButton.textContent = 'Delete'; deleteButton.textContent = 'Delete';
@@ -569,6 +631,7 @@ document.addEventListener('DOMContentLoaded', () => {
row.appendChild(label); row.appendChild(label);
row.appendChild(details); row.appendChild(details);
row.appendChild(editButton); row.appendChild(editButton);
row.appendChild(sendButton);
row.appendChild(deleteButton); row.appendChild(deleteButton);
presetsList.appendChild(row); presetsList.appendChild(row);
}); });
@@ -834,7 +897,7 @@ document.addEventListener('DOMContentLoaded', () => {
} }
// Add Color button handler // Add Color button handler
if (presetAddColorButton && presetNewColorInput) { if (presetAddColorButton && presetNewColorInput) {
presetAddColorButton.addEventListener('click', () => { presetAddColorButton.addEventListener('click', () => {
const color = presetNewColorInput.value; const color = presetNewColorInput.value;
if (!color) return; if (!color) return;
@@ -906,6 +969,26 @@ document.addEventListener('DOMContentLoaded', () => {
modalList.addEventListener('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 () => { presetSaveButton.addEventListener('click', async () => {
const payload = buildPresetPayload(); const payload = buildPresetPayload();
if (!payload.name) { if (!payload.name) {
@@ -923,6 +1006,36 @@ document.addEventListener('DOMContentLoaded', () => {
if (!response.ok) { if (!response.ok) {
throw new Error('Failed to save preset'); 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(); await loadPresets();
clearForm(); clearForm();
closeEditor(); closeEditor();
@@ -943,13 +1056,29 @@ document.addEventListener('DOMContentLoaded', () => {
// Listen for edit preset events from tab preset buttons // Listen for edit preset events from tab preset buttons
document.addEventListener('editPreset', async (event) => { document.addEventListener('editPreset', async (event) => {
const { presetId, preset } = event.detail; const { presetId, preset, tabId } = event.detail;
currentEditId = presetId; currentEditId = presetId;
currentEditTabId = tabId || null;
await loadPatterns(); await loadPatterns();
setFormValues(preset); setFormValues(preset);
openEditor(); 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) => { presetsModal.addEventListener('click', (event) => {
if (event.target === presetsModal) { if (event.target === presetsModal) {
closeModal(); closeModal();
@@ -967,10 +1096,169 @@ document.addEventListener('DOMContentLoaded', () => {
clearForm(); 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 // Store selected preset per tab
const selectedPresets = {}; const selectedPresets = {};
// Track if we're currently dragging a preset // Track if we're currently dragging a preset
let isDraggingPreset = false; 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) // Function to convert 2D grid to flat array (for backward compatibility)
const gridToArray = (presetsGrid) => { const gridToArray = (presetsGrid) => {
@@ -1217,12 +1505,12 @@ const createPresetButton = (presetId, preset, tabId, isSelected = false) => {
presetInfo.appendChild(presetDetails); presetInfo.appendChild(presetDetails);
button.appendChild(presetInfo); button.appendChild(presetInfo);
// Left-click selects preset, right-click opens editor
button.addEventListener('click', (e) => { button.addEventListener('click', (e) => {
// Don't trigger click if we just finished dragging
if (isDraggingPreset) { if (isDraggingPreset) {
return; return;
} }
// Remove active class from all presets in this tab // Remove active class from all presets in this tab
const presetsList = document.getElementById('presets-list-tab'); const presetsList = document.getElementById('presets-list-tab');
if (presetsList) { if (presetsList) {
@@ -1230,30 +1518,29 @@ const createPresetButton = (presetId, preset, tabId, isSelected = false) => {
btn.classList.remove('active'); btn.classList.remove('active');
}); });
} }
// Add active class to clicked preset // Add active class to clicked preset
button.classList.add('active'); button.classList.add('active');
// Store selected preset for this tab // Store selected preset for this tab
selectedPresets[tabId] = presetId; selectedPresets[tabId] = presetId;
// Apply preset to tab - you may want to implement this // Build and send a select message via WebSocket for all device names in this tab.
console.log('Apply preset', presetId, 'to tab', tabId); const presetName = preset.name || presetId;
const section = button.closest('.presets-section');
sendSelectForCurrentTabDevices(presetName, section);
}); });
// Create edit button button.addEventListener('contextmenu', async (e) => {
const editButton = document.createElement('button'); e.preventDefault();
editButton.className = 'btn btn-secondary btn-small'; if (isDraggingPreset) {
editButton.textContent = '✎'; return;
editButton.title = 'Edit preset'; }
editButton.style.cssText = 'min-width: 32px; height: 32px; padding: 0; font-size: 1rem; line-height: 1;'; // Right-click: directly open the preset editor using data we already have
editButton.addEventListener('click', async (e) => { await editPresetFromTab(presetId, tabId, preset);
e.stopPropagation();
await editPresetFromTab(presetId);
}); });
wrapper.appendChild(button); wrapper.appendChild(button);
wrapper.appendChild(editButton);
// Add drag event handlers // Add drag event handlers
wrapper.addEventListener('dragstart', (e) => { wrapper.addEventListener('dragstart', (e) => {
@@ -1278,20 +1565,23 @@ const createPresetButton = (presetId, preset, tabId, isSelected = false) => {
return wrapper; return wrapper;
}; };
const editPresetFromTab = async (presetId) => { const editPresetFromTab = async (presetId, tabId, existingPreset) => {
try { try {
// Load the preset data let preset = existingPreset;
const response = await fetch(`/presets/${presetId}`, { if (!preset) {
headers: { Accept: 'application/json' }, // Fallback: load the preset data from the server if we weren't given it
}); const response = await fetch(`/presets/${presetId}`, {
if (!response.ok) { headers: { Accept: 'application/json' },
throw new Error('Failed to load preset'); });
if (!response.ok) {
throw new Error('Failed to load preset');
}
preset = await response.json();
} }
const preset = await response.json();
// Dispatch a custom event to trigger the edit in the DOMContentLoaded scope // Dispatch a custom event to trigger the edit in the DOMContentLoaded scope
const editEvent = new CustomEvent('editPreset', { const editEvent = new CustomEvent('editPreset', {
detail: { presetId, preset } detail: { presetId, preset, tabId }
}); });
document.dispatchEvent(editEvent); document.dispatchEvent(editEvent);
} catch (error) { } catch (error) {
@@ -1300,7 +1590,9 @@ const editPresetFromTab = async (presetId) => {
} }
}; };
const removePresetFromTab = async (presetId, tabId) => { // 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) { if (!tabId) {
// Try to get tab ID from the left-panel // Try to get tab ID from the left-panel
const leftPanel = document.querySelector('.presets-section[data-tab-id]'); const leftPanel = document.querySelector('.presets-section[data-tab-id]');
@@ -1331,37 +1623,39 @@ const removePresetFromTab = async (presetId, tabId) => {
} }
const tabData = await tabResponse.json(); const tabData = await tabResponse.json();
// Remove preset from tab's presets array // Normalize to flat array
const presets = tabData.presets || []; let flat = [];
const index = presets.indexOf(presetId); if (Array.isArray(tabData.presets_flat)) {
if (index !== -1) { flat = tabData.presets_flat.slice();
presets.splice(index, 1); } else if (Array.isArray(tabData.presets)) {
if (tabData.presets.length && typeof tabData.presets[0] === 'string') {
// Update tab flat = tabData.presets.slice();
const updateResponse = await fetch(`/tabs/${tabId}`, { } else if (Array.isArray(tabData.presets[0])) {
method: 'PUT', flat = tabData.presets.flat();
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 updated preset list
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 not in this tab.');
} }
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) { } catch (error) {
console.error('Failed to remove preset from tab:', error); console.error('Failed to remove preset from tab:', error);
alert('Failed to remove preset from tab.'); alert('Failed to remove preset from tab.');

View File

@@ -121,10 +121,9 @@ header h1 {
.tab-content { .tab-content {
flex: 1; flex: 1;
display: flex; display: block;
overflow: hidden; overflow: auto;
padding: 1rem; padding: 0.5rem 1rem 1rem;
gap: 1rem;
} }
.left-panel { .left-panel {

View File

@@ -58,6 +58,7 @@ function renderTabsList(tabs, tabOrder, currentTabId) {
html += ` html += `
<button class="tab-button ${activeClass}" <button class="tab-button ${activeClass}"
data-tab-id="${tabId}" data-tab-id="${tabId}"
title="Click to select, right-click to edit"
onclick="selectTab('${tabId}')"> onclick="selectTab('${tabId}')">
${tabName} ${tabName}
</button> </button>
@@ -241,11 +242,13 @@ async function loadTabContent(tabId) {
// Render tab content (presets section) // Render tab content (presets section)
const tabName = tab.name || `Tab ${tabId}`; const tabName = tab.name || `Tab ${tabId}`;
const deviceNames = Array.isArray(tab.names) ? tab.names.join(',') : '';
container.innerHTML = ` container.innerHTML = `
<div class="presets-section" data-tab-id="${tabId}"> <div class="presets-section" data-tab-id="${tabId}" data-device-names="${deviceNames}">
<h3>Presets</h3> <h3>Presets for ${tabName}</h3>
<div class="profiles-actions" style="margin-bottom: 1rem;"> <div class="profiles-actions" style="margin-bottom: 1rem;">
<button class="btn btn-primary" id="preset-add-btn-tab">Add Preset</button> <button class="btn btn-primary" id="preset-add-btn-tab">Add Preset</button>
<button class="btn btn-secondary" id="send-tab-presets-btn">Send Presets</button>
</div> </div>
<div id="presets-list-tab" class="presets-list"> <div id="presets-list-tab" class="presets-list">
<!-- Presets will be loaded here by presets.js --> <!-- Presets will be loaded here by presets.js -->
@@ -253,6 +256,14 @@ async function loadTabContent(tabId) {
</div> </div>
`; `;
// Wire up "Send Presets" button for this tab
const sendBtn = container.querySelector('#send-tab-presets-btn');
if (sendBtn) {
sendBtn.addEventListener('click', () => {
sendTabPresets(tabId);
});
}
// Trigger presets loading if the function exists // Trigger presets loading if the function exists
if (typeof renderTabPresets === 'function') { if (typeof renderTabPresets === 'function') {
renderTabPresets(tabId); renderTabPresets(tabId);
@@ -263,6 +274,65 @@ async function loadTabContent(tabId) {
} }
} }
// Send all presets used by a tab via the /presets/send HTTP endpoint.
async function sendTabPresets(tabId) {
try {
// Load tab data to determine which presets are used
const tabResponse = await fetch(`/tabs/${tabId}`, {
headers: { Accept: 'application/json' },
});
if (!tabResponse.ok) {
alert('Failed to load tab to send presets.');
return;
}
const tabData = await tabResponse.json();
// Extract preset IDs from tab (supports grid, flat, and legacy formats)
let presetIds = [];
if (Array.isArray(tabData.presets_flat)) {
presetIds = tabData.presets_flat;
} else if (Array.isArray(tabData.presets)) {
if (tabData.presets.length && typeof tabData.presets[0] === 'string') {
// Flat array of IDs
presetIds = tabData.presets;
} else if (tabData.presets.length && Array.isArray(tabData.presets[0])) {
// 2D grid
presetIds = tabData.presets.flat();
}
}
presetIds = (presetIds || []).filter(Boolean);
if (!presetIds.length) {
alert('This tab has no presets to send.');
return;
}
// Call server-side ESPNow sender with just the IDs; it handles chunking.
const response = await fetch('/presets/send', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: JSON.stringify({ preset_ids: presetIds }),
});
const data = await response.json().catch(() => ({}));
if (!response.ok) {
const msg = (data && data.error) || 'Failed to send presets.';
alert(msg);
return;
}
const sent = typeof data.presets_sent === 'number' ? data.presets_sent : presetIds.length;
const messages = typeof data.messages_sent === 'number' ? data.messages_sent : '?';
alert(`Sent ${sent} preset(s) in ${messages} ESPNow message(s).`);
} catch (error) {
console.error('Failed to send tab presets:', error);
alert('Failed to send tab presets.');
}
}
// Open edit tab modal // Open edit tab modal
function openEditTabModal(tabId, tab) { function openEditTabModal(tabId, tab) {
const modal = document.getElementById('edit-tab-modal'); const modal = document.getElementById('edit-tab-modal');
@@ -360,29 +430,6 @@ document.addEventListener('DOMContentLoaded', () => {
const newTabIdsInput = document.getElementById('new-tab-ids'); const newTabIdsInput = document.getElementById('new-tab-ids');
const createTabButton = document.getElementById('create-tab-btn'); const createTabButton = document.getElementById('create-tab-btn');
// Set up edit tab button in header
const editTabBtn = document.getElementById('edit-tab-btn');
if (editTabBtn) {
editTabBtn.addEventListener('click', async () => {
if (!currentTabId) {
alert('No tab selected. Please select a tab first.');
return;
}
try {
const response = await fetch(`/tabs/${currentTabId}`);
if (response.ok) {
const tab = await response.json();
openEditTabModal(currentTabId, tab);
} else {
alert('Failed to load tab for editing');
}
} catch (error) {
console.error('Failed to load tab:', error);
alert('Failed to load tab for editing');
}
});
}
if (tabsButton && tabsModal) { if (tabsButton && tabsModal) {
tabsButton.addEventListener('click', () => { tabsButton.addEventListener('click', () => {
tabsModal.classList.add('active'); tabsModal.classList.add('active');
@@ -403,6 +450,28 @@ document.addEventListener('DOMContentLoaded', () => {
} }
}); });
} }
// Right-click on a tab button in the main header bar to edit that tab
document.addEventListener('contextmenu', async (event) => {
const btn = event.target.closest('.tab-button');
if (!btn || !btn.dataset.tabId) {
return;
}
event.preventDefault();
const tabId = btn.dataset.tabId;
try {
const response = await fetch(`/tabs/${tabId}`);
if (response.ok) {
const tab = await response.json();
openEditTabModal(tabId, tab);
} else {
alert('Failed to load tab for editing');
}
} catch (error) {
console.error('Failed to load tab:', error);
alert('Failed to load tab for editing');
}
});
// Set up create tab // Set up create tab
const createTabHandler = async () => { const createTabHandler = async () => {

View File

@@ -16,6 +16,7 @@
<button class="btn btn-secondary" id="presets-btn">Presets</button> <button class="btn btn-secondary" id="presets-btn">Presets</button>
<button class="btn btn-secondary" id="patterns-btn">Patterns</button> <button class="btn btn-secondary" id="patterns-btn">Patterns</button>
<button class="btn btn-secondary" id="profiles-btn">Profiles</button> <button class="btn btn-secondary" id="profiles-btn">Profiles</button>
<button class="btn btn-secondary" id="help-btn">Help</button>
</div> </div>
</header> </header>
@@ -153,7 +154,9 @@
</div> </div>
</div> </div>
<div class="modal-actions"> <div class="modal-actions">
<button class="btn btn-primary" id="preset-save-btn">Save</button> <button class="btn btn-secondary" id="preset-send-btn">Try</button>
<button class="btn btn-primary" id="preset-save-btn">Save &amp; Send</button>
<button class="btn btn-danger" id="preset-remove-from-tab-btn">Remove from Tab</button>
<button class="btn btn-secondary" id="preset-clear-btn">Clear</button> <button class="btn btn-secondary" id="preset-clear-btn">Clear</button>
<button class="btn btn-secondary" id="preset-editor-close-btn">Close</button> <button class="btn btn-secondary" id="preset-editor-close-btn">Close</button>
</div> </div>
@@ -187,6 +190,40 @@
</div> </div>
</div> </div>
<!-- Help Modal -->
<div id="help-modal" class="modal">
<div class="modal-content">
<h2>Help</h2>
<p class="muted-text">How to use the LED controller UI.</p>
<h3>Tabs & devices</h3>
<ul>
<li><strong>Select tab</strong>: left-click a tab button in the top bar.</li>
<li><strong>Edit tab</strong>: right-click a tab button, or click <strong>Edit</strong> in the Tabs modal.</li>
<li><strong>Send all presets</strong>: use the <strong>Send Presets</strong> button in the tab header to push every preset used in that tab to all devices.</li>
</ul>
<h3>Presets in a tab</h3>
<ul>
<li><strong>Select preset</strong>: left-click a preset tile to select it and send a <code>select</code> message to all devices in the tab.</li>
<li><strong>Edit preset</strong>: right-click a preset tile and choose <strong>Edit preset…</strong>.</li>
<li><strong>Remove from tab</strong>: right-click a preset tile and choose <strong>Remove from this tab</strong> (the preset itself is not deleted, only its link from this tab).</li>
<li><strong>Reorder presets</strong>: drag preset tiles to change their order; the new layout is saved automatically.</li>
</ul>
<h3>Presets, profiles & colors</h3>
<ul>
<li><strong>Presets</strong>: use the <strong>Presets</strong> button in the header to create and manage reusable presets.</li>
<li><strong>Profiles</strong>: use <strong>Profiles</strong> to save and recall groups of settings.</li>
<li><strong>Color Palette</strong>: use <strong>Color Palette</strong> to build a reusable set of colors you can pull into presets.</li>
</ul>
<div class="modal-actions">
<button class="btn btn-secondary" id="help-close-btn">Close</button>
</div>
</div>
</div>
<style> <style>
.modal { .modal {
display: none; display: none;
@@ -322,11 +359,27 @@
/* Ensure presets list uses grid layout */ /* Ensure presets list uses grid layout */
#presets-list-tab { #presets-list-tab {
display: grid; display: grid;
grid-template-columns: repeat(3, 1fr); grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 0.75rem; gap: 0.75rem;
width: 100%;
}
/* Help modal readability */
#help-modal .modal-content h2 {
margin-bottom: 0.5rem;
}
#help-modal .modal-content ul {
margin-top: 0.75rem;
margin-left: 1.5rem;
padding-left: 0;
text-align: left;
}
#help-modal .modal-content li {
margin: 0.25rem 0;
line-height: 1.4;
} }
</style> </style>
<script src="/static/tabs.js"></script> <script src="/static/tabs.js"></script>
<script src="/static/help.js"></script>
<script src="/static/color_palette.js"></script> <script src="/static/color_palette.js"></script>
<script src="/static/profiles.js"></script> <script src="/static/profiles.js"></script>
<script src="/static/tab_palette.js"></script> <script src="/static/tab_palette.js"></script>