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

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', () => {
const presetsButton = document.getElementById('presets-btn');
const presetsModal = document.getElementById('presets-modal');
@@ -16,12 +98,14 @@ document.addEventListener('DOMContentLoaded', () => {
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
@@ -324,6 +408,7 @@ document.addEventListener('DOMContentLoaded', () => {
const clearForm = () => {
currentEditId = null;
currentEditTabId = null;
currentPresetColors = [];
setFormValues({
name: '',
@@ -374,47 +459,15 @@ document.addEventListener('DOMContentLoaded', () => {
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,
};
// Get pattern config to map n keys to their descriptive names
const patternName = presetPatternInput ? presetPatternInput.value.trim() : '';
const patternConfig = cachedPatterns && cachedPatterns[patternName];
// 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');
// 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;
@@ -540,6 +593,15 @@ document.addEventListener('DOMContentLoaded', () => {
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';
@@ -569,6 +631,7 @@ document.addEventListener('DOMContentLoaded', () => {
row.appendChild(label);
row.appendChild(details);
row.appendChild(editButton);
row.appendChild(sendButton);
row.appendChild(deleteButton);
presetsList.appendChild(row);
});
@@ -834,7 +897,7 @@ document.addEventListener('DOMContentLoaded', () => {
}
// Add Color button handler
if (presetAddColorButton && presetNewColorInput) {
presetAddColorButton.addEventListener('click', () => {
presetAddColorButton.addEventListener('click', () => {
const color = presetNewColorInput.value;
if (!color) return;
@@ -906,6 +969,26 @@ document.addEventListener('DOMContentLoaded', () => {
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) {
@@ -923,6 +1006,36 @@ document.addEventListener('DOMContentLoaded', () => {
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();
@@ -943,13 +1056,29 @@ document.addEventListener('DOMContentLoaded', () => {
// Listen for edit preset events from tab preset buttons
document.addEventListener('editPreset', async (event) => {
const { presetId, preset } = event.detail;
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();
@@ -967,10 +1096,169 @@ document.addEventListener('DOMContentLoaded', () => {
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) => {
@@ -1217,12 +1505,12 @@ const createPresetButton = (presetId, preset, tabId, isSelected = false) => {
presetInfo.appendChild(presetDetails);
button.appendChild(presetInfo);
// Left-click selects preset, right-click opens editor
button.addEventListener('click', (e) => {
// Don't trigger click if we just finished dragging
if (isDraggingPreset) {
return;
}
// Remove active class from all presets in this tab
const presetsList = document.getElementById('presets-list-tab');
if (presetsList) {
@@ -1230,30 +1518,29 @@ const createPresetButton = (presetId, preset, tabId, isSelected = false) => {
btn.classList.remove('active');
});
}
// Add active class to clicked preset
button.classList.add('active');
// Store selected preset for this tab
selectedPresets[tabId] = presetId;
// Apply preset to tab - you may want to implement this
console.log('Apply preset', presetId, 'to tab', tabId);
// 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);
});
// Create edit button
const editButton = document.createElement('button');
editButton.className = 'btn btn-secondary btn-small';
editButton.textContent = '✎';
editButton.title = 'Edit preset';
editButton.style.cssText = 'min-width: 32px; height: 32px; padding: 0; font-size: 1rem; line-height: 1;';
editButton.addEventListener('click', async (e) => {
e.stopPropagation();
await editPresetFromTab(presetId);
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);
wrapper.appendChild(editButton);
// Add drag event handlers
wrapper.addEventListener('dragstart', (e) => {
@@ -1278,20 +1565,23 @@ const createPresetButton = (presetId, preset, tabId, isSelected = false) => {
return wrapper;
};
const editPresetFromTab = async (presetId) => {
const editPresetFromTab = async (presetId, tabId, existingPreset) => {
try {
// Load the preset data
const response = await fetch(`/presets/${presetId}`, {
headers: { Accept: 'application/json' },
});
if (!response.ok) {
throw new Error('Failed to load preset');
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();
}
const preset = await response.json();
// Dispatch a custom event to trigger the edit in the DOMContentLoaded scope
const editEvent = new CustomEvent('editPreset', {
detail: { presetId, preset }
detail: { presetId, preset, tabId }
});
document.dispatchEvent(editEvent);
} 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) {
// Try to get tab ID from the left-panel
const leftPanel = document.querySelector('.presets-section[data-tab-id]');
@@ -1331,37 +1623,39 @@ const removePresetFromTab = async (presetId, tabId) => {
}
const tabData = await tabResponse.json();
// Remove preset from tab's presets array
const presets = tabData.presets || [];
const index = presets.indexOf(presetId);
if (index !== -1) {
presets.splice(index, 1);
// 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');
// 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();
}
// 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) {
console.error('Failed to remove preset from tab:', error);
alert('Failed to remove preset from tab.');