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:
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
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();
|
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.
|
||||||
method: 'PUT',
|
if (!currentPaletteId) {
|
||||||
headers: { 'Content-Type': 'application/json' },
|
const createResponse = await fetch('/palettes', {
|
||||||
body: JSON.stringify({
|
method: 'POST',
|
||||||
palette: newPalette,
|
headers: { 'Content-Type': 'application/json' },
|
||||||
color_palette: newPalette,
|
body: JSON.stringify({ colors: newPalette }),
|
||||||
}),
|
});
|
||||||
});
|
if (!createResponse.ok) {
|
||||||
if (!response.ok) {
|
throw new Error('Failed to create palette');
|
||||||
throw new Error('Failed to save 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;
|
currentPalette = newPalette;
|
||||||
renderPalette();
|
renderPalette();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@@ -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,20 +720,26 @@ 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();
|
if (Array.isArray(tabData.presets_flat)) {
|
||||||
currentTabPresets = tabData.presets || [];
|
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
|
// Create modal
|
||||||
@@ -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,35 +856,50 @@ 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)) {
|
||||||
// Update tab
|
if (tabData.presets.length && typeof tabData.presets[0] === 'string') {
|
||||||
const updateResponse = await fetch(`/tabs/${tabId}`, {
|
flat = tabData.presets.slice();
|
||||||
method: 'PUT',
|
} else if (Array.isArray(tabData.presets[0])) {
|
||||||
headers: { 'Content-Type': 'application/json' },
|
flat = tabData.presets.flat();
|
||||||
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 (flat.includes(presetId)) {
|
||||||
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.');
|
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) {
|
} catch (error) {
|
||||||
console.error('Failed to add preset to tab:', error);
|
console.error('Failed to add preset to tab:', error);
|
||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -116,8 +116,14 @@
|
|||||||
<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">
|
||||||
<input type="number" id="preset-brightness-input" placeholder="Brightness" min="0" max="255" value="0">
|
<div style="flex: 1; display: flex; flex-direction: column;">
|
||||||
<input type="number" id="preset-delay-input" placeholder="Delay" min="0" max="10000" value="0">
|
<label for="preset-brightness-input">Brightness (0–255)</label>
|
||||||
|
<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">
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="n-params-grid">
|
<div class="n-params-grid">
|
||||||
<div class="n-param-group">
|
<div class="n-param-group">
|
||||||
@@ -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>
|
||||||
|
|||||||
Reference in New Issue
Block a user