feat(ui): devices tcp status, tabs send, preset websocket hooks
Made-with: Cursor
This commit is contained in:
@@ -2,6 +2,100 @@
|
||||
|
||||
const HEX_BOX_COUNT = 12;
|
||||
|
||||
/** Last TCP snapshot from WebSocket (so we can apply after async list render). */
|
||||
let lastTcpSnapshotIps = null;
|
||||
|
||||
/** Match server-side ``normalize_tcp_peer_ip`` for WS events vs registry rows. */
|
||||
function normalizeWifiAddressForMatch(addr) {
|
||||
let s = String(addr || '').trim();
|
||||
if (s.toLowerCase().startsWith('::ffff:')) {
|
||||
s = s.slice(7);
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
const DEVICES_MODAL_POLL_MS = 1000;
|
||||
|
||||
let devicesModalLiveTimer = null;
|
||||
|
||||
function stopDevicesModalLiveRefresh() {
|
||||
if (devicesModalLiveTimer != null) {
|
||||
clearInterval(devicesModalLiveTimer);
|
||||
devicesModalLiveTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Refetch registry and re-render the list (no loading spinner). Keeps scroll position.
|
||||
* Used while the devices modal stays open so new TCP devices, renames, and removals appear live.
|
||||
*/
|
||||
async function refreshDevicesListQuiet() {
|
||||
const modal = document.getElementById('devices-modal');
|
||||
if (!modal || !modal.classList.contains('active')) return;
|
||||
const container = document.getElementById('devices-list-modal');
|
||||
if (!container) return;
|
||||
const prevTop = container.scrollTop;
|
||||
try {
|
||||
const res = await fetch('/devices', { headers: { Accept: 'application/json' } });
|
||||
if (!res.ok) return;
|
||||
const data = await res.json();
|
||||
renderDevicesList(data || {});
|
||||
container.scrollTop = prevTop;
|
||||
} catch (_) {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
function startDevicesModalLiveRefresh() {
|
||||
stopDevicesModalLiveRefresh();
|
||||
devicesModalLiveTimer = setInterval(() => {
|
||||
refreshDevicesListQuiet();
|
||||
}, DEVICES_MODAL_POLL_MS);
|
||||
}
|
||||
|
||||
function updateWifiRowDot(row, connected) {
|
||||
const dot = row.querySelector('.device-status-dot');
|
||||
if (!dot) return;
|
||||
if ((row.dataset.deviceTransport || '') !== 'wifi') return;
|
||||
dot.classList.remove('device-status-dot--online', 'device-status-dot--offline', 'device-status-dot--unknown');
|
||||
if (connected) {
|
||||
dot.classList.add('device-status-dot--online');
|
||||
dot.title = 'Connected (Wi-Fi TCP session)';
|
||||
} else {
|
||||
dot.classList.add('device-status-dot--offline');
|
||||
dot.title = 'Not connected (no Wi-Fi TCP session)';
|
||||
}
|
||||
dot.setAttribute('aria-label', dot.title);
|
||||
}
|
||||
|
||||
function applyTcpSnapshot(ips) {
|
||||
const set = new Set(
|
||||
(ips || []).map((x) => normalizeWifiAddressForMatch(x)).filter(Boolean),
|
||||
);
|
||||
const container = document.getElementById('devices-list-modal');
|
||||
if (!container) return;
|
||||
container.querySelectorAll('.profiles-row[data-device-transport="wifi"]').forEach((row) => {
|
||||
const addr = normalizeWifiAddressForMatch(row.dataset.deviceAddress);
|
||||
updateWifiRowDot(row, set.has(addr));
|
||||
});
|
||||
}
|
||||
|
||||
/** Keep cached snapshot aligned with incremental WS events (connect/disconnect). */
|
||||
function mergeTcpSnapshotPresence(ip, connected) {
|
||||
const n = normalizeWifiAddressForMatch(ip);
|
||||
if (!n) return;
|
||||
const prev = lastTcpSnapshotIps;
|
||||
const set = new Set(
|
||||
(Array.isArray(prev) ? prev : []).map((x) => normalizeWifiAddressForMatch(x)).filter(Boolean),
|
||||
);
|
||||
if (connected) {
|
||||
set.add(n);
|
||||
} else {
|
||||
set.delete(n);
|
||||
}
|
||||
lastTcpSnapshotIps = Array.from(set);
|
||||
}
|
||||
|
||||
function makeHexAddressBoxes(container) {
|
||||
if (!container || container.querySelector('.hex-addr-box')) return;
|
||||
container.innerHTML = '';
|
||||
@@ -75,6 +169,9 @@ function getAddressForPayload(transport) {
|
||||
async function loadDevicesModal() {
|
||||
const container = document.getElementById('devices-list-modal');
|
||||
if (!container) return;
|
||||
if (typeof window.getEspnowSocket === 'function') {
|
||||
window.getEspnowSocket();
|
||||
}
|
||||
container.innerHTML = '<span class="muted-text">Loading...</span>';
|
||||
try {
|
||||
const response = await fetch('/devices', { headers: { Accept: 'application/json' } });
|
||||
@@ -101,31 +198,82 @@ function renderDevicesList(devices) {
|
||||
}
|
||||
ids.forEach((devId) => {
|
||||
const dev = devices[devId];
|
||||
const t = (dev && dev.type) || 'led';
|
||||
const tr = (dev && dev.transport) || 'espnow';
|
||||
const addrRaw = (dev && dev.address) != null ? String(dev.address).trim() : '';
|
||||
const addrDisplay = addrRaw || '—';
|
||||
|
||||
const row = document.createElement('div');
|
||||
row.className = 'profiles-row';
|
||||
row.style.display = 'flex';
|
||||
row.style.alignItems = 'center';
|
||||
row.style.gap = '0.5rem';
|
||||
row.style.flexWrap = 'wrap';
|
||||
row.dataset.deviceId = devId;
|
||||
row.dataset.deviceTransport = tr;
|
||||
row.dataset.deviceAddress = addrRaw;
|
||||
|
||||
const dot = document.createElement('span');
|
||||
dot.className = 'device-status-dot';
|
||||
dot.setAttribute('role', 'img');
|
||||
const live = dev && Object.prototype.hasOwnProperty.call(dev, 'connected') ? dev.connected : null;
|
||||
if (live === true) {
|
||||
dot.classList.add('device-status-dot--online');
|
||||
dot.title = 'Connected (Wi-Fi TCP session)';
|
||||
dot.setAttribute('aria-label', dot.title);
|
||||
} else if (live === false) {
|
||||
dot.classList.add('device-status-dot--offline');
|
||||
dot.title = 'Not connected (no Wi-Fi TCP session)';
|
||||
dot.setAttribute('aria-label', dot.title);
|
||||
} else {
|
||||
dot.classList.add('device-status-dot--unknown');
|
||||
dot.title = 'ESP-NOW — TCP status does not apply';
|
||||
dot.setAttribute('aria-label', dot.title);
|
||||
}
|
||||
|
||||
const label = document.createElement('span');
|
||||
label.textContent = (dev && dev.name) || devId;
|
||||
label.style.flex = '1';
|
||||
label.style.minWidth = '100px';
|
||||
|
||||
const macEl = document.createElement('code');
|
||||
macEl.className = 'device-row-mac';
|
||||
macEl.textContent = devId;
|
||||
macEl.title = 'MAC (registry id)';
|
||||
|
||||
const meta = document.createElement('span');
|
||||
meta.className = 'muted-text';
|
||||
meta.style.fontSize = '0.85em';
|
||||
const t = (dev && dev.type) || 'led';
|
||||
const tr = (dev && dev.transport) || 'espnow';
|
||||
const addr = (dev && dev.address) ? dev.address : '—';
|
||||
meta.textContent = `${t} · ${tr} · ${addr}`;
|
||||
meta.textContent = `${t} · ${tr} · ${addrDisplay}`;
|
||||
|
||||
const editBtn = document.createElement('button');
|
||||
editBtn.className = 'btn btn-secondary btn-small';
|
||||
editBtn.textContent = 'Edit';
|
||||
editBtn.addEventListener('click', () => openEditDeviceModal(devId, dev));
|
||||
|
||||
const identifyBtn = document.createElement('button');
|
||||
identifyBtn.className = 'btn btn-primary btn-small';
|
||||
identifyBtn.type = 'button';
|
||||
identifyBtn.textContent = 'Identify';
|
||||
identifyBtn.title = 'Red blink at 10 Hz (~50% brightness) for 2 s, then off (not saved as a preset)';
|
||||
identifyBtn.addEventListener('click', async () => {
|
||||
try {
|
||||
const res = await fetch(`/devices/${encodeURIComponent(devId)}/identify`, {
|
||||
method: 'POST',
|
||||
credentials: 'same-origin',
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) {
|
||||
alert(data.error || 'Identify failed');
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
alert('Identify failed');
|
||||
}
|
||||
});
|
||||
|
||||
const deleteBtn = document.createElement('button');
|
||||
deleteBtn.className = 'btn btn-secondary btn-small';
|
||||
deleteBtn.textContent = 'Delete';
|
||||
@@ -144,12 +292,18 @@ function renderDevicesList(devices) {
|
||||
}
|
||||
});
|
||||
|
||||
row.appendChild(dot);
|
||||
row.appendChild(label);
|
||||
row.appendChild(macEl);
|
||||
row.appendChild(meta);
|
||||
row.appendChild(editBtn);
|
||||
row.appendChild(identifyBtn);
|
||||
row.appendChild(deleteBtn);
|
||||
container.appendChild(row);
|
||||
});
|
||||
// Do not re-apply lastTcpSnapshotIps here: it is only updated on WS open and
|
||||
// device_tcp events; re-applying after each /devices poll overwrites correct
|
||||
// API "connected" with a stale list and leaves Wi-Fi rows stuck online.
|
||||
}
|
||||
|
||||
function openEditDeviceModal(devId, dev) {
|
||||
@@ -201,6 +355,29 @@ async function updateDevice(devId, name, type, transport, address) {
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
window.addEventListener('deviceTcpStatus', (ev) => {
|
||||
const { ip, connected } = ev.detail || {};
|
||||
if (ip == null || typeof connected !== 'boolean') return;
|
||||
mergeTcpSnapshotPresence(ip, connected);
|
||||
const norm = normalizeWifiAddressForMatch(ip);
|
||||
const container = document.getElementById('devices-list-modal');
|
||||
if (!container) return;
|
||||
container.querySelectorAll('.profiles-row[data-device-transport="wifi"]').forEach((row) => {
|
||||
if (normalizeWifiAddressForMatch(row.dataset.deviceAddress) === norm) {
|
||||
updateWifiRowDot(row, connected);
|
||||
}
|
||||
});
|
||||
});
|
||||
window.addEventListener('deviceTcpSnapshot', (ev) => {
|
||||
const ips = ev.detail && ev.detail.connectedIps;
|
||||
lastTcpSnapshotIps = ips;
|
||||
applyTcpSnapshot(ips);
|
||||
});
|
||||
|
||||
window.addEventListener('deviceTcpWsOpen', () => {
|
||||
refreshDevicesListQuiet();
|
||||
});
|
||||
|
||||
makeHexAddressBoxes(document.getElementById('edit-device-address-boxes'));
|
||||
|
||||
const transportEdit = document.getElementById('edit-device-transport');
|
||||
@@ -220,11 +397,26 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
if (devicesBtn && devicesModal) {
|
||||
devicesBtn.addEventListener('click', () => {
|
||||
devicesModal.classList.add('active');
|
||||
if (typeof window.getEspnowSocket === 'function') {
|
||||
window.getEspnowSocket();
|
||||
}
|
||||
loadDevicesModal();
|
||||
startDevicesModalLiveRefresh();
|
||||
});
|
||||
}
|
||||
if (devicesCloseBtn) {
|
||||
devicesCloseBtn.addEventListener('click', () => devicesModal && devicesModal.classList.remove('active'));
|
||||
devicesCloseBtn.addEventListener('click', () => {
|
||||
if (devicesModal) devicesModal.classList.remove('active');
|
||||
});
|
||||
}
|
||||
|
||||
const devicesModalEl = document.getElementById('devices-modal');
|
||||
if (devicesModalEl) {
|
||||
new MutationObserver(() => {
|
||||
if (!devicesModalEl.classList.contains('active')) {
|
||||
stopDevicesModalLiveRefresh();
|
||||
}
|
||||
}).observe(devicesModalEl, { attributes: true, attributeFilter: ['class'] });
|
||||
}
|
||||
|
||||
if (editForm) {
|
||||
|
||||
@@ -74,12 +74,14 @@ const getEspnowSocket = () => {
|
||||
return espnowSocket;
|
||||
}
|
||||
|
||||
const wsUrl = `ws://${window.location.host}/ws`;
|
||||
const wsScheme = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const wsUrl = `${wsScheme}//${window.location.host}/ws`;
|
||||
espnowSocket = new WebSocket(wsUrl);
|
||||
espnowSocketReady = false;
|
||||
|
||||
espnowSocket.onopen = () => {
|
||||
espnowSocketReady = true;
|
||||
window.dispatchEvent(new CustomEvent('deviceTcpWsOpen'));
|
||||
// Flush any queued messages
|
||||
espnowPendingMessages.forEach((msg) => {
|
||||
try {
|
||||
@@ -94,6 +96,18 @@ const getEspnowSocket = () => {
|
||||
espnowSocket.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
if (data && data.type === 'device_tcp' && typeof data.connected === 'boolean' && data.ip) {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('deviceTcpStatus', { detail: { ip: data.ip, connected: data.connected } }),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (data && data.type === 'device_tcp_snapshot' && Array.isArray(data.connected_ips)) {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('deviceTcpSnapshot', { detail: { connectedIps: data.connected_ips } }),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (data && data.error) {
|
||||
console.error('ESP-NOW:', data.error);
|
||||
alert('ESP-NOW send failed. ' + (data.error === 'ESP-NOW send failed' ? 'Check device WiFi/interface.' : data.error));
|
||||
@@ -130,17 +144,44 @@ const sendEspnowMessage = (obj) => {
|
||||
}
|
||||
};
|
||||
|
||||
// Send a select message for a preset to all device names in the current tab.
|
||||
// Uses the preset ID as the select key.
|
||||
const sendSelectForCurrentTabDevices = (presetId, sectionEl) => {
|
||||
function tabDeviceNamesFromSection(section) {
|
||||
if (typeof window.parseTabDeviceNames === 'function') {
|
||||
return window.parseTabDeviceNames(section);
|
||||
}
|
||||
const namesAttr = section && section.getAttribute('data-device-names');
|
||||
return namesAttr
|
||||
? namesAttr.split(',').map((n) => n.trim()).filter((n) => n.length > 0)
|
||||
: [];
|
||||
}
|
||||
|
||||
async function postDriverSequence(sequence, targetMacs, delayS) {
|
||||
const body = {
|
||||
sequence,
|
||||
targets: Array.isArray(targetMacs) && targetMacs.length ? [...new Set(targetMacs)] : undefined,
|
||||
};
|
||||
if (delayS != null && delayS >= 0) {
|
||||
body.delay_s = delayS;
|
||||
}
|
||||
const res = await fetch('/presets/push', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
credentials: 'same-origin',
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({}));
|
||||
throw new Error((err && err.error) || res.statusText || 'Send failed');
|
||||
}
|
||||
return res.json().catch(() => ({}));
|
||||
}
|
||||
|
||||
// Send a select message for a preset to all devices on the current tab (ESP-NOW or Wi-Fi).
|
||||
const sendSelectForCurrentTabDevices = async (presetId, sectionEl) => {
|
||||
const section = sectionEl || document.querySelector('.presets-section[data-tab-id]');
|
||||
if (!section || !presetId) {
|
||||
return;
|
||||
}
|
||||
const namesAttr = section.getAttribute('data-device-names');
|
||||
const deviceNames = namesAttr
|
||||
? namesAttr.split(',').map((n) => n.trim()).filter((n) => n.length > 0)
|
||||
: [];
|
||||
const deviceNames = tabDeviceNamesFromSection(section);
|
||||
|
||||
if (!deviceNames.length) {
|
||||
return;
|
||||
@@ -148,15 +189,23 @@ const sendSelectForCurrentTabDevices = (presetId, sectionEl) => {
|
||||
|
||||
const select = {};
|
||||
deviceNames.forEach((name) => {
|
||||
select[name] = [presetId];
|
||||
if (name) {
|
||||
select[name] = [presetId];
|
||||
}
|
||||
});
|
||||
|
||||
const message = {
|
||||
v: '1',
|
||||
select,
|
||||
};
|
||||
const targetMacs =
|
||||
typeof window.tabsManager !== 'undefined' &&
|
||||
typeof window.tabsManager.resolveTabDeviceMacs === 'function'
|
||||
? await window.tabsManager.resolveTabDeviceMacs(deviceNames)
|
||||
: [];
|
||||
|
||||
sendEspnowMessage(message);
|
||||
try {
|
||||
await postDriverSequence([{ v: '1', select }], targetMacs);
|
||||
} catch (err) {
|
||||
console.error('sendSelectForCurrentTabDevices:', err);
|
||||
alert('Failed to send preset selection to devices.');
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
@@ -812,10 +861,10 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
const sendButton = document.createElement('button');
|
||||
sendButton.className = 'btn btn-primary btn-small';
|
||||
sendButton.textContent = 'Send';
|
||||
sendButton.title = 'Send this preset via ESPNow';
|
||||
sendButton.title = 'Send this preset to drivers';
|
||||
sendButton.addEventListener('click', () => {
|
||||
// Just send the definition; selection happens when user clicks the preset.
|
||||
sendPresetViaEspNow(presetId, preset || {});
|
||||
void sendPresetViaEspNow(presetId, preset || {}, []);
|
||||
});
|
||||
|
||||
const deleteButton = document.createElement('button');
|
||||
@@ -1222,10 +1271,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
}
|
||||
// Send current editor values and then select on all devices in the current tab (if any)
|
||||
const section = document.querySelector('.presets-section[data-tab-id]');
|
||||
const namesAttr = section && section.getAttribute('data-device-names');
|
||||
const deviceNames = namesAttr
|
||||
? namesAttr.split(',').map((n) => n.trim()).filter((n) => n.length > 0)
|
||||
: [];
|
||||
const deviceNames = tabDeviceNamesFromSection(section);
|
||||
// Work out the preset ID: for existing presets use currentEditId, otherwise fall back to name
|
||||
const presetId = currentEditId || payload.name;
|
||||
// Try sends preset first, then select; never persist on device.
|
||||
@@ -1241,13 +1287,10 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
return;
|
||||
}
|
||||
const section = document.querySelector('.presets-section[data-tab-id]');
|
||||
const namesAttr = section && section.getAttribute('data-device-names');
|
||||
const deviceNames = namesAttr
|
||||
? namesAttr.split(',').map((n) => n.trim()).filter((n) => n.length > 0)
|
||||
: [];
|
||||
const deviceNames = tabDeviceNamesFromSection(section);
|
||||
const presetId = currentEditId || payload.name;
|
||||
await updateTabDefaultPreset(presetId);
|
||||
sendDefaultPreset(presetId, deviceNames);
|
||||
await sendDefaultPreset(presetId, deviceNames);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1285,20 +1328,20 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
if (currentEditId) {
|
||||
// PUT returns the preset object directly; use the existing ID
|
||||
// Save & Send should not force-select the preset on devices.
|
||||
sendPresetViaEspNow(currentEditId, saved, [], true, false);
|
||||
await sendPresetViaEspNow(currentEditId, saved, [], true, false);
|
||||
} else {
|
||||
// POST returns { id: preset }
|
||||
const entries = Object.entries(saved);
|
||||
if (entries.length > 0) {
|
||||
const [newId, presetData] = entries[0];
|
||||
// Save & Send should not force-select the preset on devices.
|
||||
sendPresetViaEspNow(newId, presetData, [], true, false);
|
||||
await sendPresetViaEspNow(newId, presetData, [], true, false);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Fallback: send what we just built
|
||||
// Save & Send should not force-select the preset on devices.
|
||||
sendPresetViaEspNow(payload.name, payload, [], true, false);
|
||||
await sendPresetViaEspNow(payload.name, payload, [], true, false);
|
||||
}
|
||||
|
||||
await loadPresets();
|
||||
@@ -1340,7 +1383,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
clearForm();
|
||||
});
|
||||
|
||||
// Build ESPNow messages for a single preset.
|
||||
// Build driver messages for a single preset; deliver via /presets/push (ESP-NOW + TCP).
|
||||
// Send order:
|
||||
// 1) preset payload (optionally with save)
|
||||
// 2) optional select for device names (never with save)
|
||||
@@ -1380,55 +1423,69 @@ const sendPresetViaEspNow = async (presetId, preset, deviceNames, saveToDevice =
|
||||
presetMessage.default = presetId;
|
||||
}
|
||||
|
||||
// 1) Send presets first, without save.
|
||||
sendEspnowMessage(presetMessage);
|
||||
const names = Array.isArray(deviceNames) ? deviceNames : [];
|
||||
const targetMacs =
|
||||
names.length > 0 &&
|
||||
typeof window.tabsManager !== 'undefined' &&
|
||||
typeof window.tabsManager.resolveTabDeviceMacs === 'function'
|
||||
? await window.tabsManager.resolveTabDeviceMacs(names)
|
||||
: [];
|
||||
|
||||
// Optionally send a separate select message for specific devices.
|
||||
if (Array.isArray(deviceNames) && deviceNames.length > 0) {
|
||||
const sequence = [presetMessage];
|
||||
if (names.length > 0) {
|
||||
const select = {};
|
||||
deviceNames.forEach((name) => {
|
||||
names.forEach((name) => {
|
||||
if (name) {
|
||||
select[name] = [presetId];
|
||||
}
|
||||
});
|
||||
if (Object.keys(select).length > 0) {
|
||||
// Small gap helps slower receivers process preset update before select.
|
||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||
sendEspnowMessage({ v: '1', select });
|
||||
sequence.push({ v: '1', select });
|
||||
}
|
||||
}
|
||||
|
||||
await postDriverSequence(sequence, targetMacs, 0.05);
|
||||
} catch (error) {
|
||||
console.error('Failed to send preset via ESPNow:', error);
|
||||
alert('Failed to send preset via ESPNow.');
|
||||
console.error('Failed to send preset to devices:', error);
|
||||
alert('Failed to send preset to devices.');
|
||||
}
|
||||
};
|
||||
|
||||
const sendDefaultPreset = (presetId, deviceNames) => {
|
||||
const sendDefaultPreset = async (presetId, deviceNames) => {
|
||||
if (!presetId) {
|
||||
alert('Select a preset to set as default.');
|
||||
return;
|
||||
}
|
||||
// Default should only set startup preset, not trigger live selection.
|
||||
// Save is attached to default messages.
|
||||
// When device names are provided, scope the default update to those devices.
|
||||
const targets = Array.isArray(deviceNames)
|
||||
const nameTargets = Array.isArray(deviceNames)
|
||||
? deviceNames.map((n) => (n || '').trim()).filter((n) => n.length > 0)
|
||||
: [];
|
||||
const message = { v: '1', default: presetId };
|
||||
message.save = true;
|
||||
if (targets.length > 0) {
|
||||
message.targets = targets;
|
||||
if (nameTargets.length > 0) {
|
||||
message.targets = nameTargets;
|
||||
}
|
||||
const macTargets =
|
||||
nameTargets.length > 0 &&
|
||||
typeof window.tabsManager !== 'undefined' &&
|
||||
typeof window.tabsManager.resolveTabDeviceMacs === 'function'
|
||||
? await window.tabsManager.resolveTabDeviceMacs(nameTargets)
|
||||
: [];
|
||||
try {
|
||||
await postDriverSequence([message], macTargets);
|
||||
} catch (e) {
|
||||
console.error('sendDefaultPreset:', e);
|
||||
alert('Failed to send default preset to devices.');
|
||||
}
|
||||
sendEspnowMessage(message);
|
||||
};
|
||||
|
||||
// Expose for other scripts (tabs.js) so they can reuse the shared WebSocket.
|
||||
try {
|
||||
window.sendPresetViaEspNow = sendPresetViaEspNow;
|
||||
window.postDriverSequence = postDriverSequence;
|
||||
// Expose a generic ESPNow sender so other scripts (tabs.js) can send
|
||||
// non-preset messages such as global brightness.
|
||||
window.sendEspnowRaw = sendEspnowMessage;
|
||||
window.getEspnowSocket = getEspnowSocket;
|
||||
} catch (e) {
|
||||
// window may not exist in some environments; ignore.
|
||||
}
|
||||
@@ -1756,7 +1813,9 @@ const createPresetButton = (presetId, preset, tabId, isSelected = false) => {
|
||||
button.classList.add('active');
|
||||
selectedPresets[tabId] = presetId;
|
||||
const section = row.closest('.presets-section');
|
||||
sendSelectForCurrentTabDevices(presetId, section);
|
||||
sendSelectForCurrentTabDevices(presetId, section).catch((err) => {
|
||||
console.error(err);
|
||||
});
|
||||
});
|
||||
|
||||
if (canDrag) {
|
||||
@@ -1926,6 +1985,12 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
const next = getPresetUiMode() === 'edit' ? 'run' : 'edit';
|
||||
setPresetUiMode(next);
|
||||
updateUiModeToggleButtons();
|
||||
if (next === 'run') {
|
||||
['devices-modal', 'edit-device-modal'].forEach((id) => {
|
||||
const el = document.getElementById(id);
|
||||
if (el) el.classList.remove('active');
|
||||
});
|
||||
}
|
||||
const mainMenu = document.getElementById('main-menu-dropdown');
|
||||
if (mainMenu) mainMenu.classList.remove('open');
|
||||
const leftPanel = document.querySelector('.presets-section[data-tab-id]');
|
||||
|
||||
@@ -53,6 +53,12 @@ input.hex-addr-box {
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
.device-row-mac {
|
||||
font-size: 0.82em;
|
||||
color: #b0b0b0;
|
||||
letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.device-form-actions {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
@@ -601,6 +607,29 @@ body.preset-ui-run .edit-mode-only {
|
||||
color: #f44336;
|
||||
}
|
||||
|
||||
/* Devices modal: live TCP presence (Wi-Fi only) */
|
||||
.device-status-dot {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.device-status-dot--online {
|
||||
background: #4caf50;
|
||||
box-shadow: 0 0 6px rgba(76, 175, 80, 0.45);
|
||||
}
|
||||
|
||||
.device-status-dot--offline {
|
||||
background: #616161;
|
||||
}
|
||||
|
||||
.device-status-dot--unknown {
|
||||
background: #424242;
|
||||
border: 1px solid #757575;
|
||||
}
|
||||
|
||||
.btn-group {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
@@ -1034,6 +1063,65 @@ body.preset-ui-run .edit-mode-only {
|
||||
background-color: #3a3a3a;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.tab-modal-create-row {
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.tab-modal-create-row input[type="text"] {
|
||||
flex: 1;
|
||||
min-width: 8rem;
|
||||
}
|
||||
|
||||
.tab-devices-label {
|
||||
display: block;
|
||||
margin-top: 0.75rem;
|
||||
margin-bottom: 0.35rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tab-devices-editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
margin-bottom: 0.5rem;
|
||||
max-height: 14rem;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.tab-device-row-label {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.tab-device-add-select {
|
||||
flex: 1;
|
||||
min-width: 10rem;
|
||||
padding: 0.5rem;
|
||||
background-color: #3a3a3a;
|
||||
border: 1px solid #4a4a4a;
|
||||
border-radius: 4px;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.tab-devices-add {
|
||||
margin-top: 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.tab-presets-section-label {
|
||||
display: block;
|
||||
margin-top: 1rem;
|
||||
margin-bottom: 0.35rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.edit-tab-presets-scroll {
|
||||
max-height: 200px;
|
||||
overflow-y: auto;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
/* Hide any text content in palette rows - only show color swatches */
|
||||
#palette-container .profiles-row {
|
||||
font-size: 0; /* Hide any text nodes */
|
||||
|
||||
@@ -18,6 +18,147 @@ function getCurrentTabFromCookie() {
|
||||
return null;
|
||||
}
|
||||
|
||||
async function fetchDevicesMap() {
|
||||
try {
|
||||
const response = await fetch("/devices", { headers: { Accept: "application/json" } });
|
||||
if (!response.ok) return {};
|
||||
const data = await response.json();
|
||||
return data && typeof data === "object" ? data : {};
|
||||
} catch (e) {
|
||||
console.error("fetchDevicesMap:", e);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
/** Registry MACs for tab device names (order matches tab names; skips unknown names). */
|
||||
async function resolveTabDeviceMacs(tabNames) {
|
||||
const dm = await fetchDevicesMap();
|
||||
const rows = namesToRows(Array.isArray(tabNames) ? tabNames : [], dm);
|
||||
const macs = rows.map((r) => r.mac).filter(Boolean);
|
||||
return [...new Set(macs)];
|
||||
}
|
||||
|
||||
function namesToRows(tabNames, devicesMap) {
|
||||
const usedMacs = new Set();
|
||||
const list = Array.isArray(tabNames) ? tabNames : [];
|
||||
return list.map((name) => {
|
||||
const n = String(name || "").trim();
|
||||
const matches = Object.entries(devicesMap || {}).filter(
|
||||
([mac, d]) => d && String((d.name || "").trim()) === n && !usedMacs.has(mac),
|
||||
);
|
||||
if (matches.length === 0) {
|
||||
return { mac: null, name: n || "unknown" };
|
||||
}
|
||||
const [mac] = matches[0];
|
||||
usedMacs.add(mac);
|
||||
return { mac, name: n };
|
||||
});
|
||||
}
|
||||
|
||||
function rowsToNames(rows) {
|
||||
return (rows || []).map((r) => String(r.name || "").trim()).filter((n) => n.length > 0);
|
||||
}
|
||||
|
||||
function renderTabDevicesEditor(containerEl, rows, devicesMap) {
|
||||
if (!containerEl) return;
|
||||
containerEl.innerHTML = "";
|
||||
const entries = Object.entries(devicesMap || {}).sort(([a], [b]) => a.localeCompare(b));
|
||||
|
||||
rows.forEach((row, idx) => {
|
||||
const div = document.createElement("div");
|
||||
div.className = "tab-device-row profiles-row";
|
||||
const label = document.createElement("span");
|
||||
label.className = "tab-device-row-label";
|
||||
const strong = document.createElement("strong");
|
||||
strong.textContent = row.name || "—";
|
||||
label.appendChild(strong);
|
||||
label.appendChild(document.createTextNode(" "));
|
||||
const sub = document.createElement("span");
|
||||
sub.className = "muted-text";
|
||||
sub.textContent = row.mac ? row.mac : "(not in registry)";
|
||||
label.appendChild(sub);
|
||||
|
||||
const rm = document.createElement("button");
|
||||
rm.type = "button";
|
||||
rm.className = "btn btn-danger btn-small";
|
||||
rm.textContent = "Remove";
|
||||
rm.addEventListener("click", () => {
|
||||
rows.splice(idx, 1);
|
||||
renderTabDevicesEditor(containerEl, rows, devicesMap);
|
||||
});
|
||||
div.appendChild(label);
|
||||
div.appendChild(rm);
|
||||
containerEl.appendChild(div);
|
||||
});
|
||||
|
||||
const macsInRows = new Set(rows.map((r) => r.mac).filter(Boolean));
|
||||
const addWrap = document.createElement("div");
|
||||
addWrap.className = "tab-devices-add profiles-actions";
|
||||
const sel = document.createElement("select");
|
||||
sel.className = "tab-device-add-select";
|
||||
sel.appendChild(new Option("Add device…", ""));
|
||||
entries.forEach(([mac, d]) => {
|
||||
if (macsInRows.has(mac)) return;
|
||||
const labelName = d && d.name ? String(d.name).trim() : "";
|
||||
const optLabel = labelName ? `${labelName} — ${mac}` : mac;
|
||||
sel.appendChild(new Option(optLabel, mac));
|
||||
});
|
||||
const addBtn = document.createElement("button");
|
||||
addBtn.type = "button";
|
||||
addBtn.className = "btn btn-primary btn-small";
|
||||
addBtn.textContent = "Add";
|
||||
addBtn.addEventListener("click", () => {
|
||||
const mac = sel.value;
|
||||
if (!mac || !devicesMap[mac]) return;
|
||||
const n = String((devicesMap[mac].name || "").trim() || mac);
|
||||
rows.push({ mac, name: n });
|
||||
sel.value = "";
|
||||
renderTabDevicesEditor(containerEl, rows, devicesMap);
|
||||
});
|
||||
addWrap.appendChild(sel);
|
||||
addWrap.appendChild(addBtn);
|
||||
containerEl.appendChild(addWrap);
|
||||
}
|
||||
|
||||
/** Default device name list when creating a tab (refined in Edit tab). */
|
||||
async function defaultDeviceNamesForNewTab() {
|
||||
const dm = await fetchDevicesMap();
|
||||
const macs = Object.keys(dm);
|
||||
if (macs.length > 0) {
|
||||
const m0 = macs[0];
|
||||
return [String((dm[m0].name || "").trim() || m0)];
|
||||
}
|
||||
return ["1"];
|
||||
}
|
||||
|
||||
/** Read tab device names from the presets section (JSON attr preferred; legacy comma list fallback). */
|
||||
function parseTabDeviceNames(section) {
|
||||
if (!section) return [];
|
||||
const enc = section.getAttribute("data-device-names-json");
|
||||
if (enc) {
|
||||
try {
|
||||
const arr = JSON.parse(decodeURIComponent(enc));
|
||||
return Array.isArray(arr) ? arr.map((n) => String(n).trim()).filter((n) => n.length > 0) : [];
|
||||
} catch (e) {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
const legacy = section.getAttribute("data-device-names");
|
||||
if (legacy) {
|
||||
return legacy.split(",").map((n) => n.trim()).filter((n) => n.length > 0);
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
window.parseTabDeviceNames = parseTabDeviceNames;
|
||||
|
||||
function escapeHtmlAttr(s) {
|
||||
return String(s)
|
||||
.replace(/&/g, "&")
|
||||
.replace(/"/g, """)
|
||||
.replace(/</g, "<");
|
||||
}
|
||||
|
||||
// Load tabs list
|
||||
async function loadTabs() {
|
||||
try {
|
||||
@@ -138,8 +279,8 @@ function renderTabsListModal(tabs, tabOrder, currentTabId) {
|
||||
const editButton = document.createElement("button");
|
||||
editButton.className = "btn btn-secondary btn-small";
|
||||
editButton.textContent = "Edit";
|
||||
editButton.addEventListener("click", () => {
|
||||
openEditTabModal(tabId, tab);
|
||||
editButton.addEventListener("click", async () => {
|
||||
await openEditTabModal(tabId, tab);
|
||||
});
|
||||
|
||||
const cloneButton = document.createElement("button");
|
||||
@@ -319,9 +460,12 @@ async function loadTabContent(tabId) {
|
||||
|
||||
// Render tab content (presets section)
|
||||
const tabName = tab.name || `Tab ${tabId}`;
|
||||
const deviceNames = Array.isArray(tab.names) ? tab.names.join(',') : '';
|
||||
const names = Array.isArray(tab.names) ? tab.names : [];
|
||||
const namesJsonAttr = encodeURIComponent(JSON.stringify(names));
|
||||
const legacyOk = names.length > 0 && !names.some((n) => /[",]/.test(String(n)));
|
||||
const legacyAttr = legacyOk ? ` data-device-names="${escapeHtmlAttr(names.join(","))}"` : "";
|
||||
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-json="${namesJsonAttr}"${legacyAttr}>
|
||||
<div class="profiles-actions presets-toolbar" style="margin-bottom: 1rem;">
|
||||
<div class="tab-brightness-group">
|
||||
<label for="tab-brightness-slider">Brightness</label>
|
||||
@@ -433,10 +577,15 @@ async function sendProfilePresets() {
|
||||
continue;
|
||||
}
|
||||
tabsWithPresets += 1;
|
||||
const tabNames = Array.isArray(tabData.names) ? tabData.names : [];
|
||||
const targets = await resolveTabDeviceMacs(tabNames);
|
||||
const payload = { preset_ids: presetIds };
|
||||
if (tabData.default_preset) {
|
||||
payload.default = tabData.default_preset;
|
||||
}
|
||||
if (targets.length > 0) {
|
||||
payload.targets = targets;
|
||||
}
|
||||
const response = await fetch('/presets/send', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@@ -464,94 +613,187 @@ async function sendProfilePresets() {
|
||||
}
|
||||
|
||||
const messagesLabel = totalMessages ? totalMessages : '?';
|
||||
alert(`Sent ${totalSent} preset(s) across ${tabsWithPresets} tab(s) in ${messagesLabel} ESPNow message(s).`);
|
||||
alert(`Sent ${totalSent} preset(s) across ${tabsWithPresets} tab(s) (${messagesLabel} driver send(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>';
|
||||
function tabPresetIdsInOrder(tabData) {
|
||||
let ids = [];
|
||||
if (Array.isArray(tabData.presets_flat)) {
|
||||
ids = tabData.presets_flat.slice();
|
||||
} else if (Array.isArray(tabData.presets)) {
|
||||
if (tabData.presets.length && typeof tabData.presets[0] === "string") {
|
||||
ids = tabData.presets.slice();
|
||||
} else if (tabData.presets.length && Array.isArray(tabData.presets[0])) {
|
||||
ids = tabData.presets.flat();
|
||||
}
|
||||
}
|
||||
return (ids || []).filter(Boolean);
|
||||
}
|
||||
|
||||
// Presets already on the tab (remove) and presets available to add (select).
|
||||
async function refreshEditTabPresetsUi(tabId) {
|
||||
const currentEl = document.getElementById("edit-tab-presets-current");
|
||||
const addEl = document.getElementById("edit-tab-presets-list");
|
||||
if (!tabId || !currentEl || !addEl) return;
|
||||
|
||||
currentEl.innerHTML = '<span class="muted-text">Loading…</span>';
|
||||
addEl.innerHTML = '<span class="muted-text">Loading…</span>';
|
||||
|
||||
try {
|
||||
const tabRes = await fetch(`/tabs/${tabId}`, { headers: { Accept: 'application/json' } });
|
||||
const tabRes = await fetch(`/tabs/${tabId}`, { headers: { Accept: "application/json" } });
|
||||
if (!tabRes.ok) {
|
||||
listEl.innerHTML = '<span class="muted-text">Failed to load presets.</span>';
|
||||
const msg = '<span class="muted-text">Failed to load tab presets.</span>';
|
||||
currentEl.innerHTML = msg;
|
||||
addEl.innerHTML = msg;
|
||||
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 inTabIds = tabPresetIdsInOrder(tabData);
|
||||
const inTabSet = new Set(inTabIds.map((id) => String(id)));
|
||||
|
||||
const presetsRes = await fetch("/presets", { headers: { Accept: "application/json" } });
|
||||
const allPresets = presetsRes.ok ? await presetsRes.json() : {};
|
||||
|
||||
const makeRow = () => {
|
||||
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";
|
||||
return row;
|
||||
};
|
||||
|
||||
currentEl.innerHTML = "";
|
||||
if (inTabIds.length === 0) {
|
||||
currentEl.innerHTML = '<span class="muted-text">No presets on this tab yet.</span>';
|
||||
} else {
|
||||
for (const presetId of inTabIds) {
|
||||
const preset = allPresets[presetId] || {};
|
||||
const name = preset.name || presetId;
|
||||
const row = makeRow();
|
||||
const label = document.createElement("span");
|
||||
label.textContent = name;
|
||||
const removeBtn = document.createElement("button");
|
||||
removeBtn.type = "button";
|
||||
removeBtn.className = "btn btn-danger btn-small";
|
||||
removeBtn.textContent = "Remove";
|
||||
removeBtn.addEventListener("click", async () => {
|
||||
if (typeof window.removePresetFromTab !== "function") return;
|
||||
if (!window.confirm(`Remove this preset from the tab?\n\n${name}`)) return;
|
||||
await window.removePresetFromTab(tabId, presetId);
|
||||
await refreshEditTabPresetsUi(tabId);
|
||||
});
|
||||
row.appendChild(label);
|
||||
row.appendChild(removeBtn);
|
||||
currentEl.appendChild(row);
|
||||
}
|
||||
}
|
||||
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 = '';
|
||||
const availableToAdd = allIds.filter((id) => !inTabSet.has(String(id)));
|
||||
addEl.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') {
|
||||
addEl.innerHTML =
|
||||
'<span class="muted-text">No presets to add. All presets are already on this tab.</span>';
|
||||
} else {
|
||||
const addWrap = document.createElement("div");
|
||||
addWrap.className = "tab-devices-add profiles-actions";
|
||||
const sel = document.createElement("select");
|
||||
sel.className = "tab-device-add-select";
|
||||
sel.setAttribute("aria-label", "Preset to add to this tab");
|
||||
sel.appendChild(new Option("Add preset…", ""));
|
||||
const sorted = availableToAdd.slice().sort((a, b) => {
|
||||
const na = (allPresets[a] && allPresets[a].name) || a;
|
||||
const nb = (allPresets[b] && allPresets[b].name) || b;
|
||||
return String(na).localeCompare(String(nb), undefined, { sensitivity: "base" });
|
||||
});
|
||||
sorted.forEach((presetId) => {
|
||||
const preset = allPresets[presetId] || {};
|
||||
const name = preset.name || presetId;
|
||||
sel.appendChild(new Option(`${name} — ${presetId}`, presetId));
|
||||
});
|
||||
const addBtn = document.createElement("button");
|
||||
addBtn.type = "button";
|
||||
addBtn.className = "btn btn-primary btn-small";
|
||||
addBtn.textContent = "Add";
|
||||
addBtn.addEventListener("click", async () => {
|
||||
const presetId = sel.value;
|
||||
if (!presetId) return;
|
||||
if (typeof window.addPresetToTab === "function") {
|
||||
await window.addPresetToTab(presetId, tabId);
|
||||
await populateEditTabPresetsList(tabId);
|
||||
sel.value = "";
|
||||
await refreshEditTabPresetsUi(tabId);
|
||||
}
|
||||
});
|
||||
row.appendChild(label);
|
||||
row.appendChild(selectBtn);
|
||||
listEl.appendChild(row);
|
||||
addWrap.appendChild(sel);
|
||||
addWrap.appendChild(addBtn);
|
||||
addEl.appendChild(addWrap);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('populateEditTabPresetsList:', e);
|
||||
listEl.innerHTML = '<span class="muted-text">Failed to load presets.</span>';
|
||||
console.error("refreshEditTabPresetsUi:", e);
|
||||
const msg = '<span class="muted-text">Failed to load presets.</span>';
|
||||
currentEl.innerHTML = msg;
|
||||
addEl.innerHTML = msg;
|
||||
}
|
||||
}
|
||||
|
||||
async function populateEditTabPresetsList(tabId) {
|
||||
await refreshEditTabPresetsUi(tabId);
|
||||
}
|
||||
|
||||
// Open edit tab modal
|
||||
function openEditTabModal(tabId, tab) {
|
||||
const modal = document.getElementById('edit-tab-modal');
|
||||
const idInput = document.getElementById('edit-tab-id');
|
||||
const nameInput = document.getElementById('edit-tab-name');
|
||||
const idsInput = document.getElementById('edit-tab-ids');
|
||||
|
||||
async function openEditTabModal(tabId, tab) {
|
||||
const modal = document.getElementById("edit-tab-modal");
|
||||
const idInput = document.getElementById("edit-tab-id");
|
||||
const nameInput = document.getElementById("edit-tab-name");
|
||||
const editor = document.getElementById("edit-tab-devices-editor");
|
||||
|
||||
let tabData = tab;
|
||||
if (!tabData || typeof tabData !== "object" || tabData.error) {
|
||||
try {
|
||||
const response = await fetch(`/tabs/${tabId}`);
|
||||
if (response.ok) {
|
||||
tabData = await response.json();
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("openEditTabModal fetch tab:", e);
|
||||
}
|
||||
}
|
||||
tabData = tabData || {};
|
||||
|
||||
if (idInput) idInput.value = tabId;
|
||||
if (nameInput) nameInput.value = tab ? (tab.name || '') : '';
|
||||
if (idsInput) idsInput.value = tab && tab.names ? tab.names.join(', ') : '1';
|
||||
|
||||
if (modal) modal.classList.add('active');
|
||||
populateEditTabPresetsList(tabId);
|
||||
if (nameInput) nameInput.value = tabData.name || "";
|
||||
|
||||
const devicesMap = await fetchDevicesMap();
|
||||
const tabNames =
|
||||
Array.isArray(tabData.names) && tabData.names.length > 0 ? tabData.names : ["1"];
|
||||
window.__editTabDeviceRows = namesToRows(tabNames, devicesMap);
|
||||
renderTabDevicesEditor(editor, window.__editTabDeviceRows, devicesMap);
|
||||
|
||||
if (modal) modal.classList.add("active");
|
||||
await refreshEditTabPresetsUi(tabId);
|
||||
}
|
||||
|
||||
function normalizeTabNamesArg(namesOrString) {
|
||||
if (Array.isArray(namesOrString)) {
|
||||
return namesOrString.map((n) => String(n).trim()).filter((n) => n.length > 0);
|
||||
}
|
||||
if (typeof namesOrString === "string" && namesOrString.trim()) {
|
||||
return namesOrString.split(",").map((id) => id.trim()).filter((id) => id.length > 0);
|
||||
}
|
||||
return ["1"];
|
||||
}
|
||||
|
||||
// Update an existing tab
|
||||
async function updateTab(tabId, name, ids) {
|
||||
async function updateTab(tabId, name, namesOrString) {
|
||||
try {
|
||||
const names = ids ? ids.split(',').map(id => id.trim()) : ['1'];
|
||||
let names = normalizeTabNamesArg(namesOrString);
|
||||
if (!names.length) names = ["1"];
|
||||
const response = await fetch(`/tabs/${tabId}`, {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
@@ -583,9 +825,10 @@ async function updateTab(tabId, name, ids) {
|
||||
}
|
||||
|
||||
// Create a new tab
|
||||
async function createTab(name, ids) {
|
||||
async function createTab(name, namesOrString) {
|
||||
try {
|
||||
const names = ids ? ids.split(',').map(id => id.trim()) : ['1'];
|
||||
let names = normalizeTabNamesArg(namesOrString);
|
||||
if (!names.length) names = ["1"];
|
||||
const response = await fetch('/tabs', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
@@ -627,14 +870,13 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
const tabsButton = document.getElementById('tabs-btn');
|
||||
const tabsModal = document.getElementById('tabs-modal');
|
||||
const tabsCloseButton = document.getElementById('tabs-close-btn');
|
||||
const newTabNameInput = document.getElementById('new-tab-name');
|
||||
const newTabIdsInput = document.getElementById('new-tab-ids');
|
||||
const createTabButton = document.getElementById('create-tab-btn');
|
||||
|
||||
const newTabNameInput = document.getElementById("new-tab-name");
|
||||
const createTabButton = document.getElementById("create-tab-btn");
|
||||
|
||||
if (tabsButton && tabsModal) {
|
||||
tabsButton.addEventListener('click', () => {
|
||||
tabsModal.classList.add('active');
|
||||
loadTabsModal();
|
||||
tabsButton.addEventListener("click", async () => {
|
||||
tabsModal.classList.add("active");
|
||||
await loadTabsModal();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -659,7 +901,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
const response = await fetch(`/tabs/${tabId}`);
|
||||
if (response.ok) {
|
||||
const tab = await response.json();
|
||||
openEditTabModal(tabId, tab);
|
||||
await openEditTabModal(tabId, tab);
|
||||
} else {
|
||||
alert('Failed to load tab for editing');
|
||||
}
|
||||
@@ -673,12 +915,11 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
const createTabHandler = async () => {
|
||||
if (!newTabNameInput) return;
|
||||
const name = newTabNameInput.value.trim();
|
||||
const ids = (newTabIdsInput && newTabIdsInput.value.trim()) || '1';
|
||||
|
||||
|
||||
if (name) {
|
||||
await createTab(name, ids);
|
||||
if (newTabNameInput) newTabNameInput.value = '';
|
||||
if (newTabIdsInput) newTabIdsInput.value = '1';
|
||||
const deviceNames = await defaultDeviceNamesForNewTab();
|
||||
await createTab(name, deviceNames);
|
||||
if (newTabNameInput) newTabNameInput.value = "";
|
||||
}
|
||||
};
|
||||
|
||||
@@ -697,18 +938,22 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
// Set up edit tab form
|
||||
const editTabForm = document.getElementById('edit-tab-form');
|
||||
if (editTabForm) {
|
||||
editTabForm.addEventListener('submit', async (e) => {
|
||||
editTabForm.addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
const idInput = document.getElementById('edit-tab-id');
|
||||
const nameInput = document.getElementById('edit-tab-name');
|
||||
const idsInput = document.getElementById('edit-tab-ids');
|
||||
|
||||
const idInput = document.getElementById("edit-tab-id");
|
||||
const nameInput = document.getElementById("edit-tab-name");
|
||||
|
||||
const tabId = idInput ? idInput.value : null;
|
||||
const name = nameInput ? nameInput.value.trim() : '';
|
||||
const ids = idsInput ? idsInput.value.trim() : '1';
|
||||
|
||||
const name = nameInput ? nameInput.value.trim() : "";
|
||||
const rows = window.__editTabDeviceRows || [];
|
||||
const deviceNames = rowsToNames(rows);
|
||||
|
||||
if (tabId && name) {
|
||||
await updateTab(tabId, name, ids);
|
||||
if (deviceNames.length === 0) {
|
||||
alert("Add at least one device.");
|
||||
return;
|
||||
}
|
||||
await updateTab(tabId, name, deviceNames);
|
||||
editTabForm.reset();
|
||||
}
|
||||
});
|
||||
@@ -726,7 +971,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
document.querySelectorAll('.ui-mode-toggle').forEach((btn) => {
|
||||
btn.addEventListener('click', async () => {
|
||||
await loadTabs();
|
||||
if (tabsModal && tabsModal.classList.contains('active')) {
|
||||
if (tabsModal && tabsModal.classList.contains("active")) {
|
||||
await loadTabsModal();
|
||||
}
|
||||
});
|
||||
@@ -741,5 +986,6 @@ window.tabsManager = {
|
||||
createTab,
|
||||
updateTab,
|
||||
openEditTabModal,
|
||||
getCurrentTabId: () => currentTabId
|
||||
resolveTabDeviceMacs,
|
||||
getCurrentTabId: () => currentTabId,
|
||||
};
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<button class="btn btn-secondary" id="profiles-btn">Profiles</button>
|
||||
<button class="btn btn-secondary" id="devices-btn">Devices</button>
|
||||
<button class="btn btn-secondary edit-mode-only" id="devices-btn">Devices</button>
|
||||
<button class="btn btn-secondary edit-mode-only" id="tabs-btn">Tabs</button>
|
||||
<button class="btn btn-secondary edit-mode-only" id="presets-btn">Presets</button>
|
||||
<button class="btn btn-secondary edit-mode-only" id="patterns-btn">Patterns</button>
|
||||
@@ -30,7 +30,7 @@
|
||||
<div id="main-menu-dropdown" class="main-menu-dropdown">
|
||||
<button type="button" class="ui-mode-toggle" id="ui-mode-toggle-mobile" aria-pressed="false">Edit mode</button>
|
||||
<button type="button" data-target="profiles-btn">Profiles</button>
|
||||
<button type="button" data-target="devices-btn">Devices</button>
|
||||
<button type="button" class="edit-mode-only" data-target="devices-btn">Devices</button>
|
||||
<button type="button" class="edit-mode-only" data-target="tabs-btn">Tabs</button>
|
||||
<button type="button" class="edit-mode-only" data-target="presets-btn">Presets</button>
|
||||
<button type="button" class="edit-mode-only" data-target="patterns-btn">Patterns</button>
|
||||
@@ -54,9 +54,8 @@
|
||||
<div id="tabs-modal" class="modal">
|
||||
<div class="modal-content">
|
||||
<h2>Tabs</h2>
|
||||
<div class="profiles-actions">
|
||||
<div class="profiles-actions tab-modal-create-row">
|
||||
<input type="text" id="new-tab-name" placeholder="Tab name">
|
||||
<input type="text" id="new-tab-ids" placeholder="Device IDs (1,2,3)" value="1">
|
||||
<button class="btn btn-primary" id="create-tab-btn">Create</button>
|
||||
</div>
|
||||
<div id="tabs-list-modal" class="profiles-list"></div>
|
||||
@@ -78,10 +77,12 @@
|
||||
</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>
|
||||
<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>
|
||||
<label class="tab-devices-label">Devices in this tab</label>
|
||||
<div id="edit-tab-devices-editor" class="tab-devices-editor"></div>
|
||||
<label class="tab-presets-section-label">Presets on this tab</label>
|
||||
<div id="edit-tab-presets-current" class="profiles-list edit-tab-presets-scroll"></div>
|
||||
<label class="tab-presets-section-label">Add presets to this tab</label>
|
||||
<div id="edit-tab-presets-list" class="profiles-list edit-tab-presets-scroll"></div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
@@ -111,7 +112,6 @@
|
||||
<div id="devices-modal" class="modal">
|
||||
<div class="modal-content">
|
||||
<h2>Devices</h2>
|
||||
<p class="muted-text">Wi-Fi LED drivers register over TCP when each hello line includes <code>device_name</code> and <code>mac</code> (12 hex). The registry key is the <strong>MAC</strong>; <strong>name</strong> is used in tabs and <code>select</code> (several devices may share the same name).</p>
|
||||
<div id="devices-list-modal" class="profiles-list"></div>
|
||||
<div class="modal-actions">
|
||||
<button type="button" class="btn btn-secondary" id="devices-close-btn">Close</button>
|
||||
@@ -368,9 +368,9 @@
|
||||
<script src="/static/help.js"></script>
|
||||
<script src="/static/color_palette.js"></script>
|
||||
<script src="/static/profiles.js"></script>
|
||||
<script src="/static/devices.js"></script>
|
||||
<script src="/static/tab_palette.js"></script>
|
||||
<script src="/static/patterns.js"></script>
|
||||
<script src="/static/presets.js"></script>
|
||||
<script src="/static/devices.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user