From 97ffc69b122963320ac790c1460e363060133238 Mon Sep 17 00:00:00 2001 From: jimmy Date: Sat, 17 Jan 2026 00:58:50 +1300 Subject: [PATCH] Add drag-and-drop for presets and colors, max_colors validation, and 2D grid layout - Add drag-and-drop to reorder presets in tabs (2D grid layout) - Add drag-and-drop to reorder colors within presets - Add max_colors field to pattern definitions - Hide color section when max_colors is 0 - Validate color count against pattern max_colors limit - Store presets in 2D grid format (3 columns) - Remove left panel from tab content, show only presets - Update color palette to show swatches instead of hex codes - Improve preset editor UI with visual color swatches --- db/pattern.json | 92 +-- src/controllers/tab.py | 79 +-- src/static/color_palette.js | 28 +- src/static/presets.js | 1086 +++++++++++++++++++++++++++++++++-- src/static/style.css | 2 +- src/templates/index.html | 70 ++- 6 files changed, 1183 insertions(+), 174 deletions(-) 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 = ( - '
' - '
' - '
' - '' - '' + device_ids + '' - '
' - '' - '
' - '
' - '
' - '

Color Palette

' - '
' - '' - '
' - '
' - '' - '' - '' - '
' - '
' - '
' - '
' - '' - '' - '127' - '
' - '
' - '' - '' - '100 ms' - '
' - '
' - '
' - '

N Parameters

' - '
' - '
' - '' - '' - '
' - '
' - '' - '' - '
' - '
' - '' - '' - '
' - '
' - '' - '' - '
' - '
' - '' - '' - '
' - '
' - '' - '' - '
' - '
' - '' - '' - '
' - '
' - '' - '' - '
' - '
' - '
' - '
' - '
' - '
' - '
' + '
' '

Presets

' + '
' + '' + '
' '
' '' '
' '
' - '
' ) return html, 200, {'Content-Type': 'text/html'} diff --git a/src/static/color_palette.js b/src/static/color_palette.js index 697d9b0..fe4bc8c 100644 --- a/src/static/color_palette.js +++ b/src/static/color_palette.js @@ -28,27 +28,35 @@ document.addEventListener('DOMContentLoaded', () => { const row = document.createElement('div'); row.className = 'profiles-row'; row.dataset.color = color; + row.style.cssText = 'display: flex; align-items: center; gap: 1rem;'; + // Ensure no text content + row.textContent = ''; const swatch = document.createElement('div'); - swatch.style.width = '28px'; - swatch.style.height = '28px'; - swatch.style.borderRadius = '4px'; - swatch.style.backgroundColor = color; - swatch.style.border = '1px solid #4a4a4a'; - - const label = document.createElement('span'); - label.textContent = color; + swatch.style.cssText = ` + width: 64px; + height: 64px; + border-radius: 8px; + background-color: ${color}; + border: 2px solid #4a4a4a; + cursor: pointer; + flex-shrink: 0; + box-shadow: 0 2px 4px rgba(0,0,0,0.3); + `; + swatch.title = color; // Show hex code on hover only + swatch.setAttribute('aria-label', `Color ${color}`); const removeButton = document.createElement('button'); removeButton.className = 'btn btn-danger btn-small'; removeButton.textContent = 'Remove'; - removeButton.addEventListener('click', async () => { + removeButton.style.fontSize = '0.8rem'; // Restore font size for button + removeButton.addEventListener('click', async (e) => { + e.stopPropagation(); const updated = currentPalette.filter((_, i) => i !== index); await savePalette(updated); }); row.appendChild(swatch); - row.appendChild(label); row.appendChild(removeButton); paletteContainer.appendChild(row); }); diff --git a/src/static/presets.js b/src/static/presets.js index 0219f17..743a9ce 100644 --- a/src/static/presets.js +++ b/src/static/presets.js @@ -8,7 +8,9 @@ document.addEventListener('DOMContentLoaded', () => { const presetEditorCloseButton = document.getElementById('preset-editor-close-btn'); const presetNameInput = document.getElementById('preset-name-input'); const presetPatternInput = document.getElementById('preset-pattern-input'); - const presetColorsInput = document.getElementById('preset-colors-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 presetSaveButton = document.getElementById('preset-save-btn'); @@ -22,6 +24,47 @@ document.addEventListener('DOMContentLoaded', () => { let currentEditId = null; let cachedPresets = {}; let cachedPatterns = {}; + let currentPresetColors = []; // Track colors for the current preset + + // Function to get max colors for current pattern + const getMaxColors = () => { + if (!presetPatternInput || !presetPatternInput.value) { + return Infinity; // No pattern selected, no limit + } + const patternName = presetPatternInput.value.trim(); + const patternConfig = cachedPatterns && cachedPatterns[patternName]; + if (patternConfig && typeof patternConfig === 'object' && patternConfig.max_colors !== undefined) { + return patternConfig.max_colors; + } + return Infinity; // No limit if not specified + }; + + // Function to show/hide color section based on max_colors + const updateColorSectionVisibility = () => { + const maxColors = getMaxColors(); + const shouldShow = maxColors > 0; + + // Find the color label (the label before the container) + if (presetColorsContainer) { + let prev = presetColorsContainer.previousElementSibling; + while (prev) { + if (prev.tagName === 'LABEL' && prev.textContent.trim().toLowerCase().includes('color')) { + prev.style.display = shouldShow ? '' : 'none'; + break; + } + prev = prev.previousElementSibling; + } + + // Hide/show the container + presetColorsContainer.style.display = shouldShow ? '' : 'none'; + + // 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'))) { + colorActions.style.display = shouldShow ? '' : 'none'; + } + } + }; const getNumberInput = (id) => { const input = document.getElementById(id); @@ -31,38 +74,257 @@ document.addEventListener('DOMContentLoaded', () => { return parseInt(input.value, 10) || 0; }; - const parseColors = (value) => { - if (!value) { - return []; + const renderPresetColors = (colors) => { + if (!presetColorsContainer) return; + + presetColorsContainer.innerHTML = ''; + currentPresetColors = colors || []; + + // Get max colors for current pattern + const maxColors = getMaxColors(); + const maxColorsText = maxColors !== Infinity ? ` (max ${maxColors})` : ''; + + 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}`; + presetColorsContainer.appendChild(empty); + return; } - return value - .split(',') - .map((color) => color.trim()) - .filter((color) => color.length > 0) - .map((color) => (color.startsWith('#') ? color : `#${color}`)); + + // Show max colors info if limit exists and reached + if (maxColors !== Infinity && currentPresetColors.length >= maxColors) { + const info = document.createElement('p'); + info.className = 'muted-text'; + info.style.cssText = 'font-size: 0.85em; margin-bottom: 0.5rem; color: #ffa500;'; + info.textContent = `Maximum ${maxColors} color${maxColors !== 1 ? 's' : ''} reached for this pattern.`; + presetColorsContainer.appendChild(info); + } + + const swatchContainer = document.createElement('div'); + swatchContainer.style.cssText = 'display: flex; flex-wrap: wrap; gap: 0.5rem;'; + swatchContainer.classList.add('color-swatches-container'); + + currentPresetColors.forEach((color, index) => { + const swatchWrapper = document.createElement('div'); + swatchWrapper.style.cssText = 'position: relative; display: inline-block;'; + swatchWrapper.draggable = true; + swatchWrapper.dataset.colorIndex = index; + swatchWrapper.classList.add('draggable-color-swatch'); + + const swatch = document.createElement('div'); + swatch.style.cssText = ` + width: 64px; + height: 64px; + border-radius: 8px; + background-color: ${color}; + border: 2px solid #4a4a4a; + cursor: move; + box-shadow: 0 2px 4px rgba(0,0,0,0.3); + transition: opacity 0.2s, transform 0.2s; + `; + swatch.title = `${color} - Drag to reorder`; + + // Color picker overlay + const colorPicker = document.createElement('input'); + colorPicker.type = 'color'; + colorPicker.value = color; + colorPicker.style.cssText = ` + position: absolute; + top: 0; + left: 0; + width: 64px; + height: 64px; + opacity: 0; + cursor: pointer; + z-index: 5; + `; + colorPicker.addEventListener('change', (e) => { + currentPresetColors[index] = e.target.value; + renderPresetColors(currentPresetColors); + }); + // Prevent color picker from interfering with drag + colorPicker.addEventListener('mousedown', (e) => { + e.stopPropagation(); + }); + + // Remove button + const removeBtn = document.createElement('button'); + removeBtn.textContent = '×'; + removeBtn.style.cssText = ` + position: absolute; + top: -8px; + right: -8px; + width: 24px; + height: 24px; + border-radius: 50%; + background-color: #ff4444; + color: white; + border: none; + cursor: pointer; + font-size: 18px; + line-height: 1; + display: flex; + align-items: center; + justify-content: center; + z-index: 10; + padding: 0; + `; + removeBtn.addEventListener('click', (e) => { + e.stopPropagation(); + currentPresetColors.splice(index, 1); + renderPresetColors(currentPresetColors); + }); + // Prevent remove button from interfering with drag + removeBtn.addEventListener('mousedown', (e) => { + e.stopPropagation(); + }); + + // Drag event handlers for reordering + swatchWrapper.addEventListener('dragstart', (e) => { + swatchWrapper.classList.add('dragging-color'); + e.dataTransfer.effectAllowed = 'move'; + e.dataTransfer.setData('text/plain', index.toString()); + swatch.style.opacity = '0.5'; + }); + + swatchWrapper.addEventListener('dragend', (e) => { + swatchWrapper.classList.remove('dragging-color'); + swatch.style.opacity = '1'; + // Remove drag-over classes from siblings + document.querySelectorAll('.draggable-color-swatch').forEach(el => { + el.classList.remove('drag-over-color'); + }); + }); + + swatchWrapper.appendChild(swatch); + swatchWrapper.appendChild(colorPicker); + swatchWrapper.appendChild(removeBtn); + swatchContainer.appendChild(swatchWrapper); + }); + + // Add drag and drop handlers to the container + swatchContainer.addEventListener('dragover', (e) => { + e.preventDefault(); + const dragging = swatchContainer.querySelector('.dragging-color'); + if (!dragging) return; + + const afterElement = getDragAfterElementForColors(swatchContainer, e.clientX); + if (afterElement == null) { + swatchContainer.appendChild(dragging); + } else { + swatchContainer.insertBefore(dragging, afterElement); + } + }); + + swatchContainer.addEventListener('drop', (e) => { + e.preventDefault(); + const dragging = swatchContainer.querySelector('.dragging-color'); + if (!dragging) return; + + // Get new order of colors from DOM + const colorElements = [...swatchContainer.querySelectorAll('.draggable-color-swatch')]; + const newColorOrder = colorElements.map(el => { + const colorPicker = el.querySelector('input[type="color"]'); + return colorPicker ? colorPicker.value : null; + }).filter(color => color !== null); + + // Update current colors array + currentPresetColors = newColorOrder; + + // Re-render to update indices + renderPresetColors(currentPresetColors); + }); + + presetColorsContainer.appendChild(swatchContainer); + }; + + // Function to get drag after element for colors (horizontal layout) + const getDragAfterElementForColors = (container, x) => { + const draggableElements = [...container.querySelectorAll('.draggable-color-swatch:not(.dragging-color)')]; + + return draggableElements.reduce((closest, child) => { + const box = child.getBoundingClientRect(); + const offset = x - box.left - box.width / 2; + + if (offset < 0 && offset > closest.offset) { + return { offset: offset, element: child }; + } else { + return closest; + } + }, { offset: Number.NEGATIVE_INFINITY }).element; }; const setFormValues = (preset) => { - if (!presetNameInput || !presetPatternInput || !presetColorsInput || !presetBrightnessInput || !presetDelayInput) { + if (!presetNameInput || !presetPatternInput || !presetBrightnessInput || !presetDelayInput) { return; } presetNameInput.value = preset.name || ''; - presetPatternInput.value = preset.pattern || ''; - presetColorsInput.value = Array.isArray(preset.colors) ? preset.colors.join(',') : ''; + const patternName = preset.pattern || ''; + presetPatternInput.value = patternName; + const colors = Array.isArray(preset.colors) ? preset.colors : []; + renderPresetColors(colors); presetBrightnessInput.value = preset.brightness || 0; presetDelayInput.value = preset.delay || 0; - document.getElementById('preset-n1-input').value = preset.n1 || 0; - document.getElementById('preset-n2-input').value = preset.n2 || 0; - document.getElementById('preset-n3-input').value = preset.n3 || 0; - document.getElementById('preset-n4-input').value = preset.n4 || 0; - document.getElementById('preset-n5-input').value = preset.n5 || 0; - document.getElementById('preset-n6-input').value = preset.n6 || 0; - document.getElementById('preset-n7-input').value = preset.n7 || 0; - document.getElementById('preset-n8-input').value = preset.n8 || 0; + + // Update color section visibility based on pattern + updateColorSectionVisibility(); + + // Make name and pattern read-only when editing (not when creating new) + const isEditing = currentEditId !== null; + presetNameInput.disabled = isEditing; + presetPatternInput.disabled = isEditing; + if (isEditing) { + presetNameInput.style.backgroundColor = '#2a2a2a'; + presetNameInput.style.cursor = 'not-allowed'; + presetPatternInput.style.backgroundColor = '#2a2a2a'; + presetPatternInput.style.cursor = 'not-allowed'; + } else { + presetNameInput.style.backgroundColor = ''; + presetNameInput.style.cursor = ''; + presetPatternInput.style.backgroundColor = ''; + presetPatternInput.style.cursor = ''; + } + + // Update labels and visibility based on pattern + updatePresetNLabels(patternName); + + // Get pattern config to map descriptive names back to n keys + const patternConfig = cachedPatterns && cachedPatterns[patternName]; + const nToLabel = {}; + if (patternConfig && typeof patternConfig === 'object') { + // Now n keys are keys, labels are values + Object.entries(patternConfig).forEach(([nKey, label]) => { + if (typeof nKey === 'string' && nKey.startsWith('n') && typeof label === 'string') { + nToLabel[nKey] = label; + } + }); + } + + // Set n values, checking both n keys and descriptive names + for (let i = 1; i <= 8; i++) { + const nKey = `n${i}`; + const inputEl = document.getElementById(`preset-${nKey}-input`); + if (inputEl) { + // First check if preset has n key directly + if (preset[nKey] !== undefined) { + inputEl.value = preset[nKey] || 0; + } else { + // Check if preset has descriptive name (from pattern.json mapping) + const label = nToLabel[nKey]; + if (label && preset[label] !== undefined) { + inputEl.value = preset[label] || 0; + } else { + inputEl.value = 0; + } + } + } + } }; const clearForm = () => { currentEditId = null; + currentPresetColors = []; setFormValues({ name: '', pattern: '', @@ -78,6 +340,17 @@ document.addEventListener('DOMContentLoaded', () => { n7: 0, n8: 0, }); + // Re-enable name and pattern when clearing (for new preset) + if (presetNameInput) { + presetNameInput.disabled = false; + presetNameInput.style.backgroundColor = ''; + presetNameInput.style.cursor = ''; + } + if (presetPatternInput) { + presetPatternInput.disabled = false; + presetPatternInput.style.backgroundColor = ''; + presetPatternInput.style.cursor = ''; + } }; const openEditor = () => { @@ -86,6 +359,7 @@ document.addEventListener('DOMContentLoaded', () => { } loadPatterns().then(() => { updatePresetNLabels(presetPatternInput ? presetPatternInput.value : ''); + updateColorSectionVisibility(); }); }; @@ -96,21 +370,54 @@ document.addEventListener('DOMContentLoaded', () => { }; const buildPresetPayload = () => { - return { + const payload = { name: presetNameInput ? presetNameInput.value.trim() : '', pattern: presetPatternInput ? presetPatternInput.value.trim() : '', - colors: parseColors(presetColorsInput ? presetColorsInput.value : ''), + colors: currentPresetColors || [], brightness: presetBrightnessInput ? parseInt(presetBrightnessInput.value, 10) || 0 : 0, delay: presetDelayInput ? parseInt(presetDelayInput.value, 10) || 0 : 0, - n1: getNumberInput('preset-n1-input'), - n2: getNumberInput('preset-n2-input'), - n3: getNumberInput('preset-n3-input'), - n4: getNumberInput('preset-n4-input'), - n5: getNumberInput('preset-n5-input'), - n6: getNumberInput('preset-n6-input'), - n7: getNumberInput('preset-n7-input'), - n8: getNumberInput('preset-n8-input'), }; + + // 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'); + } + + return payload; }; const loadPatterns = async () => { @@ -118,7 +425,8 @@ document.addEventListener('DOMContentLoaded', () => { return; } try { - const response = await fetch('/patterns', { + // Load pattern definitions from pattern.json + const response = await fetch('/patterns/definitions', { headers: { Accept: 'application/json' }, }); if (!response.ok) { @@ -141,8 +449,8 @@ document.addEventListener('DOMContentLoaded', () => { let defaultPattern = entries[0]; for (const patternName of entries) { const config = cachedPatterns[patternName]; - const hasMapping = config && Object.values(config).some((value) => { - return typeof value === 'string' && value.startsWith('n'); + const hasMapping = config && Object.keys(config).some((key) => { + return typeof key === 'string' && key.startsWith('n'); }); if (hasMapping) { defaultPattern = patternName; @@ -159,21 +467,42 @@ document.addEventListener('DOMContentLoaded', () => { const updatePresetNLabels = (patternName) => { const labels = {}; + const visibleNKeys = new Set(); + + // Initialize all labels with default n1:, n2:, etc. for (let i = 1; i <= 8; i++) { labels[`n${i}`] = `n${i}:`; } + const patternConfig = cachedPatterns && cachedPatterns[patternName]; if (patternConfig && typeof patternConfig === 'object') { - Object.entries(patternConfig).forEach(([label, key]) => { - if (typeof key === 'string' && key.startsWith('n')) { + // Now n values are keys and descriptive names are values + Object.entries(patternConfig).forEach(([key, label]) => { + if (typeof key === 'string' && key.startsWith('n') && typeof label === 'string') { labels[key] = `${label}:`; + visibleNKeys.add(key); // Mark this n key as visible } }); } + + // Update labels and show/hide input groups for (let i = 1; i <= 8; i++) { - const labelEl = document.getElementById(`preset-n${i}-label`); + const nKey = `n${i}`; + const labelEl = document.getElementById(`preset-${nKey}-label`); + const inputEl = document.getElementById(`preset-${nKey}-input`); + const groupEl = labelEl ? labelEl.closest('.n-param-group') : null; + if (labelEl) { - labelEl.textContent = labels[`n${i}`]; + labelEl.textContent = labels[nKey]; + } + + // Show or hide the entire group based on whether it has a mapping + if (groupEl) { + if (visibleNKeys.has(nKey)) { + groupEl.style.display = ''; // Show + } else { + groupEl.style.display = 'none'; // Hide + } } } }; @@ -290,6 +619,206 @@ document.addEventListener('DOMContentLoaded', () => { openEditor(); }); } + + // 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 () => { + // 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('/'); + 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; + } + + // Load all presets + try { + const response = await fetch('/presets', { + headers: { Accept: 'application/json' }, + }); + if (!response.ok) { + throw new Error('Failed to load presets'); + } + const allPresets = await response.json(); + + // Get current tab's presets to exclude already added ones + let currentTabPresets = []; + if (tabId) { + try { + const tabResponse = await fetch(`/tabs/${tabId}`, { + headers: { Accept: 'application/json' }, + }); + if (tabResponse.ok) { + const tabData = await tabResponse.json(); + currentTabPresets = tabData.presets || []; + } + } catch (e) { + console.warn('Could not load current tab presets:', e); + } + } + + // Create modal + const modal = document.createElement('div'); + modal.className = 'modal active'; + modal.id = 'add-preset-to-tab-modal'; + modal.innerHTML = ` + + `; + + document.body.appendChild(modal); + + 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.

'; + } 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 @@
- -
- - + +
+
+ + +
@@ -268,6 +270,34 @@ background-color: #3a3a3a; border-radius: 4px; } + /* Hide any text content in palette rows - only show color swatches */ + #palette-container .profiles-row { + font-size: 0; /* Hide any text nodes */ + } + #palette-container .profiles-row > * { + font-size: 1rem; /* Restore font size for buttons */ + } + #palette-container .profiles-row > span:not(.btn), + #palette-container .profiles-row > label, + #palette-container .profiles-row::before, + #palette-container .profiles-row::after { + display: none !important; + content: none !important; + } + /* Preset colors container */ + #preset-colors-container { + min-height: 80px; + padding: 0.5rem; + background-color: #2a2a2a; + border-radius: 4px; + margin-bottom: 0.5rem; + } + #preset-colors-container .muted-text { + color: #888; + font-size: 0.9rem; + padding: 1rem; + text-align: center; + } .muted-text { text-align: center; color: #888; @@ -285,6 +315,38 @@ border-radius: 4px; margin-top: 0.5rem; } + /* Drag and drop styles for presets */ + .draggable-preset { + cursor: move; + transition: opacity 0.2s, transform 0.2s; + } + .draggable-preset.dragging { + opacity: 0.5; + transform: scale(0.95); + } + .draggable-preset:hover { + opacity: 0.8; + } + /* Drag and drop styles for color swatches */ + .draggable-color-swatch { + transition: opacity 0.2s, transform 0.2s; + } + .draggable-color-swatch.dragging-color { + opacity: 0.5; + transform: scale(0.9); + } + .draggable-color-swatch.drag-over-color { + transform: scale(1.1); + } + .color-swatches-container { + min-height: 80px; + } + /* Ensure presets list uses grid layout */ + #presets-list-tab { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 0.75rem; + }