/* ProdDash admin client. Everything here is server-wide: module enable/disable and admin config (written to config/modules.json, module re-inited on save), plus named layout management. Guarded by the shared passcode when one is set. */ const loginCard = document.getElementById('login-card'); const loginStatus = document.getElementById('login-status'); const passcodeInput = document.getElementById('passcode'); const modulesSection = document.getElementById('modules-section'); const modulesList = document.getElementById('modules-list'); const layoutsSection = document.getElementById('layouts-section'); const layoutsList = document.getElementById('layouts-list'); const toastEl = document.getElementById('toast'); const availableSection = document.getElementById('available-section'); const availableList = document.getElementById('available-list'); const catalogStatus = document.getElementById('catalog-status'); const updateCard = document.getElementById('update-card'); const updateTitle = document.getElementById('update-title'); const updateCopy = document.getElementById('update-copy'); const updateStatus = document.getElementById('update-status'); const updateBtn = document.getElementById('update-btn'); const updateChanges = document.getElementById('update-changes'); const shellVersionEl = document.getElementById('shell-version'); const themeSection = document.getElementById('theme-section'); const themeSwatches = document.getElementById('theme-swatches'); const themeStatus = document.getElementById('theme-status'); /** The shell version the page loaded with — the update flow watches it change. */ let shellVersion = ''; let toastTimer = null; function toast(msg, isError = false) { toastEl.textContent = msg; toastEl.classList.toggle('error', isError); toastEl.hidden = false; clearTimeout(toastTimer); toastTimer = setTimeout(() => { toastEl.hidden = true; }, 3200); } async function api(path, opts = {}) { const res = await fetch(path, { ...opts, headers: { 'Content-Type': 'application/json', ...(opts.headers || {}) }, }); const body = await res.json().catch(() => ({})); if (!res.ok) { const err = new Error(body.error || `Request failed (${res.status})`); err.status = res.status; err.authRequired = Boolean(body.authRequired); throw err; } return body; } /* ── login ──────────────────────────────────────────────────────────── */ function showLogin() { loginCard.hidden = false; modulesSection.hidden = true; availableSection.hidden = true; layoutsSection.hidden = true; updateCard.hidden = true; themeSection.hidden = true; passcodeInput.focus(); } async function login() { loginStatus.textContent = ''; loginStatus.className = 'admin-status'; try { await api('/api/admin/login', { method: 'POST', body: JSON.stringify({ passcode: passcodeInput.value }) }); passcodeInput.value = ''; loginCard.hidden = true; await loadState(); } catch (e) { loginStatus.textContent = e.message; loginStatus.className = 'admin-status error'; } } document.getElementById('login-btn').addEventListener('click', login); passcodeInput.addEventListener('keydown', (e) => { if (e.key === 'Enter') login(); }); /* ── module cards ───────────────────────────────────────────────────── */ /** moduleId -> { dot, healthEl, toggle } for cheap in-place status refreshes */ const liveEls = new Map(); function healthClass(mod) { if (!mod.enabled) return ''; if (mod.mountError) return 'error'; return mod.health ? mod.health.status : 'ok'; } function healthText(mod) { if (!mod.enabled) return 'Disabled — hidden from the dashboard picker, server routes unmounted.'; if (mod.mountError) return ''; if (mod.health) return mod.health.message || mod.health.status; return mod.hasServer ? 'Mounted.' : 'Client-only module (no server part).'; } function fieldFor(key, spec, value, passwordSet, moduleId) { const type = spec?.type || 'string'; const label = spec?.label || key; const field = document.createElement('label'); field.className = 'field' + (type === 'boolean' ? ' check' : '') + (type === 'switch' ? ' switch-field' : ''); let read; // For boolean/switch fields, the control other fields may depend on // (showWhen) — returned so the caller can wire visibility. let control = null; // For selects: the element (optionsDependsOn sources) and, with an // optionsRoute, a function that (re)loads the options. let selectEl = null; let refetch = null; if (type === 'switch') { // Same toggle as the module enable switch, inline with its label. const toggle = document.createElement('span'); toggle.className = 'switch'; const input = document.createElement('input'); input.type = 'checkbox'; input.checked = Boolean(value); const track = document.createElement('span'); track.className = 'track'; toggle.append(input, track); const text = document.createElement('span'); text.className = 'switch-label'; text.textContent = label; field.append(toggle, text); read = () => input.checked; control = input; } else if (type === 'boolean') { const input = document.createElement('input'); input.type = 'checkbox'; input.checked = Boolean(value); field.append(input, document.createTextNode(label)); read = () => input.checked; control = input; } else if (type === 'endpoint') { // One consistent "Host / IP : Port" control for every module's upstream. const span = document.createElement('span'); span.textContent = label; const row = document.createElement('span'); row.className = 'endpoint-row'; const host = document.createElement('input'); host.type = 'text'; host.className = 'ep-host'; host.placeholder = 'Host / IP'; host.autocomplete = 'off'; host.spellcheck = false; host.value = value?.host ? String(value.host) : ''; const sep = document.createElement('span'); sep.className = 'ep-sep'; sep.textContent = ':'; const port = document.createElement('input'); port.type = 'number'; port.className = 'ep-port'; port.placeholder = 'Port'; port.min = '1'; port.max = '65535'; port.value = value?.port ? String(value.port) : ''; row.append(host, sep, port); field.append(span, row); read = () => ({ host: host.value.trim(), port: Number(port.value) || 0 }); } else if (type === 'select' && (Array.isArray(spec.options) || spec.optionsRoute)) { const span = document.createElement('span'); span.textContent = label; const select = document.createElement('select'); const optValue = (opt) => String(typeof opt === 'object' ? opt.value : opt); const fill = (options) => { // Keep what the admin has picked but not yet saved across a refill // (a dependent select refetching), else the saved value. const saved = String(value ?? ''); const wanted = select.options.length ? select.value : saved; select.innerHTML = ''; // The saved value must stay selectable even when it isn't among the // live options (device unplugged, module route down). if (saved && !options.some((opt) => optValue(opt) === saved)) { options = [{ value: saved, label: `${saved} (saved)` }, ...options]; } // Nothing saved yet → a blank "choose" entry, unless the module already // offers a blank option of its own (an "Automatic" default, say). if (!saved && !options.some((opt) => optValue(opt) === '')) { options = [{ value: '', label: '— choose —' }, ...options]; } // Options may carry a `group` (a folder path, a device class): options // sharing one render under a single , in first-seen order. const groups = new Map(); for (const opt of options) { const o = document.createElement('option'); o.value = optValue(opt); o.textContent = String(typeof opt === 'object' ? (opt.label ?? opt.value) : opt); const g = typeof opt === 'object' && opt.group ? String(opt.group) : ''; if (!g) { select.appendChild(o); continue; } if (!groups.has(g)) { const og = document.createElement('optgroup'); og.label = g; groups.set(g, og); select.appendChild(og); } groups.get(g).appendChild(o); } select.value = options.some((opt) => optValue(opt) === wanted) ? wanted : saved; }; fill(Array.isArray(spec.options) ? spec.options : []); if (spec.optionsRoute && moduleId) { // Options discovered at runtime (audio devices, ports, sources…): // the module serves them from one of its own routes. With // optionsDependsOn the caller triggers the fetch (and re-fetches when // that other field changes), passing its value as a query parameter. refetch = (query) => { const qs = query && Object.keys(query).length ? `?${new URLSearchParams(query)}` : ''; return fetch(`/api/modules/${encodeURIComponent(moduleId)}${spec.optionsRoute}${qs}`) .then((res) => (res.ok ? res.json() : null)) .then((body) => { if (Array.isArray(body?.options)) fill(body.options); }) .catch(() => { /* keep the static/saved options */ }); }; if (!spec.optionsDependsOn) refetch(); } field.append(span, select); read = () => select.value; selectEl = select; } else if (type === 'multiselect' && (Array.isArray(spec.options) || spec.optionsRoute)) { // Several picks from one list — a switch per option, stored as an array // of values. A saved value the list no longer offers stays checked and // marked, so a channel the account has left isn't dropped in silence. field.classList.add('multiselect-field'); // The wrapper is a