diff --git a/src/static/color_palette.js b/src/static/color_palette.js index fe4bc8c..a5122ef 100644 --- a/src/static/color_palette.js +++ b/src/static/color_palette.js @@ -12,6 +12,7 @@ document.addEventListener('DOMContentLoaded', () => { } let currentProfileId = null; + let currentPaletteId = null; let currentPalette = []; let currentProfileName = null; @@ -84,7 +85,27 @@ document.addEventListener('DOMContentLoaded', () => { return; } - currentPalette = profile.palette || profile.color_palette || []; + // Prefer palette_id-based storage; fall back to legacy inline palette. + currentPaletteId = profile.palette_id || profile.paletteId || null; + if (currentPaletteId) { + try { + const palResponse = await fetch(`/palettes/${currentPaletteId}`, { + headers: { Accept: 'application/json' }, + }); + if (palResponse.ok) { + const palData = await palResponse.json(); + currentPalette = (palData.colors) || []; + } else { + currentPalette = []; + } + } catch (e) { + console.error('Failed to load palette by id:', e); + currentPalette = []; + } + } else { + // Legacy: palette stored directly on profile + currentPalette = profile.palette || profile.color_palette || []; + } renderPalette(); } catch (error) { console.error('Failed to load palette:', error); @@ -99,17 +120,42 @@ document.addEventListener('DOMContentLoaded', () => { return; } try { - const response = await fetch('/profiles/current', { - method: 'PUT', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - palette: newPalette, - color_palette: newPalette, - }), - }); - if (!response.ok) { - throw new Error('Failed to save palette'); + // Ensure we have a palette ID for this profile. + if (!currentPaletteId) { + const createResponse = await fetch('/palettes', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ colors: newPalette }), + }); + if (!createResponse.ok) { + throw new Error('Failed to create palette'); + } + const pal = await createResponse.json(); + currentPaletteId = pal.id || Object.keys(pal)[0]; + + // Link the new palette to the current profile. + const linkResponse = await fetch('/profiles/current', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + palette_id: currentPaletteId, + }), + }); + if (!linkResponse.ok) { + throw new Error('Failed to link palette to profile'); + } + } else { + // Update existing palette colors + const updateResponse = await fetch(`/palettes/${currentPaletteId}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ colors: newPalette }), + }); + if (!updateResponse.ok) { + throw new Error('Failed to save palette'); + } } + currentPalette = newPalette; renderPalette(); } catch (error) { diff --git a/src/static/presets.js b/src/static/presets.js index 16dff8b..59b5b33 100644 --- a/src/static/presets.js +++ b/src/static/presets.js @@ -53,9 +53,10 @@ const sendEspnowMessage = (obj) => { }; // Send a select message for a preset to all device names in the current tab. -const sendSelectForCurrentTabDevices = (presetName, sectionEl) => { +// Uses the preset ID as the select key. +const sendSelectForCurrentTabDevices = (presetId, sectionEl) => { const section = sectionEl || document.querySelector('.presets-section[data-tab-id]'); - if (!section || !presetName) { + if (!section || !presetId) { return; } const namesAttr = section.getAttribute('data-device-names'); @@ -69,7 +70,7 @@ const sendSelectForCurrentTabDevices = (presetName, sectionEl) => { const select = {}; deviceNames.forEach((name) => { - select[name] = [presetName]; + select[name] = [presetId]; }); const message = { @@ -719,20 +720,26 @@ document.addEventListener('DOMContentLoaded', () => { } const allPresets = await response.json(); - // Get current tab's presets to exclude already added ones + // Load only the current tab's presets so we can avoid duplicates within this tab. 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 || []; + try { + const tabResponse = await fetch(`/tabs/${tabId}`, { + headers: { Accept: 'application/json' }, + }); + if (tabResponse.ok) { + const tabData = await tabResponse.json(); + if (Array.isArray(tabData.presets_flat)) { + currentTabPresets = tabData.presets_flat.slice(); + } else if (Array.isArray(tabData.presets)) { + if (tabData.presets.length && typeof tabData.presets[0] === 'string') { + currentTabPresets = tabData.presets.slice(); + } else if (Array.isArray(tabData.presets[0])) { + currentTabPresets = tabData.presets.flat(); + } } - } catch (e) { - console.warn('Could not load current tab presets:', e); } + } catch (e) { + console.warn('Could not load current tab presets:', e); } // Create modal @@ -759,7 +766,7 @@ document.addEventListener('DOMContentLoaded', () => { } else { presetNames.forEach(presetId => { const preset = allPresets[presetId]; - const isAlreadyAdded = currentTabPresets.includes(presetId); + const isInCurrentTab = currentTabPresets.includes(presetId); const row = document.createElement('div'); row.className = 'profiles-row'; @@ -773,17 +780,19 @@ document.addEventListener('DOMContentLoaded', () => { details.textContent = preset.pattern || '-'; const actionButton = document.createElement('button'); - if (isAlreadyAdded) { + if (isInCurrentTab) { + // Already in this tab: allow removing from this tab actionButton.className = 'btn btn-danger btn-small'; actionButton.textContent = 'Remove'; actionButton.addEventListener('click', async (e) => { e.stopPropagation(); if (confirm(`Remove preset "${preset.name || presetId}" from this tab?`)) { - await removePresetFromTab(presetId, tabId); + await removePresetFromTab(tabId, presetId); modal.remove(); } }); } else { + // Not yet in this tab: allow adding (even if used in other tabs) actionButton.className = 'btn btn-primary btn-small'; actionButton.textContent = 'Add'; actionButton.addEventListener('click', async () => { @@ -847,35 +856,50 @@ document.addEventListener('DOMContentLoaded', () => { } 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'); + // Normalize to flat array to check and update usage + let flat = []; + if (Array.isArray(tabData.presets_flat)) { + flat = tabData.presets_flat.slice(); + } else if (Array.isArray(tabData.presets)) { + if (tabData.presets.length && typeof tabData.presets[0] === 'string') { + flat = tabData.presets.slice(); + } else if (Array.isArray(tabData.presets[0])) { + flat = tabData.presets.flat(); } - - // Reload the tab content to show the 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 { + } + + if (flat.includes(presetId)) { alert('Preset is already added to this tab.'); + return; + } + + flat.push(presetId); + const newGrid = arrayToGrid(flat, 3); + tabData.presets = newGrid; + tabData.presets_flat = flat; + + // Update 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 update tab'); + } + + // Reload the tab content to show the new preset + if (typeof renderTabPresets === 'function') { + await renderTabPresets(tabId); + } else if (window.htmx) { + htmx.ajax('GET', `/tabs/${tabId}/content-fragment`, { + target: '#tab-content', + swap: 'innerHTML' + }); + } else { + // Fallback: reload the page + window.location.reload(); } } catch (error) { console.error('Failed to add preset to tab:', error); @@ -978,14 +1002,18 @@ document.addEventListener('DOMContentLoaded', () => { alert('Preset name is required to send.'); return; } - // Send current editor values and select on all devices in the current tab (if any) + // Send current editor values and then select on all devices in the current tab (if any) const section = document.querySelector('.presets-section[data-tab-id]'); const namesAttr = section && section.getAttribute('data-device-names'); const deviceNames = namesAttr ? namesAttr.split(',').map((n) => n.trim()).filter((n) => n.length > 0) : []; - - sendPresetViaEspNow(payload.name, payload, deviceNames); + // Work out the preset ID: for existing presets use currentEditId, otherwise fall back to name + const presetId = currentEditId || payload.name; + // First send/override the preset definition under its ID + sendPresetViaEspNow(presetId, payload, null); + // Then send a separate select-only message for this preset ID to all devices in the tab + sendSelectForCurrentTabDevices(presetId, section); }); } @@ -1021,8 +1049,8 @@ document.addEventListener('DOMContentLoaded', () => { const saved = await response.json().catch(() => null); if (saved && typeof saved === 'object') { if (currentEditId) { - // PUT returns the preset object directly - sendPresetViaEspNow(payload.name, saved, deviceNames); + // PUT returns the preset object directly; use the existing ID + sendPresetViaEspNow(currentEditId, saved, deviceNames); } else { // POST returns { id: preset } const entries = Object.entries(saved); @@ -1100,12 +1128,6 @@ document.addEventListener('DOMContentLoaded', () => { // for the given device names, then send it via WebSocket. const sendPresetViaEspNow = (presetId, preset, deviceNames) => { try { - const presetName = preset.name || presetId; - if (!presetName) { - alert('Preset has no name and cannot be sent.'); - return; - } - const colors = Array.isArray(preset.colors) && preset.colors.length ? preset.colors : ['#FFFFFF']; @@ -1113,7 +1135,7 @@ const sendPresetViaEspNow = (presetId, preset, deviceNames) => { const message = { v: '1', presets: { - [presetName]: { + [presetId]: { pattern: preset.pattern || 'off', colors, delay: typeof preset.delay === 'number' ? preset.delay : 100, @@ -1136,7 +1158,7 @@ const sendPresetViaEspNow = (presetId, preset, deviceNames) => { const select = {}; deviceNames.forEach((name) => { if (name) { - select[name] = [presetName]; + select[name] = [presetId]; } }); if (Object.keys(select).length > 0) { @@ -1526,9 +1548,8 @@ const createPresetButton = (presetId, preset, tabId, isSelected = false) => { selectedPresets[tabId] = presetId; // Build and send a select message via WebSocket for all device names in this tab. - const presetName = preset.name || presetId; const section = button.closest('.presets-section'); - sendSelectForCurrentTabDevices(presetName, section); + sendSelectForCurrentTabDevices(presetId, section); }); button.addEventListener('contextmenu', async (e) => { diff --git a/src/static/tabs.js b/src/static/tabs.js index 5430a1d..544b5ec 100644 --- a/src/static/tabs.js +++ b/src/static/tabs.js @@ -20,7 +20,20 @@ async function loadTabs() { const data = await response.json(); // Get current tab from cookie first, then from server response - currentTabId = getCurrentTabFromCookie() || data.current_tab_id; + const cookieTabId = getCurrentTabFromCookie(); + const serverCurrent = data.current_tab_id; + const tabs = data.tabs || {}; + const tabIds = Object.keys(tabs); + + let candidateId = cookieTabId || serverCurrent || null; + // If the candidate doesn't exist anymore (e.g. after DB reset), fall back to first tab. + if (candidateId && !tabIds.includes(String(candidateId))) { + candidateId = tabIds.length > 0 ? tabIds[0] : null; + // Clear stale cookie + document.cookie = 'current_tab=; path=/; max-age=0'; + } + + currentTabId = candidateId; renderTabsList(data.tabs, data.tab_order, currentTabId); // Load current tab content if available diff --git a/src/templates/index.html b/src/templates/index.html index 9ca3321..c4afb7e 100644 --- a/src/templates/index.html +++ b/src/templates/index.html @@ -116,8 +116,14 @@
- - +
+ + +
+
+ + +
@@ -364,18 +370,38 @@ width: 100%; } /* Help modal readability */ + #help-modal .modal-content { + max-width: 720px; + line-height: 1.6; + font-size: 0.95rem; + } #help-modal .modal-content h2 { + margin-bottom: 0.75rem; + } + #help-modal .modal-content h3 { + margin-top: 1.25rem; + margin-bottom: 0.4rem; + font-size: 1.05rem; + font-weight: 600; + } + #help-modal .modal-content p { + text-align: left; margin-bottom: 0.5rem; } #help-modal .modal-content ul { - margin-top: 0.75rem; - margin-left: 1.5rem; + margin-top: 0.25rem; + margin-left: 1.25rem; padding-left: 0; text-align: left; } #help-modal .modal-content li { - margin: 0.25rem 0; - line-height: 1.4; + margin: 0.2rem 0; + line-height: 1.5; + } + #help-modal .muted-text { + text-align: left; + color: #bbb; + font-size: 0.9rem; }