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

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