diff --git a/src/static/help.js b/src/static/help.js index d91996c..9656ed3 100644 --- a/src/static/help.js +++ b/src/static/help.js @@ -3,6 +3,8 @@ document.addEventListener('DOMContentLoaded', () => { const helpBtn = document.getElementById('help-btn'); const helpModal = document.getElementById('help-modal'); const helpCloseBtn = document.getElementById('help-close-btn'); + const mainMenuBtn = document.getElementById('main-menu-btn'); + const mainMenuDropdown = document.getElementById('main-menu-dropdown'); if (helpBtn && helpModal) { helpBtn.addEventListener('click', () => { @@ -24,6 +26,32 @@ document.addEventListener('DOMContentLoaded', () => { }); } + // Mobile main menu: forward clicks to existing header buttons + if (mainMenuBtn && mainMenuDropdown) { + mainMenuBtn.addEventListener('click', () => { + mainMenuDropdown.classList.toggle('open'); + }); + + mainMenuDropdown.addEventListener('click', (event) => { + const target = event.target; + if (target && target.matches('button[data-target]')) { + const id = target.getAttribute('data-target'); + const realBtn = document.getElementById(id); + if (realBtn) { + realBtn.click(); + } + mainMenuDropdown.classList.remove('open'); + } + }); + + // Close menu when clicking outside + document.addEventListener('click', (event) => { + if (!mainMenuDropdown.contains(event.target) && event.target !== mainMenuBtn) { + mainMenuDropdown.classList.remove('open'); + } + }); + } + // Settings modal wiring (reusing existing settings endpoints). const settingsButton = document.getElementById('settings-btn'); const settingsModal = document.getElementById('settings-modal'); @@ -145,11 +173,16 @@ document.addEventListener('DOMContentLoaded', () => { if (stationForm) { stationForm.addEventListener('submit', async (e) => { e.preventDefault(); + const ssid = (document.getElementById('station-ssid').value || '').trim(); + if (!ssid) { + showSettingsMessage('SSID is required', 'error'); + return; + } const formData = { - ssid: document.getElementById('station-ssid').value, - password: document.getElementById('station-password').value, - ip: document.getElementById('station-ip').value || null, - gateway: document.getElementById('station-gateway').value || null, + ssid, + password: document.getElementById('station-password').value || '', + ip: (document.getElementById('station-ip').value || '').trim() || null, + gateway: (document.getElementById('station-gateway').value || '').trim() || null, }; try { const response = await fetch('/settings/wifi/station', { @@ -157,7 +190,12 @@ document.addEventListener('DOMContentLoaded', () => { headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(formData), }); - const result = await response.json(); + let result = {}; + try { + result = await response.json(); + } catch (_) { + result = { error: response.status === 400 ? 'Bad request (check SSID and connection)' : 'Request failed' }; + } if (response.ok) { showSettingsMessage('WiFi station connected successfully!', 'success'); setTimeout(loadStationStatus, 1000); diff --git a/src/static/presets.js b/src/static/presets.js index fbdee33..31387c0 100644 --- a/src/static/presets.js +++ b/src/static/presets.js @@ -684,18 +684,13 @@ document.addEventListener('DOMContentLoaded', () => { }); } - // Handle "Add Preset" button in tab area (dynamically loaded) - document.addEventListener('click', async (e) => { - if (e.target && e.target.id === 'preset-add-btn-tab') { - await showAddPresetToTabModal(); + const showAddPresetToTabModal = async (optionalTabId) => { + let tabId = optionalTabId; + if (!tabId) { + // Get current tab ID from the presets section + const leftPanel = document.querySelector('.presets-section[data-tab-id]'); + tabId = leftPanel ? leftPanel.dataset.tabId : null; } - }); - - const showAddPresetToTabModal = async () => { - // Get current tab ID from the left-panel - const leftPanel = document.querySelector('.presets-section[data-tab-id]'); - let tabId = leftPanel ? leftPanel.dataset.tabId : null; - if (!tabId) { // Fallback: try to get from URL const pathParts = window.location.pathname.split('/'); @@ -704,7 +699,6 @@ document.addEventListener('DOMContentLoaded', () => { tabId = pathParts[tabIndex + 1]; } } - if (!tabId) { alert('Could not determine current tab.'); return; @@ -761,13 +755,12 @@ document.addEventListener('DOMContentLoaded', () => { const listContainer = document.getElementById('add-preset-list'); const presetNames = Object.keys(allPresets); - if (presetNames.length === 0) { - listContainer.innerHTML = '
No presets available. Create a preset first.
'; + const availableToAdd = presetNames.filter(presetId => !currentTabPresets.includes(presetId)); + if (availableToAdd.length === 0) { + listContainer.innerHTML = 'No presets to add. All presets are already in this tab, or create a preset first.
'; } else { - presetNames.forEach(presetId => { + availableToAdd.forEach(presetId => { const preset = allPresets[presetId]; - const isInCurrentTab = currentTabPresets.includes(presetId); - const row = document.createElement('div'); row.className = 'profiles-row'; @@ -779,31 +772,17 @@ document.addEventListener('DOMContentLoaded', () => { details.style.fontSize = '0.85em'; details.textContent = preset.pattern || '-'; - const actionButton = document.createElement('button'); - if (isInCurrentTab) { - // Already in this tab: allow removing from this tab - actionButton.className = 'btn btn-danger btn-small'; - actionButton.textContent = 'Remove'; - actionButton.addEventListener('click', async (e) => { - e.stopPropagation(); - if (confirm(`Remove preset "${preset.name || presetId}" from this tab?`)) { - await removePresetFromTab(tabId, presetId); - modal.remove(); - } - }); - } else { - // Not yet in this tab: allow adding (even if used in other tabs) - actionButton.className = 'btn btn-primary btn-small'; - actionButton.textContent = 'Add'; - actionButton.addEventListener('click', async () => { - await addPresetToTab(presetId, tabId); - modal.remove(); - }); - } + const addButton = document.createElement('button'); + addButton.className = 'btn btn-primary btn-small'; + addButton.textContent = 'Add'; + addButton.addEventListener('click', async () => { + await addPresetToTab(presetId, tabId); + modal.remove(); + }); row.appendChild(label); row.appendChild(details); - row.appendChild(actionButton); + row.appendChild(addButton); listContainer.appendChild(row); }); } @@ -824,6 +803,9 @@ document.addEventListener('DOMContentLoaded', () => { alert('Failed to load presets.'); } }; + try { + window.showAddPresetToTabModal = showAddPresetToTabModal; + } catch (e) {} const addPresetToTab = async (presetId, tabId) => { if (!tabId) { @@ -906,6 +888,9 @@ document.addEventListener('DOMContentLoaded', () => { alert('Failed to add preset to tab.'); } }; + try { + window.addPresetToTab = addPresetToTab; + } catch (e) {} if (presetEditorCloseButton) { presetEditorCloseButton.addEventListener('click', closeEditor); } @@ -1126,7 +1111,8 @@ document.addEventListener('DOMContentLoaded', () => { // 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) => { +// saveToDevice defaults to true. +const sendPresetViaEspNow = (presetId, preset, deviceNames, saveToDevice = true) => { try { const colors = Array.isArray(preset.colors) && preset.colors.length ? preset.colors @@ -1152,6 +1138,10 @@ const sendPresetViaEspNow = (presetId, preset, deviceNames) => { }, }, }; + if (saveToDevice) { + // Instruct led-driver to save this preset when received. + message.save = true; + } // Optionally include a select section for specific devices if (Array.isArray(deviceNames) && deviceNames.length > 0) { @@ -1225,11 +1215,6 @@ const ensurePresetContextMenu = () => { 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'; }); @@ -1240,20 +1225,17 @@ const ensurePresetContextMenu = () => { }; 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 { presetId } = presetContextTarget; const action = btn.dataset.action; hidePresetContextMenu(); if (action === 'edit') { await editPresetFromTab(presetId); - } else if (action === 'remove') { - await removePresetFromTab(tabId, presetId); } }); @@ -1361,24 +1343,24 @@ const savePresetGrid = async (tabId, presetGrid) => { } }; -// Function to get drop target in 2D grid +// Function to get drop target: the cell that contains the cursor (or closest if in a gap) const getDropTarget = (container, x, y) => { const draggableElements = [...container.querySelectorAll('.draggable-preset:not(.dragging)')]; - - return draggableElements.reduce((closest, child) => { + // First try: find the element whose rect contains the cursor + const containing = draggableElements.find((child) => { const box = child.getBoundingClientRect(); - const centerX = box.left + box.width / 2; - const centerY = box.top + box.height / 2; - const distanceX = Math.abs(x - centerX); - const distanceY = Math.abs(y - centerY); - const distance = Math.sqrt(distanceX * distanceX + distanceY * distanceY); - - if (distance < closest.distance) { - return { distance: distance, element: child }; - } else { - return closest; - } - }, { distance: Infinity }).element; + return x >= box.left && x <= box.right && y >= box.top && y <= box.bottom; + }); + if (containing) return containing; + // Fallback: closest element by distance to center + const closest = draggableElements.reduce((best, child) => { + const box = child.getBoundingClientRect(); + const cx = box.left + box.width / 2; + const cy = box.top + box.height / 2; + const d = Math.hypot(x - cx, y - cy); + return d < best.distance ? { distance: d, element: child } : best; + }, { distance: Infinity }); + return closest.element; }; // Function to render presets for a specific tab in 2D grid @@ -1426,17 +1408,8 @@ const renderTabPresets = async (tabId) => { const dropTarget = getDropTarget(presetsList, e.clientX, e.clientY); if (dropTarget && dropTarget !== dragging) { - // Insert before or after based on position - const rect = dropTarget.getBoundingClientRect(); - const draggingRect = dragging.getBoundingClientRect(); - - if (e.clientX < rect.left + rect.width / 2) { - // Insert before - presetsList.insertBefore(dragging, dropTarget); - } else { - // Insert after - presetsList.insertBefore(dragging, dropTarget.nextSibling); - } + // Insert before drop target so the dragged item takes that cell's position + presetsList.insertBefore(dragging, dropTarget); } }); @@ -1477,7 +1450,7 @@ const renderTabPresets = async (tabId) => { const empty = document.createElement('p'); empty.className = 'muted-text'; empty.style.gridColumn = '1 / -1'; // Span all columns - empty.textContent = 'No presets added to this tab. Click "Add Preset" to add one.'; + empty.textContent = 'No presets added to this tab. Open the tab\'s Edit menu and click "Add Preset" to add one.'; presetsList.appendChild(empty); } else { flatPresets.forEach((presetId) => { @@ -1496,97 +1469,67 @@ const renderTabPresets = async (tabId) => { }; const createPresetButton = (presetId, preset, tabId, isSelected = false) => { - // Create wrapper div for button and edit button - const wrapper = document.createElement('div'); - wrapper.style.cssText = 'display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.5rem;'; - wrapper.draggable = true; - wrapper.dataset.presetId = presetId; - wrapper.classList.add('draggable-preset'); - - // Create preset button const button = document.createElement('button'); - button.className = 'pattern-button'; - button.style.flex = '1'; + button.className = 'pattern-button draggable-preset'; + button.draggable = true; + button.dataset.presetId = presetId; if (isSelected) { button.classList.add('active'); } - button.dataset.presetId = presetId; - - const presetInfo = document.createElement('div'); - presetInfo.style.cssText = 'display: flex; flex-direction: column; align-items: flex-start; width: 100%;'; - + + const colors = Array.isArray(preset.colors) ? preset.colors.filter(c => c) : []; + const isRainbow = (preset.pattern || '').toLowerCase() === 'rainbow'; + const barColors = isRainbow + ? ['#FF0000', '#FF7F00', '#FFFF00', '#00FF00', '#0000FF', '#4B0082', '#8F00FF'] + : colors; + if (barColors.length > 0) { + const n = barColors.length; + const stops = barColors.flatMap((c, i) => { + const start = (100 * i / n).toFixed(2); + const end = (100 * (i + 1) / n).toFixed(2); + return [`${c} ${start}%`, `${c} ${end}%`]; + }).join(', '); + button.style.backgroundImage = `linear-gradient(rgba(0,0,0,0.4), rgba(0,0,0,0.4)), linear-gradient(to right, ${stops})`; + } + const presetNameLabel = document.createElement('span'); presetNameLabel.textContent = preset.name || presetId; presetNameLabel.style.fontWeight = 'bold'; - presetNameLabel.style.marginBottom = '0.25rem'; - - const presetDetails = document.createElement('span'); - presetDetails.style.fontSize = '0.85em'; - presetDetails.style.color = '#aaa'; - const colors = Array.isArray(preset.colors) ? preset.colors : []; - presetDetails.textContent = `${preset.pattern || '-'} • ${colors.length} color${colors.length !== 1 ? 's' : ''}`; - - presetInfo.appendChild(presetNameLabel); - presetInfo.appendChild(presetDetails); - button.appendChild(presetInfo); - - // Left-click selects preset, right-click opens editor - button.addEventListener('click', (e) => { - if (isDraggingPreset) { - return; - } + presetNameLabel.className = 'pattern-button-label'; + button.appendChild(presetNameLabel); - // Remove active class from all presets in this tab + button.addEventListener('click', (e) => { + if (isDraggingPreset) return; const presetsList = document.getElementById('presets-list-tab'); if (presetsList) { - presetsList.querySelectorAll('.pattern-button').forEach(btn => { - btn.classList.remove('active'); - }); + presetsList.querySelectorAll('.pattern-button').forEach(btn => btn.classList.remove('active')); } - - // Add active class to clicked preset button.classList.add('active'); - - // Store selected preset for this tab selectedPresets[tabId] = presetId; - - // Build and send a select message via WebSocket for all device names in this tab. const section = button.closest('.presets-section'); sendSelectForCurrentTabDevices(presetId, section); }); button.addEventListener('contextmenu', async (e) => { e.preventDefault(); - if (isDraggingPreset) { - return; - } - // Right-click: directly open the preset editor using data we already have + if (isDraggingPreset) return; await editPresetFromTab(presetId, tabId, preset); }); - - wrapper.appendChild(button); - - // Add drag event handlers - wrapper.addEventListener('dragstart', (e) => { + + button.addEventListener('dragstart', (e) => { isDraggingPreset = true; - wrapper.classList.add('dragging'); + button.classList.add('dragging'); e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', presetId); }); - - wrapper.addEventListener('dragend', (e) => { - wrapper.classList.remove('dragging'); - // Remove any drag-over classes from siblings - document.querySelectorAll('.draggable-preset').forEach(el => { - el.classList.remove('drag-over'); - }); - // Reset dragging flag after a short delay to allow click event to check it - setTimeout(() => { - isDraggingPreset = false; - }, 100); + + button.addEventListener('dragend', (e) => { + button.classList.remove('dragging'); + document.querySelectorAll('.draggable-preset').forEach(el => el.classList.remove('drag-over')); + setTimeout(() => { isDraggingPreset = false; }, 100); }); - - return wrapper; + + return button; }; const editPresetFromTab = async (presetId, tabId, existingPreset) => { @@ -1685,6 +1628,9 @@ const removePresetFromTab = async (tabId, presetId) => { alert('Failed to remove preset from tab.'); } }; +try { + window.removePresetFromTab = removePresetFromTab; +} catch (e) {} // Listen for HTMX swaps to render presets document.body.addEventListener('htmx:afterSwap', (event) => { diff --git a/src/static/profiles.js b/src/static/profiles.js index 33e7b82..6f2a61d 100644 --- a/src/static/profiles.js +++ b/src/static/profiles.js @@ -73,6 +73,70 @@ document.addEventListener("DOMContentLoaded", () => { } }); + const cloneButton = document.createElement("button"); + cloneButton.className = "btn btn-secondary btn-small"; + cloneButton.textContent = "Clone"; + cloneButton.addEventListener("click", async () => { + const baseName = (profile && profile.name) || profileId; + const suggested = `${baseName}`; + const name = prompt("New profile name:", suggested); + if (name === null) { + return; + } + const trimmed = String(name).trim(); + if (!trimmed) { + alert("Profile name cannot be empty."); + return; + } + try { + const response = await fetch(`/profiles/${profileId}/clone`, { + method: "POST", + headers: { "Content-Type": "application/json", Accept: "application/json" }, + body: JSON.stringify({ name: trimmed }), + }); + if (!response.ok) { + throw new Error("Failed to clone profile"); + } + const data = await response.json().catch(() => null); + let newProfileId = null; + if (data && typeof data === "object") { + if (data.id) { + newProfileId = String(data.id); + } else { + const ids = Object.keys(data); + if (ids.length > 0) { + newProfileId = String(ids[0]); + } + } + } + if (newProfileId) { + await fetch(`/profiles/${newProfileId}/apply`, { + method: "POST", + headers: { Accept: "application/json" }, + }); + } + document.cookie = "current_tab=; path=/; max-age=0"; + await loadProfiles(); + if (typeof window.loadTabs === "function") { + await window.loadTabs(); + } + if (typeof window.loadTabsModal === "function") { + await window.loadTabsModal(); + } + const tabContent = document.getElementById("tab-content"); + if (tabContent) { + tabContent.innerHTML = ` +