Files
led-controller/src/static/zones.js
2026-05-03 21:27:31 +12:00

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, "&lt;");
}
// 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;