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;
|
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) {
|
function makeHexAddressBoxes(container) {
|
||||||
if (!container || container.querySelector('.hex-addr-box')) return;
|
if (!container || container.querySelector('.hex-addr-box')) return;
|
||||||
container.innerHTML = '';
|
container.innerHTML = '';
|
||||||
@@ -75,6 +169,9 @@ function getAddressForPayload(transport) {
|
|||||||
async function loadDevicesModal() {
|
async function loadDevicesModal() {
|
||||||
const container = document.getElementById('devices-list-modal');
|
const container = document.getElementById('devices-list-modal');
|
||||||
if (!container) return;
|
if (!container) return;
|
||||||
|
if (typeof window.getEspnowSocket === 'function') {
|
||||||
|
window.getEspnowSocket();
|
||||||
|
}
|
||||||
container.innerHTML = '<span class="muted-text">Loading...</span>';
|
container.innerHTML = '<span class="muted-text">Loading...</span>';
|
||||||
try {
|
try {
|
||||||
const response = await fetch('/devices', { headers: { Accept: 'application/json' } });
|
const response = await fetch('/devices', { headers: { Accept: 'application/json' } });
|
||||||
@@ -101,31 +198,82 @@ function renderDevicesList(devices) {
|
|||||||
}
|
}
|
||||||
ids.forEach((devId) => {
|
ids.forEach((devId) => {
|
||||||
const dev = devices[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');
|
const row = document.createElement('div');
|
||||||
row.className = 'profiles-row';
|
row.className = 'profiles-row';
|
||||||
row.style.display = 'flex';
|
row.style.display = 'flex';
|
||||||
row.style.alignItems = 'center';
|
row.style.alignItems = 'center';
|
||||||
row.style.gap = '0.5rem';
|
row.style.gap = '0.5rem';
|
||||||
row.style.flexWrap = 'wrap';
|
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');
|
const label = document.createElement('span');
|
||||||
label.textContent = (dev && dev.name) || devId;
|
label.textContent = (dev && dev.name) || devId;
|
||||||
label.style.flex = '1';
|
label.style.flex = '1';
|
||||||
label.style.minWidth = '100px';
|
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');
|
const meta = document.createElement('span');
|
||||||
meta.className = 'muted-text';
|
meta.className = 'muted-text';
|
||||||
meta.style.fontSize = '0.85em';
|
meta.style.fontSize = '0.85em';
|
||||||
const t = (dev && dev.type) || 'led';
|
meta.textContent = `${t} · ${tr} · ${addrDisplay}`;
|
||||||
const tr = (dev && dev.transport) || 'espnow';
|
|
||||||
const addr = (dev && dev.address) ? dev.address : '—';
|
|
||||||
meta.textContent = `${t} · ${tr} · ${addr}`;
|
|
||||||
|
|
||||||
const editBtn = document.createElement('button');
|
const editBtn = document.createElement('button');
|
||||||
editBtn.className = 'btn btn-secondary btn-small';
|
editBtn.className = 'btn btn-secondary btn-small';
|
||||||
editBtn.textContent = 'Edit';
|
editBtn.textContent = 'Edit';
|
||||||
editBtn.addEventListener('click', () => openEditDeviceModal(devId, dev));
|
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');
|
const deleteBtn = document.createElement('button');
|
||||||
deleteBtn.className = 'btn btn-secondary btn-small';
|
deleteBtn.className = 'btn btn-secondary btn-small';
|
||||||
deleteBtn.textContent = 'Delete';
|
deleteBtn.textContent = 'Delete';
|
||||||
@@ -144,12 +292,18 @@ function renderDevicesList(devices) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
row.appendChild(dot);
|
||||||
row.appendChild(label);
|
row.appendChild(label);
|
||||||
|
row.appendChild(macEl);
|
||||||
row.appendChild(meta);
|
row.appendChild(meta);
|
||||||
row.appendChild(editBtn);
|
row.appendChild(editBtn);
|
||||||
|
row.appendChild(identifyBtn);
|
||||||
row.appendChild(deleteBtn);
|
row.appendChild(deleteBtn);
|
||||||
container.appendChild(row);
|
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) {
|
function openEditDeviceModal(devId, dev) {
|
||||||
@@ -201,6 +355,29 @@ async function updateDevice(devId, name, type, transport, address) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', () => {
|
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'));
|
makeHexAddressBoxes(document.getElementById('edit-device-address-boxes'));
|
||||||
|
|
||||||
const transportEdit = document.getElementById('edit-device-transport');
|
const transportEdit = document.getElementById('edit-device-transport');
|
||||||
@@ -220,11 +397,26 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
if (devicesBtn && devicesModal) {
|
if (devicesBtn && devicesModal) {
|
||||||
devicesBtn.addEventListener('click', () => {
|
devicesBtn.addEventListener('click', () => {
|
||||||
devicesModal.classList.add('active');
|
devicesModal.classList.add('active');
|
||||||
|
if (typeof window.getEspnowSocket === 'function') {
|
||||||
|
window.getEspnowSocket();
|
||||||
|
}
|
||||||
loadDevicesModal();
|
loadDevicesModal();
|
||||||
|
startDevicesModalLiveRefresh();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (devicesCloseBtn) {
|
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) {
|
if (editForm) {
|
||||||
|
|||||||
@@ -74,12 +74,14 @@ const getEspnowSocket = () => {
|
|||||||
return espnowSocket;
|
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);
|
espnowSocket = new WebSocket(wsUrl);
|
||||||
espnowSocketReady = false;
|
espnowSocketReady = false;
|
||||||
|
|
||||||
espnowSocket.onopen = () => {
|
espnowSocket.onopen = () => {
|
||||||
espnowSocketReady = true;
|
espnowSocketReady = true;
|
||||||
|
window.dispatchEvent(new CustomEvent('deviceTcpWsOpen'));
|
||||||
// Flush any queued messages
|
// Flush any queued messages
|
||||||
espnowPendingMessages.forEach((msg) => {
|
espnowPendingMessages.forEach((msg) => {
|
||||||
try {
|
try {
|
||||||
@@ -94,6 +96,18 @@ const getEspnowSocket = () => {
|
|||||||
espnowSocket.onmessage = (event) => {
|
espnowSocket.onmessage = (event) => {
|
||||||
try {
|
try {
|
||||||
const data = JSON.parse(event.data);
|
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) {
|
if (data && data.error) {
|
||||||
console.error('ESP-NOW:', 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));
|
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.
|
function tabDeviceNamesFromSection(section) {
|
||||||
// Uses the preset ID as the select key.
|
if (typeof window.parseTabDeviceNames === 'function') {
|
||||||
const sendSelectForCurrentTabDevices = (presetId, sectionEl) => {
|
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]');
|
const section = sectionEl || document.querySelector('.presets-section[data-tab-id]');
|
||||||
if (!section || !presetId) {
|
if (!section || !presetId) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const namesAttr = section.getAttribute('data-device-names');
|
const deviceNames = tabDeviceNamesFromSection(section);
|
||||||
const deviceNames = namesAttr
|
|
||||||
? namesAttr.split(',').map((n) => n.trim()).filter((n) => n.length > 0)
|
|
||||||
: [];
|
|
||||||
|
|
||||||
if (!deviceNames.length) {
|
if (!deviceNames.length) {
|
||||||
return;
|
return;
|
||||||
@@ -148,15 +189,23 @@ const sendSelectForCurrentTabDevices = (presetId, sectionEl) => {
|
|||||||
|
|
||||||
const select = {};
|
const select = {};
|
||||||
deviceNames.forEach((name) => {
|
deviceNames.forEach((name) => {
|
||||||
select[name] = [presetId];
|
if (name) {
|
||||||
|
select[name] = [presetId];
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const message = {
|
const targetMacs =
|
||||||
v: '1',
|
typeof window.tabsManager !== 'undefined' &&
|
||||||
select,
|
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', () => {
|
document.addEventListener('DOMContentLoaded', () => {
|
||||||
@@ -812,10 +861,10 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
const sendButton = document.createElement('button');
|
const sendButton = document.createElement('button');
|
||||||
sendButton.className = 'btn btn-primary btn-small';
|
sendButton.className = 'btn btn-primary btn-small';
|
||||||
sendButton.textContent = 'Send';
|
sendButton.textContent = 'Send';
|
||||||
sendButton.title = 'Send this preset via ESPNow';
|
sendButton.title = 'Send this preset to drivers';
|
||||||
sendButton.addEventListener('click', () => {
|
sendButton.addEventListener('click', () => {
|
||||||
// Just send the definition; selection happens when user clicks the preset.
|
// Just send the definition; selection happens when user clicks the preset.
|
||||||
sendPresetViaEspNow(presetId, preset || {});
|
void sendPresetViaEspNow(presetId, preset || {}, []);
|
||||||
});
|
});
|
||||||
|
|
||||||
const deleteButton = document.createElement('button');
|
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)
|
// 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 section = document.querySelector('.presets-section[data-tab-id]');
|
||||||
const namesAttr = section && section.getAttribute('data-device-names');
|
const deviceNames = tabDeviceNamesFromSection(section);
|
||||||
const deviceNames = namesAttr
|
|
||||||
? namesAttr.split(',').map((n) => n.trim()).filter((n) => n.length > 0)
|
|
||||||
: [];
|
|
||||||
// Work out the preset ID: for existing presets use currentEditId, otherwise fall back to name
|
// Work out the preset ID: for existing presets use currentEditId, otherwise fall back to name
|
||||||
const presetId = currentEditId || payload.name;
|
const presetId = currentEditId || payload.name;
|
||||||
// Try sends preset first, then select; never persist on device.
|
// Try sends preset first, then select; never persist on device.
|
||||||
@@ -1241,13 +1287,10 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const section = document.querySelector('.presets-section[data-tab-id]');
|
const section = document.querySelector('.presets-section[data-tab-id]');
|
||||||
const namesAttr = section && section.getAttribute('data-device-names');
|
const deviceNames = tabDeviceNamesFromSection(section);
|
||||||
const deviceNames = namesAttr
|
|
||||||
? namesAttr.split(',').map((n) => n.trim()).filter((n) => n.length > 0)
|
|
||||||
: [];
|
|
||||||
const presetId = currentEditId || payload.name;
|
const presetId = currentEditId || payload.name;
|
||||||
await updateTabDefaultPreset(presetId);
|
await updateTabDefaultPreset(presetId);
|
||||||
sendDefaultPreset(presetId, deviceNames);
|
await sendDefaultPreset(presetId, deviceNames);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1285,20 +1328,20 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
if (currentEditId) {
|
if (currentEditId) {
|
||||||
// PUT returns the preset object directly; use the existing ID
|
// PUT returns the preset object directly; use the existing ID
|
||||||
// Save & Send should not force-select the preset on devices.
|
// Save & Send should not force-select the preset on devices.
|
||||||
sendPresetViaEspNow(currentEditId, saved, [], true, false);
|
await sendPresetViaEspNow(currentEditId, saved, [], true, false);
|
||||||
} else {
|
} else {
|
||||||
// POST returns { id: preset }
|
// POST returns { id: preset }
|
||||||
const entries = Object.entries(saved);
|
const entries = Object.entries(saved);
|
||||||
if (entries.length > 0) {
|
if (entries.length > 0) {
|
||||||
const [newId, presetData] = entries[0];
|
const [newId, presetData] = entries[0];
|
||||||
// Save & Send should not force-select the preset on devices.
|
// Save & Send should not force-select the preset on devices.
|
||||||
sendPresetViaEspNow(newId, presetData, [], true, false);
|
await sendPresetViaEspNow(newId, presetData, [], true, false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Fallback: send what we just built
|
// Fallback: send what we just built
|
||||||
// Save & Send should not force-select the preset on devices.
|
// 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();
|
await loadPresets();
|
||||||
@@ -1340,7 +1383,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
clearForm();
|
clearForm();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Build ESPNow messages for a single preset.
|
// Build driver messages for a single preset; deliver via /presets/push (ESP-NOW + TCP).
|
||||||
// Send order:
|
// Send order:
|
||||||
// 1) preset payload (optionally with save)
|
// 1) preset payload (optionally with save)
|
||||||
// 2) optional select for device names (never 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;
|
presetMessage.default = presetId;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1) Send presets first, without save.
|
const names = Array.isArray(deviceNames) ? deviceNames : [];
|
||||||
sendEspnowMessage(presetMessage);
|
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.
|
const sequence = [presetMessage];
|
||||||
if (Array.isArray(deviceNames) && deviceNames.length > 0) {
|
if (names.length > 0) {
|
||||||
const select = {};
|
const select = {};
|
||||||
deviceNames.forEach((name) => {
|
names.forEach((name) => {
|
||||||
if (name) {
|
if (name) {
|
||||||
select[name] = [presetId];
|
select[name] = [presetId];
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
if (Object.keys(select).length > 0) {
|
if (Object.keys(select).length > 0) {
|
||||||
// Small gap helps slower receivers process preset update before select.
|
sequence.push({ v: '1', select });
|
||||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
|
||||||
sendEspnowMessage({ v: '1', select });
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await postDriverSequence(sequence, targetMacs, 0.05);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to send preset via ESPNow:', error);
|
console.error('Failed to send preset to devices:', error);
|
||||||
alert('Failed to send preset via ESPNow.');
|
alert('Failed to send preset to devices.');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const sendDefaultPreset = (presetId, deviceNames) => {
|
const sendDefaultPreset = async (presetId, deviceNames) => {
|
||||||
if (!presetId) {
|
if (!presetId) {
|
||||||
alert('Select a preset to set as default.');
|
alert('Select a preset to set as default.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Default should only set startup preset, not trigger live selection.
|
const nameTargets = Array.isArray(deviceNames)
|
||||||
// Save is attached to default messages.
|
|
||||||
// When device names are provided, scope the default update to those devices.
|
|
||||||
const targets = Array.isArray(deviceNames)
|
|
||||||
? deviceNames.map((n) => (n || '').trim()).filter((n) => n.length > 0)
|
? deviceNames.map((n) => (n || '').trim()).filter((n) => n.length > 0)
|
||||||
: [];
|
: [];
|
||||||
const message = { v: '1', default: presetId };
|
const message = { v: '1', default: presetId };
|
||||||
message.save = true;
|
message.save = true;
|
||||||
if (targets.length > 0) {
|
if (nameTargets.length > 0) {
|
||||||
message.targets = targets;
|
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.
|
// Expose for other scripts (tabs.js) so they can reuse the shared WebSocket.
|
||||||
try {
|
try {
|
||||||
window.sendPresetViaEspNow = sendPresetViaEspNow;
|
window.sendPresetViaEspNow = sendPresetViaEspNow;
|
||||||
|
window.postDriverSequence = postDriverSequence;
|
||||||
// Expose a generic ESPNow sender so other scripts (tabs.js) can send
|
// Expose a generic ESPNow sender so other scripts (tabs.js) can send
|
||||||
// non-preset messages such as global brightness.
|
// non-preset messages such as global brightness.
|
||||||
window.sendEspnowRaw = sendEspnowMessage;
|
window.sendEspnowRaw = sendEspnowMessage;
|
||||||
|
window.getEspnowSocket = getEspnowSocket;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
// window may not exist in some environments; ignore.
|
// window may not exist in some environments; ignore.
|
||||||
}
|
}
|
||||||
@@ -1756,7 +1813,9 @@ const createPresetButton = (presetId, preset, tabId, isSelected = false) => {
|
|||||||
button.classList.add('active');
|
button.classList.add('active');
|
||||||
selectedPresets[tabId] = presetId;
|
selectedPresets[tabId] = presetId;
|
||||||
const section = row.closest('.presets-section');
|
const section = row.closest('.presets-section');
|
||||||
sendSelectForCurrentTabDevices(presetId, section);
|
sendSelectForCurrentTabDevices(presetId, section).catch((err) => {
|
||||||
|
console.error(err);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
if (canDrag) {
|
if (canDrag) {
|
||||||
@@ -1926,6 +1985,12 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
const next = getPresetUiMode() === 'edit' ? 'run' : 'edit';
|
const next = getPresetUiMode() === 'edit' ? 'run' : 'edit';
|
||||||
setPresetUiMode(next);
|
setPresetUiMode(next);
|
||||||
updateUiModeToggleButtons();
|
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');
|
const mainMenu = document.getElementById('main-menu-dropdown');
|
||||||
if (mainMenu) mainMenu.classList.remove('open');
|
if (mainMenu) mainMenu.classList.remove('open');
|
||||||
const leftPanel = document.querySelector('.presets-section[data-tab-id]');
|
const leftPanel = document.querySelector('.presets-section[data-tab-id]');
|
||||||
|
|||||||
@@ -53,6 +53,12 @@ input.hex-addr-box {
|
|||||||
margin-bottom: 0.25rem;
|
margin-bottom: 0.25rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.device-row-mac {
|
||||||
|
font-size: 0.82em;
|
||||||
|
color: #b0b0b0;
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
|
|
||||||
.device-form-actions {
|
.device-form-actions {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: flex-end;
|
align-items: flex-end;
|
||||||
@@ -601,6 +607,29 @@ body.preset-ui-run .edit-mode-only {
|
|||||||
color: #f44336;
|
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 {
|
.btn-group {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
@@ -1034,6 +1063,65 @@ body.preset-ui-run .edit-mode-only {
|
|||||||
background-color: #3a3a3a;
|
background-color: #3a3a3a;
|
||||||
border-radius: 4px;
|
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 */
|
/* Hide any text content in palette rows - only show color swatches */
|
||||||
#palette-container .profiles-row {
|
#palette-container .profiles-row {
|
||||||
font-size: 0; /* Hide any text nodes */
|
font-size: 0; /* Hide any text nodes */
|
||||||
|
|||||||
@@ -18,6 +18,147 @@ function getCurrentTabFromCookie() {
|
|||||||
return null;
|
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
|
// Load tabs list
|
||||||
async function loadTabs() {
|
async function loadTabs() {
|
||||||
try {
|
try {
|
||||||
@@ -138,8 +279,8 @@ function renderTabsListModal(tabs, tabOrder, currentTabId) {
|
|||||||
const editButton = document.createElement("button");
|
const editButton = document.createElement("button");
|
||||||
editButton.className = "btn btn-secondary btn-small";
|
editButton.className = "btn btn-secondary btn-small";
|
||||||
editButton.textContent = "Edit";
|
editButton.textContent = "Edit";
|
||||||
editButton.addEventListener("click", () => {
|
editButton.addEventListener("click", async () => {
|
||||||
openEditTabModal(tabId, tab);
|
await openEditTabModal(tabId, tab);
|
||||||
});
|
});
|
||||||
|
|
||||||
const cloneButton = document.createElement("button");
|
const cloneButton = document.createElement("button");
|
||||||
@@ -319,9 +460,12 @@ async function loadTabContent(tabId) {
|
|||||||
|
|
||||||
// Render tab content (presets section)
|
// Render tab content (presets section)
|
||||||
const tabName = tab.name || `Tab ${tabId}`;
|
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 = `
|
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="profiles-actions presets-toolbar" style="margin-bottom: 1rem;">
|
||||||
<div class="tab-brightness-group">
|
<div class="tab-brightness-group">
|
||||||
<label for="tab-brightness-slider">Brightness</label>
|
<label for="tab-brightness-slider">Brightness</label>
|
||||||
@@ -433,10 +577,15 @@ async function sendProfilePresets() {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
tabsWithPresets += 1;
|
tabsWithPresets += 1;
|
||||||
|
const tabNames = Array.isArray(tabData.names) ? tabData.names : [];
|
||||||
|
const targets = await resolveTabDeviceMacs(tabNames);
|
||||||
const payload = { preset_ids: presetIds };
|
const payload = { preset_ids: presetIds };
|
||||||
if (tabData.default_preset) {
|
if (tabData.default_preset) {
|
||||||
payload.default = tabData.default_preset;
|
payload.default = tabData.default_preset;
|
||||||
}
|
}
|
||||||
|
if (targets.length > 0) {
|
||||||
|
payload.targets = targets;
|
||||||
|
}
|
||||||
const response = await fetch('/presets/send', {
|
const response = await fetch('/presets/send', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
@@ -464,94 +613,187 @@ async function sendProfilePresets() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const messagesLabel = totalMessages ? totalMessages : '?';
|
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) {
|
} catch (error) {
|
||||||
console.error('Failed to send profile presets:', error);
|
console.error('Failed to send profile presets:', error);
|
||||||
alert('Failed to send profile presets.');
|
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.
|
function tabPresetIdsInOrder(tabData) {
|
||||||
async function populateEditTabPresetsList(tabId) {
|
let ids = [];
|
||||||
const listEl = document.getElementById('edit-tab-presets-list');
|
if (Array.isArray(tabData.presets_flat)) {
|
||||||
if (!listEl) return;
|
ids = tabData.presets_flat.slice();
|
||||||
listEl.innerHTML = '<span class="muted-text">Loading…</span>';
|
} 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 {
|
try {
|
||||||
const tabRes = await fetch(`/tabs/${tabId}`, { headers: { Accept: 'application/json' } });
|
const tabRes = await fetch(`/tabs/${tabId}`, { headers: { Accept: "application/json" } });
|
||||||
if (!tabRes.ok) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
const tabData = await tabRes.json();
|
const tabData = await tabRes.json();
|
||||||
let inTabIds = [];
|
const inTabIds = tabPresetIdsInOrder(tabData);
|
||||||
if (Array.isArray(tabData.presets_flat)) {
|
const inTabSet = new Set(inTabIds.map((id) => String(id)));
|
||||||
inTabIds = tabData.presets_flat;
|
|
||||||
} else if (Array.isArray(tabData.presets)) {
|
const presetsRes = await fetch("/presets", { headers: { Accept: "application/json" } });
|
||||||
if (tabData.presets.length && typeof tabData.presets[0] === 'string') {
|
const allPresets = presetsRes.ok ? await presetsRes.json() : {};
|
||||||
inTabIds = tabData.presets;
|
|
||||||
} else if (tabData.presets.length && Array.isArray(tabData.presets[0])) {
|
const makeRow = () => {
|
||||||
inTabIds = tabData.presets.flat();
|
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 allIds = Object.keys(allPresets);
|
||||||
const availableToAdd = allIds.filter(id => !inTabIds.includes(id));
|
const availableToAdd = allIds.filter((id) => !inTabSet.has(String(id)));
|
||||||
listEl.innerHTML = '';
|
addEl.innerHTML = "";
|
||||||
if (availableToAdd.length === 0) {
|
if (availableToAdd.length === 0) {
|
||||||
listEl.innerHTML = '<span class="muted-text">No presets to add. All presets are already in this tab.</span>';
|
addEl.innerHTML =
|
||||||
return;
|
'<span class="muted-text">No presets to add. All presets are already on this tab.</span>';
|
||||||
}
|
} else {
|
||||||
for (const presetId of availableToAdd) {
|
const addWrap = document.createElement("div");
|
||||||
const preset = allPresets[presetId] || {};
|
addWrap.className = "tab-devices-add profiles-actions";
|
||||||
const name = preset.name || presetId;
|
const sel = document.createElement("select");
|
||||||
const row = document.createElement('div');
|
sel.className = "tab-device-add-select";
|
||||||
row.className = 'profiles-row';
|
sel.setAttribute("aria-label", "Preset to add to this tab");
|
||||||
row.style.display = 'flex';
|
sel.appendChild(new Option("Add preset…", ""));
|
||||||
row.style.alignItems = 'center';
|
const sorted = availableToAdd.slice().sort((a, b) => {
|
||||||
row.style.justifyContent = 'space-between';
|
const na = (allPresets[a] && allPresets[a].name) || a;
|
||||||
row.style.gap = '0.5rem';
|
const nb = (allPresets[b] && allPresets[b].name) || b;
|
||||||
const label = document.createElement('span');
|
return String(na).localeCompare(String(nb), undefined, { sensitivity: "base" });
|
||||||
label.textContent = name;
|
});
|
||||||
const selectBtn = document.createElement('button');
|
sorted.forEach((presetId) => {
|
||||||
selectBtn.type = 'button';
|
const preset = allPresets[presetId] || {};
|
||||||
selectBtn.className = 'btn btn-primary btn-small';
|
const name = preset.name || presetId;
|
||||||
selectBtn.textContent = 'Select';
|
sel.appendChild(new Option(`${name} — ${presetId}`, presetId));
|
||||||
selectBtn.addEventListener('click', async () => {
|
});
|
||||||
if (typeof window.addPresetToTab === 'function') {
|
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 window.addPresetToTab(presetId, tabId);
|
||||||
await populateEditTabPresetsList(tabId);
|
sel.value = "";
|
||||||
|
await refreshEditTabPresetsUi(tabId);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
row.appendChild(label);
|
addWrap.appendChild(sel);
|
||||||
row.appendChild(selectBtn);
|
addWrap.appendChild(addBtn);
|
||||||
listEl.appendChild(row);
|
addEl.appendChild(addWrap);
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('populateEditTabPresetsList:', e);
|
console.error("refreshEditTabPresetsUi:", e);
|
||||||
listEl.innerHTML = '<span class="muted-text">Failed to load presets.</span>';
|
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
|
// Open edit tab modal
|
||||||
function openEditTabModal(tabId, tab) {
|
async function openEditTabModal(tabId, tab) {
|
||||||
const modal = document.getElementById('edit-tab-modal');
|
const modal = document.getElementById("edit-tab-modal");
|
||||||
const idInput = document.getElementById('edit-tab-id');
|
const idInput = document.getElementById("edit-tab-id");
|
||||||
const nameInput = document.getElementById('edit-tab-name');
|
const nameInput = document.getElementById("edit-tab-name");
|
||||||
const idsInput = document.getElementById('edit-tab-ids');
|
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 (idInput) idInput.value = tabId;
|
||||||
if (nameInput) nameInput.value = tab ? (tab.name || '') : '';
|
if (nameInput) nameInput.value = tabData.name || "";
|
||||||
if (idsInput) idsInput.value = tab && tab.names ? tab.names.join(', ') : '1';
|
|
||||||
|
|
||||||
if (modal) modal.classList.add('active');
|
const devicesMap = await fetchDevicesMap();
|
||||||
populateEditTabPresetsList(tabId);
|
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
|
// Update an existing tab
|
||||||
async function updateTab(tabId, name, ids) {
|
async function updateTab(tabId, name, namesOrString) {
|
||||||
try {
|
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}`, {
|
const response = await fetch(`/tabs/${tabId}`, {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
headers: {
|
headers: {
|
||||||
@@ -583,9 +825,10 @@ async function updateTab(tabId, name, ids) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Create a new tab
|
// Create a new tab
|
||||||
async function createTab(name, ids) {
|
async function createTab(name, namesOrString) {
|
||||||
try {
|
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', {
|
const response = await fetch('/tabs', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
@@ -627,14 +870,13 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
const tabsButton = document.getElementById('tabs-btn');
|
const tabsButton = document.getElementById('tabs-btn');
|
||||||
const tabsModal = document.getElementById('tabs-modal');
|
const tabsModal = document.getElementById('tabs-modal');
|
||||||
const tabsCloseButton = document.getElementById('tabs-close-btn');
|
const tabsCloseButton = document.getElementById('tabs-close-btn');
|
||||||
const newTabNameInput = document.getElementById('new-tab-name');
|
const newTabNameInput = document.getElementById("new-tab-name");
|
||||||
const newTabIdsInput = document.getElementById('new-tab-ids');
|
const createTabButton = document.getElementById("create-tab-btn");
|
||||||
const createTabButton = document.getElementById('create-tab-btn');
|
|
||||||
|
|
||||||
if (tabsButton && tabsModal) {
|
if (tabsButton && tabsModal) {
|
||||||
tabsButton.addEventListener('click', () => {
|
tabsButton.addEventListener("click", async () => {
|
||||||
tabsModal.classList.add('active');
|
tabsModal.classList.add("active");
|
||||||
loadTabsModal();
|
await loadTabsModal();
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -659,7 +901,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
const response = await fetch(`/tabs/${tabId}`);
|
const response = await fetch(`/tabs/${tabId}`);
|
||||||
if (response.ok) {
|
if (response.ok) {
|
||||||
const tab = await response.json();
|
const tab = await response.json();
|
||||||
openEditTabModal(tabId, tab);
|
await openEditTabModal(tabId, tab);
|
||||||
} else {
|
} else {
|
||||||
alert('Failed to load tab for editing');
|
alert('Failed to load tab for editing');
|
||||||
}
|
}
|
||||||
@@ -673,12 +915,11 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
const createTabHandler = async () => {
|
const createTabHandler = async () => {
|
||||||
if (!newTabNameInput) return;
|
if (!newTabNameInput) return;
|
||||||
const name = newTabNameInput.value.trim();
|
const name = newTabNameInput.value.trim();
|
||||||
const ids = (newTabIdsInput && newTabIdsInput.value.trim()) || '1';
|
|
||||||
|
|
||||||
if (name) {
|
if (name) {
|
||||||
await createTab(name, ids);
|
const deviceNames = await defaultDeviceNamesForNewTab();
|
||||||
if (newTabNameInput) newTabNameInput.value = '';
|
await createTab(name, deviceNames);
|
||||||
if (newTabIdsInput) newTabIdsInput.value = '1';
|
if (newTabNameInput) newTabNameInput.value = "";
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -697,18 +938,22 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
// Set up edit tab form
|
// Set up edit tab form
|
||||||
const editTabForm = document.getElementById('edit-tab-form');
|
const editTabForm = document.getElementById('edit-tab-form');
|
||||||
if (editTabForm) {
|
if (editTabForm) {
|
||||||
editTabForm.addEventListener('submit', async (e) => {
|
editTabForm.addEventListener("submit", async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const idInput = document.getElementById('edit-tab-id');
|
const idInput = document.getElementById("edit-tab-id");
|
||||||
const nameInput = document.getElementById('edit-tab-name');
|
const nameInput = document.getElementById("edit-tab-name");
|
||||||
const idsInput = document.getElementById('edit-tab-ids');
|
|
||||||
|
|
||||||
const tabId = idInput ? idInput.value : null;
|
const tabId = idInput ? idInput.value : null;
|
||||||
const name = nameInput ? nameInput.value.trim() : '';
|
const name = nameInput ? nameInput.value.trim() : "";
|
||||||
const ids = idsInput ? idsInput.value.trim() : '1';
|
const rows = window.__editTabDeviceRows || [];
|
||||||
|
const deviceNames = rowsToNames(rows);
|
||||||
|
|
||||||
if (tabId && name) {
|
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();
|
editTabForm.reset();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -726,7 +971,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
document.querySelectorAll('.ui-mode-toggle').forEach((btn) => {
|
document.querySelectorAll('.ui-mode-toggle').forEach((btn) => {
|
||||||
btn.addEventListener('click', async () => {
|
btn.addEventListener('click', async () => {
|
||||||
await loadTabs();
|
await loadTabs();
|
||||||
if (tabsModal && tabsModal.classList.contains('active')) {
|
if (tabsModal && tabsModal.classList.contains("active")) {
|
||||||
await loadTabsModal();
|
await loadTabsModal();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -741,5 +986,6 @@ window.tabsManager = {
|
|||||||
createTab,
|
createTab,
|
||||||
updateTab,
|
updateTab,
|
||||||
openEditTabModal,
|
openEditTabModal,
|
||||||
getCurrentTabId: () => currentTabId
|
resolveTabDeviceMacs,
|
||||||
|
getCurrentTabId: () => currentTabId,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -16,7 +16,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="header-actions">
|
<div class="header-actions">
|
||||||
<button class="btn btn-secondary" id="profiles-btn">Profiles</button>
|
<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="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="presets-btn">Presets</button>
|
||||||
<button class="btn btn-secondary edit-mode-only" id="patterns-btn">Patterns</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">
|
<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" 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="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="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="presets-btn">Presets</button>
|
||||||
<button type="button" class="edit-mode-only" data-target="patterns-btn">Patterns</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 id="tabs-modal" class="modal">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<h2>Tabs</h2>
|
<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-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>
|
<button class="btn btn-primary" id="create-tab-btn">Create</button>
|
||||||
</div>
|
</div>
|
||||||
<div id="tabs-list-modal" class="profiles-list"></div>
|
<div id="tabs-list-modal" class="profiles-list"></div>
|
||||||
@@ -78,10 +77,12 @@
|
|||||||
</div>
|
</div>
|
||||||
<label>Tab Name:</label>
|
<label>Tab Name:</label>
|
||||||
<input type="text" id="edit-tab-name" placeholder="Enter tab name" required>
|
<input type="text" id="edit-tab-name" placeholder="Enter tab name" required>
|
||||||
<label>Device IDs (comma-separated):</label>
|
<label class="tab-devices-label">Devices in this tab</label>
|
||||||
<input type="text" id="edit-tab-ids" placeholder="1,2,3" required>
|
<div id="edit-tab-devices-editor" class="tab-devices-editor"></div>
|
||||||
<label style="margin-top: 1rem;">Add presets to this tab</label>
|
<label class="tab-presets-section-label">Presets on this tab</label>
|
||||||
<div id="edit-tab-presets-list" class="profiles-list" style="max-height: 200px; overflow-y: auto; margin-bottom: 1rem;"></div>
|
<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>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -111,7 +112,6 @@
|
|||||||
<div id="devices-modal" class="modal">
|
<div id="devices-modal" class="modal">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
<h2>Devices</h2>
|
<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 id="devices-list-modal" class="profiles-list"></div>
|
||||||
<div class="modal-actions">
|
<div class="modal-actions">
|
||||||
<button type="button" class="btn btn-secondary" id="devices-close-btn">Close</button>
|
<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/help.js"></script>
|
||||||
<script src="/static/color_palette.js"></script>
|
<script src="/static/color_palette.js"></script>
|
||||||
<script src="/static/profiles.js"></script>
|
<script src="/static/profiles.js"></script>
|
||||||
<script src="/static/devices.js"></script>
|
|
||||||
<script src="/static/tab_palette.js"></script>
|
<script src="/static/tab_palette.js"></script>
|
||||||
<script src="/static/patterns.js"></script>
|
<script src="/static/patterns.js"></script>
|
||||||
<script src="/static/presets.js"></script>
|
<script src="/static/presets.js"></script>
|
||||||
|
<script src="/static/devices.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
Reference in New Issue
Block a user