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 helpBtn = document.getElementById('help-btn');
const helpModal = document.getElementById('help-modal'); const helpModal = document.getElementById('help-modal');
const helpCloseBtn = document.getElementById('help-close-btn'); const helpCloseBtn = document.getElementById('help-close-btn');
const mainMenuBtn = document.getElementById('main-menu-btn');
const mainMenuDropdown = document.getElementById('main-menu-dropdown');
if (helpBtn && helpModal) { if (helpBtn && helpModal) {
helpBtn.addEventListener('click', () => { 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). // Settings modal wiring (reusing existing settings endpoints).
const settingsButton = document.getElementById('settings-btn'); const settingsButton = document.getElementById('settings-btn');
const settingsModal = document.getElementById('settings-modal'); const settingsModal = document.getElementById('settings-modal');
@@ -145,11 +173,16 @@ document.addEventListener('DOMContentLoaded', () => {
if (stationForm) { if (stationForm) {
stationForm.addEventListener('submit', async (e) => { stationForm.addEventListener('submit', async (e) => {
e.preventDefault(); e.preventDefault();
const ssid = (document.getElementById('station-ssid').value || '').trim();
if (!ssid) {
showSettingsMessage('SSID is required', 'error');
return;
}
const formData = { const formData = {
ssid: document.getElementById('station-ssid').value, ssid,
password: document.getElementById('station-password').value, password: document.getElementById('station-password').value || '',
ip: document.getElementById('station-ip').value || null, ip: (document.getElementById('station-ip').value || '').trim() || null,
gateway: document.getElementById('station-gateway').value || null, gateway: (document.getElementById('station-gateway').value || '').trim() || null,
}; };
try { try {
const response = await fetch('/settings/wifi/station', { const response = await fetch('/settings/wifi/station', {
@@ -157,7 +190,12 @@ document.addEventListener('DOMContentLoaded', () => {
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(formData), 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) { if (response.ok) {
showSettingsMessage('WiFi station connected successfully!', 'success'); showSettingsMessage('WiFi station connected successfully!', 'success');
setTimeout(loadStationStatus, 1000); setTimeout(loadStationStatus, 1000);

View File

@@ -684,18 +684,13 @@ document.addEventListener('DOMContentLoaded', () => {
}); });
} }
// Handle "Add Preset" button in tab area (dynamically loaded) const showAddPresetToTabModal = async (optionalTabId) => {
document.addEventListener('click', async (e) => { let tabId = optionalTabId;
if (e.target && e.target.id === 'preset-add-btn-tab') { if (!tabId) {
await showAddPresetToTabModal(); // 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) { if (!tabId) {
// Fallback: try to get from URL // Fallback: try to get from URL
const pathParts = window.location.pathname.split('/'); const pathParts = window.location.pathname.split('/');
@@ -704,7 +699,6 @@ document.addEventListener('DOMContentLoaded', () => {
tabId = pathParts[tabIndex + 1]; tabId = pathParts[tabIndex + 1];
} }
} }
if (!tabId) { if (!tabId) {
alert('Could not determine current tab.'); alert('Could not determine current tab.');
return; return;
@@ -761,13 +755,12 @@ document.addEventListener('DOMContentLoaded', () => {
const listContainer = document.getElementById('add-preset-list'); const listContainer = document.getElementById('add-preset-list');
const presetNames = Object.keys(allPresets); const presetNames = Object.keys(allPresets);
if (presetNames.length === 0) { const availableToAdd = presetNames.filter(presetId => !currentTabPresets.includes(presetId));
listContainer.innerHTML = '<p class="muted-text">No presets available. Create a preset first.</p>'; 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 { } else {
presetNames.forEach(presetId => { availableToAdd.forEach(presetId => {
const preset = allPresets[presetId]; const preset = allPresets[presetId];
const isInCurrentTab = currentTabPresets.includes(presetId);
const row = document.createElement('div'); const row = document.createElement('div');
row.className = 'profiles-row'; row.className = 'profiles-row';
@@ -779,31 +772,17 @@ document.addEventListener('DOMContentLoaded', () => {
details.style.fontSize = '0.85em'; details.style.fontSize = '0.85em';
details.textContent = preset.pattern || '-'; details.textContent = preset.pattern || '-';
const actionButton = document.createElement('button'); const addButton = document.createElement('button');
if (isInCurrentTab) { addButton.className = 'btn btn-primary btn-small';
// Already in this tab: allow removing from this tab addButton.textContent = 'Add';
actionButton.className = 'btn btn-danger btn-small'; addButton.addEventListener('click', async () => {
actionButton.textContent = 'Remove'; await addPresetToTab(presetId, tabId);
actionButton.addEventListener('click', async (e) => { modal.remove();
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();
});
}
row.appendChild(label); row.appendChild(label);
row.appendChild(details); row.appendChild(details);
row.appendChild(actionButton); row.appendChild(addButton);
listContainer.appendChild(row); listContainer.appendChild(row);
}); });
} }
@@ -824,6 +803,9 @@ document.addEventListener('DOMContentLoaded', () => {
alert('Failed to load presets.'); alert('Failed to load presets.');
} }
}; };
try {
window.showAddPresetToTabModal = showAddPresetToTabModal;
} catch (e) {}
const addPresetToTab = async (presetId, tabId) => { const addPresetToTab = async (presetId, tabId) => {
if (!tabId) { if (!tabId) {
@@ -906,6 +888,9 @@ document.addEventListener('DOMContentLoaded', () => {
alert('Failed to add preset to tab.'); alert('Failed to add preset to tab.');
} }
}; };
try {
window.addPresetToTab = addPresetToTab;
} catch (e) {}
if (presetEditorCloseButton) { if (presetEditorCloseButton) {
presetEditorCloseButton.addEventListener('click', closeEditor); 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 // Build an ESPNow preset message for a single preset and optionally include a select
// 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) => { // saveToDevice defaults to true.
const sendPresetViaEspNow = (presetId, preset, deviceNames, saveToDevice = true) => {
try { try {
const colors = Array.isArray(preset.colors) && preset.colors.length const colors = Array.isArray(preset.colors) && preset.colors.length
? preset.colors ? 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 // Optionally include a select section for specific devices
if (Array.isArray(deviceNames) && deviceNames.length > 0) { if (Array.isArray(deviceNames) && deviceNames.length > 0) {
@@ -1225,11 +1215,6 @@ const ensurePresetContextMenu = () => {
cursor: pointer; cursor: pointer;
font-size: 0.9rem; 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.addEventListener('mouseover', () => {
item.style.backgroundColor = '#3a3a3a'; item.style.backgroundColor = '#3a3a3a';
}); });
@@ -1240,20 +1225,17 @@ const ensurePresetContextMenu = () => {
}; };
addItem('Edit preset…', 'edit'); addItem('Edit preset…', 'edit');
addItem('Remove', 'remove');
menu.addEventListener('click', async (e) => { menu.addEventListener('click', async (e) => {
const btn = e.target.closest('button[data-action]'); const btn = e.target.closest('button[data-action]');
if (!btn || !presetContextTarget) { if (!btn || !presetContextTarget) {
return; return;
} }
const { tabId, presetId } = presetContextTarget; const { presetId } = presetContextTarget;
const action = btn.dataset.action; const action = btn.dataset.action;
hidePresetContextMenu(); hidePresetContextMenu();
if (action === 'edit') { if (action === 'edit') {
await editPresetFromTab(presetId); 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 getDropTarget = (container, x, y) => {
const draggableElements = [...container.querySelectorAll('.draggable-preset:not(.dragging)')]; const draggableElements = [...container.querySelectorAll('.draggable-preset:not(.dragging)')];
// First try: find the element whose rect contains the cursor
return draggableElements.reduce((closest, child) => { const containing = draggableElements.find((child) => {
const box = child.getBoundingClientRect(); const box = child.getBoundingClientRect();
const centerX = box.left + box.width / 2; return x >= box.left && x <= box.right && y >= box.top && y <= box.bottom;
const centerY = box.top + box.height / 2; });
const distanceX = Math.abs(x - centerX); if (containing) return containing;
const distanceY = Math.abs(y - centerY); // Fallback: closest element by distance to center
const distance = Math.sqrt(distanceX * distanceX + distanceY * distanceY); const closest = draggableElements.reduce((best, child) => {
const box = child.getBoundingClientRect();
if (distance < closest.distance) { const cx = box.left + box.width / 2;
return { distance: distance, element: child }; const cy = box.top + box.height / 2;
} else { const d = Math.hypot(x - cx, y - cy);
return closest; return d < best.distance ? { distance: d, element: child } : best;
} }, { distance: Infinity });
}, { distance: Infinity }).element; return closest.element;
}; };
// Function to render presets for a specific tab in 2D grid // 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); const dropTarget = getDropTarget(presetsList, e.clientX, e.clientY);
if (dropTarget && dropTarget !== dragging) { if (dropTarget && dropTarget !== dragging) {
// Insert before or after based on position // Insert before drop target so the dragged item takes that cell's position
const rect = dropTarget.getBoundingClientRect(); presetsList.insertBefore(dragging, dropTarget);
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);
}
} }
}); });
@@ -1477,7 +1450,7 @@ const renderTabPresets = async (tabId) => {
const empty = document.createElement('p'); const empty = document.createElement('p');
empty.className = 'muted-text'; empty.className = 'muted-text';
empty.style.gridColumn = '1 / -1'; // Span all columns 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); presetsList.appendChild(empty);
} else { } else {
flatPresets.forEach((presetId) => { flatPresets.forEach((presetId) => {
@@ -1496,97 +1469,67 @@ const renderTabPresets = async (tabId) => {
}; };
const createPresetButton = (presetId, preset, tabId, isSelected = false) => { 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'); const button = document.createElement('button');
button.className = 'pattern-button'; button.className = 'pattern-button draggable-preset';
button.style.flex = '1'; button.draggable = true;
button.dataset.presetId = presetId;
if (isSelected) { if (isSelected) {
button.classList.add('active'); button.classList.add('active');
} }
button.dataset.presetId = presetId;
const colors = Array.isArray(preset.colors) ? preset.colors.filter(c => c) : [];
const presetInfo = document.createElement('div'); const isRainbow = (preset.pattern || '').toLowerCase() === 'rainbow';
presetInfo.style.cssText = 'display: flex; flex-direction: column; align-items: flex-start; width: 100%;'; 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'); const presetNameLabel = document.createElement('span');
presetNameLabel.textContent = preset.name || presetId; presetNameLabel.textContent = preset.name || presetId;
presetNameLabel.style.fontWeight = 'bold'; presetNameLabel.style.fontWeight = 'bold';
presetNameLabel.style.marginBottom = '0.25rem'; presetNameLabel.className = 'pattern-button-label';
button.appendChild(presetNameLabel);
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;
}
// Remove active class from all presets in this tab button.addEventListener('click', (e) => {
if (isDraggingPreset) return;
const presetsList = document.getElementById('presets-list-tab'); const presetsList = document.getElementById('presets-list-tab');
if (presetsList) { if (presetsList) {
presetsList.querySelectorAll('.pattern-button').forEach(btn => { presetsList.querySelectorAll('.pattern-button').forEach(btn => btn.classList.remove('active'));
btn.classList.remove('active');
});
} }
// Add active class to clicked preset
button.classList.add('active'); button.classList.add('active');
// Store selected preset for this tab
selectedPresets[tabId] = presetId; selectedPresets[tabId] = presetId;
// Build and send a select message via WebSocket for all device names in this tab.
const section = button.closest('.presets-section'); const section = button.closest('.presets-section');
sendSelectForCurrentTabDevices(presetId, section); sendSelectForCurrentTabDevices(presetId, section);
}); });
button.addEventListener('contextmenu', async (e) => { button.addEventListener('contextmenu', async (e) => {
e.preventDefault(); e.preventDefault();
if (isDraggingPreset) { if (isDraggingPreset) return;
return;
}
// Right-click: directly open the preset editor using data we already have
await editPresetFromTab(presetId, tabId, preset); await editPresetFromTab(presetId, tabId, preset);
}); });
wrapper.appendChild(button); button.addEventListener('dragstart', (e) => {
// Add drag event handlers
wrapper.addEventListener('dragstart', (e) => {
isDraggingPreset = true; isDraggingPreset = true;
wrapper.classList.add('dragging'); button.classList.add('dragging');
e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', presetId); e.dataTransfer.setData('text/plain', presetId);
}); });
wrapper.addEventListener('dragend', (e) => { button.addEventListener('dragend', (e) => {
wrapper.classList.remove('dragging'); button.classList.remove('dragging');
// Remove any drag-over classes from siblings document.querySelectorAll('.draggable-preset').forEach(el => el.classList.remove('drag-over'));
document.querySelectorAll('.draggable-preset').forEach(el => { setTimeout(() => { isDraggingPreset = false; }, 100);
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; return button;
}; };
const editPresetFromTab = async (presetId, tabId, existingPreset) => { const editPresetFromTab = async (presetId, tabId, existingPreset) => {
@@ -1685,6 +1628,9 @@ const removePresetFromTab = async (tabId, presetId) => {
alert('Failed to remove preset from tab.'); alert('Failed to remove preset from tab.');
} }
}; };
try {
window.removePresetFromTab = removePresetFromTab;
} catch (e) {}
// Listen for HTMX swaps to render presets // Listen for HTMX swaps to render presets
document.body.addEventListener('htmx:afterSwap', (event) => { 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"); const deleteButton = document.createElement("button");
deleteButton.className = "btn btn-danger btn-small"; deleteButton.className = "btn btn-danger btn-small";
deleteButton.textContent = "Delete"; deleteButton.textContent = "Delete";
@@ -98,6 +162,7 @@ document.addEventListener("DOMContentLoaded", () => {
row.appendChild(label); row.appendChild(label);
row.appendChild(applyButton); row.appendChild(applyButton);
row.appendChild(cloneButton);
row.appendChild(deleteButton); row.appendChild(deleteButton);
profilesList.appendChild(row); profilesList.appendChild(row);
}); });
@@ -150,8 +215,44 @@ document.addEventListener("DOMContentLoaded", () => {
if (!response.ok) { if (!response.ok) {
throw new Error("Failed to create profile"); 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 = ""; 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(); 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) { } catch (error) {
console.error("Create profile failed:", error); console.error("Create profile failed:", error);
alert("Failed to create profile."); alert("Failed to create profile.");

View File

@@ -20,25 +20,65 @@ body {
header { header {
background-color: #1a1a1a; background-color: #1a1a1a;
padding: 1rem 2rem; padding: 0.75rem 1rem;
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
border-bottom: 2px solid #4a4a4a; border-bottom: 2px solid #4a4a4a;
gap: 0.75rem;
} }
header h1 { header h1 {
font-size: 1.5rem; font-size: 1.35rem;
font-weight: 600; font-weight: 600;
} }
.header-actions { .header-actions {
display: flex; display: flex;
gap: 0.5rem; 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 { .btn {
padding: 0.5rem 1rem; padding: 0.45rem 0.9rem;
border: none; border: none;
border-radius: 4px; border-radius: 4px;
cursor: pointer; cursor: pointer;
@@ -87,15 +127,22 @@ header h1 {
} }
.tabs-container { .tabs-container {
background-color: #1a1a1a; background-color: transparent;
border-bottom: 2px solid #4a4a4a; padding: 0.5rem 0;
padding: 0.5rem 1rem; flex: 1;
min-width: 0;
align-self: stretch;
display: flex;
align-items: center;
} }
.tabs-list { .tabs-list {
display: flex; display: flex;
gap: 0.5rem; gap: 0.5rem;
overflow-x: auto; overflow-x: auto;
padding-bottom: 0.25rem;
flex: 1;
min-width: 0;
} }
.tab-button { .tab-button {
@@ -122,10 +169,28 @@ header h1 {
.tab-content { .tab-content {
flex: 1; flex: 1;
display: block; display: block;
overflow: auto; overflow-y: auto;
overflow-x: hidden;
padding: 0.5rem 1rem 1rem; 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 { .left-panel {
flex: 0 0 50%; flex: 0 0 50%;
display: flex; display: flex;
@@ -355,6 +420,33 @@ header h1 {
font-size: 1.1rem; 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 modal layout */
.settings-section { .settings-section {
background-color: #1a1a1a; background-color: #1a1a1a;
@@ -478,21 +570,38 @@ header h1 {
} }
.presets-list { .presets-list {
display: grid; display: flex;
grid-template-columns: repeat(3, 1fr); flex-wrap: wrap;
gap: 0.75rem; gap: 0.75rem;
width: 100%;
} }
.pattern-button { .pattern-button {
padding: 0.75rem; height: 5rem;
padding: 0 0.5rem;
background-color: #3a3a3a; background-color: #3a3a3a;
color: white; color: white;
border: none; border: 3px solid #000;
border-radius: 4px; border-radius: 4px;
cursor: pointer; cursor: pointer;
font-size: 0.9rem; font-size: 0.85rem;
text-align: left; text-align: left;
transition: background-color 0.2s; 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 { .pattern-button:hover {
@@ -502,10 +611,28 @@ header h1 {
.pattern-button.active { .pattern-button.active {
background-color: #6a5acd; background-color: #6a5acd;
color: white; 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 { .pattern-button.default-preset {
border: 2px solid #6a5acd; /* No border; active state shows selection */
} }
.color-palette { .color-palette {
@@ -604,7 +731,7 @@ header h1 {
background-color: #2e2e2e; background-color: #2e2e2e;
padding: 2rem; padding: 2rem;
border-radius: 8px; border-radius: 8px;
min-width: 400px; min-width: 320px;
max-width: 500px; max-width: 500px;
} }
@@ -661,3 +788,269 @@ header h1 {
background: #5a5a5a; 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); 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"); const deleteButton = document.createElement("button");
deleteButton.className = "btn btn-danger btn-small"; deleteButton.className = "btn btn-danger btn-small";
deleteButton.textContent = "Delete"; deleteButton.textContent = "Delete";
@@ -166,6 +225,8 @@ function renderTabsListModal(tabs, tabOrder, currentTabId) {
row.appendChild(label); row.appendChild(label);
row.appendChild(applyButton); row.appendChild(applyButton);
row.appendChild(editButton); row.appendChild(editButton);
row.appendChild(sendPresetsButton);
row.appendChild(cloneButton);
row.appendChild(deleteButton); row.appendChild(deleteButton);
container.appendChild(row); container.appendChild(row);
}); });
@@ -259,13 +320,10 @@ async function loadTabContent(tabId) {
container.innerHTML = ` container.innerHTML = `
<div class="presets-section" data-tab-id="${tabId}" data-device-names="${deviceNames}"> <div class="presets-section" data-tab-id="${tabId}" data-device-names="${deviceNames}">
<h3>Presets for ${tabName}</h3> <h3>Presets for ${tabName}</h3>
<div class="profiles-actions" style="margin-bottom: 1rem;"> <div class="profiles-actions presets-toolbar" style="margin-bottom: 1rem;">
<button class="btn btn-primary" id="preset-add-btn-tab">Add Preset</button> <div class="tab-brightness-group">
<button class="btn btn-secondary" id="send-tab-presets-btn">Send Presets</button> <label for="tab-brightness-slider">Brightness</label>
<div style="display: flex; align-items: center; gap: 0.5rem; margin-left: auto;">
<label for="tab-brightness-slider" style="white-space: nowrap;">Brightness</label>
<input type="range" id="tab-brightness-slider" min="0" max="255" value="255"> <input type="range" id="tab-brightness-slider" min="0" max="255" value="255">
<span id="tab-brightness-value">255</span>
</div> </div>
</div> </div>
<div id="presets-list-tab" class="presets-list"> <div id="presets-list-tab" class="presets-list">
@@ -273,31 +331,19 @@ async function loadTabContent(tabId) {
</div> </div>
</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. // Wire up per-tab brightness slider to send global brightness via ESPNow.
const brightnessSlider = container.querySelector('#tab-brightness-slider'); 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; let brightnessSendTimeout = null;
if (brightnessSlider && brightnessValue) { if (brightnessSlider) {
brightnessSlider.addEventListener('input', (e) => { brightnessSlider.addEventListener('input', (e) => {
const val = parseInt(e.target.value, 10) || 0; const val = parseInt(e.target.value, 10) || 0;
brightnessValue.textContent = String(val);
if (brightnessSendTimeout) { if (brightnessSendTimeout) {
clearTimeout(brightnessSendTimeout); clearTimeout(brightnessSendTimeout);
} }
brightnessSendTimeout = setTimeout(() => { brightnessSendTimeout = setTimeout(() => {
if (typeof window.sendEspnowRaw === 'function') { if (typeof window.sendEspnowRaw === 'function') {
try { try {
// Include version so led-driver accepts the message.
window.sendEspnowRaw({ v: '1', b: val }); window.sendEspnowRaw({ v: '1', b: val });
} catch (err) { } catch (err) {
console.error('Failed to send brightness via ESPNow:', 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 // Open edit tab modal
function openEditTabModal(tabId, tab) { function openEditTabModal(tabId, tab) {
const modal = document.getElementById('edit-tab-modal'); 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 (idsInput) idsInput.value = tab && tab.names ? tab.names.join(', ') : '1';
if (modal) modal.classList.add('active'); if (modal) modal.classList.add('active');
populateEditTabPresetsList(tabId);
} }
// Update an existing tab // 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 // Export for use in other scripts

View File

@@ -9,27 +9,39 @@
<body> <body>
<div class="app-container"> <div class="app-container">
<header> <header>
<h1>LED Controller - Tab Mode</h1>
<div class="header-actions">
<button class="btn btn-secondary" id="tabs-btn">Tabs</button>
<button class="btn btn-secondary" id="color-palette-btn">Color Palette</button>
<button class="btn btn-secondary" id="presets-btn">Presets</button>
<button class="btn btn-secondary" id="patterns-btn">Patterns</button>
<button class="btn btn-secondary" id="profiles-btn">Profiles</button>
<button class="btn btn-secondary" id="settings-btn">Settings</button>
<button class="btn btn-secondary" id="help-btn">Help</button>
</div>
</header>
<div class="main-content">
<div class="tabs-container"> <div class="tabs-container">
<div id="tabs-list"> <div id="tabs-list">
Loading tabs... Loading tabs...
</div> </div>
</div> </div>
<div class="header-actions">
<button class="btn btn-secondary" id="tabs-btn">Tabs</button>
<button class="btn btn-secondary" id="color-palette-btn">Color Palette</button>
<button class="btn btn-secondary" id="presets-btn">Presets</button>
<button class="btn btn-secondary" id="send-profile-presets-btn">Send Presets</button>
<button class="btn btn-secondary" id="patterns-btn">Patterns</button>
<button class="btn btn-secondary" id="profiles-btn">Profiles</button>
<button class="btn btn-secondary" id="settings-btn">Settings</button>
<button class="btn btn-secondary" id="help-btn">Help</button>
</div>
<div class="header-menu-mobile">
<button class="btn btn-secondary" id="main-menu-btn">Menu</button>
<div id="main-menu-dropdown" class="main-menu-dropdown">
<button type="button" data-target="tabs-btn">Tabs</button>
<button type="button" data-target="color-palette-btn">Color Palette</button>
<button type="button" data-target="presets-btn">Presets</button>
<button type="button" data-target="send-profile-presets-btn">Send Presets</button>
<button type="button" data-target="patterns-btn">Patterns</button>
<button type="button" data-target="profiles-btn">Profiles</button>
<button type="button" data-target="settings-btn">Settings</button>
<button type="button" data-target="help-btn">Help</button>
</div>
</div>
</header>
<div class="main-content">
<div id="tab-content" class="tab-content"> <div id="tab-content" class="tab-content">
<div style="padding: 2rem; text-align: center; color: #aaa;"> <div class="tab-content-placeholder">
Select a tab to get started Select a tab to get started
</div> </div>
</div> </div>
@@ -58,14 +70,16 @@
<h2>Edit Tab</h2> <h2>Edit Tab</h2>
<form id="edit-tab-form"> <form id="edit-tab-form">
<input type="hidden" id="edit-tab-id"> <input type="hidden" id="edit-tab-id">
<div class="modal-actions" style="margin-bottom: 1rem;">
<button type="submit" class="btn btn-primary">Save</button>
<button type="button" class="btn btn-secondary" onclick="document.getElementById('edit-tab-modal').classList.remove('active')">Close</button>
</div>
<label>Tab Name:</label> <label>Tab Name:</label>
<input type="text" id="edit-tab-name" placeholder="Enter tab name" required> <input type="text" id="edit-tab-name" placeholder="Enter tab name" required>
<label>Device IDs (comma-separated):</label> <label>Device IDs (comma-separated):</label>
<input type="text" id="edit-tab-ids" placeholder="1,2,3" required> <input type="text" id="edit-tab-ids" placeholder="1,2,3" required>
<div class="modal-actions"> <label style="margin-top: 1rem;">Add presets to this tab</label>
<button type="submit" class="btn btn-primary">Save</button> <div id="edit-tab-presets-list" class="profiles-list" style="max-height: 200px; overflow-y: auto; margin-bottom: 1rem;"></div>
<button type="button" class="btn btn-secondary" onclick="document.getElementById('edit-tab-modal').classList.remove('active')">Cancel</button>
</div>
</form> </form>
</div> </div>
</div> </div>
@@ -111,17 +125,17 @@
</div> </div>
<label>Colors</label> <label>Colors</label>
<div id="preset-colors-container" class="preset-colors-container"></div> <div id="preset-colors-container" class="preset-colors-container"></div>
<div class="profiles-actions" style="margin-top: 0.5rem;"> <div class="profiles-actions">
<input type="color" id="preset-new-color" value="#ffffff"> <input type="color" id="preset-new-color" value="#ffffff">
<button class="btn btn-secondary btn-small" id="preset-add-color-btn">Add Color</button> <button class="btn btn-secondary btn-small" id="preset-add-color-btn">Add Color</button>
<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;"> <div class="preset-editor-field">
<label for="preset-brightness-input">Brightness (0255)</label> <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>
<div style="flex: 1; display: flex; flex-direction: column;"> <div class="preset-editor-field">
<label for="preset-delay-input">Delay (ms)</label> <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>
@@ -207,7 +221,7 @@
<ul> <ul>
<li><strong>Select tab</strong>: left-click a tab button in the top bar.</li> <li><strong>Select tab</strong>: left-click a tab button in the top bar.</li>
<li><strong>Edit tab</strong>: right-click a tab button, or click <strong>Edit</strong> in the Tabs modal.</li> <li><strong>Edit tab</strong>: right-click a tab button, or click <strong>Edit</strong> in the Tabs modal.</li>
<li><strong>Send all presets</strong>: use the <strong>Send Presets</strong> button in the tab header to push every preset used in that tab to all devices.</li> <li><strong>Send all presets</strong>: open the <strong>Tabs</strong> menu and click <strong>Send Presets</strong> next to the tab to push every preset used in that tab to all devices.</li>
</ul> </ul>
<h3>Presets in a tab</h3> <h3>Presets in a tab</h3>
@@ -233,11 +247,11 @@
<!-- Settings Modal --> <!-- Settings Modal -->
<div id="settings-modal" class="modal"> <div id="settings-modal" class="modal">
<div class="modal-content" style="max-width: 900px; max-height: 90vh; overflow-y: auto;"> <div class="modal-content">
<h2>Device Settings</h2> <h2>Device Settings</h2>
<p class="muted-text" style="margin-bottom: 1rem;">Configure WiFi and device settings.</p> <p class="muted-text">Configure WiFi and device settings.</p>
<div id="settings-message" class="message" style="display:none;"></div> <div id="settings-message" class="message"></div>
<!-- Device Name --> <!-- Device Name -->
<div class="settings-section"> <div class="settings-section">
@@ -297,7 +311,7 @@
</div> </div>
<!-- WiFi Access Point Settings --> <!-- WiFi Access Point Settings -->
<div class="settings-section" style="margin-top: 1.5rem;"> <div class="settings-section ap-settings-section">
<h3>WiFi Access Point</h3> <h3>WiFi Access Point</h3>
<div id="ap-status" class="status-info"> <div id="ap-status" class="status-info">
@@ -336,180 +350,7 @@
</div> </div>
</div> </div>
<style> <!-- Styles moved to /static/style.css -->
.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;
}
/* Ensure presets list uses grid layout */
#presets-list-tab {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 0.75rem;
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.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;
}
</style>
<script src="/static/tabs.js"></script> <script src="/static/tabs.js"></script>
<script src="/static/help.js"></script> <script src="/static/help.js"></script>
<script src="/static/color_palette.js"></script> <script src="/static/color_palette.js"></script>