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:
@@ -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);
|
||||
|
||||
@@ -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';
|
||||
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
|
||||
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) => {
|
||||
|
||||
@@ -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.");
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
@@ -274,30 +332,18 @@ async function loadTabContent(tabId) {
|
||||
</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
|
||||
|
||||
@@ -9,27 +9,39 @@
|
||||
<body>
|
||||
<div class="app-container">
|
||||
<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 id="tabs-list">
|
||||
Loading tabs...
|
||||
</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 style="padding: 2rem; text-align: center; color: #aaa;">
|
||||
<div class="tab-content-placeholder">
|
||||
Select a tab to get started
|
||||
</div>
|
||||
</div>
|
||||
@@ -58,14 +70,16 @@
|
||||
<h2>Edit Tab</h2>
|
||||
<form id="edit-tab-form">
|
||||
<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>
|
||||
<input type="text" id="edit-tab-name" placeholder="Enter tab name" required>
|
||||
<label>Device IDs (comma-separated):</label>
|
||||
<input type="text" id="edit-tab-ids" placeholder="1,2,3" required>
|
||||
<div class="modal-actions">
|
||||
<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')">Cancel</button>
|
||||
</div>
|
||||
<label style="margin-top: 1rem;">Add presets to this tab</label>
|
||||
<div id="edit-tab-presets-list" class="profiles-list" style="max-height: 200px; overflow-y: auto; margin-bottom: 1rem;"></div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@@ -111,17 +125,17 @@
|
||||
</div>
|
||||
<label>Colors</label>
|
||||
<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">
|
||||
<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>
|
||||
</div>
|
||||
<div class="profiles-actions">
|
||||
<div style="flex: 1; display: flex; flex-direction: column;">
|
||||
<div class="preset-editor-field">
|
||||
<label for="preset-brightness-input">Brightness (0–255)</label>
|
||||
<input type="number" id="preset-brightness-input" placeholder="Brightness" min="0" max="255" value="0">
|
||||
</div>
|
||||
<div style="flex: 1; display: flex; flex-direction: column;">
|
||||
<div class="preset-editor-field">
|
||||
<label for="preset-delay-input">Delay (ms)</label>
|
||||
<input type="number" id="preset-delay-input" placeholder="Delay" min="0" max="10000" value="0">
|
||||
</div>
|
||||
@@ -207,7 +221,7 @@
|
||||
<ul>
|
||||
<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>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>
|
||||
|
||||
<h3>Presets in a tab</h3>
|
||||
@@ -233,11 +247,11 @@
|
||||
|
||||
<!-- Settings 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>
|
||||
<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 -->
|
||||
<div class="settings-section">
|
||||
@@ -297,7 +311,7 @@
|
||||
</div>
|
||||
|
||||
<!-- 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>
|
||||
|
||||
<div id="ap-status" class="status-info">
|
||||
@@ -336,180 +350,7 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.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>
|
||||
<!-- Styles moved to /static/style.css -->
|
||||
<script src="/static/tabs.js"></script>
|
||||
<script src="/static/help.js"></script>
|
||||
<script src="/static/color_palette.js"></script>
|
||||
|
||||
Reference in New Issue
Block a user