Update frontend for presets, tabs, and help

Align frontend with new preset ID usage and shortened driver fields, improve tab/preset interactions, and refine help and editor UI.
This commit is contained in:
2026-01-28 23:27:50 +13:00
parent 5fdeb57b74
commit fd37183400
4 changed files with 183 additions and 77 deletions

View File

@@ -12,6 +12,7 @@ document.addEventListener('DOMContentLoaded', () => {
} }
let currentProfileId = null; let currentProfileId = null;
let currentPaletteId = null;
let currentPalette = []; let currentPalette = [];
let currentProfileName = null; let currentProfileName = null;
@@ -84,7 +85,27 @@ document.addEventListener('DOMContentLoaded', () => {
return; return;
} }
// 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 || []; currentPalette = profile.palette || profile.color_palette || [];
}
renderPalette(); renderPalette();
} catch (error) { } catch (error) {
console.error('Failed to load palette:', error); console.error('Failed to load palette:', error);
@@ -99,17 +120,42 @@ document.addEventListener('DOMContentLoaded', () => {
return; return;
} }
try { try {
const response = await fetch('/profiles/current', { // 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', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
palette: newPalette, palette_id: currentPaletteId,
color_palette: newPalette,
}), }),
}); });
if (!response.ok) { 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'); throw new Error('Failed to save palette');
} }
}
currentPalette = newPalette; currentPalette = newPalette;
renderPalette(); renderPalette();
} catch (error) { } catch (error) {

View File

@@ -53,9 +53,10 @@ const sendEspnowMessage = (obj) => {
}; };
// Send a select message for a preset to all device names in the current tab. // 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]'); const section = sectionEl || document.querySelector('.presets-section[data-tab-id]');
if (!section || !presetName) { if (!section || !presetId) {
return; return;
} }
const namesAttr = section.getAttribute('data-device-names'); const namesAttr = section.getAttribute('data-device-names');
@@ -69,7 +70,7 @@ const sendSelectForCurrentTabDevices = (presetName, sectionEl) => {
const select = {}; const select = {};
deviceNames.forEach((name) => { deviceNames.forEach((name) => {
select[name] = [presetName]; select[name] = [presetId];
}); });
const message = { const message = {
@@ -719,21 +720,27 @@ document.addEventListener('DOMContentLoaded', () => {
} }
const allPresets = await response.json(); 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 = []; let currentTabPresets = [];
if (tabId) {
try { try {
const tabResponse = await fetch(`/tabs/${tabId}`, { const tabResponse = await fetch(`/tabs/${tabId}`, {
headers: { Accept: 'application/json' }, headers: { Accept: 'application/json' },
}); });
if (tabResponse.ok) { if (tabResponse.ok) {
const tabData = await tabResponse.json(); const tabData = await tabResponse.json();
currentTabPresets = tabData.presets || []; 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) { } catch (e) {
console.warn('Could not load current tab presets:', e); console.warn('Could not load current tab presets:', e);
} }
}
// Create modal // Create modal
const modal = document.createElement('div'); const modal = document.createElement('div');
@@ -759,7 +766,7 @@ document.addEventListener('DOMContentLoaded', () => {
} else { } else {
presetNames.forEach(presetId => { presetNames.forEach(presetId => {
const preset = allPresets[presetId]; const preset = allPresets[presetId];
const isAlreadyAdded = currentTabPresets.includes(presetId); const isInCurrentTab = currentTabPresets.includes(presetId);
const row = document.createElement('div'); const row = document.createElement('div');
row.className = 'profiles-row'; row.className = 'profiles-row';
@@ -773,17 +780,19 @@ document.addEventListener('DOMContentLoaded', () => {
details.textContent = preset.pattern || '-'; details.textContent = preset.pattern || '-';
const actionButton = document.createElement('button'); 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.className = 'btn btn-danger btn-small';
actionButton.textContent = 'Remove'; actionButton.textContent = 'Remove';
actionButton.addEventListener('click', async (e) => { actionButton.addEventListener('click', async (e) => {
e.stopPropagation(); e.stopPropagation();
if (confirm(`Remove preset "${preset.name || presetId}" from this tab?`)) { if (confirm(`Remove preset "${preset.name || presetId}" from this tab?`)) {
await removePresetFromTab(presetId, tabId); await removePresetFromTab(tabId, presetId);
modal.remove(); modal.remove();
} }
}); });
} else { } else {
// Not yet in this tab: allow adding (even if used in other tabs)
actionButton.className = 'btn btn-primary btn-small'; actionButton.className = 'btn btn-primary btn-small';
actionButton.textContent = 'Add'; actionButton.textContent = 'Add';
actionButton.addEventListener('click', async () => { actionButton.addEventListener('click', async () => {
@@ -847,16 +856,33 @@ document.addEventListener('DOMContentLoaded', () => {
} }
const tabData = await tabResponse.json(); const tabData = await tabResponse.json();
// Add preset to tab's presets array if not already present // Normalize to flat array to check and update usage
const presets = tabData.presets || []; let flat = [];
if (!presets.includes(presetId)) { if (Array.isArray(tabData.presets_flat)) {
presets.push(presetId); 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();
}
}
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 // Update tab
const updateResponse = await fetch(`/tabs/${tabId}`, { const updateResponse = await fetch(`/tabs/${tabId}`, {
method: 'PUT', method: 'PUT',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...tabData, presets }), body: JSON.stringify(tabData),
}); });
if (!updateResponse.ok) { if (!updateResponse.ok) {
@@ -864,19 +890,17 @@ document.addEventListener('DOMContentLoaded', () => {
} }
// Reload the tab content to show the new preset // Reload the tab content to show the new preset
if (window.htmx) { if (typeof renderTabPresets === 'function') {
await renderTabPresets(tabId);
} else if (window.htmx) {
htmx.ajax('GET', `/tabs/${tabId}/content-fragment`, { htmx.ajax('GET', `/tabs/${tabId}/content-fragment`, {
target: '#tab-content', target: '#tab-content',
swap: 'innerHTML' swap: 'innerHTML'
}); });
// The htmx:afterSwap event listener will call renderTabPresets
} else { } else {
// Fallback: reload the page // Fallback: reload the page
window.location.reload(); window.location.reload();
} }
} else {
alert('Preset is already added to this tab.');
}
} catch (error) { } catch (error) {
console.error('Failed to add preset to tab:', error); console.error('Failed to add preset to tab:', error);
alert('Failed to add preset to tab.'); alert('Failed to add preset to tab.');
@@ -978,14 +1002,18 @@ document.addEventListener('DOMContentLoaded', () => {
alert('Preset name is required to send.'); alert('Preset name is required to send.');
return; 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 section = document.querySelector('.presets-section[data-tab-id]');
const namesAttr = section && section.getAttribute('data-device-names'); const namesAttr = section && section.getAttribute('data-device-names');
const deviceNames = namesAttr const deviceNames = namesAttr
? namesAttr.split(',').map((n) => n.trim()).filter((n) => n.length > 0) ? namesAttr.split(',').map((n) => n.trim()).filter((n) => n.length > 0)
: []; : [];
// Work out the preset ID: for existing presets use currentEditId, otherwise fall back to name
sendPresetViaEspNow(payload.name, payload, deviceNames); 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); const saved = await response.json().catch(() => null);
if (saved && typeof saved === 'object') { if (saved && typeof saved === 'object') {
if (currentEditId) { if (currentEditId) {
// PUT returns the preset object directly // PUT returns the preset object directly; use the existing ID
sendPresetViaEspNow(payload.name, saved, deviceNames); sendPresetViaEspNow(currentEditId, saved, deviceNames);
} else { } else {
// POST returns { id: preset } // POST returns { id: preset }
const entries = Object.entries(saved); const entries = Object.entries(saved);
@@ -1100,12 +1128,6 @@ document.addEventListener('DOMContentLoaded', () => {
// for the given device names, then send it via WebSocket. // for the given device names, then send it via WebSocket.
const sendPresetViaEspNow = (presetId, preset, deviceNames) => { const sendPresetViaEspNow = (presetId, preset, deviceNames) => {
try { 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 const colors = Array.isArray(preset.colors) && preset.colors.length
? preset.colors ? preset.colors
: ['#FFFFFF']; : ['#FFFFFF'];
@@ -1113,7 +1135,7 @@ const sendPresetViaEspNow = (presetId, preset, deviceNames) => {
const message = { const message = {
v: '1', v: '1',
presets: { presets: {
[presetName]: { [presetId]: {
pattern: preset.pattern || 'off', pattern: preset.pattern || 'off',
colors, colors,
delay: typeof preset.delay === 'number' ? preset.delay : 100, delay: typeof preset.delay === 'number' ? preset.delay : 100,
@@ -1136,7 +1158,7 @@ const sendPresetViaEspNow = (presetId, preset, deviceNames) => {
const select = {}; const select = {};
deviceNames.forEach((name) => { deviceNames.forEach((name) => {
if (name) { if (name) {
select[name] = [presetName]; select[name] = [presetId];
} }
}); });
if (Object.keys(select).length > 0) { if (Object.keys(select).length > 0) {
@@ -1526,9 +1548,8 @@ const createPresetButton = (presetId, preset, tabId, isSelected = false) => {
selectedPresets[tabId] = presetId; selectedPresets[tabId] = presetId;
// Build and send a select message via WebSocket for all device names in this tab. // 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'); const section = button.closest('.presets-section');
sendSelectForCurrentTabDevices(presetName, section); sendSelectForCurrentTabDevices(presetId, section);
}); });
button.addEventListener('contextmenu', async (e) => { button.addEventListener('contextmenu', async (e) => {

View File

@@ -20,7 +20,20 @@ async function loadTabs() {
const data = await response.json(); const data = await response.json();
// Get current tab from cookie first, then from server response // 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); renderTabsList(data.tabs, data.tab_order, currentTabId);
// Load current tab content if available // Load current tab content if available

View File

@@ -116,9 +116,15 @@
<button class="btn btn-secondary btn-small" id="preset-add-from-palette-btn">Add from Palette</button> <button class="btn btn-secondary btn-small" id="preset-add-from-palette-btn">Add from Palette</button>
</div> </div>
<div class="profiles-actions"> <div class="profiles-actions">
<div style="flex: 1; display: flex; flex-direction: column;">
<label for="preset-brightness-input">Brightness (0255)</label>
<input type="number" id="preset-brightness-input" placeholder="Brightness" min="0" max="255" value="0"> <input type="number" id="preset-brightness-input" placeholder="Brightness" min="0" max="255" value="0">
</div>
<div style="flex: 1; display: flex; flex-direction: column;">
<label for="preset-delay-input">Delay (ms)</label>
<input type="number" id="preset-delay-input" placeholder="Delay" min="0" max="10000" value="0"> <input type="number" id="preset-delay-input" placeholder="Delay" min="0" max="10000" value="0">
</div> </div>
</div>
<div class="n-params-grid"> <div class="n-params-grid">
<div class="n-param-group"> <div class="n-param-group">
<label for="preset-n1-input" id="preset-n1-label">n1:</label> <label for="preset-n1-input" id="preset-n1-label">n1:</label>
@@ -364,18 +370,38 @@
width: 100%; width: 100%;
} }
/* Help modal readability */ /* Help modal readability */
#help-modal .modal-content {
max-width: 720px;
line-height: 1.6;
font-size: 0.95rem;
}
#help-modal .modal-content h2 { #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; margin-bottom: 0.5rem;
} }
#help-modal .modal-content ul { #help-modal .modal-content ul {
margin-top: 0.75rem; margin-top: 0.25rem;
margin-left: 1.5rem; margin-left: 1.25rem;
padding-left: 0; padding-left: 0;
text-align: left; text-align: left;
} }
#help-modal .modal-content li { #help-modal .modal-content li {
margin: 0.25rem 0; margin: 0.2rem 0;
line-height: 1.4; line-height: 1.5;
}
#help-modal .muted-text {
text-align: left;
color: #bbb;
font-size: 0.9rem;
} }
</style> </style>
<script src="/static/tabs.js"></script> <script src="/static/tabs.js"></script>