diff --git a/src/static/help.js b/src/static/help.js new file mode 100644 index 0000000..d902092 --- /dev/null +++ b/src/static/help.js @@ -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'); + } + }); + } +} +) + diff --git a/src/static/presets.js b/src/static/presets.js index 743a9ce..16dff8b 100644 --- a/src/static/presets.js +++ b/src/static/presets.js @@ -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.'); diff --git a/src/static/style.css b/src/static/style.css index a9652c2..2e5d16e 100644 --- a/src/static/style.css +++ b/src/static/style.css @@ -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 { diff --git a/src/static/tabs.js b/src/static/tabs.js index 5a59147..5430a1d 100644 --- a/src/static/tabs.js +++ b/src/static/tabs.js @@ -58,6 +58,7 @@ function renderTabsList(tabs, tabOrder, currentTabId) { html += ` @@ -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 = ` -
-

Presets

+
+

Presets for ${tabName}

+
@@ -253,6 +256,14 @@ async function loadTabContent(tabId) {
`; + // 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'); @@ -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 const createTabHandler = async () => { diff --git a/src/templates/index.html b/src/templates/index.html index 3cf444d..9ca3321 100644 --- a/src/templates/index.html +++ b/src/templates/index.html @@ -16,6 +16,7 @@ +
@@ -153,7 +154,9 @@
@@ -187,6 +190,40 @@ + + + +