1075 lines
39 KiB
JavaScript
1075 lines
39 KiB
JavaScript
// Zone management JavaScript
|
|
let currentZoneId = null;
|
|
let brightnessSendTimeout = null;
|
|
|
|
const UI_BRIGHTNESS_STORAGE_KEY = "led_controller_ui_brightness";
|
|
|
|
function clamp255(n) {
|
|
const v = parseInt(n, 10);
|
|
if (Number.isNaN(v)) return null;
|
|
return Math.max(0, Math.min(255, v));
|
|
}
|
|
|
|
function loadSavedUiBrightness() {
|
|
try {
|
|
const raw = localStorage.getItem(UI_BRIGHTNESS_STORAGE_KEY);
|
|
if (raw == null) return null;
|
|
return clamp255(raw);
|
|
} catch (_) {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function persistUiBrightness(value) {
|
|
const v = clamp255(value);
|
|
if (v === null) return;
|
|
try {
|
|
localStorage.setItem(UI_BRIGHTNESS_STORAGE_KEY, String(v));
|
|
} catch (_) {}
|
|
}
|
|
|
|
function applyBrightnessSliders(val) {
|
|
const v = clamp255(val);
|
|
if (v === null) return;
|
|
const headerSlider = document.getElementById("header-brightness-slider");
|
|
const menuSlider = document.getElementById("menu-brightness-slider");
|
|
if (headerSlider) headerSlider.value = String(v);
|
|
if (menuSlider) menuSlider.value = String(v);
|
|
}
|
|
|
|
function sendZoneBrightness(value) {
|
|
const val = Math.max(0, Math.min(255, parseInt(value, 10) || 0));
|
|
persistUiBrightness(val);
|
|
const headerSlider = document.getElementById('header-brightness-slider');
|
|
const menuSlider = document.getElementById('menu-brightness-slider');
|
|
if (headerSlider && String(headerSlider.value) !== String(val)) {
|
|
headerSlider.value = String(val);
|
|
}
|
|
if (menuSlider && String(menuSlider.value) !== String(val)) {
|
|
menuSlider.value = String(val);
|
|
}
|
|
if (brightnessSendTimeout) {
|
|
clearTimeout(brightnessSendTimeout);
|
|
}
|
|
brightnessSendTimeout = setTimeout(() => {
|
|
(async () => {
|
|
try {
|
|
const section = document.querySelector('.presets-section[data-zone-id]');
|
|
const names = typeof window.parseTabDeviceNames === 'function'
|
|
? window.parseTabDeviceNames(section)
|
|
: [];
|
|
const targetMacs =
|
|
names.length > 0 &&
|
|
typeof window.tabsManager !== 'undefined' &&
|
|
typeof window.tabsManager.resolveTabDeviceMacs === 'function'
|
|
? await window.tabsManager.resolveTabDeviceMacs(names)
|
|
: [];
|
|
if (typeof window.postDriverSequence === 'function') {
|
|
await window.postDriverSequence([{ v: '1', b: val, save: true }], targetMacs, 0);
|
|
return;
|
|
}
|
|
// Fallback to raw websocket sender if presets.js helper isn't available yet.
|
|
if (typeof window.sendEspnowRaw === 'function') {
|
|
window.sendEspnowRaw({ v: '1', b: val, save: true });
|
|
}
|
|
} catch (err) {
|
|
console.error('Failed to send brightness via driver sequence:', err);
|
|
}
|
|
})();
|
|
}, 150);
|
|
}
|
|
|
|
const isEditModeActive = () => {
|
|
const toggle = document.querySelector('.ui-mode-toggle');
|
|
return !!(toggle && toggle.getAttribute('aria-pressed') === 'true');
|
|
};
|
|
|
|
// Get current zone from cookie
|
|
function getCurrentZoneFromCookie() {
|
|
const cookies = document.cookie.split(';');
|
|
for (let cookie of cookies) {
|
|
const [name, value] = cookie.trim().split('=');
|
|
if (name === 'current_zone') {
|
|
return value;
|
|
}
|
|
}
|
|
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 zone device names (order matches zone names; skips unknown names). */
|
|
async function resolveZoneDeviceMacs(zoneNames) {
|
|
const dm = await fetchDevicesMap();
|
|
const rows = namesToRows(Array.isArray(zoneNames) ? zoneNames : [], dm);
|
|
const macs = rows.map((r) => r.mac).filter(Boolean);
|
|
return [...new Set(macs)];
|
|
}
|
|
|
|
function namesToRows(zoneNames, devicesMap) {
|
|
const usedMacs = new Set();
|
|
const list = Array.isArray(zoneNames) ? zoneNames : [];
|
|
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 renderZoneDevicesEditor(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 = "zone-device-row profiles-row";
|
|
const label = document.createElement("span");
|
|
label.className = "zone-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);
|
|
renderZoneDevicesEditor(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 = "zone-devices-add profiles-actions";
|
|
const sel = document.createElement("select");
|
|
sel.className = "zone-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 = "";
|
|
renderZoneDevicesEditor(containerEl, rows, devicesMap);
|
|
});
|
|
addWrap.appendChild(sel);
|
|
addWrap.appendChild(addBtn);
|
|
containerEl.appendChild(addWrap);
|
|
}
|
|
|
|
/** Default device name list when creating a zone (refined in Edit zone). */
|
|
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 zone 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;
|
|
window.parseZoneDeviceNames = parseTabDeviceNames;
|
|
|
|
function escapeHtmlAttr(s) {
|
|
return String(s)
|
|
.replace(/&/g, "&")
|
|
.replace(/"/g, """)
|
|
.replace(/</g, "<");
|
|
}
|
|
|
|
// Load tabs list
|
|
async function loadZones() {
|
|
try {
|
|
const response = await fetch('/zones');
|
|
const data = await response.json();
|
|
|
|
// Get current zone from cookie first, then from server response
|
|
const cookieTabId = getCurrentZoneFromCookie();
|
|
const serverCurrent = data.current_zone_id;
|
|
const tabs = data.zones || {};
|
|
const zoneIds = Object.keys(tabs);
|
|
|
|
let candidateId = cookieTabId || serverCurrent || null;
|
|
// If the candidate doesn't exist anymore (e.g. after DB reset), fall back to first zone.
|
|
if (candidateId && !zoneIds.includes(String(candidateId))) {
|
|
candidateId = zoneIds.length > 0 ? zoneIds[0] : null;
|
|
// Clear stale cookie
|
|
document.cookie = 'current_zone=; path=/; max-age=0';
|
|
}
|
|
|
|
currentZoneId = candidateId;
|
|
renderZonesList(data.zones, data.zone_order, currentZoneId);
|
|
|
|
// Load current zone content if available
|
|
if (currentZoneId) {
|
|
await loadZoneContent(currentZoneId);
|
|
} else if (data.zone_order && data.zone_order.length > 0) {
|
|
// Set first zone as current if none is set
|
|
const firstTabId = data.zone_order[0];
|
|
await setCurrentZone(firstTabId);
|
|
await loadZoneContent(firstTabId);
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to load zones:', error);
|
|
const container = document.getElementById('zones-list');
|
|
if (container) {
|
|
container.innerHTML = '<div class="error">Failed to load zones</div>';
|
|
}
|
|
}
|
|
}
|
|
|
|
// Render tabs list in the main UI
|
|
function renderZonesList(tabs, tabOrder, currentZoneId) {
|
|
const container = document.getElementById('zones-list');
|
|
if (!container) return;
|
|
|
|
if (!tabOrder || tabOrder.length === 0) {
|
|
container.innerHTML = '<div class="muted-text">No zones available</div>';
|
|
return;
|
|
}
|
|
|
|
const editMode = isEditModeActive();
|
|
let html = '<div class="zones-list">';
|
|
for (const zoneId of tabOrder) {
|
|
const zone = tabs[zoneId];
|
|
if (zone) {
|
|
const activeClass = zoneId === currentZoneId ? 'active' : '';
|
|
const tabName = zone.name || `Zone ${zoneId}`;
|
|
html += `
|
|
<button class="zone-button ${activeClass}"
|
|
data-zone-id="${zoneId}"
|
|
title="${editMode ? 'Click to select, right-click to edit' : 'Click to select'}"
|
|
onclick="selectZone('${zoneId}')">
|
|
${tabName}
|
|
</button>
|
|
`;
|
|
}
|
|
}
|
|
html += '</div>';
|
|
container.innerHTML = html;
|
|
}
|
|
|
|
// Render tabs list in modal (like profiles)
|
|
function renderZonesListModal(tabs, tabOrder, currentZoneId) {
|
|
const container = document.getElementById('zones-list-modal');
|
|
if (!container) return;
|
|
|
|
container.innerHTML = "";
|
|
let entries = [];
|
|
|
|
if (Array.isArray(tabOrder)) {
|
|
entries = tabOrder.map((zoneId) => [zoneId, tabs[zoneId] || {}]);
|
|
} else if (tabs && typeof tabs === "object") {
|
|
entries = Object.entries(tabs).filter(([key]) => {
|
|
return key !== 'current_zone_id' && key !== 'zones' && key !== 'zone_order';
|
|
});
|
|
}
|
|
|
|
if (entries.length === 0) {
|
|
const empty = document.createElement("p");
|
|
empty.className = "muted-text";
|
|
empty.textContent = "No zones found.";
|
|
container.appendChild(empty);
|
|
return;
|
|
}
|
|
|
|
const editMode = isEditModeActive();
|
|
entries.forEach(([zoneId, zone]) => {
|
|
const row = document.createElement("div");
|
|
row.className = "profiles-row";
|
|
row.dataset.zoneId = String(zoneId);
|
|
|
|
const label = document.createElement("span");
|
|
label.textContent = (zone && zone.name) || zoneId;
|
|
if (String(zoneId) === String(currentZoneId)) {
|
|
label.textContent = `✓ ${label.textContent}`;
|
|
label.style.fontWeight = "bold";
|
|
label.style.color = "#FFD700";
|
|
}
|
|
|
|
const applyButton = document.createElement("button");
|
|
applyButton.className = "btn btn-secondary btn-small";
|
|
applyButton.textContent = "Select";
|
|
applyButton.addEventListener("click", async () => {
|
|
await selectZone(zoneId);
|
|
document.getElementById('zones-modal').classList.remove('active');
|
|
});
|
|
|
|
const editButton = document.createElement("button");
|
|
editButton.className = "btn btn-secondary btn-small";
|
|
editButton.textContent = "Edit";
|
|
editButton.addEventListener("click", async () => {
|
|
await openEditZoneModal(zoneId, zone);
|
|
});
|
|
|
|
const cloneButton = document.createElement("button");
|
|
cloneButton.className = "btn btn-secondary btn-small";
|
|
cloneButton.textContent = "Clone";
|
|
cloneButton.addEventListener("click", async () => {
|
|
const baseName = (zone && zone.name) || zoneId;
|
|
const suggested = `${baseName} Copy`;
|
|
const name = prompt("New zone name:", suggested);
|
|
if (name === null) {
|
|
return;
|
|
}
|
|
const trimmed = String(name).trim();
|
|
if (!trimmed) {
|
|
alert("Zone name cannot be empty.");
|
|
return;
|
|
}
|
|
try {
|
|
const response = await fetch(`/zones/${zoneId}/clone`, {
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
"Accept": "application/json",
|
|
},
|
|
body: JSON.stringify({ name: trimmed }),
|
|
});
|
|
if (!response.ok) {
|
|
const errorData = await response.json().catch(() => ({ error: "Failed to clone zone" }));
|
|
throw new Error(errorData.error || "Failed to clone zone");
|
|
}
|
|
const data = await response.json().catch(() => null);
|
|
let newTabId = null;
|
|
if (data && typeof data === "object") {
|
|
if (data.id) {
|
|
newTabId = String(data.id);
|
|
} else {
|
|
const ids = Object.keys(data);
|
|
if (ids.length > 0) {
|
|
newTabId = String(ids[0]);
|
|
}
|
|
}
|
|
}
|
|
await loadZonesModal();
|
|
if (newTabId) {
|
|
await selectZone(newTabId);
|
|
} else {
|
|
await loadZones();
|
|
}
|
|
} catch (error) {
|
|
console.error("Clone zone failed:", error);
|
|
alert("Failed to clone zone: " + error.message);
|
|
}
|
|
});
|
|
|
|
const deleteButton = document.createElement("button");
|
|
deleteButton.className = "btn btn-danger btn-small";
|
|
deleteButton.textContent = "Delete";
|
|
deleteButton.addEventListener("click", async () => {
|
|
const confirmed = confirm(`Delete zone "${label.textContent}"?`);
|
|
if (!confirmed) {
|
|
return;
|
|
}
|
|
try {
|
|
const response = await fetch(`/zones/${zoneId}`, {
|
|
method: "DELETE",
|
|
headers: { Accept: "application/json" },
|
|
});
|
|
if (!response.ok) {
|
|
const errorData = await response.json().catch(() => ({ error: "Failed to delete zone" }));
|
|
throw new Error(errorData.error || "Failed to delete zone");
|
|
}
|
|
// Clear cookie if deleted zone was current
|
|
if (zoneId === currentZoneId) {
|
|
document.cookie = 'current_zone=; path=/; max-age=0';
|
|
currentZoneId = null;
|
|
}
|
|
await loadZonesModal();
|
|
await loadZones(); // Reload main tabs list
|
|
} catch (error) {
|
|
console.error("Delete zone failed:", error);
|
|
alert("Failed to delete zone: " + error.message);
|
|
}
|
|
});
|
|
|
|
row.appendChild(label);
|
|
row.appendChild(applyButton);
|
|
if (editMode) {
|
|
row.appendChild(editButton);
|
|
row.appendChild(cloneButton);
|
|
row.appendChild(deleteButton);
|
|
}
|
|
container.appendChild(row);
|
|
});
|
|
}
|
|
|
|
// Load tabs in modal
|
|
async function loadZonesModal() {
|
|
const container = document.getElementById('zones-list-modal');
|
|
if (!container) return;
|
|
|
|
container.innerHTML = "";
|
|
const loading = document.createElement("p");
|
|
loading.className = "muted-text";
|
|
loading.textContent = "Loading zones...";
|
|
container.appendChild(loading);
|
|
|
|
try {
|
|
const response = await fetch("/zones", {
|
|
headers: { Accept: "application/json" },
|
|
});
|
|
if (!response.ok) {
|
|
throw new Error("Failed to load zones");
|
|
}
|
|
const data = await response.json();
|
|
const tabs = data.zones || data;
|
|
const currentZoneId = getCurrentZoneFromCookie() || data.current_zone_id || null;
|
|
renderZonesListModal(tabs, data.zone_order || [], currentZoneId);
|
|
} catch (error) {
|
|
console.error("Load tabs failed:", error);
|
|
container.innerHTML = "";
|
|
const errorMessage = document.createElement("p");
|
|
errorMessage.className = "muted-text";
|
|
errorMessage.textContent = "Failed to load zones.";
|
|
container.appendChild(errorMessage);
|
|
}
|
|
}
|
|
|
|
// Select a zone
|
|
async function selectZone(zoneId) {
|
|
// Update active state
|
|
document.querySelectorAll('.zone-button').forEach(btn => {
|
|
btn.classList.remove('active');
|
|
});
|
|
const btn = document.querySelector(`[data-zone-id="${zoneId}"]`);
|
|
if (btn) {
|
|
btn.classList.add('active');
|
|
}
|
|
|
|
// Set as current zone
|
|
await setCurrentZone(zoneId);
|
|
// Load zone content
|
|
loadZoneContent(zoneId);
|
|
}
|
|
|
|
// Set current zone in cookie
|
|
async function setCurrentZone(zoneId) {
|
|
try {
|
|
const response = await fetch(`/zones/${zoneId}/set-current`, {
|
|
method: 'POST'
|
|
});
|
|
const data = await response.json();
|
|
if (response.ok) {
|
|
currentZoneId = zoneId;
|
|
// Also set cookie on client side
|
|
document.cookie = `current_zone=${zoneId}; path=/; max-age=31536000`;
|
|
} else {
|
|
console.error('Failed to set current zone:', data.error);
|
|
}
|
|
} catch (error) {
|
|
console.error('Error setting current zone:', error);
|
|
}
|
|
}
|
|
|
|
// Load zone content
|
|
async function loadZoneContent(zoneId) {
|
|
const container = document.getElementById('zone-content');
|
|
if (!container) return;
|
|
|
|
try {
|
|
const response = await fetch(`/zones/${zoneId}`);
|
|
const zone = await response.json();
|
|
|
|
if (zone.error) {
|
|
container.innerHTML = `<div class="error">${zone.error}</div>`;
|
|
return;
|
|
}
|
|
|
|
// Render zone content (presets section)
|
|
const tabName = zone.name || `Zone ${zoneId}`;
|
|
const names = Array.isArray(zone.names) ? zone.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-zone-id="${zoneId}" data-device-names-json="${namesJsonAttr}"${legacyAttr}>
|
|
<div id="presets-list-zone" class="presets-list">
|
|
<!-- Presets will be loaded here by presets.js -->
|
|
</div>
|
|
</div>
|
|
`;
|
|
|
|
// Keep header and menu brightness controls in sync.
|
|
const brightnessSlider = document.getElementById('header-brightness-slider');
|
|
const menuBrightnessSlider = document.getElementById('menu-brightness-slider');
|
|
if (menuBrightnessSlider && brightnessSlider) {
|
|
menuBrightnessSlider.value = brightnessSlider.value;
|
|
}
|
|
|
|
// Trigger presets loading if the function exists
|
|
if (typeof renderTabPresets === 'function') {
|
|
renderTabPresets(zoneId);
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to load zone content:', error);
|
|
container.innerHTML = '<div class="error">Failed to load zone content</div>';
|
|
}
|
|
}
|
|
|
|
// Send all presets used by all tabs in the current profile via /presets/send.
|
|
async function sendProfilePresets() {
|
|
try {
|
|
// Load current profile to get its tabs
|
|
const profileRes = await fetch('/profiles/current', {
|
|
headers: { Accept: 'application/json' },
|
|
});
|
|
if (!profileRes.ok) {
|
|
alert('Failed to load current profile.');
|
|
return;
|
|
}
|
|
const profileData = await profileRes.json();
|
|
const profile = profileData.profile || {};
|
|
let zoneList = null;
|
|
if (Array.isArray(profile.zones)) {
|
|
zoneList = profile.zones;
|
|
} else if (profile.zones) {
|
|
zoneList = [profile.zones];
|
|
}
|
|
if (!zoneList || zoneList.length === 0) {
|
|
if (Array.isArray(profile.zones)) {
|
|
zoneList = profile.zones;
|
|
} else if (profile.zones) {
|
|
zoneList = [profile.zones];
|
|
}
|
|
}
|
|
if (!zoneList || zoneList.length === 0) {
|
|
console.warn('sendProfilePresets: no zones found', {
|
|
profileData,
|
|
profile,
|
|
});
|
|
}
|
|
|
|
if (!zoneList.length) {
|
|
alert('Current profile has no zones to send presets for.');
|
|
return;
|
|
}
|
|
|
|
let totalSent = 0;
|
|
let totalMessages = 0;
|
|
let zonesWithPresets = 0;
|
|
|
|
for (const zoneId of zoneList) {
|
|
try {
|
|
const tabResp = await fetch(`/zones/${zoneId}`, {
|
|
headers: { Accept: 'application/json' },
|
|
});
|
|
if (!tabResp.ok) {
|
|
continue;
|
|
}
|
|
const tabData = await tabResp.json();
|
|
let presetIds = [];
|
|
if (Array.isArray(tabData.presets_flat)) {
|
|
presetIds = tabData.presets_flat;
|
|
} else if (Array.isArray(tabData.presets)) {
|
|
if (tabData.presets.length && typeof tabData.presets[0] === 'string') {
|
|
presetIds = tabData.presets;
|
|
} else if (tabData.presets.length && Array.isArray(tabData.presets[0])) {
|
|
presetIds = tabData.presets.flat();
|
|
}
|
|
}
|
|
presetIds = (presetIds || []).filter(Boolean);
|
|
if (!presetIds.length) {
|
|
continue;
|
|
}
|
|
zonesWithPresets += 1;
|
|
const zoneNames = Array.isArray(tabData.names) ? tabData.names : [];
|
|
const targets = await resolveZoneDeviceMacs(zoneNames);
|
|
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: {
|
|
'Content-Type': 'application/json',
|
|
'Accept': 'application/json',
|
|
},
|
|
body: JSON.stringify(payload),
|
|
});
|
|
const data = await response.json().catch(() => ({}));
|
|
if (!response.ok) {
|
|
const msg = (data && data.error) || `Failed to send presets for zone ${zoneId}.`;
|
|
console.warn(msg);
|
|
continue;
|
|
}
|
|
totalSent += typeof data.presets_sent === 'number' ? data.presets_sent : presetIds.length;
|
|
totalMessages += typeof data.messages_sent === 'number' ? data.messages_sent : 0;
|
|
} catch (e) {
|
|
console.error('Failed to send profile presets for zone:', zoneId, e);
|
|
}
|
|
}
|
|
|
|
if (!zonesWithPresets) {
|
|
alert('No presets to send for the current profile.');
|
|
return;
|
|
}
|
|
|
|
const messagesLabel = totalMessages ? totalMessages : '?';
|
|
alert(`Sent ${totalSent} preset(s) across ${zonesWithPresets} zone(s) (${messagesLabel} driver send(s)).`);
|
|
} catch (error) {
|
|
console.error('Failed to send profile presets:', error);
|
|
alert('Failed to send profile presets.');
|
|
}
|
|
}
|
|
|
|
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 zone (remove) and presets available to add (select).
|
|
async function refreshEditTabPresetsUi(zoneId) {
|
|
const currentEl = document.getElementById("edit-zone-presets-current");
|
|
const addEl = document.getElementById("edit-zone-presets-list");
|
|
if (!zoneId || !currentEl || !addEl) return;
|
|
|
|
currentEl.innerHTML = '<span class="muted-text">Loading…</span>';
|
|
addEl.innerHTML = '<span class="muted-text">Loading…</span>';
|
|
|
|
try {
|
|
const tabRes = await fetch(`/zones/${zoneId}`, { headers: { Accept: "application/json" } });
|
|
if (!tabRes.ok) {
|
|
const msg = '<span class="muted-text">Failed to load zone presets.</span>';
|
|
currentEl.innerHTML = msg;
|
|
addEl.innerHTML = msg;
|
|
return;
|
|
}
|
|
const tabData = await tabRes.json();
|
|
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 zone 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 zone?\n\n${name}`)) return;
|
|
await window.removePresetFromTab(zoneId, presetId);
|
|
await refreshEditTabPresetsUi(zoneId);
|
|
});
|
|
row.appendChild(label);
|
|
row.appendChild(removeBtn);
|
|
currentEl.appendChild(row);
|
|
}
|
|
}
|
|
|
|
const allIds = Object.keys(allPresets);
|
|
const availableToAdd = allIds.filter((id) => !inTabSet.has(String(id)));
|
|
addEl.innerHTML = "";
|
|
if (availableToAdd.length === 0) {
|
|
addEl.innerHTML =
|
|
'<span class="muted-text">No presets to add. All presets are already on this zone.</span>';
|
|
} else {
|
|
const addWrap = document.createElement("div");
|
|
addWrap.className = "zone-devices-add profiles-actions";
|
|
const sel = document.createElement("select");
|
|
sel.className = "zone-device-add-select";
|
|
sel.setAttribute("aria-label", "Preset to add to this zone");
|
|
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, zoneId);
|
|
sel.value = "";
|
|
await refreshEditTabPresetsUi(zoneId);
|
|
}
|
|
});
|
|
addWrap.appendChild(sel);
|
|
addWrap.appendChild(addBtn);
|
|
addEl.appendChild(addWrap);
|
|
}
|
|
} catch (e) {
|
|
console.error("refreshEditTabPresetsUi:", e);
|
|
const msg = '<span class="muted-text">Failed to load presets.</span>';
|
|
currentEl.innerHTML = msg;
|
|
addEl.innerHTML = msg;
|
|
}
|
|
}
|
|
|
|
async function populateEditTabPresetsList(zoneId) {
|
|
await refreshEditTabPresetsUi(zoneId);
|
|
}
|
|
|
|
// Open edit zone modal
|
|
async function openEditZoneModal(zoneId, zone) {
|
|
const modal = document.getElementById("edit-zone-modal");
|
|
const idInput = document.getElementById("edit-zone-id");
|
|
const nameInput = document.getElementById("edit-zone-name");
|
|
const editor = document.getElementById("edit-zone-devices-editor");
|
|
|
|
let tabData = zone;
|
|
if (!tabData || typeof tabData !== "object" || tabData.error) {
|
|
try {
|
|
const response = await fetch(`/zones/${zoneId}`);
|
|
if (response.ok) {
|
|
tabData = await response.json();
|
|
}
|
|
} catch (e) {
|
|
console.error("openEditZoneModal fetch zone:", e);
|
|
}
|
|
}
|
|
tabData = tabData || {};
|
|
|
|
if (idInput) idInput.value = zoneId;
|
|
if (nameInput) nameInput.value = tabData.name || "";
|
|
|
|
const devicesMap = await fetchDevicesMap();
|
|
const zoneNames =
|
|
Array.isArray(tabData.names) && tabData.names.length > 0 ? tabData.names : ["1"];
|
|
window.__editTabDeviceRows = namesToRows(zoneNames, devicesMap);
|
|
renderZoneDevicesEditor(editor, window.__editTabDeviceRows, devicesMap);
|
|
|
|
if (modal) modal.classList.add("active");
|
|
await refreshEditTabPresetsUi(zoneId);
|
|
}
|
|
|
|
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 zone
|
|
async function updateZone(zoneId, name, namesOrString) {
|
|
try {
|
|
let names = normalizeTabNamesArg(namesOrString);
|
|
if (!names.length) names = ["1"];
|
|
const response = await fetch(`/zones/${zoneId}`, {
|
|
method: 'PUT',
|
|
headers: {
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify({
|
|
name: name,
|
|
names: names
|
|
})
|
|
});
|
|
|
|
const data = await response.json();
|
|
if (response.ok) {
|
|
// Reload tabs list
|
|
await loadZonesModal();
|
|
await loadZones();
|
|
// Close modal
|
|
document.getElementById('edit-zone-modal').classList.remove('active');
|
|
return true;
|
|
} else {
|
|
alert(`Error: ${data.error || 'Failed to update zone'}`);
|
|
return false;
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to update zone:', error);
|
|
alert('Failed to update zone');
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// Create a new zone
|
|
async function createZone(name, namesOrString) {
|
|
try {
|
|
let names = normalizeTabNamesArg(namesOrString);
|
|
if (!names.length) names = ["1"];
|
|
const response = await fetch('/zones', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/json'
|
|
},
|
|
body: JSON.stringify({
|
|
name: name,
|
|
names: names
|
|
})
|
|
});
|
|
|
|
const data = await response.json();
|
|
if (response.ok) {
|
|
// Reload tabs list
|
|
await loadZonesModal();
|
|
await loadZones();
|
|
// Select the new zone
|
|
if (data && Object.keys(data).length > 0) {
|
|
const newTabId = Object.keys(data)[0];
|
|
await selectZone(newTabId);
|
|
}
|
|
return true;
|
|
} else {
|
|
alert(`Error: ${data.error || 'Failed to create zone'}`);
|
|
return false;
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to create zone:', error);
|
|
alert('Failed to create zone');
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// Initialize on page load
|
|
document.addEventListener('DOMContentLoaded', () => {
|
|
loadZones();
|
|
|
|
// Set up tabs modal
|
|
const tabsButton = document.getElementById('zones-btn');
|
|
const zonesModal = document.getElementById('zones-modal');
|
|
const tabsCloseButton = document.getElementById('zones-close-btn');
|
|
const newTabNameInput = document.getElementById("new-zone-name");
|
|
const createZoneButton = document.getElementById("create-zone-btn");
|
|
|
|
if (tabsButton && zonesModal) {
|
|
tabsButton.addEventListener("click", async () => {
|
|
zonesModal.classList.add("active");
|
|
await loadZonesModal();
|
|
});
|
|
}
|
|
|
|
if (tabsCloseButton) {
|
|
tabsCloseButton.addEventListener('click', () => {
|
|
zonesModal.classList.remove('active');
|
|
});
|
|
}
|
|
|
|
// Right-click on a zone button in the main header bar to edit that zone
|
|
document.addEventListener('contextmenu', async (event) => {
|
|
if (!isEditModeActive()) {
|
|
return;
|
|
}
|
|
const btn = event.target.closest('.zone-button');
|
|
if (!btn || !btn.dataset.zoneId) {
|
|
return;
|
|
}
|
|
event.preventDefault();
|
|
const zoneId = btn.dataset.zoneId;
|
|
try {
|
|
const response = await fetch(`/zones/${zoneId}`);
|
|
if (response.ok) {
|
|
const zone = await response.json();
|
|
await openEditZoneModal(zoneId, zone);
|
|
} else {
|
|
alert('Failed to load zone for editing');
|
|
}
|
|
} catch (error) {
|
|
console.error('Failed to load zone:', error);
|
|
alert('Failed to load zone for editing');
|
|
}
|
|
});
|
|
|
|
// Set up create zone
|
|
const createZoneHandler = async () => {
|
|
if (!newTabNameInput) return;
|
|
const name = newTabNameInput.value.trim();
|
|
|
|
if (name) {
|
|
const deviceNames = await defaultDeviceNamesForNewTab();
|
|
await createZone(name, deviceNames);
|
|
if (newTabNameInput) newTabNameInput.value = "";
|
|
}
|
|
};
|
|
|
|
if (createZoneButton) {
|
|
createZoneButton.addEventListener('click', createZoneHandler);
|
|
}
|
|
|
|
if (newTabNameInput) {
|
|
newTabNameInput.addEventListener('keypress', (event) => {
|
|
if (event.key === 'Enter') {
|
|
createZoneHandler();
|
|
}
|
|
});
|
|
}
|
|
|
|
// Set up edit zone form
|
|
const editZoneForm = document.getElementById('edit-zone-form');
|
|
if (editZoneForm) {
|
|
editZoneForm.addEventListener("submit", async (e) => {
|
|
e.preventDefault();
|
|
const idInput = document.getElementById("edit-zone-id");
|
|
const nameInput = document.getElementById("edit-zone-name");
|
|
|
|
const zoneId = idInput ? idInput.value : null;
|
|
const name = nameInput ? nameInput.value.trim() : "";
|
|
const rows = window.__editTabDeviceRows || [];
|
|
const deviceNames = rowsToNames(rows);
|
|
|
|
if (zoneId && name) {
|
|
if (deviceNames.length === 0) {
|
|
alert("Add at least one device.");
|
|
return;
|
|
}
|
|
await updateZone(zoneId, name, deviceNames);
|
|
editZoneForm.reset();
|
|
}
|
|
});
|
|
}
|
|
|
|
// Profile-wide "Send Presets" button in header
|
|
const sendProfilePresetsBtn = document.getElementById('send-profile-presets-btn');
|
|
if (sendProfilePresetsBtn) {
|
|
sendProfilePresetsBtn.addEventListener('click', async () => {
|
|
await sendProfilePresets();
|
|
});
|
|
}
|
|
|
|
const menuBrightnessSlider = document.getElementById('menu-brightness-slider');
|
|
const headerBrightnessSlider = document.getElementById('header-brightness-slider');
|
|
const savedBr = loadSavedUiBrightness();
|
|
if (savedBr !== null) {
|
|
applyBrightnessSliders(savedBr);
|
|
}
|
|
if (menuBrightnessSlider) {
|
|
menuBrightnessSlider.addEventListener('input', (e) => {
|
|
sendZoneBrightness(e.target.value);
|
|
});
|
|
}
|
|
if (headerBrightnessSlider) {
|
|
headerBrightnessSlider.addEventListener('input', (e) => {
|
|
sendZoneBrightness(e.target.value);
|
|
});
|
|
// Apply saved (or default) level to devices once the page is ready.
|
|
sendZoneBrightness(headerBrightnessSlider.value);
|
|
}
|
|
|
|
// When run/edit mode toggles, refresh tabs UI so edit actions show/hide immediately.
|
|
document.querySelectorAll('.ui-mode-toggle').forEach((btn) => {
|
|
btn.addEventListener('click', async () => {
|
|
await loadZones();
|
|
if (zonesModal && zonesModal.classList.contains("active")) {
|
|
await loadZonesModal();
|
|
}
|
|
});
|
|
});
|
|
});
|
|
|
|
// Export for use in other scripts
|
|
window.zonesManager = {
|
|
loadZones,
|
|
loadZonesModal,
|
|
selectZone,
|
|
createZone,
|
|
updateZone,
|
|
openEditZoneModal,
|
|
resolveZoneDeviceMacs,
|
|
resolveTabDeviceMacs: resolveZoneDeviceMacs,
|
|
getCurrentZoneId: () => currentZoneId,
|
|
};
|
|
window.tabsManager = window.zonesManager;
|
|
window.tabsManager.getCurrentTabId = () => currentZoneId;
|
|
window.tabsManager.loadTabs = loadZones;
|
|
window.tabsManager.loadTabsModal = loadZonesModal;
|
|
window.tabsManager.openEditTabModal = openEditZoneModal;
|