diff --git a/db/pattern.json b/db/pattern.json index 46a57af..73bdd08 100644 --- a/db/pattern.json +++ b/db/pattern.json @@ -1,46 +1,54 @@ { "on": { "min_delay": 10, - "max_delay": 10000 + "max_delay": 10000, + "max_colors": 1 }, - "off": { - "min_delay": 10, - "max_delay": 10000 - }, - "rainbow": { - "Step Rate": "n1", - "min_delay": 10, - "max_delay": 10000 - }, - "transition": { - "min_delay": 10, - "max_delay": 10000 - }, - "chase": { - "Colour 1 Length": "n1", - "Colour 2 Length": "n2", - "Step 1": "n3", - "Step 2": "n4", - "min_delay": 10, - "max_delay": 10000 - }, - "pulse": { - "Attack": "n1", - "Hold": "n2", - "Decay": "n3", - "min_delay": 10, - "max_delay": 10000 - }, - "circle": { - "Head Rate": "n1", - "Max Length": "n2", - "Tail Rate": "n3", - "Min Length": "n4", - "min_delay": 10, - "max_delay": 10000 - }, - "blink": { - "min_delay": 10, - "max_delay": 10000 - } - } \ No newline at end of file + "off": { + "min_delay": 10, + "max_delay": 10000, + "max_colors": 0 + }, + "rainbow": { + "n1": "Step Rate", + "min_delay": 10, + "max_delay": 10000, + "max_colors": 0 + }, + "transition": { + "min_delay": 10, + "max_delay": 10000, + "max_colors": 10 + }, + "chase": { + "n1": "Colour 1 Length", + "n2": "Colour 2 Length", + "n3": "Step 1", + "n4": "Step 2", + "min_delay": 10, + "max_delay": 10000, + "max_colors": 2 + }, + "pulse": { + "n1": "Attack", + "n2": "Hold", + "n3": "Decay", + "min_delay": 10, + "max_delay": 10000, + "max_colors": 10 + }, + "circle": { + "n1": "Head Rate", + "n2": "Max Length", + "n3": "Tail Rate", + "n4": "Min Length", + "min_delay": 10, + "max_delay": 10000, + "max_colors": 2 + }, + "blink": { + "min_delay": 10, + "max_delay": 10000, + "max_colors": 10 + } +} \ No newline at end of file diff --git a/src/controllers/tab.py b/src/controllers/tab.py index 1a52d85..ea0f741 100644 --- a/src/controllers/tab.py +++ b/src/controllers/tab.py @@ -163,88 +163,17 @@ async def tab_content_fragment(request, session, id): return send_file('templates/index.html') tab_name = tab.get('name', 'Tab ' + str(id)) - device_ids = ', '.join(tab.get('names', [])) html = ( - '
No presets available. Create a preset first.
'; + } else { + presetNames.forEach(presetId => { + const preset = allPresets[presetId]; + const isAlreadyAdded = currentTabPresets.includes(presetId); + + const row = document.createElement('div'); + row.className = 'profiles-row'; + + const label = document.createElement('span'); + label.textContent = preset.name || presetId; + + const details = document.createElement('span'); + details.style.color = '#aaa'; + details.style.fontSize = '0.85em'; + details.textContent = preset.pattern || '-'; + + const actionButton = document.createElement('button'); + if (isAlreadyAdded) { + 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(presetId, tabId); + modal.remove(); + } + }); + } else { + actionButton.className = 'btn btn-primary btn-small'; + actionButton.textContent = 'Add'; + actionButton.addEventListener('click', async () => { + await addPresetToTab(presetId, tabId); + modal.remove(); + }); + } + + row.appendChild(label); + row.appendChild(details); + row.appendChild(actionButton); + listContainer.appendChild(row); + }); + } + + // Close button handler + document.getElementById('add-preset-to-tab-close-btn').addEventListener('click', () => { + modal.remove(); + }); + + // Close on outside click + modal.addEventListener('click', (e) => { + if (e.target === modal) { + modal.remove(); + } + }); + } catch (error) { + console.error('Failed to show add preset modal:', error); + alert('Failed to load presets.'); + } + }; + + const addPresetToTab = async (presetId, tabId) => { + if (!tabId) { + // Try to get tab ID from the left-panel + const leftPanel = document.querySelector('.presets-section[data-tab-id]'); + tabId = leftPanel ? leftPanel.dataset.tabId : null; + + if (!tabId) { + // Fallback: try to get from URL + const pathParts = window.location.pathname.split('/'); + const tabIndex = pathParts.indexOf('tabs'); + if (tabIndex !== -1 && tabIndex + 1 < pathParts.length) { + tabId = pathParts[tabIndex + 1]; + } + } + } + + if (!tabId) { + alert('Could not determine current tab.'); + return; + } + + try { + // Get current tab data + const tabResponse = await fetch(`/tabs/${tabId}`, { + headers: { Accept: 'application/json' }, + }); + if (!tabResponse.ok) { + throw new Error('Failed to load tab'); + } + const tabData = await tabResponse.json(); + + // Add preset to tab's presets array if not already present + const presets = tabData.presets || []; + if (!presets.includes(presetId)) { + presets.push(presetId); + + // 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'); + } + + // Reload the tab content to show the new preset + 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 already added to this tab.'); + } + } catch (error) { + console.error('Failed to add preset to tab:', error); + alert('Failed to add preset to tab.'); + } + }; if (presetEditorCloseButton) { presetEditorCloseButton.addEventListener('click', closeEditor); } @@ -297,8 +826,35 @@ document.addEventListener('DOMContentLoaded', () => { if (presetPatternInput) { presetPatternInput.addEventListener('change', () => { updatePresetNLabels(presetPatternInput.value); + // Update color section visibility + updateColorSectionVisibility(); + // Re-render colors to show updated max colors limit + renderPresetColors(currentPresetColors); }); } + // Add Color button handler + if (presetAddColorButton && presetNewColorInput) { + presetAddColorButton.addEventListener('click', () => { + const color = presetNewColorInput.value; + if (!color) return; + + if (currentPresetColors.includes(color)) { + 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' : ''}.`); + return; + } + + currentPresetColors.push(color); + renderPresetColors(currentPresetColors); + }); + } + + // Add from Palette button handler if (presetAddFromPaletteButton) { presetAddFromPaletteButton.addEventListener('click', () => { const openButton = document.getElementById('color-palette-btn'); @@ -310,7 +866,7 @@ document.addEventListener('DOMContentLoaded', () => { if (modal) { modal.classList.add('active'); } - if (!modalList || !presetColorsInput) { + if (!modalList) { return; } @@ -323,11 +879,24 @@ document.addEventListener('DOMContentLoaded', () => { if (!picked) { return; } - const currentColors = parseColors(presetColorsInput.value); - if (!currentColors.includes(picked)) { - currentColors.push(picked); - presetColorsInput.value = currentColors.join(','); + + 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'); + } + modalList.removeEventListener('click', handlePick); + return; + } + + currentPresetColors.push(picked); + renderPresetColors(currentPresetColors); if (modal) { modal.classList.remove('active'); } @@ -357,12 +926,30 @@ document.addEventListener('DOMContentLoaded', () => { await loadPresets(); clearForm(); closeEditor(); + + // Reload tab presets if we're in a tab view + const leftPanel = document.querySelector('.presets-section[data-tab-id]'); + if (leftPanel) { + const tabId = leftPanel.dataset.tabId; + if (tabId && typeof renderTabPresets !== 'undefined') { + renderTabPresets(tabId); + } + } } catch (error) { console.error('Save preset failed:', error); alert('Failed to save preset.'); } }); + // Listen for edit preset events from tab preset buttons + document.addEventListener('editPreset', async (event) => { + const { presetId, preset } = event.detail; + currentEditId = presetId; + await loadPatterns(); + setFormValues(preset); + openEditor(); + }); + presetsModal.addEventListener('click', (event) => { if (event.target === presetsModal) { closeModal(); @@ -379,3 +966,418 @@ document.addEventListener('DOMContentLoaded', () => { clearForm(); }); + +// Store selected preset per tab +const selectedPresets = {}; +// Track if we're currently dragging a preset +let isDraggingPreset = false; + +// Function to convert 2D grid to flat array (for backward compatibility) +const gridToArray = (presetsGrid) => { + if (!presetsGrid || !Array.isArray(presetsGrid)) { + return []; + } + // If it's already a flat array (old format), return it + if (presetsGrid.length > 0 && typeof presetsGrid[0] === 'string') { + return presetsGrid; + } + // If it's a 2D grid, flatten it + if (Array.isArray(presetsGrid[0])) { + return presetsGrid.flat(); + } + // If it's an array of objects with positions, convert to grid then flatten + if (presetsGrid.length > 0 && typeof presetsGrid[0] === 'object' && presetsGrid[0].id) { + // Find max row and col + let maxRow = 0, maxCol = 0; + presetsGrid.forEach(p => { + if (p.row > maxRow) maxRow = p.row; + if (p.col > maxCol) maxCol = p.col; + }); + // Create grid + const grid = Array(maxRow + 1).fill(null).map(() => Array(maxCol + 1).fill(null)); + presetsGrid.forEach(p => { + grid[p.row][p.col] = p.id; + }); + return grid.flat().filter(id => id !== null); + } + return []; +}; + +// Function to convert flat array to 2D grid +const arrayToGrid = (presetIds, columns = 3) => { + if (!presetIds || !Array.isArray(presetIds)) { + return []; + } + const grid = []; + for (let i = 0; i < presetIds.length; i += columns) { + grid.push(presetIds.slice(i, i + columns)); + } + return grid; +}; + +// Function to save preset grid for a tab +const savePresetGrid = async (tabId, presetGrid) => { + try { + // Get current tab data + const tabResponse = await fetch(`/tabs/${tabId}`, { + headers: { Accept: 'application/json' }, + }); + if (!tabResponse.ok) { + throw new Error('Failed to load tab'); + } + const tabData = await tabResponse.json(); + + // Store as 2D grid + tabData.presets = presetGrid; + // Also store as flat array for backward compatibility + tabData.presets_flat = presetGrid.flat(); + + // Save updated tab + 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 save preset grid'); + } + } catch (error) { + console.error('Failed to save preset grid:', error); + throw error; + } +}; + +// Function to get drop target in 2D grid +const getDropTarget = (container, x, y) => { + const draggableElements = [...container.querySelectorAll('.draggable-preset:not(.dragging)')]; + + return draggableElements.reduce((closest, 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; +}; + +// Function to render presets for a specific tab in 2D grid +const renderTabPresets = async (tabId) => { + const presetsList = document.getElementById('presets-list-tab'); + if (!presetsList) return; + + try { + // Get tab data to see which presets are associated + const tabResponse = await fetch(`/tabs/${tabId}`, { + headers: { Accept: 'application/json' }, + }); + if (!tabResponse.ok) { + throw new Error('Failed to load tab'); + } + const tabData = await tabResponse.json(); + + // Get presets - support both 2D grid and flat array (for backward compatibility) + let presetGrid = tabData.presets; + if (!presetGrid || !Array.isArray(presetGrid)) { + // Try to get from flat array or convert old format + const flatArray = tabData.presets_flat || tabData.presets || []; + presetGrid = arrayToGrid(flatArray, 3); // Default to 3 columns + } else if (presetGrid.length > 0 && typeof presetGrid[0] === 'string') { + // It's a flat array, convert to grid + presetGrid = arrayToGrid(presetGrid, 3); + } + + // Get all presets + const presetsResponse = await fetch('/presets', { + headers: { Accept: 'application/json' }, + }); + if (!presetsResponse.ok) { + throw new Error('Failed to load presets'); + } + const allPresets = await presetsResponse.json(); + + 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 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); + } + } + }); + + 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); + } + }); + + // Get the currently selected preset for this tab + const selectedPresetId = selectedPresets[tabId]; + + // Render presets in grid layout + // Flatten the grid and render all presets (grid CSS will handle layout) + const flatPresets = presetGrid.flat().filter(id => id); + + if (flatPresets.length === 0) { + // Show empty message if this tab has no presets + 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.'; + presetsList.appendChild(empty); + } else { + flatPresets.forEach((presetId) => { + const preset = allPresets[presetId]; + if (preset) { + const isSelected = presetId === selectedPresetId; + const wrapper = createPresetButton(presetId, preset, tabId, isSelected); + presetsList.appendChild(wrapper); + } + }); + } + } catch (error) { + console.error('Failed to render tab presets:', error); + presetsList.innerHTML = 'Failed to load presets.
'; + } +}; + +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'; + 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 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); + + 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) { + 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; + + // Apply preset to tab - you may want to implement this + console.log('Apply preset', presetId, 'to tab', tabId); + }); + + // 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); + }); + + wrapper.appendChild(button); + wrapper.appendChild(editButton); + + // Add drag event handlers + wrapper.addEventListener('dragstart', (e) => { + isDraggingPreset = true; + wrapper.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); + }); + + return wrapper; +}; + +const editPresetFromTab = async (presetId) => { + 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'); + } + 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 } + }); + document.dispatchEvent(editEvent); + } catch (error) { + console.error('Failed to load preset for editing:', error); + alert('Failed to load preset for editing.'); + } +}; + +const removePresetFromTab = async (presetId, tabId) => { + if (!tabId) { + // Try to get tab ID from the left-panel + const leftPanel = document.querySelector('.presets-section[data-tab-id]'); + tabId = leftPanel ? leftPanel.dataset.tabId : null; + + if (!tabId) { + // Fallback: try to get from URL + const pathParts = window.location.pathname.split('/'); + const tabIndex = pathParts.indexOf('tabs'); + if (tabIndex !== -1 && tabIndex + 1 < pathParts.length) { + tabId = pathParts[tabIndex + 1]; + } + } + } + + if (!tabId) { + alert('Could not determine current tab.'); + return; + } + + try { + // Get current tab data + const tabResponse = await fetch(`/tabs/${tabId}`, { + headers: { Accept: 'application/json' }, + }); + if (!tabResponse.ok) { + throw new Error('Failed to load tab'); + } + 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'); + } + + // 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.'); + } + } catch (error) { + console.error('Failed to remove preset from tab:', error); + alert('Failed to remove preset from tab.'); + } +}; + +// Listen for HTMX swaps to render presets +document.body.addEventListener('htmx:afterSwap', (event) => { + if (event.target && event.target.id === 'tab-content') { + // Get tab ID from the left-panel + const leftPanel = document.querySelector('.presets-section[data-tab-id]'); + if (leftPanel) { + const tabId = leftPanel.dataset.tabId; + if (tabId) { + renderTabPresets(tabId); + } + } + } +}); diff --git a/src/static/style.css b/src/static/style.css index 9822e4a..a9652c2 100644 --- a/src/static/style.css +++ b/src/static/style.css @@ -364,7 +364,7 @@ header h1 { .presets-list { display: grid; - grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + grid-template-columns: repeat(3, 1fr); gap: 0.75rem; } diff --git a/src/templates/index.html b/src/templates/index.html index 82fd08e..ea67a95 100644 --- a/src/templates/index.html +++ b/src/templates/index.html @@ -129,10 +129,12 @@