Refresh tabs/presets UI and add a mobile menu.

This improves navigation and profile workflows on smaller screens.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-02-08 13:51:09 +13:00
parent 6c6ed22dbe
commit d907ca37ad
6 changed files with 917 additions and 379 deletions

View File

@@ -3,6 +3,8 @@ document.addEventListener('DOMContentLoaded', () => {
const helpBtn = document.getElementById('help-btn');
const helpModal = document.getElementById('help-modal');
const helpCloseBtn = document.getElementById('help-close-btn');
const mainMenuBtn = document.getElementById('main-menu-btn');
const mainMenuDropdown = document.getElementById('main-menu-dropdown');
if (helpBtn && helpModal) {
helpBtn.addEventListener('click', () => {
@@ -24,6 +26,32 @@ document.addEventListener('DOMContentLoaded', () => {
});
}
// Mobile main menu: forward clicks to existing header buttons
if (mainMenuBtn && mainMenuDropdown) {
mainMenuBtn.addEventListener('click', () => {
mainMenuDropdown.classList.toggle('open');
});
mainMenuDropdown.addEventListener('click', (event) => {
const target = event.target;
if (target && target.matches('button[data-target]')) {
const id = target.getAttribute('data-target');
const realBtn = document.getElementById(id);
if (realBtn) {
realBtn.click();
}
mainMenuDropdown.classList.remove('open');
}
});
// Close menu when clicking outside
document.addEventListener('click', (event) => {
if (!mainMenuDropdown.contains(event.target) && event.target !== mainMenuBtn) {
mainMenuDropdown.classList.remove('open');
}
});
}
// Settings modal wiring (reusing existing settings endpoints).
const settingsButton = document.getElementById('settings-btn');
const settingsModal = document.getElementById('settings-modal');
@@ -145,11 +173,16 @@ document.addEventListener('DOMContentLoaded', () => {
if (stationForm) {
stationForm.addEventListener('submit', async (e) => {
e.preventDefault();
const ssid = (document.getElementById('station-ssid').value || '').trim();
if (!ssid) {
showSettingsMessage('SSID is required', 'error');
return;
}
const formData = {
ssid: document.getElementById('station-ssid').value,
password: document.getElementById('station-password').value,
ip: document.getElementById('station-ip').value || null,
gateway: document.getElementById('station-gateway').value || null,
ssid,
password: document.getElementById('station-password').value || '',
ip: (document.getElementById('station-ip').value || '').trim() || null,
gateway: (document.getElementById('station-gateway').value || '').trim() || null,
};
try {
const response = await fetch('/settings/wifi/station', {
@@ -157,7 +190,12 @@ document.addEventListener('DOMContentLoaded', () => {
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(formData),
});
const result = await response.json();
let result = {};
try {
result = await response.json();
} catch (_) {
result = { error: response.status === 400 ? 'Bad request (check SSID and connection)' : 'Request failed' };
}
if (response.ok) {
showSettingsMessage('WiFi station connected successfully!', 'success');
setTimeout(loadStationStatus, 1000);

View File

@@ -684,18 +684,13 @@ document.addEventListener('DOMContentLoaded', () => {
});
}
// 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 (optionalTabId) => {
let tabId = optionalTabId;
if (!tabId) {
// Get current tab ID from the presets section
const leftPanel = document.querySelector('.presets-section[data-tab-id]');
tabId = leftPanel ? leftPanel.dataset.tabId : null;
}
});
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('/');
@@ -704,7 +699,6 @@ document.addEventListener('DOMContentLoaded', () => {
tabId = pathParts[tabIndex + 1];
}
}
if (!tabId) {
alert('Could not determine current tab.');
return;
@@ -761,13 +755,12 @@ document.addEventListener('DOMContentLoaded', () => {
const listContainer = document.getElementById('add-preset-list');
const presetNames = Object.keys(allPresets);
if (presetNames.length === 0) {
listContainer.innerHTML = '<p class="muted-text">No presets available. Create a preset first.</p>';
const availableToAdd = presetNames.filter(presetId => !currentTabPresets.includes(presetId));
if (availableToAdd.length === 0) {
listContainer.innerHTML = '<p class="muted-text">No presets to add. All presets are already in this tab, or create a preset first.</p>';
} else {
presetNames.forEach(presetId => {
availableToAdd.forEach(presetId => {
const preset = allPresets[presetId];
const isInCurrentTab = currentTabPresets.includes(presetId);
const row = document.createElement('div');
row.className = 'profiles-row';
@@ -779,31 +772,17 @@ document.addEventListener('DOMContentLoaded', () => {
details.style.fontSize = '0.85em';
details.textContent = preset.pattern || '-';
const actionButton = document.createElement('button');
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(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 () => {
await addPresetToTab(presetId, tabId);
modal.remove();
});
}
const addButton = document.createElement('button');
addButton.className = 'btn btn-primary btn-small';
addButton.textContent = 'Add';
addButton.addEventListener('click', async () => {
await addPresetToTab(presetId, tabId);
modal.remove();
});
row.appendChild(label);
row.appendChild(details);
row.appendChild(actionButton);
row.appendChild(addButton);
listContainer.appendChild(row);
});
}
@@ -824,6 +803,9 @@ document.addEventListener('DOMContentLoaded', () => {
alert('Failed to load presets.');
}
};
try {
window.showAddPresetToTabModal = showAddPresetToTabModal;
} catch (e) {}
const addPresetToTab = async (presetId, tabId) => {
if (!tabId) {
@@ -906,6 +888,9 @@ document.addEventListener('DOMContentLoaded', () => {
alert('Failed to add preset to tab.');
}
};
try {
window.addPresetToTab = addPresetToTab;
} catch (e) {}
if (presetEditorCloseButton) {
presetEditorCloseButton.addEventListener('click', closeEditor);
}
@@ -1126,7 +1111,8 @@ document.addEventListener('DOMContentLoaded', () => {
// Build an ESPNow preset message for a single preset and optionally include a select
// for the given device names, then send it via WebSocket.
const sendPresetViaEspNow = (presetId, preset, deviceNames) => {
// saveToDevice defaults to true.
const sendPresetViaEspNow = (presetId, preset, deviceNames, saveToDevice = true) => {
try {
const colors = Array.isArray(preset.colors) && preset.colors.length
? preset.colors
@@ -1152,6 +1138,10 @@ const sendPresetViaEspNow = (presetId, preset, deviceNames) => {
},
},
};
if (saveToDevice) {
// Instruct led-driver to save this preset when received.
message.save = true;
}
// Optionally include a select section for specific devices
if (Array.isArray(deviceNames) && deviceNames.length > 0) {
@@ -1225,11 +1215,6 @@ const ensurePresetContextMenu = () => {
cursor: pointer;
font-size: 0.9rem;
`;
if (action === 'remove') {
// Visually emphasize and align remove to the right
item.style.textAlign = 'right';
item.style.color = '#ff8080';
}
item.addEventListener('mouseover', () => {
item.style.backgroundColor = '#3a3a3a';
});
@@ -1240,20 +1225,17 @@ const ensurePresetContextMenu = () => {
};
addItem('Edit preset…', 'edit');
addItem('Remove', 'remove');
menu.addEventListener('click', async (e) => {
const btn = e.target.closest('button[data-action]');
if (!btn || !presetContextTarget) {
return;
}
const { tabId, presetId } = presetContextTarget;
const { presetId } = presetContextTarget;
const action = btn.dataset.action;
hidePresetContextMenu();
if (action === 'edit') {
await editPresetFromTab(presetId);
} else if (action === 'remove') {
await removePresetFromTab(tabId, presetId);
}
});
@@ -1361,24 +1343,24 @@ const savePresetGrid = async (tabId, presetGrid) => {
}
};
// Function to get drop target in 2D grid
// Function to get drop target: the cell that contains the cursor (or closest if in a gap)
const getDropTarget = (container, x, y) => {
const draggableElements = [...container.querySelectorAll('.draggable-preset:not(.dragging)')];
return draggableElements.reduce((closest, child) => {
// First try: find the element whose rect contains the cursor
const containing = draggableElements.find((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;
return x >= box.left && x <= box.right && y >= box.top && y <= box.bottom;
});
if (containing) return containing;
// Fallback: closest element by distance to center
const closest = draggableElements.reduce((best, child) => {
const box = child.getBoundingClientRect();
const cx = box.left + box.width / 2;
const cy = box.top + box.height / 2;
const d = Math.hypot(x - cx, y - cy);
return d < best.distance ? { distance: d, element: child } : best;
}, { distance: Infinity });
return closest.element;
};
// Function to render presets for a specific tab in 2D grid
@@ -1426,17 +1408,8 @@ const renderTabPresets = async (tabId) => {
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);
}
// Insert before drop target so the dragged item takes that cell's position
presetsList.insertBefore(dragging, dropTarget);
}
});
@@ -1477,7 +1450,7 @@ const renderTabPresets = async (tabId) => {
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.';
empty.textContent = 'No presets added to this tab. Open the tab\'s Edit menu and click "Add Preset" to add one.';
presetsList.appendChild(empty);
} else {
flatPresets.forEach((presetId) => {
@@ -1496,97 +1469,67 @@ const renderTabPresets = async (tabId) => {
};
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';
button.className = 'pattern-button draggable-preset';
button.draggable = true;
button.dataset.presetId = presetId;
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 colors = Array.isArray(preset.colors) ? preset.colors.filter(c => c) : [];
const isRainbow = (preset.pattern || '').toLowerCase() === 'rainbow';
const barColors = isRainbow
? ['#FF0000', '#FF7F00', '#FFFF00', '#00FF00', '#0000FF', '#4B0082', '#8F00FF']
: colors;
if (barColors.length > 0) {
const n = barColors.length;
const stops = barColors.flatMap((c, i) => {
const start = (100 * i / n).toFixed(2);
const end = (100 * (i + 1) / n).toFixed(2);
return [`${c} ${start}%`, `${c} ${end}%`];
}).join(', ');
button.style.backgroundImage = `linear-gradient(rgba(0,0,0,0.4), rgba(0,0,0,0.4)), linear-gradient(to right, ${stops})`;
}
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);
// Left-click selects preset, right-click opens editor
button.addEventListener('click', (e) => {
if (isDraggingPreset) {
return;
}
presetNameLabel.className = 'pattern-button-label';
button.appendChild(presetNameLabel);
// Remove active class from all presets in this tab
button.addEventListener('click', (e) => {
if (isDraggingPreset) return;
const presetsList = document.getElementById('presets-list-tab');
if (presetsList) {
presetsList.querySelectorAll('.pattern-button').forEach(btn => {
btn.classList.remove('active');
});
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;
// Build and send a select message via WebSocket for all device names in this tab.
const section = button.closest('.presets-section');
sendSelectForCurrentTabDevices(presetId, section);
});
button.addEventListener('contextmenu', async (e) => {
e.preventDefault();
if (isDraggingPreset) {
return;
}
// Right-click: directly open the preset editor using data we already have
if (isDraggingPreset) return;
await editPresetFromTab(presetId, tabId, preset);
});
wrapper.appendChild(button);
// Add drag event handlers
wrapper.addEventListener('dragstart', (e) => {
button.addEventListener('dragstart', (e) => {
isDraggingPreset = true;
wrapper.classList.add('dragging');
button.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);
button.addEventListener('dragend', (e) => {
button.classList.remove('dragging');
document.querySelectorAll('.draggable-preset').forEach(el => el.classList.remove('drag-over'));
setTimeout(() => { isDraggingPreset = false; }, 100);
});
return wrapper;
return button;
};
const editPresetFromTab = async (presetId, tabId, existingPreset) => {
@@ -1685,6 +1628,9 @@ const removePresetFromTab = async (tabId, presetId) => {
alert('Failed to remove preset from tab.');
}
};
try {
window.removePresetFromTab = removePresetFromTab;
} catch (e) {}
// Listen for HTMX swaps to render presets
document.body.addEventListener('htmx:afterSwap', (event) => {

View File

@@ -73,6 +73,70 @@ document.addEventListener("DOMContentLoaded", () => {
}
});
const cloneButton = document.createElement("button");
cloneButton.className = "btn btn-secondary btn-small";
cloneButton.textContent = "Clone";
cloneButton.addEventListener("click", async () => {
const baseName = (profile && profile.name) || profileId;
const suggested = `${baseName}`;
const name = prompt("New profile name:", suggested);
if (name === null) {
return;
}
const trimmed = String(name).trim();
if (!trimmed) {
alert("Profile name cannot be empty.");
return;
}
try {
const response = await fetch(`/profiles/${profileId}/clone`, {
method: "POST",
headers: { "Content-Type": "application/json", Accept: "application/json" },
body: JSON.stringify({ name: trimmed }),
});
if (!response.ok) {
throw new Error("Failed to clone profile");
}
const data = await response.json().catch(() => null);
let newProfileId = null;
if (data && typeof data === "object") {
if (data.id) {
newProfileId = String(data.id);
} else {
const ids = Object.keys(data);
if (ids.length > 0) {
newProfileId = String(ids[0]);
}
}
}
if (newProfileId) {
await fetch(`/profiles/${newProfileId}/apply`, {
method: "POST",
headers: { Accept: "application/json" },
});
}
document.cookie = "current_tab=; path=/; max-age=0";
await loadProfiles();
if (typeof window.loadTabs === "function") {
await window.loadTabs();
}
if (typeof window.loadTabsModal === "function") {
await window.loadTabsModal();
}
const tabContent = document.getElementById("tab-content");
if (tabContent) {
tabContent.innerHTML = `
<div class="tab-content-placeholder">
Select a tab to get started
</div>
`;
}
} catch (error) {
console.error("Clone profile failed:", error);
alert("Failed to clone profile.");
}
});
const deleteButton = document.createElement("button");
deleteButton.className = "btn btn-danger btn-small";
deleteButton.textContent = "Delete";
@@ -98,6 +162,7 @@ document.addEventListener("DOMContentLoaded", () => {
row.appendChild(label);
row.appendChild(applyButton);
row.appendChild(cloneButton);
row.appendChild(deleteButton);
profilesList.appendChild(row);
});
@@ -150,8 +215,44 @@ document.addEventListener("DOMContentLoaded", () => {
if (!response.ok) {
throw new Error("Failed to create profile");
}
const data = await response.json().catch(() => null);
let newProfileId = null;
if (data && typeof data === "object") {
if (data.id) {
newProfileId = String(data.id);
} else {
const ids = Object.keys(data);
if (ids.length > 0) {
newProfileId = String(ids[0]);
}
}
}
if (newProfileId) {
await fetch(`/profiles/${newProfileId}/apply`, {
method: "POST",
headers: { Accept: "application/json" },
});
}
newProfileInput.value = "";
// Clear current tab and refresh the UI so the new profile starts empty.
document.cookie = "current_tab=; path=/; max-age=0";
await loadProfiles();
if (typeof window.loadTabs === "function") {
await window.loadTabs();
}
if (typeof window.loadTabsModal === "function") {
await window.loadTabsModal();
}
const tabContent = document.getElementById("tab-content");
if (tabContent) {
tabContent.innerHTML = `
<div class="tab-content-placeholder">
Select a tab to get started
</div>
`;
}
} catch (error) {
console.error("Create profile failed:", error);
alert("Failed to create profile.");

View File

@@ -20,25 +20,65 @@ body {
header {
background-color: #1a1a1a;
padding: 1rem 2rem;
padding: 0.75rem 1rem;
display: flex;
justify-content: space-between;
align-items: center;
border-bottom: 2px solid #4a4a4a;
gap: 0.75rem;
}
header h1 {
font-size: 1.5rem;
font-size: 1.35rem;
font-weight: 600;
}
.header-actions {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
justify-content: flex-end;
}
.header-menu-mobile {
display: none;
position: relative;
}
.main-menu-dropdown {
position: absolute;
top: 100%;
right: 0;
background-color: #1a1a1a;
border: 1px solid #4a4a4a;
border-radius: 4px;
padding: 0.25rem 0;
display: none;
min-width: 160px;
z-index: 1100;
}
.main-menu-dropdown.open {
display: block;
}
.main-menu-dropdown button {
width: 100%;
background: none;
border: none;
color: white;
text-align: left;
padding: 0.4rem 0.75rem;
font-size: 0.85rem;
cursor: pointer;
}
.main-menu-dropdown button:hover {
background-color: #333;
}
.btn {
padding: 0.5rem 1rem;
padding: 0.45rem 0.9rem;
border: none;
border-radius: 4px;
cursor: pointer;
@@ -87,15 +127,22 @@ header h1 {
}
.tabs-container {
background-color: #1a1a1a;
border-bottom: 2px solid #4a4a4a;
padding: 0.5rem 1rem;
background-color: transparent;
padding: 0.5rem 0;
flex: 1;
min-width: 0;
align-self: stretch;
display: flex;
align-items: center;
}
.tabs-list {
display: flex;
gap: 0.5rem;
overflow-x: auto;
padding-bottom: 0.25rem;
flex: 1;
min-width: 0;
}
.tab-button {
@@ -122,10 +169,28 @@ header h1 {
.tab-content {
flex: 1;
display: block;
overflow: auto;
overflow-y: auto;
overflow-x: hidden;
padding: 0.5rem 1rem 1rem;
}
.presets-toolbar {
align-items: center;
}
.tab-brightness-group {
display: flex;
flex-direction: column;
align-items: stretch;
gap: 0.25rem;
margin-left: auto;
}
.tab-brightness-group label {
white-space: nowrap;
font-size: 0.85rem;
}
.left-panel {
flex: 0 0 50%;
display: flex;
@@ -355,6 +420,33 @@ header h1 {
font-size: 1.1rem;
}
/* Make the presets area fill available vertical space; no border around presets */
.presets-section {
display: flex;
flex-direction: column;
height: 100%;
min-width: 0;
overflow-x: hidden;
border: none;
background-color: transparent;
padding: 0;
}
/* Tab preset selecting area: 3 columns, vertical scroll only */
#presets-list-tab {
flex: 1;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
grid-auto-rows: 5rem;
column-gap: 0.3rem;
row-gap: 0.3rem;
align-content: start;
width: 100%;
}
/* Settings modal layout */
.settings-section {
background-color: #1a1a1a;
@@ -478,21 +570,38 @@ header h1 {
}
.presets-list {
display: grid;
grid-template-columns: repeat(3, 1fr);
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
width: 100%;
}
.pattern-button {
padding: 0.75rem;
height: 5rem;
padding: 0 0.5rem;
background-color: #3a3a3a;
color: white;
border: none;
border: 3px solid #000;
border-radius: 4px;
cursor: pointer;
font-size: 0.9rem;
font-size: 0.85rem;
text-align: left;
transition: background-color 0.2s;
line-height: 1;
display: flex;
align-items: center;
overflow: hidden;
box-shadow: none;
outline: none;
position: relative;
}
/* Preset select buttons inside the tab grid */
#presets-list-tab .pattern-button {
display: flex;
}
.pattern-button .pattern-button-label {
text-shadow: 0 0 2px rgba(0,0,0,0.8), 0 1px 2px rgba(0,0,0,0.6);
}
.pattern-button:hover {
@@ -502,10 +611,28 @@ header h1 {
.pattern-button.active {
background-color: #6a5acd;
color: white;
border-color: #ffffff;
}
.pattern-button.active[style*="background-image"] {
background-color: transparent;
}
.pattern-button.active::after {
content: '';
position: absolute;
inset: -3px;
border-radius: 7px;
padding: 3px;
pointer-events: none;
background: #ffffff;
-webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
-webkit-mask-composite: xor;
mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
mask-composite: exclude;
}
.pattern-button.default-preset {
border: 2px solid #6a5acd;
/* No border; active state shows selection */
}
.color-palette {
@@ -604,7 +731,7 @@ header h1 {
background-color: #2e2e2e;
padding: 2rem;
border-radius: 8px;
min-width: 400px;
min-width: 320px;
max-width: 500px;
}
@@ -661,3 +788,269 @@ header h1 {
background: #5a5a5a;
}
/* Mobile-friendly layout */
@media (max-width: 800px) {
header {
flex-direction: row;
align-items: center;
gap: 0.25rem;
}
header h1 {
font-size: 1.1rem;
} /* On mobile, hide header buttons; all actions (including Tabs) are in the Menu dropdown */
.header-actions {
display: none;
}
.header-menu-mobile {
display: block;
margin-top: 0;
margin-left: auto;
}
.btn {
font-size: 0.8rem;
padding: 0.4rem 0.7rem;
}
.tabs-container {
padding: 0.5rem 0;
border-bottom: none;
}
.tab-content {
padding: 0.5rem;
}
.left-panel {
flex: 1;
border-right: none;
padding-right: 0;
}
.right-panel {
padding-left: 0;
margin-top: 1rem;
}
/* Hide the "Presets for ..." heading to save space on mobile */
.presets-section h3 {
display: none;
}
.modal-content {
min-width: 280px;
max-width: 95vw;
padding: 1.25rem;
}
.form-row {
grid-template-columns: 1fr;
}
}
/* Styles moved from inline <style> in templates/index.html */
.modal {
display: none;
position: fixed;
z-index: 1000;
left: 0;
top: 0;
width: 100%;
height: 100%;
background-color: rgba(0,0,0,0.7);
}
.modal.active {
display: flex;
align-items: center;
justify-content: center;
}
.modal-content {
background-color: #2e2e2e;
padding: 2rem;
border-radius: 8px;
min-width: 400px;
max-width: 600px;
}
.modal-content label {
display: block;
margin-top: 1rem;
margin-bottom: 0.5rem;
}
.modal-content input[type="text"] {
width: 100%;
padding: 0.5rem;
background-color: #3a3a3a;
border: 1px solid #4a4a4a;
border-radius: 4px;
color: white;
}
.profiles-actions {
display: flex;
gap: 0.5rem;
margin-top: 1rem;
}
.profiles-actions input[type="text"] {
flex: 1;
}
.profiles-list {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-top: 1rem;
max-height: 50vh;
overflow-y: auto;
}
.profiles-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
padding: 0.5rem;
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;
}
.modal-actions {
display: flex;
gap: 0.5rem;
margin-top: 1.5rem;
justify-content: flex-end;
}
.error {
color: #d32f2f;
padding: 0.5rem;
background-color: #3a1a1a;
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;
}
/* Presets list: 3 columns and vertical scroll (defined above); mobile same */
@media (max-width: 800px) {
#presets-list-tab {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
}
/* 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.25rem;
margin-left: 1.25rem;
padding-left: 0;
text-align: left;
}
#help-modal .modal-content li {
margin: 0.2rem 0;
line-height: 1.5;
}
#help-modal .muted-text {
text-align: left;
color: #bbb;
font-size: 0.9rem;
}
/* Tab content placeholder (no tab selected) */
.tab-content-placeholder {
padding: 2rem;
text-align: center;
color: #aaa;
}
/* Preset editor: color actions row */
#preset-editor-modal .preset-colors-container + .profiles-actions {
margin-top: 0.5rem;
}
/* Preset editor: brightness/delay field wrappers */
.preset-editor-field {
flex: 1;
display: flex;
flex-direction: column;
}
/* Settings modal */
#settings-modal .modal-content {
max-width: 900px;
max-height: 90vh;
overflow-y: auto;
}
#settings-modal .modal-content > p.muted-text {
margin-bottom: 1rem;
}#settings-modal .settings-section.ap-settings-section {
margin-top: 1.5rem;
}

View File

@@ -133,6 +133,65 @@ function renderTabsListModal(tabs, tabOrder, currentTabId) {
openEditTabModal(tabId, tab);
});
const sendPresetsButton = document.createElement("button");
sendPresetsButton.className = "btn btn-secondary btn-small";
sendPresetsButton.textContent = "Send Presets";
sendPresetsButton.addEventListener("click", async () => {
await sendTabPresets(tabId);
});
const cloneButton = document.createElement("button");
cloneButton.className = "btn btn-secondary btn-small";
cloneButton.textContent = "Clone";
cloneButton.addEventListener("click", async () => {
const baseName = (tab && tab.name) || tabId;
const suggested = `${baseName} Copy`;
const name = prompt("New tab name:", suggested);
if (name === null) {
return;
}
const trimmed = String(name).trim();
if (!trimmed) {
alert("Tab name cannot be empty.");
return;
}
try {
const response = await fetch(`/tabs/${tabId}/clone`, {
method: "POST",
headers: {
"Content-Type": "application/json",
"Accept": "application/json",
},
body: JSON.stringify({ name: trimmed }),
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({ error: "Failed to clone tab" }));
throw new Error(errorData.error || "Failed to clone tab");
}
const data = await response.json().catch(() => null);
let newTabId = null;
if (data && typeof data === "object") {
if (data.id) {
newTabId = String(data.id);
} else {
const ids = Object.keys(data);
if (ids.length > 0) {
newTabId = String(ids[0]);
}
}
}
await loadTabsModal();
if (newTabId) {
await selectTab(newTabId);
} else {
await loadTabs();
}
} catch (error) {
console.error("Clone tab failed:", error);
alert("Failed to clone tab: " + error.message);
}
});
const deleteButton = document.createElement("button");
deleteButton.className = "btn btn-danger btn-small";
deleteButton.textContent = "Delete";
@@ -166,6 +225,8 @@ function renderTabsListModal(tabs, tabOrder, currentTabId) {
row.appendChild(label);
row.appendChild(applyButton);
row.appendChild(editButton);
row.appendChild(sendPresetsButton);
row.appendChild(cloneButton);
row.appendChild(deleteButton);
container.appendChild(row);
});
@@ -259,13 +320,10 @@ async function loadTabContent(tabId) {
container.innerHTML = `
<div class="presets-section" data-tab-id="${tabId}" data-device-names="${deviceNames}">
<h3>Presets for ${tabName}</h3>
<div class="profiles-actions" style="margin-bottom: 1rem;">
<button class="btn btn-primary" id="preset-add-btn-tab">Add Preset</button>
<button class="btn btn-secondary" id="send-tab-presets-btn">Send Presets</button>
<div style="display: flex; align-items: center; gap: 0.5rem; margin-left: auto;">
<label for="tab-brightness-slider" style="white-space: nowrap;">Brightness</label>
<div class="profiles-actions presets-toolbar" style="margin-bottom: 1rem;">
<div class="tab-brightness-group">
<label for="tab-brightness-slider">Brightness</label>
<input type="range" id="tab-brightness-slider" min="0" max="255" value="255">
<span id="tab-brightness-value">255</span>
</div>
</div>
<div id="presets-list-tab" class="presets-list">
@@ -273,31 +331,19 @@ async function loadTabContent(tabId) {
</div>
</div>
`;
// Wire up "Send Presets" button for this tab
const sendBtn = container.querySelector('#send-tab-presets-btn');
if (sendBtn) {
sendBtn.addEventListener('click', () => {
sendTabPresets(tabId);
});
}
// Wire up per-tab brightness slider to send global brightness via ESPNow.
const brightnessSlider = container.querySelector('#tab-brightness-slider');
const brightnessValue = container.querySelector('#tab-brightness-value');
// Simple debounce so we don't spam ESPNow while dragging
let brightnessSendTimeout = null;
if (brightnessSlider && brightnessValue) {
if (brightnessSlider) {
brightnessSlider.addEventListener('input', (e) => {
const val = parseInt(e.target.value, 10) || 0;
brightnessValue.textContent = String(val);
if (brightnessSendTimeout) {
clearTimeout(brightnessSendTimeout);
}
brightnessSendTimeout = setTimeout(() => {
if (typeof window.sendEspnowRaw === 'function') {
try {
// Include version so led-driver accepts the message.
window.sendEspnowRaw({ v: '1', b: val });
} catch (err) {
console.error('Failed to send brightness via ESPNow:', err);
@@ -376,6 +422,170 @@ async function sendTabPresets(tabId) {
}
}
// Send all presets used by all tabs in the current profile via /presets/send.
async function sendProfilePresets() {
try {
// Load current profile to get its tabs
const profileRes = await fetch('/profiles/current', {
headers: { Accept: 'application/json' },
});
if (!profileRes.ok) {
alert('Failed to load current profile.');
return;
}
const profileData = await profileRes.json();
const profile = profileData.profile || {};
let tabList = null;
if (Array.isArray(profile.tabs)) {
tabList = profile.tabs;
} else if (profile.tabs) {
tabList = [profile.tabs];
}
if (!tabList || tabList.length === 0) {
if (Array.isArray(profile.tab_order)) {
tabList = profile.tab_order;
} else if (profile.tab_order) {
tabList = [profile.tab_order];
} else {
tabList = [];
}
}
if (!tabList || tabList.length === 0) {
console.warn('sendProfilePresets: no tabs found', {
profileData,
profile,
});
}
if (!tabList.length) {
alert('Current profile has no tabs to send presets for.');
return;
}
const allPresetIdsSet = new Set();
// Collect all preset IDs used in all tabs of this profile
for (const tabId of tabList) {
try {
const tabResp = await fetch(`/tabs/${tabId}`, {
headers: { Accept: 'application/json' },
});
if (!tabResp.ok) {
continue;
}
const tabData = await tabResp.json();
let presetIds = [];
if (Array.isArray(tabData.presets_flat)) {
presetIds = tabData.presets_flat;
} else if (Array.isArray(tabData.presets)) {
if (tabData.presets.length && typeof tabData.presets[0] === 'string') {
presetIds = tabData.presets;
} else if (tabData.presets.length && Array.isArray(tabData.presets[0])) {
presetIds = tabData.presets.flat();
}
}
(presetIds || []).forEach((id) => {
if (id) allPresetIdsSet.add(id);
});
} catch (e) {
console.error('Failed to load tab for profile presets:', e);
}
}
const allPresetIds = Array.from(allPresetIdsSet);
if (!allPresetIds.length) {
alert('No presets to send for the current profile.');
return;
}
// Call server-side ESPNow sender with all unique preset IDs; it handles chunking and save flag.
const response = await fetch('/presets/send', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
},
body: JSON.stringify({ preset_ids: allPresetIds }),
});
const data = await response.json().catch(() => ({}));
if (!response.ok) {
const msg = (data && data.error) || 'Failed to send presets for profile.';
alert(msg);
return;
}
const sent = typeof data.presets_sent === 'number' ? data.presets_sent : allPresetIds.length;
const messages = typeof data.messages_sent === 'number' ? data.messages_sent : '?';
alert(`Sent ${sent} preset(s) for the current profile in ${messages} ESPNow message(s).`);
} catch (error) {
console.error('Failed to send profile presets:', error);
alert('Failed to send profile presets.');
}
}
// Populate the "Add presets to this tab" list: only presets NOT already in the tab, each with a Select button.
async function populateEditTabPresetsList(tabId) {
const listEl = document.getElementById('edit-tab-presets-list');
if (!listEl) return;
listEl.innerHTML = '<span class="muted-text">Loading…</span>';
try {
const tabRes = await fetch(`/tabs/${tabId}`, { headers: { Accept: 'application/json' } });
if (!tabRes.ok) {
listEl.innerHTML = '<span class="muted-text">Failed to load presets.</span>';
return;
}
const tabData = await tabRes.json();
let inTabIds = [];
if (Array.isArray(tabData.presets_flat)) {
inTabIds = tabData.presets_flat;
} else if (Array.isArray(tabData.presets)) {
if (tabData.presets.length && typeof tabData.presets[0] === 'string') {
inTabIds = tabData.presets;
} else if (tabData.presets.length && Array.isArray(tabData.presets[0])) {
inTabIds = tabData.presets.flat();
}
}
const presetsRes = await fetch('/presets', { headers: { Accept: 'application/json' } });
const allPresets = presetsRes.ok ? await presetsRes.json() : {};
const allIds = Object.keys(allPresets);
const availableToAdd = allIds.filter(id => !inTabIds.includes(id));
listEl.innerHTML = '';
if (availableToAdd.length === 0) {
listEl.innerHTML = '<span class="muted-text">No presets to add. All presets are already in this tab.</span>';
return;
}
for (const presetId of availableToAdd) {
const preset = allPresets[presetId] || {};
const name = preset.name || presetId;
const row = document.createElement('div');
row.className = 'profiles-row';
row.style.display = 'flex';
row.style.alignItems = 'center';
row.style.justifyContent = 'space-between';
row.style.gap = '0.5rem';
const label = document.createElement('span');
label.textContent = name;
const selectBtn = document.createElement('button');
selectBtn.type = 'button';
selectBtn.className = 'btn btn-primary btn-small';
selectBtn.textContent = 'Select';
selectBtn.addEventListener('click', async () => {
if (typeof window.addPresetToTab === 'function') {
await window.addPresetToTab(presetId, tabId);
await populateEditTabPresetsList(tabId);
}
});
row.appendChild(label);
row.appendChild(selectBtn);
listEl.appendChild(row);
}
} catch (e) {
console.error('populateEditTabPresetsList:', e);
listEl.innerHTML = '<span class="muted-text">Failed to load presets.</span>';
}
}
// Open edit tab modal
function openEditTabModal(tabId, tab) {
const modal = document.getElementById('edit-tab-modal');
@@ -388,6 +598,7 @@ function openEditTabModal(tabId, tab) {
if (idsInput) idsInput.value = tab && tab.names ? tab.names.join(', ') : '1';
if (modal) modal.classList.add('active');
populateEditTabPresetsList(tabId);
}
// Update an existing tab
@@ -570,6 +781,14 @@ document.addEventListener('DOMContentLoaded', () => {
}
});
}
// Profile-wide "Send Presets" button in header
const sendProfilePresetsBtn = document.getElementById('send-profile-presets-btn');
if (sendProfilePresetsBtn) {
sendProfilePresetsBtn.addEventListener('click', async () => {
await sendProfilePresets();
});
}
});
// Export for use in other scripts