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:
27
src/static/help.js
Normal file
27
src/static/help.js
Normal 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');
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
@@ -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
|
||||
// Always store numeric parameters as n1..n8.
|
||||
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');
|
||||
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);
|
||||
});
|
||||
@@ -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,8 +1505,8 @@ 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;
|
||||
}
|
||||
@@ -1237,23 +1525,22 @@ const createPresetButton = (presetId, preset, tabId, isSelected = false) => {
|
||||
// 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
|
||||
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');
|
||||
}
|
||||
const preset = await response.json();
|
||||
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);
|
||||
// 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;
|
||||
|
||||
// Update tab
|
||||
const updateResponse = await fetch(`/tabs/${tabId}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ ...tabData, presets }),
|
||||
body: JSON.stringify(tabData),
|
||||
});
|
||||
|
||||
if (!updateResponse.ok) {
|
||||
throw new Error('Failed to update tab');
|
||||
throw new Error('Failed to update tab presets');
|
||||
}
|
||||
|
||||
// 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.');
|
||||
}
|
||||
await renderTabPresets(tabId);
|
||||
} catch (error) {
|
||||
console.error('Failed to remove preset from tab:', error);
|
||||
alert('Failed to remove preset from tab.');
|
||||
|
||||
@@ -121,10 +121,9 @@ header h1 {
|
||||
|
||||
.tab-content {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
padding: 1rem;
|
||||
gap: 1rem;
|
||||
display: block;
|
||||
overflow: auto;
|
||||
padding: 0.5rem 1rem 1rem;
|
||||
}
|
||||
|
||||
.left-panel {
|
||||
|
||||
@@ -58,6 +58,7 @@ function renderTabsList(tabs, tabOrder, currentTabId) {
|
||||
html += `
|
||||
<button class="tab-button ${activeClass}"
|
||||
data-tab-id="${tabId}"
|
||||
title="Click to select, right-click to edit"
|
||||
onclick="selectTab('${tabId}')">
|
||||
${tabName}
|
||||
</button>
|
||||
@@ -241,11 +242,13 @@ async function loadTabContent(tabId) {
|
||||
|
||||
// Render tab content (presets section)
|
||||
const tabName = tab.name || `Tab ${tabId}`;
|
||||
const deviceNames = Array.isArray(tab.names) ? tab.names.join(',') : '';
|
||||
container.innerHTML = `
|
||||
<div class="presets-section" data-tab-id="${tabId}">
|
||||
<h3>Presets</h3>
|
||||
<div class="presets-section" data-tab-id="${tabId}" data-device-names="${deviceNames}">
|
||||
<h3>Presets for ${tabName}</h3>
|
||||
<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-secondary" id="send-tab-presets-btn">Send Presets</button>
|
||||
</div>
|
||||
<div id="presets-list-tab" class="presets-list">
|
||||
<!-- Presets will be loaded here by presets.js -->
|
||||
@@ -253,6 +256,14 @@ async function loadTabContent(tabId) {
|
||||
</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
|
||||
if (typeof renderTabPresets === 'function') {
|
||||
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
|
||||
function openEditTabModal(tabId, tab) {
|
||||
const modal = document.getElementById('edit-tab-modal');
|
||||
@@ -360,29 +430,6 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
const newTabIdsInput = document.getElementById('new-tab-ids');
|
||||
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) {
|
||||
tabsButton.addEventListener('click', () => {
|
||||
tabsModal.classList.add('active');
|
||||
@@ -404,6 +451,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
|
||||
const createTabHandler = async () => {
|
||||
if (!newTabNameInput) return;
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
<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="profiles-btn">Profiles</button>
|
||||
<button class="btn btn-secondary" id="help-btn">Help</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -153,7 +154,9 @@
|
||||
</div>
|
||||
</div>
|
||||
<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 & 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-editor-close-btn">Close</button>
|
||||
</div>
|
||||
@@ -187,6 +190,40 @@
|
||||
</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>
|
||||
.modal {
|
||||
display: none;
|
||||
@@ -322,11 +359,27 @@
|
||||
/* Ensure presets list uses grid layout */
|
||||
#presets-list-tab {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
||||
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>
|
||||
<script src="/static/tabs.js"></script>
|
||||
<script src="/static/help.js"></script>
|
||||
<script src="/static/color_palette.js"></script>
|
||||
<script src="/static/profiles.js"></script>
|
||||
<script src="/static/tab_palette.js"></script>
|
||||
|
||||
Reference in New Issue
Block a user