diff --git a/src/static/color_palette.js b/src/static/color_palette.js index a5122ef..7cac0d4 100644 --- a/src/static/color_palette.js +++ b/src/static/color_palette.js @@ -4,7 +4,6 @@ document.addEventListener('DOMContentLoaded', () => { const closeButton = document.getElementById('color-palette-close-btn'); const paletteContainer = document.getElementById('palette-container'); const paletteNewColor = document.getElementById('palette-new-color'); - const paletteAddButton = document.getElementById('palette-add-color-btn'); const profileNameDisplay = document.getElementById('palette-current-profile-name'); if (!paletteButton || !paletteModal || !paletteContainer) { @@ -177,8 +176,8 @@ document.addEventListener('DOMContentLoaded', () => { if (closeButton) { closeButton.addEventListener('click', closeModal); } - if (paletteAddButton && paletteNewColor) { - paletteAddButton.addEventListener('click', async () => { + if (paletteNewColor) { + const addSelectedColor = async () => { const color = paletteNewColor.value; if (!color) { return; @@ -188,7 +187,9 @@ document.addEventListener('DOMContentLoaded', () => { return; } await savePalette([...currentPalette, color]); - }); + }; + // Add when the picker closes (user confirms selection). + paletteNewColor.addEventListener('change', addSelectedColor); } paletteModal.addEventListener('click', (event) => { if (event.target === paletteModal) { diff --git a/src/static/presets.js b/src/static/presets.js index c2b2af4..bbc1308 100644 --- a/src/static/presets.js +++ b/src/static/presets.js @@ -2,6 +2,72 @@ let espnowSocket = null; let espnowSocketReady = false; let espnowPendingMessages = []; +let currentProfileIdCache = null; + +const getCurrentProfileId = async () => { + try { + const res = await fetch('/profiles/current', { headers: { Accept: 'application/json' } }); + if (!res.ok) return currentProfileIdCache ? String(currentProfileIdCache) : null; + const data = await res.json(); + const id = data && (data.id || (data.profile && data.profile.id)); + currentProfileIdCache = id ? String(id) : null; + return currentProfileIdCache; + } catch (_) { + return currentProfileIdCache ? String(currentProfileIdCache) : null; + } +}; + +const filterPresetsForCurrentProfile = async (presetsObj) => { + const scoped = presetsObj && typeof presetsObj === 'object' ? presetsObj : {}; + const currentProfileId = await getCurrentProfileId(); + if (!currentProfileId) return scoped; + return Object.fromEntries( + Object.entries(scoped).filter(([, preset]) => { + if (!preset || typeof preset !== 'object') return false; + if (!('profile_id' in preset)) return true; // Legacy records + return String(preset.profile_id) === String(currentProfileId); + }), + ); +}; + +const getCurrentProfileData = async () => { + try { + const res = await fetch('/profiles/current', { headers: { Accept: 'application/json' } }); + if (!res.ok) return null; + return await res.json(); + } catch (_) { + return null; + } +}; + +const getCurrentProfilePaletteColors = async () => { + const profileData = await getCurrentProfileData(); + const profile = profileData && profileData.profile; + const paletteId = profile && (profile.palette_id || profile.paletteId); + if (!paletteId) return []; + try { + const res = await fetch(`/palettes/${paletteId}`, { headers: { Accept: 'application/json' } }); + if (!res.ok) return []; + const pal = await res.json(); + return Array.isArray(pal.colors) ? pal.colors : []; + } catch (_) { + return []; + } +}; + +const resolveColorsWithPaletteRefs = (colors, paletteRefs, paletteColors) => { + const baseColors = Array.isArray(colors) ? colors : []; + const refs = Array.isArray(paletteRefs) ? paletteRefs : []; + const pal = Array.isArray(paletteColors) ? paletteColors : []; + return baseColors.map((color, idx) => { + const refRaw = refs[idx]; + const ref = Number.isInteger(refRaw) ? refRaw : parseInt(refRaw, 10); + if (Number.isInteger(ref) && ref >= 0 && ref < pal.length && pal[ref]) { + return pal[ref]; + } + return color; + }); +}; const getEspnowSocket = () => { if (espnowSocket && (espnowSocket.readyState === WebSocket.OPEN || espnowSocket.readyState === WebSocket.CONNECTING)) { @@ -105,7 +171,6 @@ document.addEventListener('DOMContentLoaded', () => { const presetPatternInput = document.getElementById('preset-pattern-input'); const presetColorsContainer = document.getElementById('preset-colors-container'); const presetNewColorInput = document.getElementById('preset-new-color'); - const presetAddColorButton = document.getElementById('preset-add-color-btn'); const presetBrightnessInput = document.getElementById('preset-brightness-input'); const presetDelayInput = document.getElementById('preset-delay-input'); const presetDefaultButton = document.getElementById('preset-default-btn'); @@ -123,6 +188,7 @@ document.addEventListener('DOMContentLoaded', () => { let cachedPresets = {}; let cachedPatterns = {}; let currentPresetColors = []; // Track colors for the current preset + let currentPresetPaletteRefs = []; // Palette index refs per color (null for direct colors) // Function to get max colors for current pattern const getMaxColors = () => { @@ -158,7 +224,7 @@ document.addEventListener('DOMContentLoaded', () => { // Hide/show the actions (color picker and buttons) const colorActions = presetColorsContainer.nextElementSibling; - if (colorActions && (colorActions.querySelector('#preset-add-color-btn') || colorActions.querySelector('#preset-new-color'))) { + if (colorActions && colorActions.querySelector('#preset-new-color')) { colorActions.style.display = shouldShow ? '' : 'none'; } } @@ -172,11 +238,24 @@ document.addEventListener('DOMContentLoaded', () => { return parseInt(input.value, 10) || 0; }; - const renderPresetColors = (colors) => { + const renderPresetColors = (colors, paletteRefs) => { if (!presetColorsContainer) return; presetColorsContainer.innerHTML = ''; - currentPresetColors = colors || []; + currentPresetColors = Array.isArray(colors) ? colors.slice() : []; + if (Array.isArray(paletteRefs)) { + currentPresetPaletteRefs = currentPresetColors.map((_, i) => { + const refRaw = paletteRefs[i]; + const ref = Number.isInteger(refRaw) ? refRaw : parseInt(refRaw, 10); + return Number.isInteger(ref) ? ref : null; + }); + } else { + currentPresetPaletteRefs = currentPresetColors.map((_, i) => { + const refRaw = currentPresetPaletteRefs[i]; + const ref = Number.isInteger(refRaw) ? refRaw : parseInt(refRaw, 10); + return Number.isInteger(ref) ? ref : null; + }); + } // Get max colors for current pattern const maxColors = getMaxColors(); @@ -185,7 +264,7 @@ document.addEventListener('DOMContentLoaded', () => { if (currentPresetColors.length === 0) { const empty = document.createElement('p'); empty.className = 'muted-text'; - empty.textContent = `No colors added. Click "Add Color" to add colors.${maxColorsText}`; + empty.textContent = `No colors added. Use the color picker to add colors.${maxColorsText}`; presetColorsContainer.appendChild(empty); return; } @@ -208,6 +287,8 @@ document.addEventListener('DOMContentLoaded', () => { swatchWrapper.style.cssText = 'position: relative; display: inline-block;'; swatchWrapper.draggable = true; swatchWrapper.dataset.colorIndex = index; + const refAtIndex = currentPresetPaletteRefs[index]; + swatchWrapper.dataset.paletteRef = Number.isInteger(refAtIndex) ? String(refAtIndex) : ''; swatchWrapper.classList.add('draggable-color-swatch'); const swatch = document.createElement('div'); @@ -222,6 +303,31 @@ document.addEventListener('DOMContentLoaded', () => { transition: opacity 0.2s, transform 0.2s; `; swatch.title = `${color} - Drag to reorder`; + + if (Number.isInteger(refAtIndex)) { + const linkedBadge = document.createElement('span'); + linkedBadge.textContent = 'P'; + linkedBadge.title = `Linked to palette color #${refAtIndex + 1}`; + linkedBadge.style.cssText = ` + position: absolute; + left: -6px; + top: -6px; + min-width: 18px; + height: 18px; + border-radius: 9px; + background: #3f51b5; + color: #fff; + font-size: 11px; + font-weight: 700; + display: flex; + align-items: center; + justify-content: center; + z-index: 11; + border: 1px solid rgba(255,255,255,0.35); + box-shadow: 0 1px 3px rgba(0,0,0,0.35); + `; + swatchWrapper.appendChild(linkedBadge); + } // Color picker overlay const colorPicker = document.createElement('input'); @@ -239,7 +345,9 @@ document.addEventListener('DOMContentLoaded', () => { `; colorPicker.addEventListener('change', (e) => { currentPresetColors[index] = e.target.value; - renderPresetColors(currentPresetColors); + // Manual picker edit breaks palette linkage for this slot. + currentPresetPaletteRefs[index] = null; + renderPresetColors(currentPresetColors, currentPresetPaletteRefs); }); // Prevent color picker from interfering with drag colorPicker.addEventListener('mousedown', (e) => { @@ -271,7 +379,8 @@ document.addEventListener('DOMContentLoaded', () => { removeBtn.addEventListener('click', (e) => { e.stopPropagation(); currentPresetColors.splice(index, 1); - renderPresetColors(currentPresetColors); + currentPresetPaletteRefs.splice(index, 1); + renderPresetColors(currentPresetColors, currentPresetPaletteRefs); }); // Prevent remove button from interfering with drag removeBtn.addEventListener('mousedown', (e) => { @@ -326,12 +435,18 @@ document.addEventListener('DOMContentLoaded', () => { const colorPicker = el.querySelector('input[type="color"]'); return colorPicker ? colorPicker.value : null; }).filter(color => color !== null); + const newRefOrder = colorElements.map((el) => { + const refRaw = el.dataset.paletteRef; + const ref = Number.isInteger(refRaw) ? refRaw : parseInt(refRaw, 10); + return Number.isInteger(ref) ? ref : null; + }); // Update current colors array currentPresetColors = newColorOrder; + currentPresetPaletteRefs = newRefOrder; // Re-render to update indices - renderPresetColors(currentPresetColors); + renderPresetColors(currentPresetColors, currentPresetPaletteRefs); }); presetColorsContainer.appendChild(swatchContainer); @@ -361,7 +476,8 @@ document.addEventListener('DOMContentLoaded', () => { const patternName = preset.pattern || ''; presetPatternInput.value = patternName; const colors = Array.isArray(preset.colors) ? preset.colors : []; - renderPresetColors(colors); + const paletteRefs = Array.isArray(preset.palette_refs) ? preset.palette_refs : []; + renderPresetColors(colors, paletteRefs); presetBrightnessInput.value = preset.brightness || 0; presetDelayInput.value = preset.delay || 0; @@ -424,6 +540,7 @@ document.addEventListener('DOMContentLoaded', () => { currentEditId = null; currentEditTabId = null; currentPresetColors = []; + currentPresetPaletteRefs = []; setFormValues({ name: '', pattern: '', @@ -505,6 +622,7 @@ document.addEventListener('DOMContentLoaded', () => { name: presetNameInput ? presetNameInput.value.trim() : '', pattern: presetPatternInput ? presetPatternInput.value.trim() : '', colors: currentPresetColors || [], + palette_refs: currentPresetPaletteRefs || [], // 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, @@ -633,9 +751,18 @@ document.addEventListener('DOMContentLoaded', () => { const editButton = document.createElement('button'); editButton.className = 'btn btn-secondary btn-small'; editButton.textContent = 'Edit'; - editButton.addEventListener('click', () => { + editButton.addEventListener('click', async () => { currentEditId = presetId; - setFormValues(preset || {}); + const paletteColors = await getCurrentProfilePaletteColors(); + const presetForEditor = { + ...(preset || {}), + colors: resolveColorsWithPaletteRefs( + (preset && preset.colors) || [], + (preset && preset.palette_refs) || [], + paletteColors, + ), + }; + setFormValues(presetForEditor); openEditor(); }); @@ -698,7 +825,8 @@ document.addEventListener('DOMContentLoaded', () => { throw new Error('Failed to load presets'); } const presets = await response.json(); - renderPresets(presets); + const filtered = await filterPresetsForCurrentProfile(presets); + renderPresets(filtered); } catch (error) { console.error('Load presets failed:', error); presetsList.innerHTML = ''; @@ -757,7 +885,8 @@ document.addEventListener('DOMContentLoaded', () => { if (!response.ok) { throw new Error('Failed to load presets'); } - const allPresets = await response.json(); + const allPresetsRaw = await response.json(); + const allPresets = await filterPresetsForCurrentProfile(allPresetsRaw); // Load only the current tab's presets so we can avoid duplicates within this tab. let currentTabPresets = []; @@ -946,12 +1075,12 @@ document.addEventListener('DOMContentLoaded', () => { // Update color section visibility updateColorSectionVisibility(); // Re-render colors to show updated max colors limit - renderPresetColors(currentPresetColors); + renderPresetColors(currentPresetColors, currentPresetPaletteRefs); }); } - // Add Color button handler - if (presetAddColorButton && presetNewColorInput) { - presetAddColorButton.addEventListener('click', () => { + // Color picker auto-add handler + if (presetNewColorInput) { + const tryAddSelectedColor = () => { const color = presetNewColorInput.value; if (!color) return; @@ -967,60 +1096,86 @@ document.addEventListener('DOMContentLoaded', () => { } currentPresetColors.push(color); - renderPresetColors(currentPresetColors); - }); + currentPresetPaletteRefs.push(null); + renderPresetColors(currentPresetColors, currentPresetPaletteRefs); + }; + // Add when the picker closes (user confirms selection). + presetNewColorInput.addEventListener('change', tryAddSelectedColor); } - // Add from Palette button handler if (presetAddFromPaletteButton) { - presetAddFromPaletteButton.addEventListener('click', () => { - const openButton = document.getElementById('color-palette-btn'); - if (openButton) { - openButton.click(); - } - const modal = document.getElementById('color-palette-modal'); - const modalList = document.getElementById('palette-container'); - if (modal) { - modal.classList.add('active'); - } - if (!modalList) { - return; - } + presetAddFromPaletteButton.addEventListener('click', async () => { + try { + const paletteColors = await getCurrentProfilePaletteColors(); + if (!Array.isArray(paletteColors) || paletteColors.length === 0) { + alert('No profile palette colors available.'); + return; + } - const handlePick = (event) => { - const row = event.target.closest('[data-color]'); - if (!row) { - return; - } - const picked = row.dataset.color; - if (!picked) { - return; - } - - if (currentPresetColors.includes(picked)) { - alert('This color is already in the list.'); - return; - } - - const maxColors = getMaxColors(); - if (currentPresetColors.length >= maxColors) { - alert(`This pattern allows a maximum of ${maxColors} color${maxColors !== 1 ? 's' : ''}.`); - if (modal) { - modal.classList.remove('active'); + const modal = document.createElement('div'); + modal.className = 'modal active'; + modal.innerHTML = ` +
+ `; + document.body.appendChild(modal); + + const list = modal.querySelector('#pick-palette-list'); + paletteColors.forEach((color, idx) => { + const row = document.createElement('div'); + row.className = 'profiles-row'; + row.style.display = 'flex'; + row.style.alignItems = 'center'; + row.style.gap = '0.75rem'; + row.dataset.paletteIndex = String(idx); + row.dataset.paletteColor = color; + row.innerHTML = ` + + ${color} + + `; + list.appendChild(row); + }); + + const close = () => modal.remove(); + modal.querySelector('#pick-palette-close-btn').addEventListener('click', close); + modal.addEventListener('click', (e) => { + if (e.target === modal) close(); + }); + + list.addEventListener('click', (e) => { + const btn = e.target.closest('button'); + if (!btn) return; + const row = e.target.closest('[data-palette-index]'); + if (!row) return; + const color = row.dataset.paletteColor; + const ref = parseInt(row.dataset.paletteIndex, 10); + if (!color || !Number.isInteger(ref)) return; + + if (currentPresetColors.includes(color) && currentPresetPaletteRefs.includes(ref)) { + alert('That palette color is already linked.'); + return; + } + const maxColors = getMaxColors(); + if (currentPresetColors.length >= maxColors) { + alert(`This pattern allows a maximum of ${maxColors} color${maxColors !== 1 ? 's' : ''}.`); + return; } - modalList.removeEventListener('click', handlePick); - return; - } - - currentPresetColors.push(picked); - renderPresetColors(currentPresetColors); - if (modal) { - modal.classList.remove('active'); - } - modalList.removeEventListener('click', handlePick); - }; - modalList.addEventListener('click', handlePick); + currentPresetColors.push(color); + currentPresetPaletteRefs.push(ref); + renderPresetColors(currentPresetColors, currentPresetPaletteRefs); + close(); + }); + } catch (err) { + console.error('Failed to add from palette:', err); + alert('Failed to load palette colors.'); + } }); } const presetSendButton = document.getElementById('preset-send-btn'); @@ -1136,7 +1291,15 @@ document.addEventListener('DOMContentLoaded', () => { currentEditId = presetId; currentEditTabId = tabId || null; await loadPatterns(); - setFormValues(preset); + const paletteColors = await getCurrentProfilePaletteColors(); + setFormValues({ + ...(preset || {}), + colors: resolveColorsWithPaletteRefs( + (preset && preset.colors) || [], + (preset && preset.palette_refs) || [], + paletteColors, + ), + }); openEditor(); }); @@ -1175,11 +1338,13 @@ 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. // saveToDevice defaults to true. -const sendPresetViaEspNow = (presetId, preset, deviceNames, saveToDevice = true, setDefault = false) => { +const sendPresetViaEspNow = async (presetId, preset, deviceNames, saveToDevice = true, setDefault = false) => { try { - const colors = Array.isArray(preset.colors) && preset.colors.length + const baseColors = Array.isArray(preset.colors) && preset.colors.length ? preset.colors : ['#FFFFFF']; + const paletteColors = await getCurrentProfilePaletteColors(); + const colors = resolveColorsWithPaletteRefs(baseColors, preset.palette_refs, paletteColors); const message = { v: '1', @@ -1261,97 +1426,29 @@ try { // Store selected preset per tab const selectedPresets = {}; +// Run vs Edit for tab preset strip (in-memory only — each full page load starts in run mode) +let presetUiMode = 'run'; + +const getPresetUiMode = () => (presetUiMode === 'edit' ? 'edit' : 'run'); + +const setPresetUiMode = (mode) => { + presetUiMode = mode === 'edit' ? 'edit' : 'run'; +}; + +const updateUiModeToggleButtons = () => { + const mode = getPresetUiMode(); + // Label is the mode you switch *to* (opposite of current) + const label = mode === 'edit' ? 'Run mode' : 'Edit mode'; + document.querySelectorAll('.ui-mode-toggle').forEach((btn) => { + btn.textContent = label; + btn.setAttribute('aria-pressed', mode === 'edit' ? 'true' : 'false'); + btn.classList.toggle('ui-mode-toggle--edit', mode === 'edit'); + }); + document.body.classList.toggle('preset-ui-edit', mode === 'edit'); + document.body.classList.toggle('preset-ui-run', mode === 'run'); +}; // 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; - `; - item.addEventListener('mouseover', () => { - item.style.backgroundColor = '#3a3a3a'; - }); - item.addEventListener('mouseout', () => { - item.style.backgroundColor = 'transparent'; - }); - menu.appendChild(item); - }; - - addItem('Edit preset…', 'edit'); - - menu.addEventListener('click', async (e) => { - const btn = e.target.closest('button[data-action]'); - if (!btn || !presetContextTarget) { - return; - } - const { presetId } = presetContextTarget; - const action = btn.dataset.action; - hidePresetContextMenu(); - if (action === 'edit') { - await editPresetFromTab(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) => { @@ -1449,6 +1546,25 @@ const getDropTarget = (container, x, y) => { return closest.element; }; +/** + * Move dragged tile onto the drop target's slot. + * When moving down the list (fromIdx < toIdx), insertBefore(dragging, dropTarget) lands one index + * too early; use the next element sibling so the item occupies the target slot. + */ +const insertDraggingOntoTarget = (presetsList, dragging, dropTarget) => { + const siblings = [...presetsList.querySelectorAll('.draggable-preset')]; + const fromIdx = siblings.indexOf(dragging); + const toIdx = siblings.indexOf(dropTarget); + if (fromIdx === -1 || toIdx === -1) return; + + if (fromIdx < toIdx) { + const next = dropTarget.nextElementSibling; + presetsList.insertBefore(dragging, next); + } else { + presetsList.insertBefore(dragging, dropTarget); + } +}; + // Function to render presets for a specific tab in 2D grid const renderTabPresets = async (tabId) => { const presetsList = document.getElementById('presets-list-tab'); @@ -1482,47 +1598,74 @@ const renderTabPresets = async (tabId) => { if (!presetsResponse.ok) { throw new Error('Failed to load presets'); } - const allPresets = await presetsResponse.json(); + const allPresetsRaw = await presetsResponse.json(); + const allPresets = await filterPresetsForCurrentProfile(allPresetsRaw); + const paletteColors = await getCurrentProfilePaletteColors(); presetsList.innerHTML = ''; - - // Add drag and drop handlers to the container - presetsList.addEventListener('dragover', (e) => { - e.preventDefault(); - const dragging = presetsList.querySelector('.dragging'); - if (!dragging) return; - - const dropTarget = getDropTarget(presetsList, e.clientX, e.clientY); - if (dropTarget && dropTarget !== dragging) { - // Insert before drop target so the dragged item takes that cell's position - presetsList.insertBefore(dragging, dropTarget); - } - }); - - presetsList.addEventListener('drop', async (e) => { - e.preventDefault(); - const dragging = presetsList.querySelector('.dragging'); - if (!dragging) return; - - // Get new grid layout from DOM - const presetElements = [...presetsList.querySelectorAll('.draggable-preset')]; - const presetIds = presetElements.map(el => el.dataset.presetId); - - // Convert to 2D grid (3 columns) - const newGrid = arrayToGrid(presetIds, 3); - - // Save new grid - try { - await savePresetGrid(tabId, newGrid); - // Re-render to ensure consistency - await renderTabPresets(tabId); - } catch (error) { - console.error('Failed to save preset grid:', error); - alert('Failed to save preset order. Please try again.'); - // Re-render to restore original order - await renderTabPresets(tabId); - } - }); + presetsList.dataset.reorderTabId = tabId; + + // Drag-and-drop on the list (wire once — re-render would duplicate listeners otherwise) + if (!presetsList.dataset.dragWired) { + presetsList.dataset.dragWired = '1'; + // dragenter + dropEffect tell the browser this zone accepts a move (avoids ⊘ cursor) + presetsList.addEventListener('dragenter', (e) => { + if (getPresetUiMode() !== 'edit') return; + e.preventDefault(); + }); + presetsList.addEventListener('dragover', (e) => { + if (getPresetUiMode() !== 'edit') return; + e.preventDefault(); + try { + e.dataTransfer.dropEffect = 'move'; + } catch (_) {} + const dragging = presetsList.querySelector('.dragging'); + if (!dragging) return; + + const dropTarget = getDropTarget(presetsList, e.clientX, e.clientY); + // Keep dragover side-effect free; commit placement only on drop. + if (!dropTarget || dropTarget === dragging) { + delete presetsList.dataset.dropTargetId; + return; + } + presetsList.dataset.dropTargetId = dropTarget.dataset.presetId || ''; + }); + + presetsList.addEventListener('drop', async (e) => { + if (getPresetUiMode() !== 'edit') return; + e.preventDefault(); + const dragging = presetsList.querySelector('.dragging'); + if (!dragging) return; + const targetId = presetsList.dataset.dropTargetId; + if (targetId) { + const dropTarget = presetsList.querySelector(`.draggable-preset[data-preset-id="${targetId}"]:not(.dragging)`); + if (dropTarget) { + insertDraggingOntoTarget(presetsList, dragging, dropTarget); + } + } + delete presetsList.dataset.dropTargetId; + + const saveId = presetsList.dataset.reorderTabId; + const presetElements = [...presetsList.querySelectorAll('.draggable-preset')]; + const presetIds = presetElements.map((el) => el.dataset.presetId); + + const newGrid = arrayToGrid(presetIds, 3); + + try { + if (!saveId) { + console.warn('No tab id for preset reorder save'); + return; + } + await savePresetGrid(saveId, newGrid); + await renderTabPresets(saveId); + } catch (error) { + console.error('Failed to save preset grid:', error); + alert('Failed to save preset order. Please try again.'); + const fallbackId = presetsList.dataset.reorderTabId; + if (fallbackId) await renderTabPresets(fallbackId); + } + }); + } // Get the currently selected preset for this tab const selectedPresetId = selectedPresets[tabId]; @@ -1543,7 +1686,11 @@ const renderTabPresets = async (tabId) => { const preset = allPresets[presetId]; if (preset) { const isSelected = presetId === selectedPresetId; - const wrapper = createPresetButton(presetId, preset, tabId, isSelected); + const displayPreset = { + ...preset, + colors: resolveColorsWithPaletteRefs(preset.colors, preset.palette_refs, paletteColors), + }; + const wrapper = createPresetButton(presetId, displayPreset, tabId, isSelected); presetsList.appendChild(wrapper); } }); @@ -1555,15 +1702,22 @@ const renderTabPresets = async (tabId) => { }; const createPresetButton = (presetId, preset, tabId, isSelected = false) => { + const uiMode = getPresetUiMode(); + + const row = document.createElement('div'); + const canDrag = uiMode === 'edit'; + row.className = `preset-tile-row preset-tile-row--${uiMode}${canDrag ? ' draggable-preset' : ''}`; + row.draggable = canDrag; + row.dataset.presetId = presetId; + const button = document.createElement('button'); - button.className = 'pattern-button draggable-preset'; - button.draggable = true; - button.dataset.presetId = presetId; + button.type = 'button'; + button.className = 'pattern-button preset-tile-main'; if (isSelected) { button.classList.add('active'); } - const colors = Array.isArray(preset.colors) ? preset.colors.filter(c => c) : []; + 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'] @@ -1584,38 +1738,76 @@ const createPresetButton = (presetId, preset, tabId, isSelected = false) => { presetNameLabel.className = 'pattern-button-label'; button.appendChild(presetNameLabel); - button.addEventListener('click', (e) => { + button.addEventListener('click', () => { if (isDraggingPreset) return; - const presetsList = document.getElementById('presets-list-tab'); - if (presetsList) { - presetsList.querySelectorAll('.pattern-button').forEach(btn => btn.classList.remove('active')); + const presetsListEl = document.getElementById('presets-list-tab'); + if (presetsListEl) { + presetsListEl.querySelectorAll('.pattern-button').forEach((btn) => btn.classList.remove('active')); } button.classList.add('active'); selectedPresets[tabId] = presetId; - const section = button.closest('.presets-section'); + const section = row.closest('.presets-section'); sendSelectForCurrentTabDevices(presetId, section); }); - button.addEventListener('contextmenu', async (e) => { - e.preventDefault(); - if (isDraggingPreset) return; - await editPresetFromTab(presetId, tabId, preset); - }); + if (canDrag) { + row.addEventListener('dragstart', (e) => { + isDraggingPreset = true; + row.classList.add('dragging'); + e.dataTransfer.effectAllowed = 'move'; + e.dataTransfer.setData('text/plain', presetId); + }); - button.addEventListener('dragstart', (e) => { - isDraggingPreset = true; - button.classList.add('dragging'); - e.dataTransfer.effectAllowed = 'move'; - e.dataTransfer.setData('text/plain', presetId); - }); + row.addEventListener('dragend', () => { + row.classList.remove('dragging'); + const presetsListEl = document.getElementById('presets-list-tab'); + if (presetsListEl) { + delete presetsListEl.dataset.dropTargetId; + } + document.querySelectorAll('.draggable-preset').forEach((el) => el.classList.remove('drag-over')); + 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); - }); + row.appendChild(button); - return button; + if (uiMode === 'edit') { + const actions = document.createElement('div'); + actions.className = 'preset-tile-actions'; + + const editBtn = document.createElement('button'); + editBtn.type = 'button'; + editBtn.className = 'btn btn-secondary btn-small'; + editBtn.textContent = 'Edit'; + editBtn.title = 'Edit preset'; + editBtn.addEventListener('click', (e) => { + e.preventDefault(); + e.stopPropagation(); + if (isDraggingPreset) return; + editPresetFromTab(presetId, tabId, preset); + }); + + const removeBtn = document.createElement('button'); + removeBtn.type = 'button'; + removeBtn.className = 'btn btn-danger btn-small'; + removeBtn.textContent = 'Remove'; + removeBtn.title = 'Remove from this tab'; + removeBtn.addEventListener('click', async (e) => { + e.preventDefault(); + e.stopPropagation(); + if (isDraggingPreset) return; + if (!window.confirm('Remove this preset from this tab?')) return; + await removePresetFromTab(tabId, presetId); + }); + + actions.appendChild(editBtn); + actions.appendChild(removeBtn); + row.appendChild(actions); + } + + return row; }; const editPresetFromTab = async (presetId, tabId, existingPreset) => { @@ -1731,3 +1923,20 @@ document.body.addEventListener('htmx:afterSwap', (event) => { } } }); + +document.addEventListener('DOMContentLoaded', () => { + updateUiModeToggleButtons(); + document.querySelectorAll('.ui-mode-toggle').forEach((btn) => { + btn.addEventListener('click', () => { + const next = getPresetUiMode() === 'edit' ? 'run' : 'edit'; + setPresetUiMode(next); + updateUiModeToggleButtons(); + const mainMenu = document.getElementById('main-menu-dropdown'); + if (mainMenu) mainMenu.classList.remove('open'); + const leftPanel = document.querySelector('.presets-section[data-tab-id]'); + if (leftPanel) { + renderTabPresets(leftPanel.dataset.tabId); + } + }); + }); +}); diff --git a/src/static/style.css b/src/static/style.css index 126cba2..10a69c4 100644 --- a/src/static/style.css +++ b/src/static/style.css @@ -77,6 +77,11 @@ header h1 { background-color: #333; } +/* Header/menu actions that should only appear in Edit mode */ +body.preset-ui-run .edit-mode-only { + display: none !important; +} + .btn { padding: 0.45rem 0.9rem; border: none; @@ -596,6 +601,52 @@ header h1 { position: relative; } +/* Preset tile: main button + optional edit/remove (Edit mode) */ +.preset-tile-row { + display: flex; + flex-direction: row; + align-items: stretch; + min-width: 0; + min-height: 0; +} + +.preset-tile-row--run .preset-tile-actions { + display: none; +} + +.preset-tile-main { + flex: 1; + min-width: 0; + height: 5rem; +} + +.preset-tile-actions { + display: flex; + flex-direction: column; + gap: 0.2rem; + justify-content: center; + flex-shrink: 0; + padding: 0.15rem 0 0.15rem 0.25rem; +} + +.preset-tile-actions .btn { + flex: 1 1 0; + min-height: 0; + padding: 0.15rem 0.35rem; + font-size: 0.68rem; + line-height: 1.15; + white-space: nowrap; +} + +.ui-mode-toggle--edit { + background-color: #4a3f8f; + border: 1px solid #7b6fd6; +} + +.ui-mode-toggle--edit:hover { + background-color: #5a4f9f; +} + /* Preset select buttons inside the tab grid */ #presets-list-tab .pattern-button { display: flex; diff --git a/src/templates/index.html b/src/templates/index.html index 2c5a1b2..015f4d8 100644 --- a/src/templates/index.html +++ b/src/templates/index.html @@ -15,24 +15,26 @@select message to all devices in the tab.