openvibers/openvibe.network Commits

Load the admin panel and account hub from deferred modules

OP OpenVibers committed 4 Oct 2026 · b0f1adb860 · 26 files, +5465 −5296

Asked: T2 steps 1 and 2 — OpenVibe.Network: split the two inline-script monoliths, public/admin.html and public/my.html, into classic deferred scripts under public/js/admin/ and public/js/my/ (dossier /home/workstation/openvibe/agents/ds/prep/T2.md sections "T2 step 1" and "T2 step 2", attached by the pla…
Harness job network-t2-steps-1-and-2-210508 (plan T2).

Changes

ddiff --git a/docs/t2-cells-and-node-principal.md b/docs/t2-cells-and-node-principal.md
iindex c730a261..0429650e 100644
---- a/docs/t2-cells-and-node-principal.md
++++ b/docs/t2-cells-and-node-principal.md
@@@ -4,8 +4,9 @@ Status: **N1–N7 merged** on `origin/main` `011355a` (registry slice 5, the nod
operator writers; each slice's state is in §8). The cells layer of `docs/t2-resource-registry.md` §9 was already on
`origin/main` (`d6ed8c4`, PR #5: `migrations/0008_cells_and_node_principals.sql`, `server/registry/cells.js`,
`test/cells-registry.test.js`); this document designed the rest of lane B on top of it and re-planned registry slice 5.
--Pinned: Network `openvibe-contracts` **v0.85.0** (`package.json:33`), which closes every contract gap in §2 and the §9.1
--T1 brief (the doc was written against v0.83.0, checked then against v0.84.0). OpenVibe.Bot
++Pinned: Network `openvibe-contracts` **v0.90.0** (`package.json:33`, `origin/main` `fb34f33`; the latest Contracts tag,
++v0.94.0, is additive and not pinned yet). v0.85.0 closed every contract gap in §2 and the §9.1 T1 brief (the doc was
++written against v0.83.0, checked then against v0.84.0). OpenVibe.Bot
`origin/main` `17ce069` (pins Contracts v0.79.0, `package.json:29`), OpenVibe.Node `origin/main` `4d2b8e1`. Bot is not
deployed (`ov access run openvibe-ovh releases bot` → `unknown service "bot"`), so no production device exists.
ddiff --git a/docs/t2-projects-and-grants.md b/docs/t2-projects-and-grants.md
iindex ce80413a..1cab608c 100644
---- a/docs/t2-projects-and-grants.md
++++ b/docs/t2-projects-and-grants.md
@@@ -10,7 +10,8 @@ owner side, and budgets: `0018`, `0019`) and **slices 6–7 built** (`GET /inter
`server/developer/confirmations.js`); the owner notification of slice 9 still waits on an openvibe-shared type (§8).
Plan T2, "Projects and grants: projects as the ownership boundary for every resource;
`agt_` principals, delegated grants with modes, sensitive capabilities, confirmation requests, budgets (WS-Z2)".
--Pinned version: `openvibe-contracts` **v0.90.0** (`package.json:33`, since slice 7); every contract claim below was
++Pinned version: `openvibe-contracts` **v0.90.0** (`package.json:33`, since slice 7; still the pin at `origin/main` `fb34f33`,
++the latest Contracts tag v0.94.0 is additive and not pinned yet); every contract claim below was
re-checked against the `v0.85.0` tag of OpenVibe.Contracts when slices 1–2 were built (it was written against v0.83.0).
v0.89.0 published `network.confirmation.manage` (`planned`, `implementedBy` the four `/internal/confirmations` routes),
the `agent` actor in `identity.service-token-claims@1` and `network.confirmation.changed@1`; v0.86–v0.90 are additive.
ddiff --git a/docs/t2-resource-registry.md b/docs/t2-resource-registry.md
iindex 9cac12e0..bb2cf467 100644
---- a/docs/t2-resource-registry.md
++++ b/docs/t2-resource-registry.md
@@@ -3,7 +3,7 @@
Status: **resource registry and N6 built** (`migrations/0007_resource_registry.sql`,
`migrations/0009_resource_offer_kinds.sql`, `server/registry/offers.js`); **cells and node principals built**
(section 9, `migrations/0008`). The original slice plan in §7 is historical.
--Current pin: `openvibe-contracts` **v0.90.0** (`package.json`; the kinds below are as of v0.85.0); the v0.83.0 observations below describe the original design.
++Current pin: `openvibe-contracts` **v0.90.0** (`package.json` at `origin/main` `fb34f33`; the latest tag, v0.94.0, is additive and not pinned yet; the kinds below are as of v0.85.0); the v0.83.0 observations below describe the original design.
Scope: one table and one module that store **what the network can sell or place on** — node, provider, storage, delivery,
runtime, agent and harness offers — reported by the components that own them, listed publicly so that `openvibe-sdk/placement` can plan against
ddiff --git a/public/admin.html b/public/admin.html
iindex cfcbb0d1..22cbfef2 100644
---- a/public/admin.html
++++ b/public/admin.html
@@@ -779,3884 +779,17 @@ body{min-height:100%;color:var(--text);font-family:'Inter',-apple-system,BlinkMa
<script src="/shared/notification-ui.js"></script>
<script src="/shared/account-switcher.js"></script>
--<script>
--/* ═══════════════════════════════════════════════════════════════
-- OpenVibe — Unified Admin Panel
-- ═══════════════════════════════════════════════════════════════ */
--
--const API = window.location.origin;
--let token = localStorage.getItem('ov_token');
--let currentUser = null;
--
--// ── Helpers ──────────────────────────────────────────────────
--// Attribute-safe: also escapes quotes, otherwise any value containing '"' (e.g. JSON) is cut
--// short inside value="..." — which is how JSON settings got rendered as '{' and saved back as '{'.
--function esc(s) { return String(s ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;'); }
--
--function timeAgo(dateStr) {
-- const d = new Date(dateStr);
-- const s = Math.floor((Date.now() - d.getTime()) / 1000);
-- if (s < 60) return 'just now';
-- if (s < 3600) return Math.floor(s / 60) + 'm ago';
-- if (s < 86400) return Math.floor(s / 3600) + 'h ago';
-- if (s < 604800) return Math.floor(s / 86400) + 'd ago';
-- return d.toLocaleDateString();
--}
--
--function fmtBytes(bytes) {
-- if (!bytes || bytes < 0) return '0 B';
-- const units = ['B', 'KB', 'MB', 'GB', 'TB'];
-- let i = 0, val = bytes;
-- while (val >= 1024 && i < units.length - 1) { val /= 1024; i++; }
-- return `${val.toFixed(i === 0 ? 0 : 1)} ${units[i]}`;
--}
--
--function fmtDuration(sec) {
-- if (!sec) return '—';
-- const h = Math.floor(sec / 3600), m = Math.floor((sec % 3600) / 60), s = sec % 60;
-- return h > 0 ? `${h}h ${m}m` : `${m}m ${s}s`;
--}
--
--function toast(msg, type = 'info') {
-- const c = document.getElementById('toast-container');
-- const t = document.createElement('div');
-- t.className = `toast toast-${type}`;
-- t.textContent = msg;
-- c.appendChild(t);
-- setTimeout(() => { t.style.opacity = '0'; t.style.transition = 'opacity .3s'; setTimeout(() => t.remove(), 300); }, 3500);
--}
--
--async function api(path, opts = {}) {
-- const headers = { 'Content-Type': 'application/json', ...opts.headers };
-- if (token) headers.Authorization = 'Bearer ' + token;
-- const fetchOpts = { ...opts, headers, credentials: 'include' };
-- if (opts.body && typeof opts.body === 'object') fetchOpts.body = JSON.stringify(opts.body);
-- const res = await fetch(API + path, fetchOpts);
-- const data = await res.json();
-- if (!res.ok) {
-- // Keep the payload: endpoints that reject with structured detail (e.g. the cookie
-- // validator explaining WHICH check failed) are useless if only the message survives.
-- const err = new Error(data.error || 'Request failed');
-- err.body = data;
-- err.status = res.status;
-- throw err;
-- }
-- return data;
--}
--
--// Auth headers for raw fetch() calls (SSH tab, Deploy tab, etc.).
--function authHeaders() {
-- const h = {};
-- if (token) h.Authorization = 'Bearer ' + token;
-- return h;
--}
--
--// ── Tab switching ────────────────────────────────────────────
--let currentTab = 'dashboard';
--// Tabs restricted to the network owner (API keys / money / infrastructure / core settings).
--// NOTE: 'ai' is NOT here — admins may use its analytics/explorer tools; only the
--// AI configuration form inside it is owner-gated (see loadAi).
--const OWNER_ONLY_TABS = new Set(['deploy', 'domains', 'payments', 'cashouts', 'storage', 'email', 'settings', 'grants', 'eventsops']);
--const tabLoaders = {
-- dashboard: loadDashboard,
-- users: loadUsers,
-- streams: loadStreams,
-- lineage: () => loadLineage(),
-- bans: loadBans,
-- 'chat-logs': () => {}, // manual search
-- moderators: loadModerators,
-- settings: loadSettings,
-- tts: loadTTS,
-- cashouts: loadCashouts,
-- payments: loadPayments,
-- ai: loadAi,
-- vpn: loadVPNQueue,
-- pastes: loadPastesAdmin,
-- storage: loadStorage,
-- notifications: () => {},
-- email: loadEmail,
-- vkeys: loadVKeys,
-- audit: loadAudit,
-- modlog: () => loadModLog(true),
-- grants: () => loadGrants(),
-- themes: () => loadThemeReview(),
-- checklist: () => loadOperatorChecklist(),
-- eventsops: () => loadDeliveries(),
-- analytics: loadAnalytics,
-- deploy: loadDeployOverview,
-- domains: loadDomains,
-- ssh: loadSSH,
--};
--
--
--// ── Tool domains (owner only; the server enforces it) ───────────────
--let _domCatalog = null;
--async function loadDomains() {
-- const el = document.getElementById('domains-content');
-- try {
-- const [list, cat] = await Promise.all([api('/api/admin/domains'), _domCatalog ? Promise.resolve(_domCatalog) : api('/api/admin/domains/catalog')]);
-- _domCatalog = cat;
-- const byTool = {}; list.domains.forEach(d => { (byTool[d.tool_id] = byTool[d.tool_id] || []).push(d); });
-- const opts = [...cat.families.map(f => ({ id: f.id, name: f.name + ' (family)' })), ...cat.tools.map(t => ({ id: t.id, name: t.name }))];
-- const roleBadge = r => `<span class="badge ${r === 'canonical' ? 'badge-success' : r === 'short' ? 'badge-info' : ''}">${esc(r)}</span>`;
-- el.innerHTML = `
-- <div class="card"><h3><i class="fa-solid fa-globe"></i> Tool domains</h3>
-- <p class="muted">Every tool has a <b>canonical</b> host (used in links, sitemaps and canonical tags), a <b>short</b> host people type, any number of <b>aliases</b> that redirect to the short one, and <b>mirrors</b> that serve the tool on another address while pointing search engines at the canonical host. Custom domains work the same way: point DNS at the server, add the host here, then run <code>add-custom-domain.sh</code> on the host for nginx and TLS. Tools picks changes up within a minute.${cat.source !== 'live' ? ' <br><span class="badge badge-warning">Tools catalog unreachable: showing the built-in list</span>' : ''}</p>
-- <div class="form-group" style="display:grid;grid-template-columns:repeat(auto-fit,minmax(170px,1fr));gap:10px;align-items:end">
-- <div class="form-field"><label for="dom-tool">Tool</label><select id="dom-tool">${opts.map(o => `<option value="${esc(o.id)}">${esc(o.name)}</option>`).join('')}</select></div>
-- <div class="form-field"><label for="dom-host">Host</label><input id="dom-host" placeholder="youtubedownloadonline.com" autocomplete="off" spellcheck="false"></div>
-- <div class="form-field"><label for="dom-role">Role</label><select id="dom-role"><option value="alias">alias (redirects to the short host)</option><option value="mirror">mirror (serves the tool, canonical tag points at the SEO host)</option><option value="short">short (what people type)</option><option value="canonical">canonical (SEO host)</option></select></div>
-- <div class="form-field"><label for="dom-note">Note</label><input id="dom-note" maxlength="500"></div>
-- <div><button class="btn btn-outline" onclick="checkDomainDns()">Check DNS</button> <button class="btn btn-primary" onclick="addDomain()">Add</button></div>
-- </div>
-- <div id="dom-check" class="muted" style="margin-top:8px"></div>
-- </div>
-- <div class="card"><h3>Overrides</h3>
-- ${list.domains.length ? `<div style="overflow-x:auto"><table class="table" style="width:100%"><thead><tr><th>Tool</th><th>Host</th><th>Role</th><th>On</th><th>Note</th><th></th></tr></thead><tbody>
-- ${list.domains.map(d => `<tr><td>${esc(d.tool_id)}${d.tool_known === false ? ' <span class="badge badge-warning">unknown tool</span>' : ''}</td>
-- <td><a href="https://${esc(d.host)}/" target="_blank" rel="noopener">${esc(d.host)}</a></td><td>${roleBadge(d.role)}</td>
-- <td><input type="checkbox" ${d.enabled ? 'checked' : ''} onchange="updateDomain(${d.id},{enabled:this.checked})" aria-label="Enabled"></td>
-- <td>${esc(d.note || '')}</td>
-- <td style="white-space:nowrap"><select onchange="updateDomain(${d.id},{role:this.value})" aria-label="Role">${['canonical','short','alias','mirror'].map(r => `<option ${r === d.role ? 'selected' : ''}>${r}</option>`).join('')}</select>
-- <button class="btn btn-sm btn-danger" onclick="deleteDomain(${d.id},'${esc(d.host)}')">Remove</button></td></tr>`).join('')}
-- </tbody></table></div>` : '<p class="muted">No overrides yet. Every tool is using its built-in hosts.</p>'}
-- </div>
-- <div class="card"><h3>Built-in hosts</h3><div style="overflow-x:auto"><table class="table" style="width:100%"><thead><tr><th>Tool</th><th>Canonical</th><th>Short</th><th>Aliases</th></tr></thead><tbody>
-- ${cat.tools.map(t => `<tr><td>${esc(t.name)} <small class="muted">${esc(t.id)}</small></td><td>${esc(t.hosts?.canonical || '')}</td><td>${esc(t.hosts?.short || '')}</td><td>${esc((t.hosts?.aliases || []).join(', '))}</td></tr>`).join('')}
-- </tbody></table></div></div>`;
-- } catch (e) { el.innerHTML = `<div class="card"><p class="muted">Could not load domains: ${esc(e.message)}</p></div>`; }
--}
--async function addDomain() {
-- const body = { tool_id: document.getElementById('dom-tool').value, host: document.getElementById('dom-host').value, role: document.getElementById('dom-role').value, note: document.getElementById('dom-note').value };
-- try { const r = await api('/api/admin/domains', { method: 'POST', body }); toast('Domain added', 'success'); (r.warnings || []).forEach(w => toast(w, 'warning')); loadDomains(); } catch (e) { toast(e.message, 'error'); }
--}
--async function updateDomain(id, patch) { try { const r = await api('/api/admin/domains/' + id, { method: 'PUT', body: patch }); (r.warnings || []).forEach(w => toast(w, 'warning')); toast('Saved', 'success'); } catch (e) { toast(e.message, 'error'); } loadDomains(); }
--async function deleteDomain(id, host) { if (!confirm(`Remove ${host}?`)) return; try { await api('/api/admin/domains/' + id, { method: 'DELETE' }); toast('Removed', 'success'); } catch (e) { toast(e.message, 'error'); } loadDomains(); }
--async function checkDomainDns() {
-- const out = document.getElementById('dom-check'); out.textContent = 'Checking…';
-- try { const r = await api('/api/admin/domains/check', { method: 'POST', body: { host: document.getElementById('dom-host').value } }); const c = r.check || {};
-- out.textContent = (c.status === 'ready' ? 'Ready. ' : '') + (c.message || c.status || ''); } catch (e) { out.textContent = e.message; }
--}
--
--function showTab(id, btn) {
-- if (!tabLoaders[id]) id = 'dashboard';
-- // Owner-only tabs (secrets / money / infra) — bounce non-owners to dashboard.
-- if (OWNER_ONLY_TABS.has(id) && currentUser && !currentUser.is_owner) id = 'dashboard';
-- currentTab = id;
-- btn = btn || _tabButton(id);
-- document.querySelectorAll('.section').forEach(s => s.classList.remove('active'));
-- document.querySelectorAll('#main-tabs .admin-tab-btn').forEach(b => b.classList.remove('active'));
-- document.getElementById('sec-' + id)?.classList.add('active');
-- btn?.classList.add('active');
-- btn?.scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' });
-- setTimeout(checkMainTabsOverflow, 180);
-- tabLoaders[id]?.();
-- _syncAdminUrl();
--}
--
--/* ── Admin URL routing (History API + slugs + localStorage) ──────────
-- /admin/<tab>[/<subtab>][?last=30d] e.g. /admin/settings,
-- /admin/analytics/openvibelive?last=12h. On load a slug wins; otherwise the
-- last-visited location is restored from localStorage. */
--let deployCurrentSubTab = 'overview';
--let _applyingRoute = false;
--
--function _tabButton(id) {
-- return [...document.querySelectorAll('#main-tabs .admin-tab-btn')]
-- .find(b => (b.getAttribute('onclick') || '').includes(`showTab('${id}'`)) || null;
--}
--function _subBtn(containerId, tab) {
-- return [...document.querySelectorAll(`#${containerId} button`)]
-- .find(b => (b.getAttribute('onclick') || '').includes(`('${tab}'`)) || null;
--}
--
--// Analytics period ↔ URL param. Day options become "<n>d"; hour/3d pass through.
--function periodToUrlParam(v) {
-- if (v == null) return '';
-- v = String(v);
-- return /^\d+$/.test(v) ? v + 'd' : v; // 30 -> 30d ; 12h/3d stay
--}
--function urlParamToPeriod(p) {
-- if (!p) return null;
-- p = String(p).toLowerCase().trim();
-- const alias = { '1y': '365', '365d': '365', '1d': '24h', '24': '24h' };
-- if (alias[p]) return alias[p];
-- if (/^\d+d$/.test(p)) return p.slice(0, -1); // 30d -> 30
-- const valid = ['1h', '12h', '24h', '3d', '7', '14', '30', '90', '365'];
-- if (valid.includes(p)) return p;
-- if (/^\d+$/.test(p)) return p; // bare day count
-- return null;
--}
--
--function _adminPath() {
-- if (currentTab === 'analytics') return '/admin/analytics/' + (analyticsCurrentSubTab || 'overview');
-- if (currentTab === 'deploy') return '/admin/deploy/' + (deployCurrentSubTab || 'overview');
-- if (currentTab && currentTab !== 'dashboard') return '/admin/' + currentTab;
-- return '/admin';
--}
--function _syncAdminUrl(replace) {
-- if (_applyingRoute) return;
-- let url = _adminPath();
-- if (currentTab === 'analytics') {
-- const per = document.getElementById('analytics-period')?.value;
-- if (per) url += '?last=' + periodToUrlParam(per);
-- }
-- try { history[replace ? 'replaceState' : 'pushState']({ adminTab: currentTab }, '', url); } catch { /* */ }
-- try { localStorage.setItem('openvibe_admin_last_path', url); } catch { /* */ }
--}
--
--function routeFromUrl() {
-- _applyingRoute = true;
-- try {
-- let rest = location.pathname.replace(/^\/admin\/?/, '');
-- let parts = rest.split('/').filter(Boolean);
-- let search = location.search;
-- // Bare /admin → restore last-visited location (a real slug always wins).
-- if (parts.length === 0) {
-- const last = localStorage.getItem('openvibe_admin_last_path');
-- if (last && last !== '/admin' && last.startsWith('/admin')) {
-- try { history.replaceState({}, '', last); } catch { /* */ }
-- const u = new URL(last, location.origin);
-- parts = u.pathname.replace(/^\/admin\/?/, '').split('/').filter(Boolean);
-- search = u.search;
-- }
-- }
-- let tab = parts[0];
-- const sub = parts[1] || null;
-- if (!tab || !tabLoaders[tab]) tab = 'dashboard';
-- const per = urlParamToPeriod(new URLSearchParams(search).get('last'));
-- if (tab === 'analytics' && per) {
-- const sel = document.getElementById('analytics-period'); if (sel) sel.value = per;
-- }
-- showTab(tab, _tabButton(tab));
-- // Use the effective tab (showTab may bounce a non-owner off /admin/deploy).
-- if (currentTab === 'analytics' && sub) showAnalyticsSubTab(sub, _subBtn('analytics-sub-tabs', sub));
-- else if (currentTab === 'deploy' && sub) showDeploySubTab(sub, _subBtn('deploy-sub-tabs', sub));
-- } finally {
-- _applyingRoute = false;
-- _syncAdminUrl(true); // normalize (e.g. /admin/analytics -> /admin/analytics/overview)
-- }
--}
--
--window.addEventListener('popstate', () => routeFromUrl());
--
--function checkMainTabsOverflow() {
-- const tabs = document.getElementById('main-tabs');
-- if (!tabs) return;
-- const left = document.getElementById('main-tabs-left');
-- const right = document.getElementById('main-tabs-right');
-- const hasOverflow = tabs.scrollWidth > tabs.clientWidth + 2;
-- const atStart = tabs.scrollLeft <= 1;
-- const atEnd = tabs.scrollLeft >= tabs.scrollWidth - tabs.clientWidth - 1;
-- left?.classList.toggle('visible', hasOverflow && !atStart);
-- right?.classList.toggle('visible', hasOverflow && !atEnd);
--}
--
--function scrollMainTabs(dir) {
-- const tabs = document.getElementById('main-tabs');
-- if (!tabs) return;
-- tabs.scrollBy({ left: dir * 180, behavior: 'smooth' });
-- let checks = 0;
-- let lastPos = tabs.scrollLeft;
-- const poll = setInterval(() => {
-- checkMainTabsOverflow();
-- if (tabs.scrollLeft === lastPos || ++checks > 12) clearInterval(poll);
-- lastPos = tabs.scrollLeft;
-- }, 60);
--}
--
--function initMainTabsOverflow() {
-- const tabs = document.getElementById('main-tabs');
-- if (!tabs) return;
-- tabs.addEventListener('scroll', checkMainTabsOverflow, { passive: true });
-- if (typeof ResizeObserver !== 'undefined') {
-- new ResizeObserver(checkMainTabsOverflow).observe(tabs);
-- }
-- window.addEventListener('resize', checkMainTabsOverflow);
-- setTimeout(checkMainTabsOverflow, 100);
--}
--
--// ═══════════════════════════════════════════════════════════════
--// Dashboard
--// ═══════════════════════════════════════════════════════════════
--async function loadDashboard() {
-- const statsEl = document.getElementById('dashboard-stats');
-- const healthEl = document.getElementById('health-info');
--
-- // Load both openvibe-network health and OpenVibe.Live stats in parallel
-- const [toolsHealth, streamerStats] = await Promise.allSettled([
-- api('/api/admin/health'),
-- api('/api/admin/streamer/stats'),
-- ]);
--
-- const th = toolsHealth.status === 'fulfilled' ? toolsHealth.value.health : null;
-- const ss = streamerStats.status === 'fulfilled' ? (streamerStats.value.stats || streamerStats.value) : null;
--
-- const stats = [
-- { label: 'Registered Users', value: th?.users || 0, icon: 'fa-users' },
-- { label: 'Active Sessions', value: th?.active_sessions || 0, icon: 'fa-plug' },
-- { label: 'Active Streams', value: ss?.streams?.live || ss?.activeStreams || 0, icon: 'fa-broadcast-tower' },
-- { label: 'Total Streams', value: ss?.streams?.total || ss?.totalStreams || 0, icon: 'fa-video' },
-- { label: 'Active Bans', value: ss?.users?.banned || ss?.activeBans || 0, icon: 'fa-ban' },
-- { label: 'Notifications', value: th?.total_notifications || 0, icon: 'fa-bell' },
-- { label: 'Unread Notifs', value: th?.unread_notifications || 0, icon: 'fa-bell-slash' },
-- { label: 'Anon Users', value: th?.anon_users || 0, icon: 'fa-ghost' },
-- ];
--
-- statsEl.innerHTML = stats.map(s => `
-- <div class="stat-card">
-- <div class="value">${typeof s.value === 'number' ? s.value.toLocaleString() : s.value}</div>
-- <div class="label"><i class="fa-solid ${s.icon}"></i> ${s.label}</div>
-- </div>
-- `).join('');
--
-- // Health details
-- if (th) {
-- const mem = th.memory;
-- const upH = Math.floor(th.uptime / 3600);
-- const upM = Math.floor((th.uptime % 3600) / 60);
-- healthEl.innerHTML = `
-- <div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:12px;font-size:13px">
-- <div><strong>Uptime:</strong> ${upH}h ${upM}m</div>
-- <div><strong>Heap Used:</strong> ${fmtBytes(mem?.heapUsed)}</div>
-- <div><strong>Heap Total:</strong> ${fmtBytes(mem?.heapTotal)}</div>
-- <div><strong>RSS:</strong> ${fmtBytes(mem?.rss)}</div>
-- <div><strong>Email:</strong> ${th.ses_enabled ? '<span style="color:var(--success)">Enabled</span>' : '<span style="color:var(--text-muted)">Disabled</span>'}</div>
-- </div>
-- `;
-- } else {
-- healthEl.innerHTML = '<p class="muted">Could not load health data</p>';
-- }
--}
--
--// ═══════════════════════════════════════════════════════════════
--// Users (from the openvibe.network central user DB)
--// ═══════════════════════════════════════════════════════════════
--let usersOffset = 0;
--async function loadUsers() {
-- const c = document.getElementById('users-content');
-- const pager = document.getElementById('users-pager');
-- const search = document.getElementById('user-search')?.value.trim() || '';
-- c.innerHTML = '<div class="loading">Loading...</div>';
--
-- try {
-- const params = new URLSearchParams({ limit: '50', offset: String(usersOffset) });
-- if (search) params.set('search', search);
-- const data = await api(`/api/admin/users?${params}`);
-- const users = data.users || [];
-- const total = data.total || 0;
--
-- if (!users.length) { c.innerHTML = '<p class="muted">No users found</p>'; pager.innerHTML = ''; return; }
--
-- c.innerHTML = `
-- <table class="admin-table">
-- <thead><tr>
-- <th>Username</th><th>Display Name</th><th>Email</th><th>Role</th><th>Created</th><th>Last Seen</th><th>Actions</th>
-- </tr></thead>
-- <tbody>${users.map(u => `
-- <tr>
-- <td><strong>${esc(u.username)}</strong></td>
-- <td>${esc(u.display_name || '-')}</td>
-- <td style="font-size:11px">${esc(u.email || '-')}</td>
-- <td><span class="badge badge-${u.role}">${esc(u.role)}</span></td>
-- <td style="font-size:11px">${u.created_at ? new Date(u.created_at).toLocaleDateString() : '-'}</td>
-- <td style="font-size:11px">${u.last_seen ? timeAgo(u.last_seen) : 'Never'}</td>
-- <td style="display:flex;gap:4px;align-items:center">
-- <select onchange="changeUserRole('${u.id}',this.value)" style="padding:4px 6px;background:var(--bg-input);color:var(--text);border:1px solid var(--border);border-radius:4px;font-size:11px">
-- ${['user','streamer','global_mod','admin'].map(r =>
-- `<option value="${r}" ${r===u.role?'selected':''}>${r}</option>`
-- ).join('')}
-- </select>
-- ${u.is_banned ?
-- `<button class="btn btn-sm btn-success" onclick="toggleBan('${u.id}','${esc(u.username)}',false)"><i class="fa-solid fa-user-check"></i></button>` :
-- `<button class="btn btn-sm btn-danger" onclick="toggleBan('${u.id}','${esc(u.username)}',true)"><i class="fa-solid fa-ban"></i></button>`
-- }
-- <button class="btn btn-sm btn-outline" title="Rename (the old name redirects on Live and stays reserved)" onclick="renameUser('${u.id}','${esc(u.username)}')"><i class="fa-solid fa-signature"></i></button>
-- <button class="btn btn-sm btn-outline" title="Sign out on every device and site" onclick="signOutUser('${u.id}','${esc(u.username)}')"><i class="fa-solid fa-right-from-bracket"></i></button>
-- ${currentUser?.is_owner ? `
-- <button class="btn btn-sm btn-outline" title="Edit email address"
-- onclick="editUserEmail('${u.id}','${esc(u.username)}','${esc(u.email || '')}')"><i class="fa-solid fa-envelope"></i></button>
-- <button class="btn btn-sm btn-outline" title="${u.email ? 'Send a password reset link' : 'No email address on file'}"
-- ${u.email ? '' : 'disabled'}
-- onclick="sendUserReset('${u.id}','${esc(u.username)}')"><i class="fa-solid fa-key"></i></button>
-- ` : ''}
-- </td>
-- </tr>
-- `).join('')}</tbody>
-- </table>`;
--
-- const pages = Math.ceil(total / 50);
-- const curPage = Math.floor(usersOffset / 50) + 1;
-- pager.innerHTML = pages > 1 ? `
-- <button class="btn btn-sm btn-outline" ${usersOffset <= 0 ? 'disabled' : ''} onclick="usersOffset=Math.max(0,usersOffset-50);loadUsers()"><i class="fa-solid fa-chevron-left"></i></button>
-- <span class="muted">Page ${curPage} / ${pages} (${total} total)</span>
-- <button class="btn btn-sm btn-outline" ${curPage >= pages ? 'disabled' : ''} onclick="usersOffset+=50;loadUsers()"><i class="fa-solid fa-chevron-right"></i></button>
-- ` : `<span class="muted">${total} users</span>`;
-- } catch (e) { c.innerHTML = `<p class="muted">Error: ${esc(e.message)}</p>`; pager.innerHTML = ''; }
--}
--
--async function changeUserRole(userId, role) {
-- try {
-- await api(`/api/admin/users/${userId}/role`, { method: 'PUT', body: { role } });
-- toast(`Role updated to ${role}`, 'success');
-- } catch (e) { toast(e.message, 'error'); loadUsers(); }
--}
--
--// ── Owner-only account recovery ──────────────────────────────────────────────
--// The server enforces owner-only on both of these (requireOwner); hiding the buttons
--// for non-owners is purely so admins are not shown controls they cannot use.
--async function editUserEmail(userId, username, current) {
-- const next = prompt(`Email address for @${username}\n\nLeave blank to remove it.`, current || '');
-- if (next === null) return; // cancelled
-- const email = next.trim();
-- if (email === (current || '').trim()) return; // unchanged
-- try {
-- const r = await api(`/api/admin/users/${userId}/email`, { method: 'PUT', body: { email } });
-- toast(r.email ? `Email for @${username} set to ${r.email}` : `Email removed from @${username}`, 'success');
-- loadUsers();
-- } catch (e) { toast(e.message, 'error'); }
--}
--
--async function sendUserReset(userId, username) {
-- // The link is emailed to the account holder and never shown here — that is what keeps
-- // this a recovery tool rather than a way to take over an account.
-- if (!confirm(`Send a password reset link to @${username}?\n\nIt goes to their email address on file and expires in 1 hour. Any previous reset link for this account stops working.`)) return;
-- try {
-- const r = await api(`/api/admin/users/${userId}/send-reset`, { method: 'POST' });
-- toast(`Reset link sent to ${r.sentTo} (expires in ${r.expiresMinutes} min)`, 'success');
-- } catch (e) { toast(e.message, 'error'); }
--}
--
--async function renameUser(userId, username) {
-- const next = prompt(`New username for ${username}? (3-24 letters, numbers, underscores. /@${username} will redirect to it on Live, and "${username}" stays reserved for them.)`, username);
-- if (next === null || next.trim() === username) return;
-- try {
-- const r = await api(`/api/admin/users/${userId}/username`, { method: 'PUT', body: { username: next.trim() } });
-- toast(`${r.from} is now ${r.to}`, 'success');
-- loadUsers();
-- } catch (e) { toast(e.message, 'error'); }
--}
--
--async function signOutUser(userId, username) {
-- if (!confirm(`Sign ${username} out on every device and every OpenVibe site?`)) return;
-- try {
-- await api(`/api/admin/users/${userId}/sign-out`, { method: 'POST' });
-- toast(`${username} signed out everywhere`, 'success');
-- } catch (e) { toast(e.message, 'error'); }
--}
--
--async function toggleBan(userId, username, ban) {
-- if (ban) {
-- const reason = prompt(`Ban ${username}? Enter reason:`);
-- if (reason === null) return;
-- try {
-- await api(`/api/admin/users/${userId}/ban`, { method: 'PUT', body: { banned: true, reason } });
-- toast(`${username} banned`, 'success');
-- loadUsers();
-- } catch (e) { toast(e.message, 'error'); }
-- } else {
-- try {
-- await api(`/api/admin/users/${userId}/ban`, { method: 'PUT', body: { banned: false } });
-- toast(`${username} unbanned`, 'success');
-- loadUsers();
-- } catch (e) { toast(e.message, 'error'); }
-- }
--}
--
--// ═══════════════════════════════════════════════════════════════
--// Streams (via openvibelive proxy)
--// ═══════════════════════════════════════════════════════════════
--async function loadStreams() {
-- const c = document.getElementById('streams-content');
-- c.innerHTML = '<div class="loading">Loading...</div>';
-- try {
-- const data = await api('/api/admin/streamer/streams');
-- const streams = data.streams || [];
-- if (!streams.length) { c.innerHTML = '<p class="muted">No active streams</p>'; return; }
-- c.innerHTML = `
-- <table class="admin-table">
-- <thead><tr>
-- <th>Title</th><th>Streamer</th><th>Protocol</th><th>Viewers</th><th>Started</th><th>Actions</th>
-- </tr></thead>
-- <tbody>${streams.map(s => `
-- <tr>
-- <td>${esc(s.title || 'Untitled')}</td>
-- <td><strong>${esc(s.username || '-')}</strong></td>
-- <td><span class="badge badge-info">${esc(s.protocol)}</span></td>
-- <td>${s.viewer_count || 0}</td>
-- <td>${s.started_at ? timeAgo(s.started_at) : '-'}</td>
-- <td>
-- <button class="btn btn-sm btn-danger" onclick="endStream('${s.id}')"><i class="fa-solid fa-stop"></i> End</button>
-- </td>
-- </tr>
-- `).join('')}</tbody>
-- </table>`;
-- } catch (e) { c.innerHTML = `<p class="muted">Error: ${esc(e.message)}</p>`; }
--}
--
--// Unresolved lineage references (Live GET /api/admin/lineage/unresolved through the /api/admin/streamer proxy).
--async function loadLineage(reason = '') {
-- const c = document.getElementById('lineage-content');
-- const tabs = document.getElementById('lineage-reasons');
-- c.innerHTML = '<div class="loading">Loading...</div>';
-- try {
-- const data = await api(`/api/admin/streamer/lineage/unresolved${reason ? `?reason=${encodeURIComponent(reason)}` : ''}`);
-- const by = data.by_reason || {};
-- tabs.innerHTML = [['', 'All']].concat(Object.keys(by).sort().map(r => [r, `${r.replace(/_/g, ' ')} (${by[r].refs})`]))
-- .map(([r, label]) => `<button class="${r === reason ? 'active' : ''}" onclick="loadLineage('${esc(r)}')">${esc(label)}</button>`).join('');
-- const rows = data.unresolved || [];
-- if (!rows.length) { c.innerHTML = '<p class="muted">Nothing unresolved in the last 30 days.</p>'; return; }
-- c.innerHTML = `
-- <div style="overflow-x:auto"><table class="admin-table">
-- <thead><tr><th>Reference</th><th>Reason</th><th>Detail</th><th>Last caller</th><th>Times</th><th>First seen</th><th>Last seen</th></tr></thead>
-- <tbody>${rows.map(u => `
-- <tr>
-- <td><code>${esc(u.ref)}</code></td>
-- <td><span class="badge badge-warning">${esc(u.reason)}</span></td>
-- <td>${esc(u.detail || '')}</td>
-- <td>${esc(u.last_caller || '-')}</td>
-- <td>${Number(u.count) || 0}</td>
-- <td>${u.first_at ? timeAgo(u.first_at) : '-'}</td>
-- <td>${u.last_at ? timeAgo(u.last_at) : '-'}</td>
-- </tr>`).join('')}</tbody>
-- </table></div>`;
-- } catch (e) { c.innerHTML = `<p class="muted">Error: ${esc(e.message)}</p>`; }
--}
--
--async function endStream(id) {
-- if (!confirm('Force end this stream?')) return;
-- try {
-- await api(`/api/admin/streamer/streams/${id}`, { method: 'DELETE' });
-- toast('Stream ended', 'success');
-- loadStreams();
-- } catch (e) { toast(e.message, 'error'); }
--}
--
--// ═══════════════════════════════════════════════════════════════
--// Bans (via openvibelive proxy)
--// ═══════════════════════════════════════════════════════════════
--async function loadBans() {
-- const c = document.getElementById('bans-content');
-- c.innerHTML = '<div class="loading">Loading...</div>';
-- try {
-- const data = await api('/api/admin/streamer/bans');
-- const bans = data.bans || [];
-- if (!bans.length) { c.innerHTML = '<p class="muted">No active bans</p>'; return; }
-- c.innerHTML = `
-- <table class="admin-table">
-- <thead><tr>
-- <th>User</th><th>Reason</th><th>Banned By</th><th>Banned At</th><th>Expires</th><th>Actions</th>
-- </tr></thead>
-- <tbody>${bans.map(b => `
-- <tr>
-- <td><strong>${esc(b.username || b.user_id)}</strong></td>
-- <td>${esc(b.reason || '-')}</td>
-- <td>${esc(b.banned_by_username || '-')}</td>
-- <td>${b.created_at ? timeAgo(b.created_at) : '-'}</td>
-- <td>${b.expires_at ? new Date(b.expires_at).toLocaleString() : 'Permanent'}</td>
-- <td>
-- <button class="btn btn-sm btn-outline" onclick="unbanStreamer('${b.user_id}')"><i class="fa-solid fa-user-check"></i> Unban</button>
-- </td>
-- </tr>
-- `).join('')}</tbody>
-- </table>`;
-- } catch (e) { c.innerHTML = `<p class="muted">Error: ${esc(e.message)}</p>`; }
--}
--
--async function unbanStreamer(userId) {
-- try {
-- await api(`/api/admin/streamer/users/${userId}/ban`, { method: 'DELETE' });
-- toast('User unbanned on OpenVibe.Live', 'success');
-- loadBans();
-- } catch (e) { toast(e.message, 'error'); }
--}
--
--// ═══════════════════════════════════════════════════════════════
--// Chat Logs (via openvibelive mod proxy)
--// ═══════════════════════════════════════════════════════════════
--let chatLogsOffset = 0;
--async function searchChatLogs() {
-- chatLogsOffset = 0;
-- await fetchChatLogs();
--}
--
--async function fetchChatLogs() {
-- const c = document.getElementById('chat-logs-content');
-- const pager = document.getElementById('chat-logs-pager');
-- const q = document.getElementById('chat-search-q')?.value.trim() || '';
-- const uid = document.getElementById('chat-search-uid')?.value.trim() || '';
-- if (!q && !uid) { c.innerHTML = '<p class="muted">Enter a search query or user ID</p>'; return; }
-- c.innerHTML = '<div class="loading">Loading...</div>';
--
-- try {
-- const params = new URLSearchParams({ limit: '50', offset: String(chatLogsOffset) });
-- if (q) params.set('q', q);
-- if (uid) params.set('user_id', uid);
-- const data = await api(`/api/admin/streamer-mod/chat/search?${params}`);
-- const msgs = data.messages || [];
-- const total = data.total || 0;
--
-- if (!msgs.length) { c.innerHTML = '<p class="muted">No messages found</p>'; pager.innerHTML = ''; return; }
--
-- c.innerHTML = `
-- <table class="admin-table">
-- <thead><tr><th>Time</th><th>User</th><th>Message</th><th>Stream</th></tr></thead>
-- <tbody>${msgs.map(m => `
-- <tr>
-- <td style="white-space:nowrap;font-size:11px">${m.timestamp ? timeAgo(m.timestamp) : '-'}</td>
-- <td style="white-space:nowrap;color:${esc(m.profile_color || (m.anon_id ? '#6b7280' : '#999'))}">${esc(m.display_name || m.username || m.anon_id || 'anon')}</td>
-- <td style="word-break:break-word">${esc(m.message)}</td>
-- <td style="font-size:11px">${m.stream_id || '-'}</td>
-- </tr>
-- `).join('')}</tbody>
-- </table>`;
--
-- const pages = Math.ceil(total / 50);
-- const curPage = Math.floor(chatLogsOffset / 50) + 1;
-- pager.innerHTML = pages > 1 ? `
-- <button class="btn btn-sm btn-outline" ${chatLogsOffset<=0?'disabled':''} onclick="chatLogsOffset=Math.max(0,chatLogsOffset-50);fetchChatLogs()"><i class="fa-solid fa-chevron-left"></i></button>
-- <span class="muted">Page ${curPage}/${pages} (${total} results)</span>
-- <button class="btn btn-sm btn-outline" ${curPage>=pages?'disabled':''} onclick="chatLogsOffset+=50;fetchChatLogs()"><i class="fa-solid fa-chevron-right"></i></button>
-- ` : `<span class="muted">${total} results</span>`;
-- } catch (e) { c.innerHTML = `<p class="muted">Error: ${esc(e.message)}</p>`; pager.innerHTML = ''; }
--}
--
--// ═══════════════════════════════════════════════════════════════
--// Moderators (via openvibelive proxy)
--// ═══════════════════════════════════════════════════════════════
--async function loadModerators() {
-- const c = document.getElementById('mods-content');
-- c.innerHTML = '<div class="loading">Loading...</div>';
-- try {
-- const data = await api('/api/admin/streamer/moderators');
-- const mods = data.moderators || [];
-- if (!mods.length) { c.innerHTML = '<p class="muted">No global moderators yet</p>'; return; }
-- c.innerHTML = `
-- <table class="admin-table">
-- <thead><tr><th>Username</th><th>Display Name</th><th>Last Seen</th><th>Actions</th></tr></thead>
-- <tbody>${mods.map(m => `
-- <tr>
-- <td><strong>${esc(m.username)}</strong></td>
-- <td>${esc(m.display_name || m.username)}</td>
-- <td>${m.last_seen ? timeAgo(m.last_seen) : 'Never'}</td>
-- <td>
-- <button class="btn btn-sm btn-danger" onclick="demoteMod('${m.id}','${esc(m.username)}')"><i class="fa-solid fa-user-minus"></i> Demote</button>
-- </td>
-- </tr>
-- `).join('')}</tbody>
-- </table>`;
-- } catch (e) { c.innerHTML = `<p class="muted">Error: ${esc(e.message)}</p>`; }
--}
--
--async function promoteMod() {
-- const username = document.getElementById('mod-username')?.value.trim();
-- if (!username) return toast('Enter a username', 'error');
-- try {
-- await api('/api/admin/streamer/moderators', { method: 'POST', body: { username } });
-- toast(`${username} promoted to global moderator`, 'success');
-- document.getElementById('mod-username').value = '';
-- loadModerators();
-- } catch (e) { toast(e.message, 'error'); }
--}
--
--async function demoteMod(id, username) {
-- if (!confirm(`Demote ${username} from global moderator?`)) return;
-- try {
-- await api(`/api/admin/streamer/moderators/${id}`, { method: 'DELETE' });
-- toast(`${username} demoted`, 'success');
-- loadModerators();
-- } catch (e) { toast(e.message, 'error'); }
--}
--
--// ═══════════════════════════════════════════════════════════════
--// Settings (dual: openvibe-network + OpenVibe.Live)
--// ═══════════════════════════════════════════════════════════════
--let currentSettingsSub = 'tools';
--function showSettingsSub(sub, btn) {
-- currentSettingsSub = sub;
-- document.querySelectorAll('#settings-sub-tabs button').forEach(b => b.classList.remove('active'));
-- btn?.classList.add('active');
-- document.getElementById('settings-tools').style.display = sub === 'tools' ? 'block' : 'none';
-- document.getElementById('settings-streamer').style.display = sub === 'streamer' ? 'block' : 'none';
-- document.getElementById('settings-media').style.display = sub === 'media' ? 'block' : 'none';
-- document.getElementById('settings-urls').style.display = sub === 'urls' ? 'block' : 'none';
-- document.getElementById('settings-discord').style.display = sub === 'discord' ? 'block' : 'none';
-- document.getElementById('settings-github').style.display = sub === 'github' ? 'block' : 'none';
-- if (sub === 'tools') loadToolsSettings();
-- else if (sub === 'streamer') loadStreamerSettings();
-- else if (sub === 'media') loadStreamerMediaTools();
-- else if (sub === 'urls') loadUrlRegistry();
-- else if (sub === 'discord') loadDiscordSettings();
-- else if (sub === 'github') loadGithubSettings();
--}
--
--async function loadSettings() {
-- loadToolsSettings();
--}
--
--async function loadToolsSettings() {
-- const c = document.getElementById('settings-tools');
-- c.innerHTML = '<div class="loading">Loading...</div>';
-- try {
-- const data = await api('/api/admin/settings');
-- const settings = data.settings || {};
-- const keys = Object.keys(settings);
-- if (!keys.length) { c.innerHTML = '<p class="muted">No settings configured</p>'; return; }
-- _toolsSettingsOriginal = {};
-- for (const key of keys) _toolsSettingsOriginal[key] = settings[key].type === 'boolean' ? (settings[key].value === 'true' ? 'true' : 'false') : String(settings[key].value ?? '');
-- c.innerHTML = `
-- <form id="tools-settings-form" style="max-width:700px">
-- ${keys.map(key => {
-- const s = settings[key];
-- const isSensitive = /(password|secret|token|private|credential|api[_-]?key|bearer)/i.test(key);
-- if (s.source === 'env') return `
-- <div class="setting-row" style="flex-direction:column;align-items:stretch">
-- <label for="ts-${esc(key)}"><strong>${esc(key)}</strong></label>
-- <input type="password" id="ts-${esc(key)}" data-key="${esc(key)}" data-type="${esc(s.type||'string')}" value="" disabled placeholder="Set in the environment (${esc(s.env || '')})"
-- style="margin-top:4px;padding:8px 10px;background:var(--bg-input);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:13px">
-- </div>`;
-- if (s.type === 'boolean') return `
-- <div class="setting-row">
-- <label for="ts-${esc(key)}"><strong>${esc(key)}</strong></label>
-- <input type="checkbox" id="ts-${esc(key)}" data-key="${esc(key)}" data-type="boolean" ${s.value==='true'?'checked':''} style="width:18px;height:18px;cursor:pointer">
-- </div>`;
-- return `
-- <div class="setting-row" style="flex-direction:column;align-items:stretch">
-- <label for="ts-${esc(key)}"><strong>${esc(key)}</strong></label>
-- <input type="${isSensitive?'password':'text'}" id="ts-${esc(key)}" data-key="${esc(key)}" data-type="${esc(s.type||'string')}" value="${esc(s.value)}"
-- style="margin-top:4px;padding:8px 10px;background:var(--bg-input);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:13px">
-- ${s.source === 'database' && s.env && s.secret ? `<span class="muted" style="font-size:12px;margin-top:4px">Stored in the database; prefer <code>${esc(s.env)}</code> in the server's environment file.</span>` : ''}
-- </div>`;
-- }).join('')}
-- <div style="margin-top:16px"><button type="button" class="btn btn-primary" onclick="saveToolsSettings()"><i class="fa-solid fa-floppy-disk"></i> Save Settings</button></div>
-- </form>`;
-- } catch (e) { c.innerHTML = `<p class="muted">Error: ${esc(e.message)}</p>`; }
--}
--
--let _toolsSettingsOriginal = {};
--async function saveToolsSettings() {
-- const inputs = document.querySelectorAll('#tools-settings-form [data-key]');
-- const changed = [];
-- for (const input of inputs) {
-- if (input.disabled) continue; // provided by the environment
-- const key = input.dataset.key;
-- const value = input.dataset.type === 'boolean' ? (input.checked ? 'true' : 'false') : input.value;
-- if (value.startsWith('••••')) continue; // masked secret, untouched
-- if (_toolsSettingsOriginal[key] !== undefined && _toolsSettingsOriginal[key] === value) continue;
-- if (input.dataset.type === 'number' && !Number.isFinite(Number(value))) { toast(`${key}: must be a number`, 'error'); return; }
-- changed.push({ key, value, type: input.dataset.type });
-- }
-- if (!changed.length) { toast('No changes to save', 'info'); return; }
-- try {
-- for (const { key, value, type } of changed) {
-- await api('/api/admin/settings', { method: 'PUT', body: { key, value, type } });
-- _toolsSettingsOriginal[key] = value;
-- }
-- toast(`Saved ${changed.length} setting${changed.length === 1 ? '' : 's'}: ${changed.map(c => c.key).join(', ')}`, 'success');
-- } catch (e) { toast(e.message, 'error'); }
--}
--
--async function loadUrlRegistry() {
-- const c = document.getElementById('settings-urls');
-- c.innerHTML = '<div class="loading">Loading URL registry...</div>';
-- try {
-- const data = await api('/api/admin/url-registry');
-- const entries = data.entries || [];
-- if (!entries.length) {
-- c.innerHTML = '<p class="muted">No URL registry overrides configured.</p>';
-- return;
-- }
-- c.innerHTML = `
-- <div style="max-width:900px;display:grid;gap:16px">
-- <div style="display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap">
-- <div>
-- <strong>Network registry</strong>
-- <div class="muted" style="font-size:13px">Edit first-party networking values and push refresh to services.</div>
-- </div>
-- <div style="display:flex;gap:8px;flex-wrap:wrap">
-- <button class="btn btn-sm btn-primary" onclick="refreshAllUrlRegistry()"><i class="fa-solid fa-arrows-rotate"></i> Refresh all services</button>
-- </div>
-- </div>
-- <div id="url-registry-refresh-status" class="muted" style="font-size:13px"></div>
-- ${entries.map(entry => `
-- <div class="card" style="padding:16px">
-- <div style="display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap">
-- <div>
-- <div style="font-weight:700">${esc(entry.key)}</div>
-- <div class="muted" style="font-size:13px">${esc(entry.label)} — ${esc(entry.type)}</div>
-- </div>
-- <div style="display:flex;gap:8px;flex-wrap:wrap">
-- <button class="btn btn-sm btn-outline" onclick="resetUrlRegistryEntry('${esc(entry.key)}')"><i class="fa-solid fa-rotate-left"></i> Reset</button>
-- <button class="btn btn-sm btn-primary" onclick="saveUrlRegistryEntry('${esc(entry.key)}')"><i class="fa-solid fa-floppy-disk"></i> Save</button>
-- </div>
-- </div>
-- <div class="form-field" style="margin-top:12px">
-- <label for="url-registry-${esc(entry.key)}">Value</label>
-- ${entry.type === 'boolean' ? `
-- <div style="display:flex;align-items:center;gap:8px">
-- <input type="checkbox" id="url-registry-${esc(entry.key)}" ${entry.value ? 'checked' : ''}>
-- <span>${esc(entry.label)}</span>
-- </div>
-- ` : `
-- <input type="text" id="url-registry-${esc(entry.key)}" value="${esc(entry.value || '')}" placeholder="${esc(entry.placeholder || '')}">
-- `}
-- <div class="muted" style="margin-top:4px">Source: ${esc(entry.source)}</div>
-- ${entry.key === 'WHIP_PUBLIC_URL' ? `<div class="badge badge-warning" style="margin-top:8px;display:inline-block">Note</div><div class="muted" style="margin-top:4px">A dedicated WHIP hostname also requires explicit enablement via WHIP_PUBLIC_URL_ENABLED, plus DNS/vhost/TLS support.</div>` : ''}
-- ${entry.warning ? `<div class="badge badge-warning" style="margin-top:8px;display:inline-block">Warning</div><div class="muted" style="margin-top:4px">${esc(entry.warning)}</div>` : ''}
-- </div>
-- </div>
-- `).join('')}
-- </div>`;
-- } catch (e) {
-- c.innerHTML = `<p class="muted">Error: ${esc(e.message)}</p>`;
-- }
--}
--
--async function saveUrlRegistryEntry(key) {
-- const input = document.getElementById(`url-registry-${key}`);
-- if (!input) return toast('Missing registry field', 'error');
-- const value = input.type === 'checkbox' ? input.checked : input.value.trim();
-- try {
-- await api(`/api/admin/url-registry/${encodeURIComponent(key)}`, { method: 'PUT', body: { value } });
-- toast('URL registry entry saved', 'success');
-- loadUrlRegistry();
-- } catch (e) { toast(e.message, 'error'); }
--}
--
--async function resetUrlRegistryEntry(key) {
-- if (!confirm('Reset this URL registry entry to its default?')) return;
-- try {
-- await api(`/api/admin/url-registry/${encodeURIComponent(key)}/reset`, { method: 'POST' });
-- toast('URL registry entry reset', 'success');
-- loadUrlRegistry();
-- } catch (e) { toast(e.message, 'error'); }
--}
--
--async function refreshAllUrlRegistry() {
-- const statusEl = document.getElementById('url-registry-refresh-status');
-- statusEl.textContent = 'Refreshing all services...';
-- try {
-- const data = await api('/api/admin/url-registry/refresh-all', { method: 'POST' });
-- if (!data.ok) throw new Error(data.error || 'Refresh failed');
-- const results = data.refreshResults || [];
-- statusEl.innerHTML = results.map(service => {
-- const result = (service.results && service.results[0]) || {};
-- const mode = result.mode || (result.ok ? 'success' : 'failed');
-- const badge = result.mode === 'local'
-- ? '<span class="badge badge-info">Local</span>'
-- : result.mode === 'not_configured'
-- ? '<span class="badge badge-warning">Not configured</span>'
-- : result.mode === 'unsupported'
-- ? '<span class="badge badge-warning">Unsupported</span>'
-- : result.ok
-- ? '<span class="badge badge-success">Success</span>'
-- : '<span class="badge badge-danger">Failed</span>';
-- const message = result.error ? ` <span class="muted">${esc(result.error)}</span>` : (result.message ? ` <span class="muted">${esc(result.message)}</span>` : '');
-- return `<div><strong>${esc(service.service)}</strong>: ${badge}${message}</div>`;
-- }).join('');
-- toast('Refresh command sent to services', 'success');
-- } catch (e) {
-- statusEl.textContent = `Refresh failed: ${esc(e.message)}`;
-- toast(e.message, 'error');
-- }
--}
--
--// Grouped, icon'd sections for the (otherwise huge) OpenVibe.Live settings list.
--const STREAMER_SETTINGS_GROUPS = [
-- { title: 'Site & General', icon: 'fa-sliders', test: k => /^(site_|motd$|registration_open$|require_email$|nsfw_enabled$)/.test(k) },
-- { title: 'Payment Processors', icon: 'fa-credit-card', test: k => /^(payments_|bucks_|sub_|paypal_|stripe_|ccbill_|crypto_|min_cashout)/.test(k) },
-- { title: 'Kick / Twitch / YouTube', icon: 'fa-plug', test: k => /^(twitch_|kick_|google_client)/.test(k) },
-- { title: 'AI Config', icon: 'fa-robot', test: k => /^ai_/.test(k) },
-- { title: 'Cloud TTS', icon: 'fa-volume-high', test: k => /^tts_/.test(k) },
-- { title: 'Media & Uploads', icon: 'fa-photo-film', test: k => /^(gif_|soundboard_|paste_|storage_tier|max_emotes|max_clip|max_video|max_audio|max_vod)/.test(k) },
-- { title: 'Chat & Rewards', icon: 'fa-comments', test: k => /^(chat_|coins_per)/.test(k) },
-- { title: 'Other', icon: 'fa-ellipsis', test: () => true },
--];
--
--const REDACTED_MASK = '••••';
--function _looksJson(v) { return typeof v === 'string' && /^\s*[\[{]/.test(v); }
--function _isMultiline(s) {
-- return s.type === 'json' || _looksJson(s.value) || /\n/.test(String(s.value ?? '')) || String(s.value ?? '').length > 140;
--}
--function _streamerSettingField(s) {
-- const isSensitive = /(password|secret|token|private|credential|api[_-]?key|bearer|salt)/i.test(s.key);
-- const label = `<label for="ss-${esc(s.key)}"><strong>${esc(s.key)}</strong>${s.redacted ? ' <span class="badge badge-warning" style="font-size:10px">owner-only</span>' : ''}<br><small>${esc(s.description||'')}</small></label>`;
-- const base = 'margin-top:4px;padding:8px 10px;background:var(--bg-input);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:13px';
-- if (s.type === 'boolean') return `<div class="setting-row">${label}
-- <input type="checkbox" id="ss-${esc(s.key)}" data-key="${esc(s.key)}" data-type="boolean" ${s.value==='true'?'checked':''} style="width:18px;height:18px;cursor:pointer"></div>`;
-- if (!isSensitive && _isMultiline(s)) {
-- const kind = (s.type === 'json' || _looksJson(s.value)) ? 'json' : 'text';
-- let shown = String(s.value ?? '');
-- if (kind === 'json') { try { shown = JSON.stringify(JSON.parse(shown), null, 2); } catch { /* show as-is; save validation will flag it */ } }
-- const rows = Math.min(18, Math.max(3, shown.split('\n').length + 1));
-- return `<div class="setting-row" style="flex-direction:column;align-items:stretch">${label}
-- <textarea id="ss-${esc(s.key)}" data-key="${esc(s.key)}" data-type="${kind}" rows="${rows}" spellcheck="false"
-- style="${base};font-family:ui-monospace,SFMono-Regular,Menlo,monospace;resize:vertical;white-space:pre;overflow:auto">${esc(shown)}</textarea>
-- <div class="setting-err muted" data-err-for="${esc(s.key)}" style="display:none;color:var(--danger,#f87171);font-size:12px;margin-top:4px"></div></div>`;
-- }
-- const type = s.type === 'number' ? 'number' : (isSensitive ? 'password' : 'text');
-- const width = s.type === 'number' ? 'width:200px;' : '';
-- return `<div class="setting-row" style="flex-direction:column;align-items:stretch">${label}
-- <input type="${type}" id="ss-${esc(s.key)}" data-key="${esc(s.key)}" data-type="${esc(s.type||'string')}" value="${esc(s.value)}" ${type === 'number' ? 'step="any"' : ''}
-- style="${base};${width}">
-- <div class="setting-err muted" data-err-for="${esc(s.key)}" style="display:none;color:var(--danger,#f87171);font-size:12px;margin-top:4px"></div></div>`;
--}
--
--// Snapshot of what the server sent (keyed by setting key) so Save only submits real changes.
--let _streamerSettingsOriginal = {};
--function _streamerFieldValue(input) {
-- return input.dataset.type === 'boolean' ? (input.checked ? 'true' : 'false') : input.value;
--}
--// Normalize so cosmetic differences (pretty-printed JSON, trailing whitespace) don't count as edits.
--function _streamerNormalize(input, raw) {
-- if (input.dataset.type === 'json') { try { return JSON.stringify(JSON.parse(raw)); } catch { return raw; } }
-- if (input.dataset.type === 'number') return String(raw).trim();
-- return raw;
--}
--function _markDirty(input) {
-- const orig = _streamerSettingsOriginal[input.dataset.key];
-- const dirty = orig !== undefined && _streamerNormalize(input, _streamerFieldValue(input)) !== _streamerNormalize(input, orig);
-- input.style.borderColor = dirty ? 'var(--accent, #8b5cf6)' : '';
-- const btn = document.getElementById('streamer-settings-save');
-- if (btn) {
-- const n = document.querySelectorAll('#streamer-settings-form [data-key][data-dirty="1"]').length + (dirty && input.dataset.dirty !== '1' ? 1 : (!dirty && input.dataset.dirty === '1' ? -1 : 0));
-- input.dataset.dirty = dirty ? '1' : '0';
-- btn.innerHTML = `<i class="fa-solid fa-floppy-disk"></i> Save ${n ? n + ' change' + (n === 1 ? '' : 's') : 'Settings'}`;
-- }
--}
--
--async function loadStreamerSettings() {
-- const c = document.getElementById('settings-streamer');
-- c.innerHTML = '<div class="loading">Loading...</div>';
-- try {
-- const data = await api('/api/admin/streamer/settings');
-- const settings = data.settings || [];
-- if (!settings.length) { c.innerHTML = '<p class="muted">No OpenVibe.Live settings found</p>'; return; }
-- _streamerSettingsOriginal = {};
-- for (const s of settings) _streamerSettingsOriginal[s.key] = s.value == null ? '' : String(s.value);
-- // Bucket each setting into the first matching group.
-- const buckets = STREAMER_SETTINGS_GROUPS.map(() => []);
-- for (const s of settings) {
-- const gi = STREAMER_SETTINGS_GROUPS.findIndex(g => g.test(s.key));
-- buckets[gi === -1 ? buckets.length - 1 : gi].push(s);
-- }
-- const sections = STREAMER_SETTINGS_GROUPS.map((g, i) => {
-- const items = buckets[i].sort((a, b) => a.key.localeCompare(b.key));
-- if (!items.length) return '';
-- return `<details class="settings-group" ${i < 2 ? 'open' : ''} style="border:1px solid var(--border);border-radius:8px;margin-bottom:10px">
-- <summary style="cursor:pointer;padding:10px 14px;font-weight:700;list-style:none"><i class="fa-solid ${g.icon}"></i> ${esc(g.title)} <span style="opacity:.5;font-weight:400">(${items.length})</span></summary>
-- <div style="padding:4px 14px 12px">${items.map(_streamerSettingField).join('')}</div>
-- </details>`;
-- }).join('');
-- c.innerHTML = `
-- <form id="streamer-settings-form" style="max-width:760px">
-- ${sections}
-- <div style="margin-top:8px;display:flex;gap:10px;align-items:center;flex-wrap:wrap">
-- <button type="button" id="streamer-settings-save" class="btn btn-primary" onclick="saveStreamerSettings()"><i class="fa-solid fa-floppy-disk"></i> Save Settings</button>
-- <button type="button" class="btn btn-outline" onclick="loadStreamerSettings()"><i class="fa-solid fa-rotate-left"></i> Discard changes</button>
-- <span class="muted" style="font-size:12px">Only fields you changed are sent. JSON and numeric fields are validated first.</span>
-- </div>
-- </form>`;
-- c.querySelectorAll('#streamer-settings-form [data-key]').forEach(el => el.addEventListener('input', () => _markDirty(el)));
-- } catch (e) { c.innerHTML = `<p class="muted">Error: ${esc(e.message)}</p>`; }
--}
--
--function _setFieldError(key, msg) {
-- const el = document.querySelector(`#streamer-settings-form [data-err-for="${CSS.escape(key)}"]`);
-- if (el) { el.textContent = msg || ''; el.style.display = msg ? '' : 'none'; }
-- const input = document.getElementById(`ss-${key}`);
-- if (input) input.style.borderColor = msg ? 'var(--danger, #f87171)' : (input.dataset.dirty === '1' ? 'var(--accent, #8b5cf6)' : '');
--}
--
--async function saveStreamerSettings() {
-- const inputs = document.querySelectorAll('#streamer-settings-form [data-key]');
-- const settings = {};
-- const errors = [];
-- inputs.forEach(input => {
-- const key = input.dataset.key;
-- const raw = _streamerFieldValue(input);
-- _setFieldError(key, '');
-- // Not in the snapshot (shouldn't happen) or redacted mask untouched → never send.
-- const orig = _streamerSettingsOriginal[key];
-- if (orig === undefined) return;
-- if (raw.startsWith(REDACTED_MASK)) return;
-- if (_streamerNormalize(input, raw) === _streamerNormalize(input, orig)) return;
-- let value = raw;
-- if (input.dataset.type === 'json') {
-- try { value = JSON.stringify(JSON.parse(raw)); }
-- catch (e) { errors.push(key); _setFieldError(key, `Invalid JSON: ${e.message}`); return; }
-- } else if (input.dataset.type === 'number') {
-- if (raw.trim() === '' || !Number.isFinite(Number(raw))) { errors.push(key); _setFieldError(key, 'Must be a number'); return; }
-- value = String(Number(raw));
-- } else if (_looksJson(orig) && !_looksJson(raw)) {
-- // Previously JSON, now something that clearly isn't → almost certainly a truncation/mistake.
-- errors.push(key); _setFieldError(key, 'This setting was JSON; the new value is not. Clear the field completely if you really mean to blank it.'); return;
-- }
-- settings[key] = value;
-- });
-- if (errors.length) {
-- toast(`Not saved — fix ${errors.length} invalid field${errors.length === 1 ? '' : 's'}: ${errors.join(', ')}`, 'error');
-- const first = document.getElementById(`ss-${errors[0]}`);
-- if (first) { first.closest('details')?.setAttribute('open', ''); first.scrollIntoView({ behavior: 'smooth', block: 'center' }); }
-- return;
-- }
-- const keys = Object.keys(settings);
-- if (!keys.length) { toast('No changes to save', 'info'); return; }
-- try {
-- const out = await api('/api/admin/streamer/settings', { method: 'PUT', body: { settings } });
-- if (out && out.rejected && Object.keys(out.rejected).length) {
-- for (const [k, msg] of Object.entries(out.rejected)) _setFieldError(k, msg);
-- toast(`Server rejected: ${Object.keys(out.rejected).join(', ')}`, 'error');
-- return;
-- }
-- for (const k of keys) _streamerSettingsOriginal[k] = settings[k];
-- inputs.forEach(el => { el.dataset.dirty = '0'; el.style.borderColor = ''; });
-- const btn = document.getElementById('streamer-settings-save');
-- if (btn) btn.innerHTML = '<i class="fa-solid fa-floppy-disk"></i> Save Settings';
-- toast(`Saved ${keys.length} setting${keys.length === 1 ? '' : 's'}: ${keys.join(', ')}` + (out?.message && /skipped/.test(out.message) ? ` (${out.message})` : ''), 'success');
-- } catch (e) {
-- // Server-side validation failed: nothing was written; surface the per-field reasons.
-- const rejected = e.body && e.body.rejected;
-- if (rejected && Object.keys(rejected).length) for (const [k, msg] of Object.entries(rejected)) _setFieldError(k, msg);
-- toast(e.message, 'error');
-- }
--}
--
--// ── Payments: configure PayPal / Stripe / CCBill / crypto + subscriptions ──
--const PAYMENT_GROUPS = [
-- { title: 'General', keys: ['payments_enabled', 'bucks_per_usd', 'bucks_min_purchase_usd', 'sub_price_usd', 'sub_streamer_share_pct'] },
-- { title: 'PayPal', keys: ['paypal_enabled', 'paypal_mode', 'paypal_client_id', 'paypal_client_secret', 'paypal_webhook_id'] },
-- { title: 'Stripe', keys: ['stripe_enabled', 'stripe_secret_key', 'stripe_publishable_key', 'stripe_webhook_secret'] },
-- { title: 'CCBill', keys: ['ccbill_enabled', 'ccbill_client_account', 'ccbill_subaccount', 'ccbill_flexform_id', 'ccbill_salt', 'ccbill_webhook_secret'] },
-- { title: 'Crypto (NOWPayments)', keys: ['crypto_enabled', 'crypto_provider', 'crypto_api_key', 'crypto_ipn_secret'] },
--];
--const PAYMENT_WEBHOOKS = {
-- Stripe: 'https://openvibe.live/api/payments/webhook/stripe',
-- PayPal: 'https://openvibe.live/api/payments/webhook/paypal',
-- CCBill: 'https://openvibe.live/api/payments/webhook/ccbill?secret=YOUR_ccbill_webhook_secret',
-- 'Crypto (NOWPayments)': 'https://openvibe.live/api/payments/webhook/crypto',
--};
--
--async function loadPayments() {
-- const c = document.getElementById('payments-content');
-- c.innerHTML = '<div class="loading">Loading...</div>';
-- try {
-- const data = await api('/api/admin/streamer/settings');
-- const byKey = {}; (data.settings || []).forEach(s => { byKey[s.key] = s; });
-- const field = (s) => {
-- if (!s) return '';
-- const sensitive = /(secret|salt|api[_-]?key|client_secret|ipn)/i.test(s.key);
-- if (s.type === 'boolean') return `
-- <div class="setting-row">
-- <label><strong>${esc(s.key)}</strong><br><small>${esc(s.description||'')}</small></label>
-- <input type="checkbox" data-key="${esc(s.key)}" data-type="boolean" ${s.value==='true'?'checked':''} style="width:18px;height:18px;cursor:pointer">
-- </div>`;
-- const type = s.type === 'number' ? 'number' : (sensitive ? 'password' : 'text');
-- return `
-- <div class="setting-row" style="flex-direction:column;align-items:stretch">
-- <label><strong>${esc(s.key)}</strong><br><small>${esc(s.description||'')}</small></label>
-- <input type="${type}" data-key="${esc(s.key)}" data-type="${esc(s.type||'string')}" value="${esc(s.value)}"
-- style="margin-top:4px;padding:8px 10px;background:var(--bg-input);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:13px">
-- </div>`;
-- };
-- const groups = PAYMENT_GROUPS.map(g => `
-- <fieldset style="border:1px solid var(--border);border-radius:8px;padding:12px 16px;margin-bottom:16px">
-- <legend style="padding:0 8px;font-weight:700">${esc(g.title)}</legend>
-- ${g.keys.map(k => field(byKey[k])).join('')}
-- ${PAYMENT_WEBHOOKS[g.title] ? `<p class="muted" style="font-size:12px;margin-top:8px"><i class="fa-solid fa-link"></i> Webhook URL: <code>${esc(PAYMENT_WEBHOOKS[g.title])}</code></p>` : ''}
-- </fieldset>`).join('');
-- c.innerHTML = `
-- <div style="max-width:760px">
-- <p class="muted" style="margin-bottom:12px">Configure real-money payments for Vibes &amp; channel subscriptions. Nothing goes live until <strong>payments_enabled</strong> is on and the provider is enabled with valid keys. Set each provider's webhook to the URL shown, and add the redirect/return URLs from OpenVibe.Live.</p>
-- <form id="payments-form">${groups}
-- <button type="button" class="btn btn-primary" onclick="savePayments()"><i class="fa-solid fa-floppy-disk"></i> Save Payment Settings</button>
-- </form>
-- </div>`;
-- } catch (e) { c.innerHTML = `<p class="muted">Error: ${esc(e.message)}</p>`; }
--}
--
--async function savePayments() {
-- const inputs = document.querySelectorAll('#payments-form [data-key]');
-- const settings = {};
-- inputs.forEach(input => {
-- settings[input.dataset.key] = input.dataset.type === 'boolean' ? (input.checked ? 'true' : 'false') : input.value;
-- });
-- try {
-- await api('/api/admin/streamer/settings', { method: 'PUT', body: { settings } });
-- toast('Payment settings saved', 'success');
-- } catch (e) { toast(e.message, 'error'); }
--}
--
--// ── AI: provider config + usage cost breakdown ──
--// Live's own provider settings are gone (WS-O task 2): OpenVibe.AI holds the key, the models and their prices.
--const AI_KEYS = ['ai_enabled',
-- 'ai_paste_analysis_enabled', 'ai_stream_memory_enabled', 'ai_stream_capture_interval_sec', 'ai_transcription_enabled', 'ai_timeline_enabled',
-- 'ai_max_cost_usd_per_day',
-- 'ai_viewers_enabled', 'ai_viewers_max_roster', 'ai_viewers_max_lines_per_min', 'ai_viewers_global_cap_usd_per_day', 'ai_viewers_default_settings_json'];
--
--async function loadAi() {
-- const c = document.getElementById('ai-content');
-- c.innerHTML = '<div class="loading">Loading...</div>';
-- try {
-- const [data, usageResp] = await Promise.all([
-- api('/api/admin/streamer/settings'),
-- api('/api/admin/streamer/ai/usage?days=30').catch(() => ({ usage: null })),
-- ]);
-- const byKey = {}; (data.settings || []).forEach(s => { byKey[s.key] = s; });
-- const field = (key) => {
-- const s = byKey[key]; if (!s) return '';
-- const sensitive = /(api_key|secret)/i.test(key);
-- // Server redacts API keys / money settings for non-owner admins — show locked.
-- if (s.redacted) return `
-- <div class="setting-row" style="flex-direction:column;align-items:stretch">
-- <label><strong>${esc(key)}</strong> <span style="color:var(--muted);font-size:11px;font-weight:600">🔒 Owner only</span><br><small>${esc(s.description||'')}</small></label>
-- <input type="text" value="${esc(s.value)}" disabled data-redacted="1"
-- style="margin-top:4px;padding:8px 10px;background:var(--bg-input);border:1px solid var(--border);border-radius:6px;color:var(--muted);font-size:13px;opacity:0.65"></div>`;
-- if (s.type === 'boolean') return `
-- <div class="setting-row"><label><strong>${esc(key)}</strong><br><small>${esc(s.description||'')}</small></label>
-- <input type="checkbox" data-key="${esc(key)}" data-type="boolean" ${s.value==='true'?'checked':''} style="width:18px;height:18px;cursor:pointer"></div>`;
-- if (s.type === 'json' || /_json$/.test(key)) return `<div class="setting-row" style="flex-direction:column;align-items:stretch">
-- <label><strong>${esc(key)}</strong><br><small>${esc(s.description||'')}</small></label>
-- <textarea data-key="${esc(key)}" data-type="json" rows="4" spellcheck="false" style="margin-top:4px;padding:8px 10px;background:var(--bg-input);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:12px;font-family:ui-monospace,monospace">${esc((() => { try { return JSON.stringify(JSON.parse(s.value), null, 2); } catch { return s.value; } })())}</textarea></div>`;
-- const type = s.type === 'number' ? 'number' : (sensitive ? 'password' : 'text');
-- return `<div class="setting-row" style="flex-direction:column;align-items:stretch">
-- <label><strong>${esc(key)}</strong><br><small>${esc(s.description||'')}</small></label>
-- <input type="${type}" data-key="${esc(key)}" data-type="${esc(s.type||'string')}" value="${esc(s.value)}"
-- style="margin-top:4px;padding:8px 10px;background:var(--bg-input);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:13px"></div>`;
-- };
-- const u = usageResp && usageResp.usage;
-- let costHtml = '<p class="muted">No AI usage recorded yet.</p>';
-- if (u && u.totals) {
-- const money = (n) => '$' + (Number(n||0)).toFixed(4);
-- costHtml = `
-- <div style="display:flex;gap:18px;flex-wrap:wrap;margin-bottom:10px">
-- <div><div style="font-size:22px;font-weight:700">${money(u.today)}</div><small class="muted">Today</small></div>
-- <div><div style="font-size:22px;font-weight:700">${money(u.totals.cost_usd)}</div><small class="muted">30 days</small></div>
-- <div><div style="font-size:22px;font-weight:700">${u.totals.calls||0}</div><small class="muted">Calls (30d)</small></div>
-- <div><div style="font-size:22px;font-weight:700">${((u.totals.input_tokens||0)+(u.totals.output_tokens||0)).toLocaleString()}</div><small class="muted">Tokens (30d)</small></div>
-- </div>
-- <div class="muted" style="font-size:12px;margin-bottom:8px">Prompt cache hit rate: <b>${Math.round((u.cachedShare||0)*100)}%</b> of input tokens (30d) · ${(u.totals.cached_tokens||0).toLocaleString()} cached tokens</div>
-- ${(u.byRole||[]).length ? `<table style="width:100%;font-size:13px;border-collapse:collapse;margin-bottom:10px"><tr><th style="text-align:left">Role</th><th style="text-align:right">Calls</th><th style="text-align:right">Cached</th><th style="text-align:right">Avg ms</th><th style="text-align:right">Cost</th></tr>
-- ${u.byRole.map(k=>`<tr><td>${esc(k.role||'—')}</td><td style="text-align:right">${k.calls}</td><td style="text-align:right">${k.input_tokens ? Math.round((k.cached_tokens||0)/k.input_tokens*100) : 0}%</td><td style="text-align:right">${Math.round(k.avg_latency_ms||0)}</td><td style="text-align:right">${money(k.cost_usd)}</td></tr>`).join('')}</table>` : ''}
-- ${(u.byKind||[]).length ? `<details><summary class="muted" style="cursor:pointer;font-size:12px">By feature</summary><table style="width:100%;font-size:13px;border-collapse:collapse"><tr><th style="text-align:left">Feature</th><th style="text-align:right">Calls</th><th style="text-align:right">Cost</th></tr>
-- ${u.byKind.map(k=>`<tr><td>${esc(k.kind||'—')}</td><td style="text-align:right">${k.calls}</td><td style="text-align:right">${money(k.cost_usd)}</td></tr>`).join('')}</table></details>` : ''}
-- ${(u.byOwner||[]).length ? `<details style="margin-top:8px"><summary class="muted" style="cursor:pointer;font-size:12px">By streamer (attributed spend)</summary><table style="width:100%;font-size:13px;border-collapse:collapse"><tr><th style="text-align:left">Streamer</th><th style="text-align:right">Calls</th><th style="text-align:right">Today</th><th style="text-align:right">30d</th><th style="text-align:right">of which BYO</th></tr>
-- ${u.byOwner.map(k=>`<tr><td>${esc(k.username||('#'+k.user_id))}</td><td style="text-align:right">${k.calls}</td><td style="text-align:right">${money(k.cost_today)}</td><td style="text-align:right">${money(k.cost_usd)}</td><td style="text-align:right">${money(k.cost_byo)}</td></tr>`).join('')}</table></details>` : ''}`;
-- }
-- c.innerHTML = `
-- <div style="max-width:760px">
-- <p class="muted" style="margin-bottom:12px">AI analysis for pastes, live-stream memories and AI viewers. Every call is a run on OpenVibe.AI, which holds the provider keys, models and prices (its console at ai.openvibe.network/console). Nothing runs until <strong>ai_enabled</strong> is on. Set <strong>ai_max_cost_usd_per_day</strong> to cap Live's daily spend.</p>
-- <fieldset style="border:1px solid var(--border);border-radius:8px;padding:12px 16px;margin-bottom:16px">
-- <legend style="padding:0 8px;font-weight:700">Estimated Cost</legend>
-- ${costHtml}
-- </fieldset>
-- <fieldset style="border:1px solid var(--border);border-radius:8px;padding:12px 16px;margin-bottom:16px">
-- <legend style="padding:0 8px;font-weight:700">AI Chat Viewers</legend>
-- <div id="ai-viewers-fleet"><span class="muted">Loading…</span></div>
-- </fieldset>
-- <fieldset style="border:1px solid var(--border);border-radius:8px;padding:12px 16px;margin-bottom:16px">
-- <legend style="padding:0 8px;font-weight:700">AI Status</legend>
-- <button type="button" class="btn" onclick="testAiStatus()"><i class="fa-solid fa-heart-pulse"></i> Run Status Check</button>
-- <div id="ai-status-out" style="margin-top:10px"></div>
-- </fieldset>
-- <fieldset style="border:1px solid var(--border);border-radius:8px;padding:12px 16px;margin-bottom:16px">
-- <legend style="padding:0 8px;font-weight:700">AI Explorer</legend>
-- <p class="muted" style="margin:0 0 8px">Browse a streamer's AI memory, analyzed pastes, and VODs — and generate a per-streamer overview aggregated across all of it.</p>
-- <div style="display:flex;gap:8px">
-- <input id="ai-explorer-search" placeholder="Search streamer username…" onkeydown="if(event.key==='Enter'){event.preventDefault();aiExplorerSearch();}"
-- style="flex:1;padding:8px 10px;background:var(--bg-input);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:13px">
-- <button type="button" class="btn" onclick="aiExplorerSearch()"><i class="fa-solid fa-magnifying-glass"></i></button>
-- </div>
-- <div id="ai-explorer-results" style="margin-top:8px"></div>
-- <div id="ai-explorer-detail" style="margin-top:12px"></div>
-- </fieldset>
-- ${currentUser.is_owner ? `
-- <form id="ai-form">
-- <fieldset style="border:1px solid var(--border);border-radius:8px;padding:12px 16px;margin-bottom:16px">
-- <legend style="padding:0 8px;font-weight:700">Configuration</legend>
-- ${AI_KEYS.map(field).join('')}
-- </fieldset>
-- <button type="button" class="btn btn-primary" onclick="saveAi()"><i class="fa-solid fa-floppy-disk"></i> Save AI Settings</button>
-- </form>` : `
-- <fieldset style="border:1px solid var(--border);border-radius:8px;padding:12px 16px;margin-bottom:16px;opacity:0.8">
-- <legend style="padding:0 8px;font-weight:700">Configuration</legend>
-- <p class="muted" style="margin:0"><i class="fa-solid fa-lock"></i> AI provider configuration and API keys are owner-only. You have full access to usage, costs, status, and the AI Explorer above.</p>
-- </fieldset>`}
-- </div>`;
-- loadAiViewersFleet();
-- } catch (e) { c.innerHTML = `<p class="muted">Error: ${esc(e.message)}</p>`; }
--}
--
--async function loadAiViewersFleet() {
-- const el = document.getElementById('ai-viewers-fleet'); if (!el) return;
-- try {
-- const d = await api('/api/admin/streamer/ai/viewers/status');
-- const money = (n) => '$' + (Number(n||0)).toFixed(4);
-- el.innerHTML = `
-- <div style="display:flex;gap:16px;flex-wrap:wrap;align-items:center;margin-bottom:10px">
-- <span>Default engine: <b>${esc(d.engine_default)}</b></span>
-- <span>Kill switch: <b style="color:${d.kill_switch ? '#f87171' : '#4ade80'}">${d.kill_switch ? 'ENGAGED (all channels silent)' : 'off'}</b></span>
-- <span>Shared-key viewer spend today: <b>${money(d.global_spend_today_usd)}</b></span>
-- <span class="muted" style="font-size:12px">The kill switch is ai_viewers_enabled in Configuration below.</span>
-- </div>
-- ${d.running.length ? `<table style="width:100%;font-size:13px;border-collapse:collapse;margin-bottom:10px"><tr><th style="text-align:left">Running now</th><th>Engine</th><th>Stream</th><th>Bots</th><th>Mode</th><th>Lines</th><th>Passes</th><th style="text-align:right">Cost</th><th></th></tr>
-- ${d.running.map(r=>`<tr><td>${esc(r.username||('#'+r.user_id))}</td><td>${esc(r.engine)}</td><td>${r.stream_id}</td><td>${r.bots}</td><td>${esc(r.paused ? 'paused' : (r.mode||''))}</td><td>${r.stats ? r.stats.lines : '—'}</td><td>${r.stats ? r.stats.ticks : '—'}</td><td style="text-align:right">${r.stats ? money(r.stats.cost) : '—'}</td>
-- <td style="white-space:nowrap"><button class="btn btn-sm btn-outline" onclick="aiViewersAdmin(${r.user_id},'${r.paused ? 'resume' : 'pause'}')">${r.paused ? 'Resume' : 'Pause'}</button> <button class="btn btn-sm btn-danger" onclick="aiViewersAdmin(${r.user_id},'stop')">Stop + disable</button></td></tr>`).join('')}</table>` : '<p class="muted" style="margin:0 0 8px">No AI viewer workers running right now.</p>'}
-- <details><summary class="muted" style="cursor:pointer;font-size:12px">Channels with AI viewers configured (${d.channels.length})</summary>
-- <table style="width:100%;font-size:13px;border-collapse:collapse"><tr><th style="text-align:left">Channel</th><th>Enabled</th><th>Activity</th><th>Bots</th><th>Key</th><th style="text-align:right">Today / cap</th></tr>
-- ${d.channels.map(c=>`<tr><td>${esc(c.username)}</td><td>${c.enabled ? '✔' : '—'}</td><td>${esc(c.activity||'—')}</td><td>${c.bots}</td><td>${c.shared_key ? 'shared' : 'BYO'}</td><td style="text-align:right">${money(c.spent_today_usd)} / ${c.shared_key ? '$'+c.cap_usd.toFixed(2) : '∞'}</td></tr>`).join('')}</table></details>`;
-- } catch (e) { el.innerHTML = `<span class="muted">Unavailable: ${esc(e.message)}</span>`; }
--}
--async function aiViewersAdmin(userId, action) {
-- if (action === 'stop' && !confirm('Stop this channel\'s AI viewers now and disable them?')) return;
-- try { const d = await api(`/api/admin/streamer/ai/viewers/${userId}/${action}`, { method: 'POST', body: {} }); toast(d.message || 'ok', 'success'); loadAiViewersFleet(); }
-- catch (e) { toast(e.message, 'error'); }
--}
--
--async function saveAi() {
-- const inputs = document.querySelectorAll('#ai-form [data-key]');
-- const settings = {};
-- for (const input of inputs) {
-- let v = input.dataset.type === 'boolean' ? (input.checked ? 'true' : 'false') : input.value;
-- if (input.dataset.type === 'json') { try { v = JSON.stringify(JSON.parse(input.value)); } catch (e) { return toast(`${input.dataset.key}: invalid JSON — ${e.message}`, 'error'); } }
-- settings[input.dataset.key] = v;
-- }
-- try {
-- await api('/api/admin/streamer/settings', { method: 'PUT', body: { settings } });
-- toast('AI settings saved', 'success');
-- } catch (e) { toast(e.message, 'error'); }
--}
--
--// ── AI Status + Explorer ─────────────────────────────────────
--async function testAiStatus() {
-- const out = document.getElementById('ai-status-out');
-- if (!out) return;
-- out.innerHTML = '<span class="muted">Checking… (this makes one tiny live call)</span>';
-- try {
-- const { status } = await api('/api/admin/streamer/ai/status');
-- const badge = status.ok ? '<span style="color:#4ade80">● OK</span>' : '<span style="color:#f87171">● Problem</span>';
-- out.innerHTML = `<div style="font-size:13px;line-height:1.7">
-- ${badge} &nbsp; via <b>${esc(status.service || 'openvibe-ai')}</b>${status.probed ? ` · reply "${esc(status.reply||'')}" · ${status.latency_ms}ms` : ''}
-- <br>enabled: <b>${status.enabled}</b> · paste analysis: ${status.paste_analysis} · stream memory: ${status.stream_memory}
-- <br>cost today: <b>$${Number(status.cost_today||0).toFixed(4)}</b> · daily cap: ${status.budget_cap_usd_per_day ? '$'+status.budget_cap_usd_per_day : 'none'}
-- ${status.error ? `<br><span style="color:#f87171">${esc(status.error)}</span>` : ''}
-- </div>`;
-- } catch (e) { out.innerHTML = `<span style="color:#f87171">${esc(e.message)}</span>`; }
--}
--
--async function aiExplorerSearch() {
-- const q = (document.getElementById('ai-explorer-search').value || '').trim();
-- const box = document.getElementById('ai-explorer-results');
-- box.innerHTML = '<span class="muted">Searching…</span>';
-- try {
-- const data = await api('/api/admin/streamer/users?search=' + encodeURIComponent(q) + '&limit=15');
-- const users = data.users || [];
-- box.innerHTML = users.length
-- ? users.map(u => `<button type="button" class="btn btn-sm" style="margin:2px" onclick="aiExplorerLoad(${u.id})">@${esc(u.username)}${u.display_name && u.display_name !== u.username ? ' ('+esc(u.display_name)+')' : ''}</button>`).join('')
-- : '<span class="muted">No matching users.</span>';
-- } catch (e) { box.innerHTML = `<span style="color:#f87171">${esc(e.message)}</span>`; }
--}
--
--async function aiExplorerLoad(userId) {
-- const box = document.getElementById('ai-explorer-detail');
-- box.innerHTML = '<div class="loading">Loading…</div>';
-- try {
-- const d = await api('/api/admin/streamer/ai/explorer/' + userId);
-- const ov = d.overview;
-- const tbl = (rows, head) => `<table style="width:100%;font-size:12px;border-collapse:collapse;margin-top:6px"><thead><tr>${head.map(h=>`<th style="text-align:left;border-bottom:1px solid var(--border);padding:3px 6px">${h}</th>`).join('')}</tr></thead><tbody>${rows||`<tr><td style="padding:6px" class="muted">None</td></tr>`}</tbody></table>`;
-- const scrollCell = (t) => t ? `<div style="max-height:90px;overflow:auto;white-space:pre-wrap;font-size:11px;line-height:1.45">${esc(t)}</div>` : '<span class="muted">—</span>';
-- const isHeard = (s) => /heard:/i.test(s||'');
-- const memRows = (d.memories||[]).map(m => `<tr><td style="padding:3px 6px;white-space:nowrap;color:var(--muted)">${esc((m.created_at||'').slice(0,16))}${isHeard(m.description)?' <span title="contains audio transcript" style="color:var(--accent)">🔊</span>':''}</td><td style="padding:3px 6px">${esc(m.description||'')}</td></tr>`).join('');
-- const pasteRows = (d.pastes||[]).map(p => `<tr><td style="padding:3px 6px">${esc(p.type||'')}</td><td style="padding:3px 6px">${esc(p.title||'')}</td><td style="padding:3px 6px">${esc(p.ai_summary||'—')}</td></tr>`).join('');
-- const vodRows = (d.vods||[]).map(v => `<tr><td style="padding:3px 6px">${esc(v.title||'')}</td><td style="padding:3px 6px">${esc(v.ai_overview||'—')}</td><td style="padding:3px 6px;min-width:180px">${scrollCell(v.ai_transcript)}</td></tr>`).join('');
-- const clipRows = (d.clips||[]).map(c => `<tr><td style="padding:3px 6px">${esc(c.title||'')}</td><td style="padding:3px 6px">${esc(c.ai_overview||'—')}</td><td style="padding:3px 6px;min-width:180px">${scrollCell(c.ai_transcript)}</td></tr>`).join('');
-- const txCount = (d.vods||[]).filter(v=>v.ai_transcript).length + (d.clips||[]).filter(c=>c.ai_transcript).length + (d.memories||[]).filter(m=>isHeard(m.description)).length;
-- box.innerHTML = `
-- <div style="display:flex;align-items:center;gap:10px;margin-bottom:8px">
-- <h3 style="margin:0">@${esc(d.user.username)}</h3>
-- <button type="button" class="btn btn-sm btn-primary" onclick="aiGenerateOverview(${userId})"><i class="fa-solid fa-wand-magic-sparkles"></i> Generate Overview</button>
-- </div>
-- <div style="padding:10px;background:var(--bg-input);border-radius:6px;margin-bottom:10px">
-- <strong>AI Overview</strong> ${ov?`<small class="muted">(${esc((ov.generated_at||'').slice(0,16))} · ${esc(ov.model||'')})</small>`:''}
-- <div style="margin-top:6px;white-space:pre-wrap;font-size:13px;line-height:1.5">${ov?esc(ov.overview):'<span class="muted">Not generated yet — click “Generate Overview”.</span>'}</div>
-- </div>
-- <p class="muted" style="font-size:12px;margin:0 0 6px">${d.counts.memories} memories · ${d.counts.pastes} pastes · ${d.counts.vods} vods · ${d.counts.clips||0} clips · <span style="color:var(--accent)">🔊 ${txCount} with transcript</span></p>
-- <details style="margin-bottom:6px"><summary style="cursor:pointer">Stream memories (${d.counts.memories}) — 🔊 = includes audio transcript</summary>${tbl(memRows,['When','Description'])}</details>
-- <details style="margin-bottom:6px"><summary style="cursor:pointer">Pastes (${d.counts.pastes})</summary>${tbl(pasteRows,['Type','Title','AI summary'])}</details>
-- <details style="margin-bottom:6px"><summary style="cursor:pointer">VODs (${d.counts.vods}) — overview + transcript</summary>${tbl(vodRows,['Title','AI overview','Transcript'])}</details>
-- <details><summary style="cursor:pointer">Clips (${d.counts.clips||0}) — overview + transcript</summary>${tbl(clipRows,['Title','AI overview','Transcript'])}</details>`;
-- } catch (e) { box.innerHTML = `<span style="color:#f87171">${esc(e.message)}</span>`; }
--}
--
--async function aiGenerateOverview(userId) {
-- toast('Generating overview… (one AI call)', 'info');
-- try {
-- await api('/api/admin/streamer/ai/streamer/' + userId + '/overview', { method: 'POST' });
-- toast('Overview generated', 'success');
-- aiExplorerLoad(userId);
-- } catch (e) { toast(e.message || 'Failed', 'error'); }
--}
--
--async function loadStreamerMediaTools() {
-- const c = document.getElementById('settings-media');
-- c.innerHTML = '<div class="loading">Loading...</div>';
--
-- let status = {};
-- try {
-- status = await api('/api/admin/streamer/media-tools/status');
-- } catch (e) {
-- c.innerHTML = `<p class="muted">Error: ${esc(e.message)}</p>`;
-- return;
-- }
--
-- c.innerHTML = `
-- <div style="display:grid;gap:16px;max-width:860px">
-- <div class="card">
-- <h3><i class="fa-solid fa-wrench"></i> OpenVibe.Live yt-dlp Status</h3>
-- <div style="display:grid;gap:8px;font-size:0.95rem">
-- <div><strong>yt-dlp available:</strong> ${status.ytdlp_available
-- ? '<span style="color:var(--success,#22c55e)"><i class="fa-solid fa-check-circle"></i> Yes</span>'
-- : '<span style="color:var(--danger,#ef4444)"><i class="fa-solid fa-xmark-circle"></i> No</span>'
-- }</div>
-- <div><strong>Path:</strong> <span style="font-family:monospace;font-size:0.88rem">${esc(status.ytdlp_path || '')}</span></div>
-- <div><strong>YouTube cookies:</strong> ${status.cookies_configured
-- ? `<span style="color:var(--success,#22c55e)"><i class="fa-solid fa-cookie"></i> Configured (${esc(String(status.cookies_size || 0))} bytes)</span>`
-- : '<span class="muted"><i class="fa-solid fa-cookie-bite"></i> Not configured</span>'
-- }</div>
-- ${!status.cookies_configured ? '<div style="color:var(--warn,#f59e0b);font-size:0.88rem"><i class="fa-solid fa-triangle-exclamation"></i> YouTube server-side downloads will fail until cookies.txt is added.</div>' : ''}
-- </div>
-- </div>
--
-- <div class="card">
-- <h3><i class="fa-solid fa-cookie"></i> YouTube Cookies</h3>
-- <p class="muted" style="margin-top:0">Paste a Netscape-format cookies.txt export from a browser session logged into YouTube. This is required when YouTube bot detection blocks server-side downloads.</p>
-- <textarea id="streamer-media-cookies-input" rows="10" placeholder="# Netscape HTTP Cookie File&#10;.youtube.com&#9;TRUE&#9;/&#9;TRUE&#9;0&#9;SID&#9;value..."
-- style="width:100%;background:var(--bg-input);color:var(--text);border:1px solid var(--border);padding:10px;border-radius:6px;font-family:monospace;font-size:12px;resize:vertical"></textarea>
-- <div style="display:flex;gap:8px;flex-wrap:wrap;margin-top:12px">
-- <button type="button" class="btn btn-primary" onclick="saveStreamerMediaCookies()"><i class="fa-solid fa-floppy-disk"></i> Save &amp; Test</button>
-- <button type="button" class="btn" onclick="checkStreamerMediaCookies()" ${status.cookies_configured ? '' : 'disabled'}><i class="fa-solid fa-stethoscope"></i> Test Current Cookies</button>
-- <button type="button" class="btn" onclick="deleteStreamerMediaCookies()" ${status.cookies_configured ? '' : 'disabled'}><i class="fa-solid fa-trash"></i> Remove Cookies</button>
-- </div>
-- <div id="streamer-media-cookie-verdict"></div>
--
-- <details style="margin-top:16px">
-- <summary style="cursor:pointer;font-weight:600"><i class="fa-solid fa-circle-question"></i> How to get cookies that actually work</summary>
-- <div style="margin-top:12px;font-size:0.9rem;line-height:1.65">
-- <p style="margin-top:0"><strong>Why this is needed.</strong> YouTube treats requests from datacenter IPs as suspicious and answers with
-- <em>&ldquo;Sign in to confirm you&rsquo;re not a bot&rdquo;</em>. A signed-in cookie jar is what gets past that. Some videos work without it;
-- age-restricted and heavily-flagged ones will not.</p>
--
-- <p><strong>The one thing that matters.</strong> The export must contain YouTube&rsquo;s <em>first-party</em> session cookies —
-- <code>SID</code>, <code>HSID</code>, <code>SSID</code>, <code>APISID</code>, <code>SAPISID</code>, <code>__Secure-1PSID</code>.
-- An export holding only <code>__Secure-3P*</code> cookies parses fine and is <em>not signed in</em>; it will fail exactly the same way.
-- Save &amp; Test now tells you which of these are present.</p>
--
-- <p><strong>Recommended method — a throwaway account in a separate browser profile:</strong></p>
-- <ol style="margin:6px 0 0 18px;padding:0">
-- <li>Create a <em>burner</em> Google account. Do not use your main one: this jar grants access to that account, and it lives on the server.</li>
-- <li>Open a brand-new browser profile (not incognito &mdash; incognito is what produces the third-party-only exports that fail).</li>
-- <li>Sign in to YouTube in that profile and watch a few seconds of any video so the session is fully established.</li>
-- <li>Install a cookies.txt exporter extension (&ldquo;Get cookies.txt LOCALLY&rdquo; or similar) and export for <code>youtube.com</code>.</li>
-- <li>Paste the file contents above and press <strong>Save &amp; Test</strong>. It validates the jar and runs a real extraction before accepting it.</li>
-- <li>Then <strong>close that browser profile without logging out</strong>. Logging out invalidates the cookies you just exported.</li>
-- </ol>
--
-- <p style="margin-top:12px"><strong>Keeping it working:</strong></p>
-- <ul style="margin:6px 0 0 18px;padding:0">
-- <li>Cookies expire. Press <strong>Test Current Cookies</strong> if requests start failing &mdash; it reports the earliest expiry.</li>
-- <li>Do not use that burner account in another browser afterwards; YouTube rotates the session and the saved jar goes stale.</li>
-- <li>The file must be tab-separated Netscape format. Copying from a viewer that converts tabs to spaces produces a file that parses to nothing &mdash; the validator flags this.</li>
-- <li>A residential proxy avoids the problem at its source, since the block is driven by the server&rsquo;s datacenter IP.</li>
-- </ul>
--
-- <p style="margin-top:12px;color:var(--warn,#f59e0b)"><i class="fa-solid fa-triangle-exclamation"></i>
-- Anyone with admin access to this panel can read these cookies, and they are stored unencrypted on the server. Treat the account as compromised-by-design &mdash; burner only.</p>
-- </div>
-- </details>
-- </div>
--
-- <div class="card">
-- <h3><i class="fa-solid fa-flask-vial"></i> Test Extraction</h3>
-- <p class="muted" style="margin-top:0">Test a YouTube URL against the live OpenVibe.Live server to confirm cookies and yt-dlp are working.</p>
-- <div class="input-row">
-- <input type="text" id="streamer-media-test-url" placeholder="https://www.youtube.com/watch?v=...">
-- <button type="button" class="btn btn-primary" onclick="testStreamerMediaExtraction()"><i class="fa-solid fa-play"></i> Test</button>
-- </div>
-- <div id="streamer-media-test-results" style="margin-top:12px"></div>
-- </div>
-- </div>`;
--}
--
--
--/**
-- * Render the cookie validation + live-test verdict returned by the server.
-- * The old flow just said "saved", so a jar that could never work looked identical to a
-- * good one — this shows exactly which check failed and what to do about it.
-- */
--function renderCookieVerdict(data) {
-- const el = document.getElementById('streamer-media-cookie-verdict');
-- if (!el) return;
-- if (!data) { el.innerHTML = ''; return; }
-- const c = data.check || {};
-- const t = data.test || {};
-- const ok = (v) => v
-- ? '<span style="color:var(--success,#22c55e)"><i class="fa-solid fa-circle-check"></i> '
-- : '<span style="color:var(--danger,#ef4444)"><i class="fa-solid fa-circle-xmark"></i> ';
--
-- const rows = [];
-- rows.push(`<div>${ok(c.ok)}Signed-in cookies${c.ok ? '' : ' — missing'}</span></div>`);
-- if (c.firstParty && c.firstParty.length) {
-- rows.push(`<div class="muted" style="font-size:.85rem">First-party session: ${esc(c.firstParty.join(', '))}</div>`);
-- }
-- if (c.missingFirstParty && c.missingFirstParty.length && !c.ok) {
-- rows.push(`<div class="muted" style="font-size:.85rem">Missing: ${esc(c.missingFirstParty.join(', '))}</div>`);
-- }
-- if (c.thirdParty && c.thirdParty.length && !c.firstParty?.length) {
-- rows.push(`<div class="muted" style="font-size:.85rem">Only third-party found: ${esc(c.thirdParty.join(', '))}</div>`);
-- }
-- if (c.cookieCount != null) {
-- rows.push(`<div class="muted" style="font-size:.85rem">${c.cookieCount} cookie(s)${c.expiresAt ? `, earliest expiry ${esc(new Date(c.expiresAt).toLocaleString())}` : ''}</div>`);
-- }
-- if (t.ran) {
-- rows.push(`<div style="margin-top:6px">${ok(t.ok)}Live extraction${t.ok ? `: ${esc(t.title || 'ok')}` : ' failed'}</span></div>`);
-- if (!t.ok && t.error) rows.push(`<div class="muted" style="font-size:.82rem;word-break:break-word">${esc(String(t.error).slice(0, 300))}</div>`);
-- }
-- (c.errors || []).forEach(e => rows.push(`<div style="color:var(--danger,#ef4444);font-size:.86rem;margin-top:6px">${esc(e)}</div>`));
-- (c.warnings || []).forEach(w => rows.push(`<div style="color:var(--warn,#f59e0b);font-size:.86rem">${esc(w)}</div>`));
--
-- el.innerHTML = `<div style="margin-top:12px;padding:12px;border:1px solid var(--border);border-radius:8px;background:var(--bg-input)">${rows.join('')}</div>`;
--}
--
--async function checkStreamerMediaCookies() {
-- const el = document.getElementById('streamer-media-cookie-verdict');
-- if (el) el.innerHTML = '<div class="muted" style="margin-top:10px"><i class="fa-solid fa-spinner fa-spin"></i> Validating and running a live extraction…</div>';
-- try {
-- const data = await api('/api/admin/streamer/media-tools/cookies/check', { method: 'POST', body: {} });
-- renderCookieVerdict(data);
-- } catch (e) {
-- if (el) el.innerHTML = `<div style="color:var(--danger,#ef4444);margin-top:10px">${esc(e.message)}</div>`;
-- }
--}
--
--async function saveStreamerMediaCookies() {
-- const ta = document.getElementById('streamer-media-cookies-input');
-- const cookies = ta?.value?.trim() || '';
-- if (!cookies) return toast('Paste cookies.txt content first', 'error');
-- const el = document.getElementById('streamer-media-cookie-verdict');
-- if (el) el.innerHTML = '<div class="muted" style="margin-top:10px"><i class="fa-solid fa-spinner fa-spin"></i> Validating and running a live extraction…</div>';
-- try {
-- const data = await api('/api/admin/streamer/media-tools/cookies', { method: 'PUT', body: { cookies } });
-- toast(data?.test?.ok ? 'Cookies saved — live extraction passed' : 'Cookies saved', 'success');
-- renderCookieVerdict(data);
-- } catch (e) {
-- // The server refuses a jar it can already tell is unusable and returns the reason.
-- toast(e.message, 'error');
-- if (e.body) renderCookieVerdict(e.body);
-- else if (el) el.innerHTML = `<div style="color:var(--danger,#ef4444);margin-top:10px">${esc(e.message)}</div>`;
-- }
--}
--
--async function deleteStreamerMediaCookies() {
-- if (!confirm('Remove OpenVibe.Live yt-dlp cookies?')) return;
-- try {
-- await api('/api/admin/streamer/media-tools/cookies', { method: 'DELETE' });
-- toast('OpenVibe.Live cookies removed', 'success');
-- loadStreamerMediaTools();
-- } catch (e) { toast(e.message, 'error'); }
--}
--
--async function testStreamerMediaExtraction() {
-- const url = document.getElementById('streamer-media-test-url')?.value?.trim() || '';
-- const results = document.getElementById('streamer-media-test-results');
-- if (!url) return toast('Enter a URL to test', 'error');
-- results.innerHTML = '<p class="muted"><i class="fa-solid fa-spinner fa-spin"></i> Testing live OpenVibe.Live extraction…</p>';
-- try {
-- const data = await api('/api/admin/streamer/media-tools/test', { method: 'POST', body: { url } });
-- results.innerHTML = `<div style="display:grid;gap:8px;font-size:0.9rem">${(data.steps || []).map(step => {
-- const icon = step.ok
-- ? '<i class="fa-solid fa-check-circle" style="color:var(--success,#22c55e)"></i>'
-- : '<i class="fa-solid fa-xmark-circle" style="color:var(--danger,#ef4444)"></i>';
-- const detail = step.error
-- ? `<span style="color:var(--danger,#ef4444)"> — ${esc(step.error)}</span>`
-- : step.data ? ` — ${esc(JSON.stringify(step.data))}` : '';
-- return `<div>${icon} <strong>${esc(step.name)}</strong>${detail}</div>`;
-- }).join('')}</div>`;
-- } catch (e) {
-- results.innerHTML = `<p style="color:var(--danger,#ef4444)">${esc(e.message)}</p>`;
-- }
--}
--
--// ═══════════════════════════════════════════════════════════════
--// TTS (via openvibelive TTS proxy)
--// ═══════════════════════════════════════════════════════════════
--async function loadTTS() {
-- const c = document.getElementById('tts-content');
-- c.innerHTML = '<div class="loading">Loading...</div>';
-- try {
-- const [settingsRes, voicesRes] = await Promise.all([
-- api('/api/admin/streamer-tts/admin/settings'),
-- api('/api/admin/streamer-tts/voices'),
-- ]);
-- const s = settingsRes.settings || {};
-- const voices = voicesRes.voices || [];
-- const byEngine = (engine) => voices.filter(v => v.engine === engine);
-- const voiceOptions = (list) => list.map(v => `<option value="${esc(v.id)}" ${v.id === s.defaultVoice ? 'selected' : ''}>${esc(v.name)} (${esc(v.rarity || 'standard')})${v.available ? '' : ' ⚠ unavailable'}</option>`).join('');
--
-- c.innerHTML = `
-- <form id="tts-form" style="display:grid;gap:16px;max-width:760px">
-- <div class="card">
-- <h3><i class="fa-solid fa-sliders"></i> TTS Configuration</h3>
-- <div class="setting-row"><label><strong>Enabled</strong><br><small>Allow TTS requests on OpenVibe.Live.</small></label><input type="checkbox" id="tts-enabled" ${s.enabled ? 'checked' : ''} style="width:18px;height:18px"></div>
-- <div class="form-field"><label>Provider</label><select id="tts-provider"><option value="espeak-ng" ${s.provider === 'espeak-ng' ? 'selected' : ''}>espeak-ng</option><option value="google-cloud" ${s.provider === 'google-cloud' ? 'selected' : ''}>Google Cloud</option><option value="amazon-polly" ${s.provider === 'amazon-polly' ? 'selected' : ''}>Amazon Polly</option></select></div>
-- <div class="form-field"><label>Default Voice</label><select id="tts-default-voice"><optgroup label="espeak-ng">${voiceOptions(byEngine('espeak-ng'))}</optgroup><optgroup label="Google Cloud">${voiceOptions(byEngine('google-cloud'))}</optgroup><optgroup label="Amazon Polly">${voiceOptions(byEngine('amazon-polly'))}</optgroup></select></div>
-- <div style="display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px">
-- <div class="form-field"><label>Max Message Length</label><input type="number" id="tts-max-length" value="${esc(s.maxLength || 200)}"></div>
-- <div class="form-field"><label>Max Queue / User</label><input type="number" id="tts-max-per-user" value="${esc(s.maxQueuePerUser || 3)}"></div>
-- <div class="form-field"><label>Max Global Queue</label><input type="number" id="tts-max-global" value="${esc(s.maxQueueGlobal || 20)}"></div>
-- </div>
-- </div>
--
-- <div class="card">
-- <h3><i class="fa-brands fa-google"></i> Google Cloud</h3>
-- <div class="form-field"><label>API Key</label><input type="password" id="tts-google-api-key" value="${esc(s.googleApiKey || '')}" autocomplete="off"></div>
-- <div class="form-field"><label>Service Account JSON</label><textarea id="tts-google-sa" rows="4" style="resize:vertical">${esc(s.googleServiceAccount || '')}</textarea></div>
-- </div>
--
-- <div class="card">
-- <h3><i class="fa-brands fa-aws"></i> Amazon Polly</h3>
-- <div class="form-field"><label>AWS Access Key ID</label><input type="password" id="tts-aws-key" value="${esc(s.awsAccessKeyId || '')}" autocomplete="off"></div>
-- <div class="form-field"><label>AWS Secret Access Key</label><input type="password" id="tts-aws-secret" value="${esc(s.awsSecretAccessKey || '')}" autocomplete="off"></div>
-- <div class="form-field"><label>AWS Region</label><input type="text" id="tts-aws-region" value="${esc(s.awsRegion || 'us-east-1')}"></div>
-- </div>
--
-- <div style="display:flex;gap:10px;flex-wrap:wrap">
-- <button type="button" class="btn btn-primary" onclick="saveTTS()"><i class="fa-solid fa-floppy-disk"></i> Save TTS Settings</button>
-- <button type="button" class="btn btn-outline" onclick="testTTSVoice()"><i class="fa-solid fa-volume-high"></i> Test Voice</button>
-- </div>
-- </form>`;
-- } catch (e) { c.innerHTML = `<p class="muted">Error: ${esc(e.message)}</p>`; }
--}
--
--async function saveTTS() {
-- try {
-- await api('/api/admin/streamer-tts/admin/settings', {
-- method: 'PUT',
-- body: {
-- settings: {
-- tts_enabled: document.getElementById('tts-enabled').checked,
-- tts_provider: document.getElementById('tts-provider').value,
-- tts_default_voice: document.getElementById('tts-default-voice').value,
-- tts_max_length: parseInt(document.getElementById('tts-max-length').value, 10) || 200,
-- tts_max_queue_per_user: parseInt(document.getElementById('tts-max-per-user').value, 10) || 3,
-- tts_max_queue_global: parseInt(document.getElementById('tts-max-global').value, 10) || 20,
-- tts_google_api_key: document.getElementById('tts-google-api-key').value,
-- tts_google_service_account: document.getElementById('tts-google-sa').value,
-- tts_aws_access_key_id: document.getElementById('tts-aws-key').value,
-- tts_aws_secret_access_key: document.getElementById('tts-aws-secret').value,
-- tts_aws_region: document.getElementById('tts-aws-region').value || 'us-east-1',
-- },
-- },
-- });
-- toast('TTS settings saved', 'success');
-- } catch (e) { toast(e.message, 'error'); }
--}
--
--async function testTTSVoice() {
-- try {
-- const voiceId = document.getElementById('tts-default-voice').value;
-- const result = await api('/api/admin/streamer-tts/admin/test', {
-- method: 'POST',
-- body: { voiceId, text: 'Hello, this is a OpenVibe.Live voice test from the unified admin panel.' },
-- });
-- if (!result.audio || !result.mimeType) {
-- toast('No audio returned from the provider', 'warning');
-- return;
-- }
-- const binary = atob(result.audio);
-- const bytes = new Uint8Array(binary.length);
-- for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
-- const url = URL.createObjectURL(new Blob([bytes], { type: result.mimeType }));
-- const audio = new Audio(url);
-- audio.onended = () => URL.revokeObjectURL(url);
-- await audio.play();
-- toast(`Testing ${result.voiceName || voiceId}`, 'info');
-- } catch (e) { toast(e.message, 'error'); }
--}
--
--// ═══════════════════════════════════════════════════════════════
--// Cashouts (via funds proxy)
--// ═══════════════════════════════════════════════════════════════
--async function loadCashouts() {
-- const c = document.getElementById('cashouts-content');
-- c.innerHTML = '<div class="loading">Loading...</div>';
-- try {
-- const data = await api('/api/admin/streamer-funds/cashouts/pending');
-- const cashouts = data.cashouts || [];
-- if (!cashouts.length) { c.innerHTML = '<p class="muted">No pending cashouts</p>'; return; }
-- c.innerHTML = `
-- <table class="admin-table">
-- <thead><tr><th>User</th><th>Amount</th><th>USD</th><th>PayPal</th><th>Requested</th><th>Actions</th></tr></thead>
-- <tbody>${cashouts.map(co => `
-- <tr>
-- <td><strong>${esc(co.username || '-')}</strong></td>
-- <td>${Number(co.amount || 0).toLocaleString()} CF</td>
-- <td>$${((Number(co.amount) || 0) * 0.01).toFixed(2)}</td>
-- <td>${esc(co.paypal_email || '-')}</td>
-- <td>${co.created_at ? timeAgo(co.created_at) : '-'}</td>
-- <td style="display:flex;gap:6px">
-- <button class="btn btn-sm btn-success" onclick="approveCashout('${co.id}')"><i class="fa-solid fa-check"></i> Approve</button>
-- <button class="btn btn-sm btn-danger" onclick="denyCashout('${co.id}')"><i class="fa-solid fa-xmark"></i> Deny</button>
-- </td>
-- </tr>
-- `).join('')}</tbody>
-- </table>`;
-- } catch (e) { c.innerHTML = `<p class="muted">Error: ${esc(e.message)}</p>`; }
--}
--
--async function approveCashout(id) {
-- try {
-- await api(`/api/admin/streamer-funds/cashout/${id}/approve`, { method: 'POST' });
-- toast('Cashout approved', 'success');
-- loadCashouts();
-- } catch (e) { toast(e.message, 'error'); }
--}
--
--async function denyCashout(id) {
-- const reason = prompt('Denial reason:');
-- if (reason === null) return;
-- try {
-- await api(`/api/admin/streamer-funds/cashout/${id}/deny`, { method: 'POST', body: { reason } });
-- toast('Cashout denied', 'info');
-- loadCashouts();
-- } catch (e) { toast(e.message, 'error'); }
--}
--
--// ═══════════════════════════════════════════════════════════════
--// VPN queue (via admin proxy)
--// ═══════════════════════════════════════════════════════════════
--async function loadVPNQueue() {
-- const c = document.getElementById('vpn-content');
-- c.innerHTML = '<div class="loading">Loading...</div>';
-- try {
-- const data = await api('/api/admin/streamer/vpn-queue');
-- const queue = data.queue || [];
-- if (!queue.length) { c.innerHTML = '<p class="muted">VPN approval queue empty</p>'; return; }
-- c.innerHTML = `
-- <table class="admin-table">
-- <thead><tr><th>User</th><th>IP</th><th>Reason</th><th>Status</th><th>Actions</th></tr></thead>
-- <tbody>${queue.map(item => `
-- <tr>
-- <td><strong>${esc(item.username || item.user_id || '-')}</strong></td>
-- <td>${esc(item.ip_address || '-')}</td>
-- <td>${esc(item.reason || '-')}</td>
-- <td><span class="badge badge-${item.status === 'approved' ? 'success' : item.status === 'denied' ? 'danger' : 'warning'}">${esc(item.status || 'pending')}</span></td>
-- <td>${item.status === 'pending' ? `
-- <div style="display:flex;gap:6px">
-- <button class="btn btn-sm btn-success" onclick="updateVPNStatus('${item.id}','approved')"><i class="fa-solid fa-check"></i> Approve</button>
-- <button class="btn btn-sm btn-danger" onclick="updateVPNStatus('${item.id}','denied')"><i class="fa-solid fa-xmark"></i> Deny</button>
-- </div>` : '<span class="muted">Handled</span>'}
-- </td>
-- </tr>
-- `).join('')}</tbody>
-- </table>`;
-- } catch (e) { c.innerHTML = `<p class="muted">Error: ${esc(e.message)}</p>`; }
--}
--
--async function updateVPNStatus(id, status) {
-- try {
-- await api(`/api/admin/streamer/vpn-queue/${id}`, { method: 'PUT', body: { status } });
-- toast(`VPN request ${status}`, status === 'approved' ? 'success' : 'info');
-- loadVPNQueue();
-- } catch (e) { toast(e.message, 'error'); }
--}
--
--// ═══════════════════════════════════════════════════════════════
--// Pastes (via pastes proxy + streamer settings)
--// ═══════════════════════════════════════════════════════════════
--async function loadPastesAdmin() {
-- const c = document.getElementById('pastes-content');
-- c.innerHTML = '<div class="loading">Loading...</div>';
-- try {
-- const [statsData, configData] = await Promise.all([
-- api('/api/admin/streamer-pastes/admin/stats'),
-- api('/api/admin/streamer-pastes/config'),
-- ]);
-- const s = statsData.stats || {};
-- const cfg = configData || {};
-- c.innerHTML = `
-- <div class="stats-grid">
-- <div class="stat-card"><div class="value">${Number(s.total || 0).toLocaleString()}</div><div class="label"><i class="fa-solid fa-paste"></i> Total Pastes</div></div>
-- <div class="stat-card"><div class="value">${Number(s.textPastes || 0).toLocaleString()}</div><div class="label"><i class="fa-solid fa-code"></i> Text Pastes</div></div>
-- <div class="stat-card"><div class="value">${Number(s.screenshots || 0).toLocaleString()}</div><div class="label"><i class="fa-solid fa-image"></i> Screenshots</div></div>
-- <div class="stat-card"><div class="value">${Number(s.forks || 0).toLocaleString()}</div><div class="label"><i class="fa-solid fa-code-fork"></i> Forks</div></div>
-- </div>
-- <div class="card">
-- <h3><i class="fa-solid fa-sliders"></i> Paste Limits</h3>
-- <div style="display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px">
-- <div class="form-field"><label>Max Paste Size (KB)</label><input type="number" id="paste-max-size-kb" value="${esc(cfg.maxSizeKb || 512)}"></div>
-- <div class="form-field"><label>Max Image Size (MB)</label><input type="number" id="paste-screenshot-max-mb" value="${esc(cfg.screenshotMaxSizeMb || 8)}"></div>
-- <div class="form-field"><label>Cooldown (seconds)</label><input type="number" id="paste-cooldown" value="${esc(cfg.cooldownSeconds || 30)}"></div>
-- <div class="form-field"><label>Max / User / Day</label><input type="number" id="paste-max-per-day" value="${esc(cfg.maxPerUserPerDay || 50)}"></div>
-- </div>
-- <div class="setting-row"><label><strong>Allow anonymous pastes</strong></label><input type="checkbox" id="paste-anon" ${cfg.anonAllowed !== false ? 'checked' : ''} style="width:18px;height:18px"></div>
-- <div class="setting-row"><label><strong>Allow image uploads</strong></label><input type="checkbox" id="paste-image-upload" ${cfg.imageUploadEnabled !== false ? 'checked' : ''} style="width:18px;height:18px"></div>
-- <div style="display:flex;gap:10px;flex-wrap:wrap;margin-top:10px">
-- <button class="btn btn-primary" onclick="savePasteAdminConfig()"><i class="fa-solid fa-floppy-disk"></i> Save Paste Settings</button>
-- <button class="btn btn-danger" onclick="deleteAllPasteForks()"><i class="fa-solid fa-trash-can"></i> Delete All Forks (${Number(s.forks || 0).toLocaleString()})</button>
-- </div>
-- </div>`;
-- } catch (e) { c.innerHTML = `<p class="muted">Error: ${esc(e.message)}</p>`; }
--}
--
--async function savePasteAdminConfig() {
-- try {
-- await api('/api/admin/streamer/settings', {
-- method: 'PUT',
-- body: {
-- settings: {
-- paste_max_size_kb: document.getElementById('paste-max-size-kb').value,
-- paste_screenshot_max_size_mb: document.getElementById('paste-screenshot-max-mb').value,
-- paste_cooldown_seconds: document.getElementById('paste-cooldown').value,
-- paste_max_per_user_per_day: document.getElementById('paste-max-per-day').value,
-- paste_anon_allowed: document.getElementById('paste-anon').checked ? 'true' : 'false',
-- paste_image_upload_enabled: document.getElementById('paste-image-upload').checked ? 'true' : 'false',
-- },
-- },
-- });
-- toast('Paste settings saved', 'success');
-- } catch (e) { toast(e.message, 'error'); }
--}
--
--async function deleteAllPasteForks() {
-- if (!confirm('Delete all forked pastes? This cannot be undone.')) return;
-- try {
-- const data = await api('/api/admin/streamer-pastes/admin/forks', { method: 'DELETE' });
-- toast(`Deleted ${Number(data.deleted || 0).toLocaleString()} forked paste(s)`, 'success');
-- loadPastesAdmin();
-- } catch (e) { toast(e.message, 'error'); }
--}
--
--// ═══════════════════════════════════════════════════════════════
--// Storage (via openvibelive proxy)
--// ═══════════════════════════════════════════════════════════════
--async function loadStorage() {
-- const c = document.getElementById('storage-content');
-- c.innerHTML = '<div class="loading"><i class="fa-solid fa-spinner fa-spin"></i> Analyzing storage...</div>';
-- try {
-- const [data, tierData] = await Promise.all([
-- api('/api/admin/streamer/storage'),
-- api('/api/admin/streamer/storage/tiers').catch(() => null),
-- ]);
-- const d = data.disk || {};
-- const usePct = d.total ? ((d.used / d.total) * 100).toFixed(1) : 0;
-- const dataPct = d.total ? ((data.dataTotal?.bytes || 0) / d.total * 100).toFixed(1) : 0;
-- const pctColor = parseFloat(usePct) >= 90 ? 'var(--danger)' : parseFloat(usePct) >= 75 ? 'var(--warning)' : 'var(--success)';
-- const breakdown = (data.breakdown || []).sort((a, b) => b.bytes - a.bytes);
-- const maxB = Math.max(...breakdown.map(b => b.bytes), 1);
--
-- c.innerHTML = `
-- <div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-bottom:24px">
-- <div class="card">
-- <h3><i class="fa-solid fa-hard-drive"></i> Disk Usage</h3>
-- <div style="display:flex;justify-content:space-between;margin-bottom:6px;font-size:14px">
-- <span>${fmtBytes(d.used)} used of ${fmtBytes(d.total)}</span>
-- <span style="font-weight:700;color:${pctColor}">${usePct}%</span>
-- </div>
-- <div class="storage-bar">
-- <div class="storage-bar-fill" style="background:${pctColor};width:${usePct}%"></div>
-- </div>
-- <div style="display:flex;justify-content:space-between;margin-top:8px;font-size:12px;color:var(--text-muted)">
-- <span>${fmtBytes(d.available)} available</span>
-- <span>Mount: ${esc(d.mount || '/')}</span>
-- </div>
-- </div>
-- <div class="card">
-- <h3><i class="fa-solid fa-database"></i> Data Summary</h3>
-- <div class="stats-grid" style="margin:0">
-- <div class="stat-card" style="padding:10px"><div class="value" style="font-size:18px">${fmtBytes(data.dataTotal?.bytes || 0)}</div><div class="label">Total Data</div></div>
-- <div class="stat-card" style="padding:10px"><div class="value" style="font-size:18px">${(data.dataTotal?.files || 0).toLocaleString()}</div><div class="label">Files</div></div>
-- <div class="stat-card" style="padding:10px"><div class="value" style="font-size:18px">${fmtBytes(data.database?.bytes || 0)}</div><div class="label">Database</div></div>
-- <div class="stat-card" style="padding:10px"><div class="value" style="font-size:18px">${dataPct}%</div><div class="label">% of Disk</div></div>
-- </div>
-- </div>
-- </div>
--
-- <h3 style="margin-bottom:12px"><i class="fa-solid fa-folder-tree"></i> Storage Breakdown</h3>
-- <div style="display:grid;gap:8px;margin-bottom:24px">
-- ${breakdown.map(b => `
-- <div style="display:grid;grid-template-columns:140px 1fr 100px 80px;align-items:center;gap:12px;padding:10px 14px;background:var(--bg-card);border:1px solid var(--border);border-radius:8px">
-- <span style="font-weight:600;font-size:13px"><i class="fa-solid ${esc(b.icon || 'fa-folder')}" style="width:18px;text-align:center;margin-right:6px;color:var(--accent)"></i>${esc(b.name)}</span>
-- <div class="storage-bar"><div class="storage-bar-fill" style="background:var(--accent);width:${(b.bytes/maxB*100).toFixed(1)}%"></div></div>
-- <span style="text-align:right;font-weight:600;font-size:12px">${fmtBytes(b.bytes)}</span>
-- <span style="text-align:right;font-size:11px;color:var(--text-muted)">${b.files?.toLocaleString() || 0} files</span>
-- </div>
-- `).join('')}
-- </div>
--
-- <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:12px">
-- <h3><i class="fa-solid fa-video"></i> VOD Management</h3>
-- <div style="display:flex;gap:8px">
-- <select id="vod-sort" onchange="loadVodTable()" style="padding:6px 10px;background:var(--bg-input);color:var(--text);border:1px solid var(--border);border-radius:6px;font-size:12px">
-- <option value="size">Sort by Size</option>
-- <option value="date">Sort by Date</option>
-- <option value="duration">Sort by Duration</option>
-- </select>
-- <button class="btn btn-sm btn-danger" id="vod-bulk-btn" disabled onclick="bulkDeleteVods()"><i class="fa-solid fa-trash-can"></i> Delete Selected</button>
-- </div>
-- </div>
-- <div id="vod-table"><div class="loading">Loading VODs...</div></div>
-- `;
-- loadVodTable();
-- loadStreamerStorageExtras(tierData);
-- } catch (e) { c.innerHTML = `<p class="muted">Error: ${esc(e.message)}</p>`; }
--}
--
--// ── Streamer storage tiers (Local/B2/R2) + cloud usage/cost (openvibe.network admin) ──
--function buildStreamerTierHtml(tierData) {
-- if (!tierData) return '';
-- const t = tierData.tiers || {};
-- const ct = tierData.clipTiers || {};
-- const prov = tierData.providers || {};
-- const ts = tierData.settings || {};
-- const L = t.local || { count: 0, bytes: 0 };
-- const B = t.b2 || { count: 0, bytes: 0 };
-- const R = t.r2 || { count: 0, bytes: 0 };
-- const totalBytes = (L.bytes || 0) + (B.bytes || 0) + (R.bytes || 0);
-- const totalVods = (L.count || 0) + (B.count || 0) + (R.count || 0);
-- const totalClips = ((ct.local && ct.local.count) || 0) + ((ct.b2 && ct.b2.count) || 0) + ((ct.r2 && ct.r2.count) || 0);
-- const offloaded = (B.bytes || 0) + (R.bytes || 0);
-- const offloadPct = totalBytes > 0 ? ((offloaded / totalBytes) * 100).toFixed(1) : '0.0';
-- const pctOf = (b) => totalBytes > 0 ? (b / totalBytes) * 100 : 0;
-- const provStatus = (p) => {
-- if (!p) return '<span style="color:var(--text-muted)">—</span>';
-- if (!p.configured) return '<span style="color:var(--text-muted)">not configured</span>';
-- return p.healthy
-- ? '<span style="color:var(--success,#22c55e)"><i class="fa-solid fa-circle-check"></i> healthy</span>' + (p.bucket ? ' <span style="color:var(--text-muted);font-size:11px">· ' + esc(p.bucket) + '</span>' : '')
-- : '<span style="color:var(--danger,#ef4444)"><i class="fa-solid fa-circle-xmark"></i> error</span>';
-- };
-- const rows = [
-- { label: 'Local (SSD)', icon: 'fa-hard-drive', color: '#f59e0b', v: L, clips: (ct.local && ct.local.count) || 0, status: '<span style="color:var(--text-muted)">primary disk</span>' },
-- { label: 'Backblaze B2', icon: 'fa-box-archive', color: '#e21e2b', v: B, clips: (ct.b2 && ct.b2.count) || 0, status: provStatus(prov.b2) },
-- { label: 'Cloudflare R2', icon: 'fa-cloud', color: '#f6821f', v: R, clips: (ct.r2 && ct.r2.count) || 0, status: provStatus(prov.r2) },
-- ];
-- const bar = '<div style="display:flex;height:16px;border-radius:8px;overflow:hidden;margin:6px 0 14px;background:var(--bg-tertiary)">'
-- + '<div title="Local ' + pctOf(L.bytes).toFixed(1) + '%" style="width:' + pctOf(L.bytes) + '%;background:#f59e0b"></div>'
-- + '<div title="B2 ' + pctOf(B.bytes).toFixed(1) + '%" style="width:' + pctOf(B.bytes) + '%;background:#e21e2b"></div>'
-- + '<div title="R2 ' + pctOf(R.bytes).toFixed(1) + '%" style="width:' + pctOf(R.bytes) + '%;background:#f6821f"></div></div>';
-- const rowHtml = rows.map(r => '<tr>'
-- + '<td style="padding:8px 10px"><i class="fa-solid ' + r.icon + '" style="color:' + r.color + '"></i> <strong>' + r.label + '</strong></td>'
-- + '<td style="padding:8px 10px;text-align:right">' + (r.v.count || 0).toLocaleString() + '</td>'
-- + '<td style="padding:8px 10px;text-align:right">' + fmtBytes(r.v.bytes || 0) + '</td>'
-- + '<td style="padding:8px 10px;text-align:right">' + pctOf(r.v.bytes).toFixed(1) + '%</td>'
-- + '<td style="padding:8px 10px;text-align:right">' + (r.clips || 0).toLocaleString() + '</td>'
-- + '<td style="padding:8px 10px">' + r.status + '</td></tr>').join('');
-- return '<div class="card" style="margin-bottom:24px">'
-- + '<h3 style="margin:0 0 6px"><i class="fa-solid fa-layer-group"></i> Storage Tiers <span style="color:var(--text-muted);font-size:13px;font-weight:400">— ' + offloadPct + '% of VOD data offloaded to cloud</span></h3>'
-- + bar
-- + '<div style="overflow-x:auto"><table style="width:100%;border-collapse:collapse;font-size:13px">'
-- + '<thead><tr style="text-align:left;color:var(--text-muted);border-bottom:1px solid var(--border)">'
-- + '<th style="padding:6px 10px">Tier</th><th style="padding:6px 10px;text-align:right">VODs</th><th style="padding:6px 10px;text-align:right">VOD Size</th><th style="padding:6px 10px;text-align:right">Share</th><th style="padding:6px 10px;text-align:right">Clips</th><th style="padding:6px 10px">Provider</th></tr></thead>'
-- + '<tbody>' + rowHtml
-- + '<tr style="border-top:2px solid var(--border);font-weight:700"><td style="padding:8px 10px">Total</td><td style="padding:8px 10px;text-align:right">' + totalVods.toLocaleString() + '</td><td style="padding:8px 10px;text-align:right">' + fmtBytes(totalBytes) + '</td><td style="padding:8px 10px;text-align:right">100%</td><td style="padding:8px 10px;text-align:right">' + totalClips.toLocaleString() + '</td><td style="padding:8px 10px">' + (tierData.sweepRunning ? '<span style="color:#f59e0b"><i class="fa-solid fa-spinner fa-spin"></i> sweeping</span>' : '<span style="color:var(--text-muted)">idle</span>') + '</td></tr>'
-- + '</tbody></table></div>'
-- + '<div style="display:flex;flex-wrap:wrap;gap:8px;font-size:12px;color:var(--text-muted);margin-top:12px">'
-- + '<span><i class="fa-solid ' + (ts.enabled ? 'fa-toggle-on' : 'fa-toggle-off') + '" style="color:' + (ts.enabled ? '#22c55e' : '#ef4444') + '"></i> Auto-offload: ' + (ts.enabled ? 'ON' : 'OFF') + '</span>'
-- + '<span>| Age &ge; ' + (ts.minAgeDays != null ? ts.minAgeDays : '?') + 'd</span><span>| Views &le; ' + (ts.maxViewsForCold != null ? ts.maxViewsForCold : '?') + '</span><span>| Idle &ge; ' + (ts.minLastAccessDays != null ? ts.minLastAccessDays : '?') + 'd</span>'
-- + '</div></div>';
--}
--
--function loadStreamerStorageExtras(tierData) {
-- const c = document.getElementById('storage-content');
-- if (!c) return;
-- c.insertAdjacentHTML('afterbegin', '<div id="streamer-cloud-storage" style="margin-bottom:24px"></div>');
-- c.insertAdjacentHTML('afterbegin', buildStreamerTierHtml(tierData));
-- loadStreamerCloudStorage();
--}
--
--async function loadStreamerCloudStorage(force) {
-- const el = document.getElementById('streamer-cloud-storage');
-- if (!el) return;
-- const header = '<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:12px">'
-- + '<h3 style="margin:0"><i class="fa-solid fa-cloud"></i> Cloud Storage — B2 &amp; R2 Usage &amp; Cost</h3>'
-- + '<button class="btn btn-sm btn-outline" onclick="loadStreamerCloudStorage(true)"><i class="fa-solid fa-rotate"></i> Rescan</button></div>';
-- el.innerHTML = header + '<div class="loading"><i class="fa-solid fa-spinner fa-spin"></i> Scanning cloud buckets…</div>';
-- try {
-- const data = await api('/api/admin/streamer/storage/buckets' + (force ? '?force=1' : ''));
-- const usage = data.usage || {};
-- const costs = data.costs || {};
-- const money = (n) => '$' + (Number(n) || 0).toFixed(2);
-- const card = (key, label, color) => {
-- const u = usage[key];
-- const cc = costs[key];
-- if (!u || u.configured === false) return '<div class="card" style="flex:1;min-width:260px"><strong style="color:' + color + '"><i class="fa-solid fa-cloud"></i> ' + label + '</strong><div style="color:var(--text-muted);margin-top:8px">Not configured</div></div>';
-- if (u.error) return '<div class="card" style="flex:1;min-width:260px"><strong style="color:' + color + '"><i class="fa-solid fa-cloud"></i> ' + label + '</strong><div style="color:var(--danger,#ef4444);margin-top:8px">Scan error: ' + esc(u.error) + '</div></div>';
-- const tops = Object.entries(u.prefixes || {}).sort((a, b) => b[1].bytes - a[1].bytes).slice(0, 8);
-- const prefixRows = tops.map(([p, v]) => '<div style="display:flex;justify-content:space-between;font-size:12px;color:var(--text-muted);padding:2px 0"><span>' + esc(p) + '</span><span>' + fmtBytes(v.bytes) + ' · ' + v.objects.toLocaleString() + '</span></div>').join('');
-- return '<div class="card" style="flex:1;min-width:260px">'
-- + '<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:8px"><strong style="color:' + color + '"><i class="fa-solid fa-cloud"></i> ' + label + '</strong><span style="color:var(--text-muted);font-size:12px">' + esc(u.bucket || '') + '</span></div>'
-- + '<div style="display:flex;gap:16px;margin-bottom:10px"><div><div style="font-size:20px;font-weight:700">' + fmtBytes(u.bytes) + '</div><div style="color:var(--text-muted);font-size:11px">' + u.objects.toLocaleString() + ' objects</div></div>'
-- + '<div><div style="font-size:20px;font-weight:700;color:' + color + '">' + (cc ? money(cc.storageMonthly) : '—') + '<span style="font-size:12px;font-weight:400;color:var(--text-muted)">/mo</span></div><div style="color:var(--text-muted);font-size:11px">storage @ $' + (cc ? cc.storagePerGbMonth : '?') + '/GB</div></div></div>'
-- + '<div style="color:var(--text-muted);font-size:11px;margin-bottom:6px">Egress: ' + (cc ? esc(cc.egressNote) : '—') + '</div>'
-- + (prefixRows ? '<div style="border-top:1px solid var(--border);padding-top:6px">' + prefixRows + '</div>' : '') + '</div>';
-- };
-- el.innerHTML = header
-- + '<div style="display:flex;gap:16px;flex-wrap:wrap;margin-bottom:12px">' + card('b2', 'Backblaze B2', '#e21e2b') + card('r2', 'Cloudflare R2', '#f6821f') + '</div>'
-- + '<div class="card" style="display:flex;align-items:center;justify-content:space-between"><span><i class="fa-solid fa-file-invoice-dollar" style="color:var(--accent)"></i> <strong>Estimated cloud storage cost</strong> <span style="color:var(--text-muted);font-size:12px">(list prices; excludes egress/operations)</span></span><span style="font-size:22px;font-weight:800;color:var(--accent)">' + money(costs.totalStorageMonthly) + '<span style="font-size:13px;font-weight:400;color:var(--text-muted)">/mo</span></span></div>';
-- } catch (e) {
-- el.innerHTML = header + '<div style="color:var(--danger,#ef4444)">Failed to scan buckets: ' + esc(e.message) + '</div>';
-- }
--}
--
--async function loadVodTable() {
-- const c = document.getElementById('vod-table');
-- if (!c) return;
-- const sort = document.getElementById('vod-sort')?.value || 'size';
-- c.innerHTML = '<div class="loading">Loading...</div>';
-- try {
-- const data = await api(`/api/admin/streamer/storage/vods?sort=${sort}&order=desc&limit=100`);
-- const vods = data.vods || [];
-- if (!vods.length) { c.innerHTML = '<p class="muted">No VODs found</p>'; return; }
--
-- c.innerHTML = `
-- <table class="admin-table">
-- <thead><tr>
-- <th style="width:32px"><input type="checkbox" onchange="toggleAllVods(this.checked)" style="cursor:pointer"></th>
-- <th>Title</th><th>User</th><th style="text-align:right">Size</th><th style="text-align:right">Duration</th><th>Date</th>
-- </tr></thead>
-- <tbody>${vods.map(v => `
-- <tr>
-- <td><input type="checkbox" class="vod-cb" value="${v.id}" onchange="updateVodSelection()" style="cursor:pointer"></td>
-- <td style="max-width:240px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${esc(v.title||'')}">${esc(v.title || '(untitled)')}</td>
-- <td>${esc(v.username || '-')}</td>
-- <td style="text-align:right;font-weight:600;font-variant-numeric:tabular-nums">${fmtBytes(v.diskSize || v.file_size || 0)}</td>
-- <td style="text-align:right;color:var(--text-secondary)">${fmtDuration(v.duration_seconds)}</td>
-- <td>${v.created_at ? timeAgo(v.created_at) : '-'}</td>
-- </tr>
-- `).join('')}</tbody>
-- </table>
-- ${data.total > 100 ? `<p class="muted" style="margin-top:8px;font-size:11px">Showing top 100 of ${data.total.toLocaleString()} VODs</p>` : ''}`;
-- } catch (e) { c.innerHTML = `<p class="muted">Error: ${esc(e.message)}</p>`; }
--}
--
--function toggleAllVods(checked) { document.querySelectorAll('.vod-cb').forEach(cb => cb.checked = checked); updateVodSelection(); }
--function updateVodSelection() {
-- const n = document.querySelectorAll('.vod-cb:checked').length;
-- const btn = document.getElementById('vod-bulk-btn');
-- if (btn) { btn.disabled = n === 0; btn.innerHTML = `<i class="fa-solid fa-trash-can"></i> Delete Selected${n ? ` (${n})` : ''}`; }
--}
--
--async function bulkDeleteVods() {
-- const ids = [...document.querySelectorAll('.vod-cb:checked')].map(cb => parseInt(cb.value));
-- if (!ids.length) return;
-- if (!confirm(`Permanently delete ${ids.length} VOD(s)?`)) return;
-- try {
-- const data = await api('/api/admin/streamer/storage/vods/bulk', { method: 'DELETE', body: { ids } });
-- toast(`Deleted ${data.deleted || 0} VOD(s), freed ${fmtBytes(data.freed || 0)}`, 'success');
-- loadStorage();
-- } catch (e) { toast(e.message, 'error'); }
--}
--
--// ═══════════════════════════════════════════════════════════════
--// Broadcast Notification
--// ═══════════════════════════════════════════════════════════════
--async function sendBroadcast() {
-- const title = document.getElementById('broadcast-title')?.value.trim();
-- const message = document.getElementById('broadcast-msg')?.value.trim();
-- const icon = document.getElementById('broadcast-icon')?.value.trim() || '📢';
-- const priority = document.getElementById('broadcast-priority')?.value || 'normal';
-- const url = document.getElementById('broadcast-url')?.value.trim();
-- if (!title) return toast('Title required', 'error');
-- if (!confirm(`Send broadcast "${title}" to all users?`)) return;
-- try {
-- const data = await api('/api/admin/broadcast', { method: 'POST', body: { title, message, icon, priority, url } });
-- toast(`Broadcast sent to ${data.sent || 0} users`, 'success');
-- document.getElementById('broadcast-title').value = '';
-- document.getElementById('broadcast-msg').value = '';
-- } catch (e) { toast(e.message, 'error'); }
--}
--
--// ═══════════════════════════════════════════════════════════════
--// Email Configuration
--// ═══════════════════════════════════════════════════════════════
--async function loadEmail() {
-- const c = document.getElementById('email-content');
-- c.innerHTML = '<div class="loading">Loading...</div>';
-- try {
-- const data = await api('/api/admin/email');
-- const email = data.email || {};
-- const metrics = data.metrics || {};
-- const summary = metrics.summary || {};
-- const byType = Array.isArray(metrics.by_type) ? metrics.by_type : [];
-- const recent = Array.isArray(metrics.recent) ? metrics.recent : [];
-- c.innerHTML = `
-- <div class="stats-grid" style="margin-bottom:16px">
-- <div class="stat-card"><div class="value">${Number(summary.sent || 0).toLocaleString()}</div><div class="label"><i class="fa-solid fa-paper-plane"></i> Emails Sent</div></div>
-- <div class="stat-card"><div class="value">${Number(summary.failed || 0).toLocaleString()}</div><div class="label"><i class="fa-solid fa-triangle-exclamation"></i> Failed</div></div>
-- <div class="stat-card"><div class="value">${Number(summary.sent_24h || 0).toLocaleString()}</div><div class="label"><i class="fa-solid fa-clock"></i> Sent · 24h</div></div>
-- <div class="stat-card"><div class="value">${Number(summary.password_resets_24h || 0).toLocaleString()}</div><div class="label"><i class="fa-solid fa-key"></i> Password Resets · 24h</div></div>
-- <div class="stat-card"><div class="value">${Number(summary.notifications_24h || 0).toLocaleString()}</div><div class="label"><i class="fa-solid fa-bell"></i> Notification Emails · 24h</div></div>
-- <div class="stat-card"><div class="value">${Number(summary.tests_24h || 0).toLocaleString()}</div><div class="label"><i class="fa-solid fa-vial"></i> Test Emails · 24h</div></div>
-- </div>
--
-- <div class="card" style="padding:16px;margin-bottom:16px">
-- <h3 style="margin-bottom:10px"><i class="fa-solid fa-chart-line"></i> Email Delivery Metrics</h3>
-- <div class="muted" style="display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:10px">
-- <div><strong>Total Logged:</strong> ${Number(summary.total || 0).toLocaleString()}</div>
-- <div><strong>Failed · 24h:</strong> ${Number(summary.failed_24h || 0).toLocaleString()}</div>
-- <div><strong>Last Sent:</strong> ${summary.last_sent_at ? esc(timeAgo(summary.last_sent_at)) : 'Never'}</div>
-- <div><strong>Last Failure:</strong> ${summary.last_failed_at ? esc(timeAgo(summary.last_failed_at)) : 'Never'}</div>
-- </div>
-- </div>
--
-- <form id="email-form" style="max-width:600px;margin-bottom:16px">
-- <div class="setting-row">
-- <label><strong>Email Enabled</strong></label>
-- <input type="checkbox" id="email-enabled" ${email.enabled ? 'checked' : ''} style="width:18px;height:18px;cursor:pointer">
-- </div>
-- ${email.issue ? `<div style="background:#e74c3c22;border:1px solid #e74c3c44;border-radius:8px;padding:10px 14px;margin:8px 0 12px;font-size:13px;color:#e74c3c"><i class="fa-solid fa-triangle-exclamation"></i> ${esc(email.issue)}</div>` : email.ready ? `<div style="background:#27ae6022;border:1px solid #27ae6044;border-radius:8px;padding:10px 14px;margin:8px 0 12px;font-size:13px;color:#27ae60"><i class="fa-solid fa-circle-check"></i> Resend is ready — emails will be delivered</div>` : ''}
--
-- <h4 style="margin:12px 0 10px;font-size:14px"><i class="fa-solid fa-key"></i> Resend API</h4>
-- ${email.api_key_source === 'env'
-- ? `<div class="form-field"><label>API Key</label><input type="password" id="email-api-key" value="" placeholder="Set in the environment (${esc(email.api_key_env || 'RESEND_API_KEY')})" disabled autocomplete="off"></div>
-- <p class="muted" style="margin:-4px 0 8px;font-size:11px">The key comes from <code>${esc(email.api_key_env || 'RESEND_API_KEY')}</code> in the server's environment file and cannot be changed here.</p>`
-- : `<div class="form-field"><label>API Key</label><input type="password" id="email-api-key" value="${esc(email.api_key || '')}" placeholder="re_xxxxxxxxxxxx" autocomplete="off"></div>
-- <p class="muted" style="margin:-4px 0 8px;font-size:11px">Get your API key from <a href="https://resend.com/api-keys" target="_blank" style="color:var(--accent)">resend.com/api-keys</a>. Key is masked after saving.${email.api_key_source === 'database' ? ` Stored in the database; prefer <code>${esc(email.api_key_env || 'RESEND_API_KEY')}</code> in the server's environment file.` : ''}</p>`}
--
-- <h4 style="margin:20px 0 10px;font-size:14px"><i class="fa-solid fa-envelope"></i> From Addresses</h4>
-- <div class="form-field"><label>Default From Email</label><input type="email" id="email-from-email" value="${esc(email.from_email || '')}" placeholder="[email protected]"></div>
-- <div class="form-field"><label>Default From Name</label><input type="text" id="email-from-name" value="${esc(email.from_name || '')}" placeholder="OpenVibe"></div>
-- <div class="form-field"><label>OpenVibe.Live From <span class="muted">(optional override)</span></label><input type="email" id="email-from-openvibelive" value="${esc(email.from_email_openvibelive || '')}" placeholder="[email protected]"></div>
-- <div class="form-field"><label>OpenVibe Quest From <span class="muted">(optional override)</span></label><input type="email" id="email-from-openvibegames" value="${esc(email.from_email_openvibegames || '')}" placeholder="[email protected]"></div>
-- <div class="form-field"><label>OpenVibe Tools From <span class="muted">(optional override)</span></label><input type="email" id="email-from-openvibenetwork" value="${esc(email.from_email_openvibenetwork || '')}" placeholder="[email protected]"></div>
--
-- <div style="display:flex;gap:8px;margin-top:16px">
-- <button type="button" class="btn btn-primary" onclick="saveEmail()"><i class="fa-solid fa-floppy-disk"></i> Save Email Config</button>
-- <button type="button" class="btn btn-outline" onclick="testEmail()"><i class="fa-solid fa-paper-plane"></i> Send Test Email</button>
-- </div>
-- <div style="background:#2a2a3822;border:1px solid #333340;border-radius:8px;padding:12px 14px;margin-top:16px;font-size:12px;color:var(--text-muted)">
-- <strong style="color:var(--text)"><i class="fa-solid fa-globe"></i> DNS Setup</strong><br>
-- Add your domain at <a href="https://resend.com/domains" target="_blank" style="color:var(--accent)">resend.com/domains</a> and add the SPF (TXT + MX) and DKIM records to your DNS provider. Once verified, emails will be sent from your domain.
-- </div>
-- </form>
--
-- <div class="card" style="padding:16px;margin-bottom:16px">
-- <h3 style="margin-bottom:10px"><i class="fa-solid fa-layer-group"></i> Delivery Breakdown · 30 days</h3>
-- ${byType.length ? `
-- <table class="admin-table">
-- <thead><tr><th>Email Type</th><th>Status</th><th>Count</th></tr></thead>
-- <tbody>
-- ${byType.map(row => `
-- <tr>
-- <td>${esc(row.email_type)}</td>
-- <td>${row.status === 'sent' ? '<span class="badge badge-success">Sent</span>' : '<span class="badge badge-danger">Failed</span>'}</td>
-- <td>${Number(row.count || 0).toLocaleString()}</td>
-- </tr>
-- `).join('')}
-- </tbody>
-- </table>`
-- : '<p class="muted">No email activity logged yet.</p>'}
-- </div>
--
-- <div class="card" style="padding:16px">
-- <h3 style="margin-bottom:10px"><i class="fa-solid fa-clock-rotate-left"></i> Recent Email Activity</h3>
-- ${recent.length ? `
-- <table class="admin-table">
-- <thead><tr><th>When</th><th>Type</th><th>Recipient</th><th>Status</th><th>Subject</th><th>Error</th></tr></thead>
-- <tbody>
-- ${recent.map(row => `
-- <tr>
-- <td style="white-space:nowrap">${esc(timeAgo(row.created_at))}</td>
-- <td>${esc(row.email_type)}</td>
-- <td>${esc(row.recipient)}</td>
-- <td>${row.status === 'sent' ? '<span class="badge badge-success">Sent</span>' : '<span class="badge badge-danger">Failed</span>'}</td>
-- <td>${esc(row.subject || '-')}</td>
-- <td style="font-size:11px;color:var(--text-muted)">${esc(row.error_message || '-')}</td>
-- </tr>
-- `).join('')}
-- </tbody>
-- </table>`
-- : '<p class="muted">No email activity logged yet.</p>'}
-- </div>`;
-- } catch (e) { c.innerHTML = `<p class="muted">Error: ${esc(e.message)}</p>`; }
--}
--
--async function saveEmail() {
-- try {
-- await api('/api/admin/email', { method: 'PUT', body: {
-- enabled: document.getElementById('email-enabled').checked,
-- api_key: document.getElementById('email-api-key').disabled ? undefined : document.getElementById('email-api-key').value,
-- from_email: document.getElementById('email-from-email').value,
-- from_name: document.getElementById('email-from-name').value,
-- from_email_openvibelive: document.getElementById('email-from-openvibelive').value,
-- from_email_openvibegames: document.getElementById('email-from-openvibegames').value,
-- from_email_openvibenetwork: document.getElementById('email-from-openvibenetwork').value,
-- }});
-- toast('Email configuration saved', 'success');
-- loadEmail();
-- } catch (e) { toast(e.message, 'error'); }
--}
--
--async function testEmail() {
-- const email = prompt('Send test email to:');
-- if (!email) return;
-- try {
-- const result = await api('/api/admin/email/test', { method: 'POST', body: { email } });
-- if (result.sent) {
-- toast('Test email sent successfully! Check your inbox.', 'success');
-- } else {
-- toast(result.error || 'Send failed — check delivery log', 'error');
-- }
-- } catch (e) { toast(e.message, 'error'); }
--}
--
--// ═══════════════════════════════════════════════════════════════
--// Verification Keys
--// ═══════════════════════════════════════════════════════════════
--const secretStore = {};
--async function loadVKeys() {
-- const c = document.getElementById('vkeys-content');
-- c.innerHTML = '<div class="loading">Loading...</div>';
-- try {
-- const data = await api('/api/admin/verification-keys');
-- const keys = data.keys || [];
-- keys.forEach(k => { if (k.key) secretStore[`vk-${k.id}`] = k.key; });
-- if (!keys.length) { c.innerHTML = '<p class="muted">No verification keys generated yet</p>'; return; }
-- c.innerHTML = `
-- <table class="admin-table">
-- <thead><tr><th>Key</th><th>Reserved Username</th><th>Status</th><th>Note</th><th>Created</th><th>Actions</th></tr></thead>
-- <tbody>${keys.map(k => `
-- <tr>
-- <td><code id="vk-${k.id}" style="font-size:12px;color:var(--text-muted)">••••••••</code></td>
-- <td><strong>${esc(k.target_username)}</strong></td>
-- <td><span class="badge badge-${k.status==='active'?'success':k.status==='used'?'info':'danger'}">${esc(k.status)}</span></td>
-- <td style="font-size:12px">${esc(k.note || '-')}</td>
-- <td style="font-size:11px">${k.created_at ? new Date(k.created_at).toLocaleDateString() : '-'}</td>
-- <td style="display:flex;gap:4px">
-- ${k.status === 'active' ? `
-- <button class="btn btn-sm btn-outline" onclick="revealVKey('vk-${k.id}')"><i class="fa-solid fa-eye"></i></button>
-- <button class="btn btn-sm btn-outline" onclick="copyVKey('vk-${k.id}')"><i class="fa-solid fa-copy"></i></button>
-- <button class="btn btn-sm btn-danger" onclick="revokeVKey('${k.id}')"><i class="fa-solid fa-trash"></i></button>
-- ` : k.status === 'used' ? `<span class="muted">Used by ${esc(k.used_by_name || '?')}</span>` : '<span class="muted">Revoked</span>'}
-- </td>
-- </tr>
-- `).join('')}</tbody>
-- </table>`;
-- } catch (e) { c.innerHTML = `<p class="muted">Error: ${esc(e.message)}</p>`; }
--}
--
--function revealVKey(id) {
-- const el = document.getElementById(id);
-- if (!el) return;
-- const revealed = el.dataset.revealed === 'true';
-- el.textContent = revealed ? '••••••••' : (secretStore[id] || '••••••••');
-- el.dataset.revealed = revealed ? 'false' : 'true';
-- el.style.color = revealed ? 'var(--text-muted)' : 'var(--accent-light)';
--}
--
--function copyVKey(id) {
-- const val = secretStore[id];
-- if (!val) return toast('No key to copy', 'error');
-- navigator.clipboard.writeText(val).then(() => toast('Key copied', 'success')).catch(() => toast('Copy failed', 'error'));
--}
--
--async function generateVKey() {
-- const username = document.getElementById('vkey-username')?.value.trim();
-- const note = document.getElementById('vkey-note')?.value.trim();
-- if (!username) return toast('Enter a username', 'error');
-- try {
-- const data = await api('/api/admin/verification-keys', { method: 'POST', body: { target_username: username, note } });
-- const key = data.key;
-- if (key?.id && key?.key) secretStore[`vk-${key.id}`] = key.key;
-- toast('Verification key generated', 'success');
-- document.getElementById('vkey-username').value = '';
-- document.getElementById('vkey-note').value = '';
-- loadVKeys();
-- } catch (e) { toast(e.message, 'error'); }
--}
--
--async function revokeVKey(id) {
-- if (!confirm('Revoke this verification key?')) return;
-- try {
-- await api(`/api/admin/verification-keys/${id}`, { method: 'DELETE' });
-- toast('Key revoked', 'success');
-- loadVKeys();
-- } catch (e) { toast(e.message, 'error'); }
--}
--
--// ═══════════════════════════════════════════════════════════════
--// Analytics
--// ═══════════════════════════════════════════════════════════════
--let analyticsCurrentSubTab = 'overview';
--let analyticsCharts = {};
--const CHART_COLORS = {
-- accent: '#8b5cf6',
-- accentLight: '#a78bfa',
-- success: '#2ecc71',
-- danger: '#e74c3c',
-- warning: '#f59e0b',
-- info: '#3498db',
-- purple: '#9b59b6',
-- pink: '#e84393',
-- teal: '#00cec9',
-- navy: '#2d3436',
--};
--const PIE_COLORS = ['#8b5cf6', '#3498db', '#2ecc71', '#e74c3c', '#9b59b6', '#f59e0b', '#e84393', '#00cec9', '#1abc9c', '#fd79a8'];
--
--function getAnalyticsDays() {
-- return parseInt(document.getElementById('analytics-period')?.value) || 30;
--}
--
--/** Parse the period select value into {days, hours, qs} */
--function getAnalyticsPeriod() {
-- const val = document.getElementById('analytics-period')?.value || '30';
-- if (val.endsWith('h')) {
-- const hours = parseInt(val);
-- return { days: Math.ceil(hours / 24), hours, qs: `days=${Math.ceil(hours / 24)}&hours=${hours}` };
-- }
-- if (val.endsWith('d')) {
-- const d = parseInt(val);
-- const hours = d * 24;
-- return { days: d, hours, qs: `days=${d}&hours=${hours}` };
-- }
-- const days = parseInt(val) || 30;
-- return { days, hours: null, qs: `days=${days}` };
--}
--
--function fmtBytes(bytes) {
-- if (!bytes || bytes < 0) return '0 B';
-- const units = ['B', 'KB', 'MB', 'GB', 'TB'];
-- let i = 0;
-- let v = bytes;
-- while (v >= 1024 && i < units.length - 1) { v /= 1024; i++; }
-- return v.toFixed(i > 0 ? 1 : 0) + ' ' + units[i];
--}
--
--function showAnalyticsSubTab(tab, btn) {
-- analyticsCurrentSubTab = tab;
-- document.querySelectorAll('#analytics-sub-tabs button').forEach(b => b.classList.remove('active'));
-- btn?.classList.add('active');
-- document.getElementById('analytics-overview').style.display = tab === 'overview' ? 'block' : 'none';
-- const serviceNames = ['live','openvibe-network','tools','games','media'];
-- document.getElementById('analytics-service-detail').style.display = serviceNames.includes(tab) ? 'block' : 'none';
-- document.getElementById('analytics-bots-detail').style.display = tab === 'bots' ? 'block' : 'none';
-- if (tab === 'overview') loadAnalyticsOverview();
-- else if (tab === 'bots') loadBotAnalysis();
-- else loadServiceAnalytics(tab);
-- _syncAdminUrl();
--}
--
--function reloadAnalytics() {
-- if (analyticsCurrentSubTab === 'overview') loadAnalyticsOverview();
-- else if (analyticsCurrentSubTab === 'bots') loadBotAnalysis();
-- else loadServiceAnalytics(analyticsCurrentSubTab);
-- _syncAdminUrl(true); // period lives in ?last= — replace, don't stack history
--}
--
--async function loadAnalytics() {
-- loadAnalyticsOverview();
--}
--
--function destroyChart(id) {
-- if (analyticsCharts[id]) { analyticsCharts[id].destroy(); delete analyticsCharts[id]; }
--}
--
--function createChart(canvasId, config) {
-- destroyChart(canvasId);
-- const ctx = document.getElementById(canvasId);
-- if (!ctx) return null;
-- // Default dark theme
-- config.options = config.options || {};
-- config.options.responsive = true;
-- config.options.maintainAspectRatio = true;
-- config.options.plugins = config.options.plugins || {};
-- config.options.plugins.legend = config.options.plugins.legend || { labels: { color: '#b0b0b8', font: { size: 11 } } };
-- if (config.options.scales) {
-- for (const [, axis] of Object.entries(config.options.scales)) {
-- axis.ticks = axis.ticks || {};
-- axis.ticks.color = axis.ticks.color || '#707080';
-- axis.grid = axis.grid || {};
-- axis.grid.color = axis.grid.color || 'rgba(42,42,58,0.5)';
-- }
-- }
-- analyticsCharts[canvasId] = new Chart(ctx, config);
-- return analyticsCharts[canvasId];
--}
--
--function fmtNum(n) { return (n || 0).toLocaleString(); }
--
--async function loadAnalyticsOverview() {
-- const period = getAnalyticsPeriod();
-- const statsEl = document.getElementById('analytics-stats');
-- statsEl.innerHTML = '<div class="loading">Loading analytics...</div>';
-- try {
-- const data = await api(`/api/admin/analytics/overview?${period.qs}`);
-- const ov = data.overview;
-- const t = ov.totals || {};
-- const bw = ov.bandwidth || {};
--
-- statsEl.innerHTML = [
-- { icon: 'fa-eye', label: 'Page Views', value: fmtNum(t.total_pageviews) },
-- { icon: 'fa-code', label: 'API Calls', value: fmtNum(t.total_api_calls) },
-- { icon: 'fa-users', label: 'Unique Visitors', value: fmtNum(t.total_unique_visitors) },
-- { icon: 'fa-id-badge', label: 'Sessions', value: fmtNum(ov.sessionCount || 0) },
-- { icon: 'fa-robot', label: 'Bot Hits', value: fmtNum(t.total_bot_hits) },
-- { icon: 'fa-circle-exclamation', label: 'Errors', value: fmtNum(t.total_errors) },
-- { icon: 'fa-gauge-high', label: 'Avg Response', value: (t.avg_response_ms || 0) + 'ms' },
-- { icon: 'fa-arrow-up-from-bracket', label: 'Est. Bandwidth', value: fmtBytes(bw.estimated_bytes) },
-- ].map(s => `<div class="stat-card"><div class="value">${s.value}</div><div class="label"><i class="fa-solid ${s.icon}"></i> ${s.label}</div></div>`).join('');
--
-- // Real-time card
-- const rt = ov.realtime || {};
-- const rtCard = document.getElementById('analytics-realtime-card');
-- if (rt.requests > 0) {
-- rtCard.style.display = 'block';
-- document.getElementById('analytics-realtime').innerHTML = `
-- <div style="display:flex;gap:24px;font-size:14px">
-- <span><strong style="color:var(--accent-light)">${fmtNum(rt.requests)}</strong> requests</span>
-- <span><strong style="color:var(--success)">${fmtNum(rt.visitors)}</strong> visitors</span>
-- <span><strong style="color:var(--danger)">${fmtNum(rt.bots)}</strong> bots</span>
-- </div>`;
-- } else {
-- rtCard.style.display = 'none';
-- }
--
-- // Traffic trend chart — use timeBuckets for sub-day, dailyTrend for multi-day
-- const buckets = ov.timeBuckets || [];
-- const trend = ov.dailyTrend || [];
-- const useSubDay = period.hours && period.hours < 24 && buckets.length > 0;
-- const trendData = useSubDay ? buckets : trend;
-- const trendLabels = useSubDay
-- ? trendData.map(d => { const parts = d.bucket?.split(' '); return parts?.[1] || d.bucket; })
-- : trendData.map(d => d.date?.slice(5) || '');
-- if (trendData.length) {
-- createChart('chart-traffic-trend', {
-- type: 'line',
-- data: {
-- labels: trendLabels,
-- datasets: [
-- { label: 'Page Views', data: trendData.map(d => d.pageviews), borderColor: CHART_COLORS.accent, backgroundColor: 'rgba(139,92,246,0.1)', fill: true, tension: 0.3 },
-- { label: 'API Calls', data: trendData.map(d => d.api_calls), borderColor: CHART_COLORS.info, backgroundColor: 'rgba(52,152,219,0.1)', fill: true, tension: 0.3 },
-- { label: 'Visitors', data: trendData.map(d => d.unique_visitors), borderColor: CHART_COLORS.success, backgroundColor: 'rgba(46,204,113,0.1)', fill: true, tension: 0.3 },
-- ],
-- },
-- options: { scales: { x: {}, y: { beginAtZero: true } } },
-- });
-- }
--
-- // Service comparison bar chart
-- const svcs = ov.services || [];
-- if (svcs.length) {
-- createChart('chart-service-compare', {
-- type: 'bar',
-- data: {
-- labels: svcs.map(s => s.label || s.name),
-- datasets: [
-- { label: 'Page Views', data: svcs.map(s => s.total_pageviews || 0), backgroundColor: CHART_COLORS.accent },
-- { label: 'API Calls', data: svcs.map(s => s.total_api_calls || 0), backgroundColor: CHART_COLORS.info },
-- { label: 'Bot Hits', data: svcs.map(s => s.total_bot_hits || 0), backgroundColor: CHART_COLORS.danger },
-- ],
-- },
-- options: { scales: { x: {}, y: { beginAtZero: true } } },
-- });
-- }
--
-- // ── Unique Visitors Trend (dedicated chart) ──────────
-- if (trendData.length) {
-- createChart('chart-visitors-trend', {
-- type: 'line',
-- data: {
-- labels: trendLabels,
-- datasets: [
-- { label: 'Unique Visitors', data: trendData.map(d => d.unique_visitors || 0), borderColor: CHART_COLORS.success, backgroundColor: 'rgba(46,204,113,0.15)', fill: true, tension: 0.3, pointRadius: 3 },
-- ],
-- },
-- options: {
-- scales: { x: {}, y: { beginAtZero: true } },
-- plugins: { legend: { display: false } },
-- },
-- });
-- }
--
-- // ── Logged-in vs Anonymous Trend ─────────────────────
-- const authTrend = ov.authTrend || [];
-- if (authTrend.length) {
-- const authLabels = useSubDay
-- ? authTrend.map(d => { const parts = d.bucket?.split(' '); return parts?.[1] || d.bucket; })
-- : authTrend.map(d => d.bucket?.slice(5) || '');
-- createChart('chart-auth-trend', {
-- type: 'line',
-- data: {
-- labels: authLabels,
-- datasets: [
-- { label: 'Logged-in Visitors', data: authTrend.map(d => d.auth_visitors || 0), borderColor: CHART_COLORS.accent, backgroundColor: 'rgba(139,92,246,0.15)', fill: true, tension: 0.3, pointRadius: 2 },
-- { label: 'Anonymous Visitors', data: authTrend.map(d => d.anon_visitors || 0), borderColor: '#6c6c80', backgroundColor: 'rgba(108,108,128,0.15)', fill: true, tension: 0.3, pointRadius: 2 },
-- ],
-- },
-- options: { scales: { x: {}, y: { beginAtZero: true, stacked: true } }, plugins: { legend: { position: 'bottom' } } },
-- });
-- }
--
-- // ── Per-Service Traffic Share (doughnut) ─────────────
-- if (svcs.length) {
-- const svcTraffic = svcs.map(s => (s.total_pageviews || 0) + (s.total_api_calls || 0)).filter(v => v > 0);
-- const svcLabelsFiltered = svcs.filter((s, i) => svcTraffic.length > i && ((s.total_pageviews || 0) + (s.total_api_calls || 0)) > 0);
-- if (svcTraffic.length) {
-- createChart('chart-service-share', {
-- type: 'doughnut',
-- data: {
-- labels: svcLabelsFiltered.map(s => s.label || s.name),
-- datasets: [{ data: svcLabelsFiltered.map(s => (s.total_pageviews || 0) + (s.total_api_calls || 0)), backgroundColor: PIE_COLORS.slice(0, svcLabelsFiltered.length), borderWidth: 0 }],
-- },
-- options: { plugins: { legend: { position: 'bottom' } } },
-- });
-- }
--
-- // Per-service visitors doughnut
-- const svcVis = svcs.filter(s => (s.total_unique_visitors || 0) > 0);
-- if (svcVis.length) {
-- createChart('chart-service-visitors', {
-- type: 'doughnut',
-- data: {
-- labels: svcVis.map(s => s.label || s.name),
-- datasets: [{ data: svcVis.map(s => s.total_unique_visitors || 0), backgroundColor: PIE_COLORS.slice(0, svcVis.length), borderWidth: 0 }],
-- },
-- options: { plugins: { legend: { position: 'bottom' } } },
-- });
-- }
-- }
--
-- // ── Per-Service Traffic Over Time (stacked area) ─────
-- const perSvcTrend = ov.perServiceTrend || [];
-- if (perSvcTrend.length) {
-- // Build unified labels from all series
-- const labelSet = new Set();
-- for (const s of perSvcTrend) {
-- for (const d of s.data) labelSet.add(d.date || d.bucket);
-- }
-- const labels = Array.from(labelSet).sort();
-- const fmtLabel = (l) => useSubDay ? (l.split(' ')[1] || l) : (l.slice(5) || l);
-- const datasets = perSvcTrend.map((s, i) => {
-- const dataMap = new Map(s.data.map(d => [d.date || d.bucket, (d.pageviews || 0) + (d.api_calls || 0)]));
-- const color = PIE_COLORS[i % PIE_COLORS.length];
-- return {
-- label: s.label || s.name,
-- data: labels.map(l => dataMap.get(l) || 0),
-- borderColor: color,
-- backgroundColor: color + '33',
-- fill: true,
-- tension: 0.3,
-- pointRadius: 1,
-- };
-- });
-- createChart('chart-per-service-trend', {
-- type: 'line',
-- data: { labels: labels.map(fmtLabel), datasets },
-- options: { scales: { x: {}, y: { beginAtZero: true, stacked: true } }, plugins: { legend: { position: 'bottom' } } },
-- });
-- }
--
-- // ── Service table with auth breakdown columns ────────
-- const tblEl = document.getElementById('analytics-service-table');
-- tblEl.innerHTML = svcs.length ? `
-- <table class="admin-table">
-- <thead><tr><th>Service</th><th>Views</th><th>API</th><th>Visitors</th><th>Logged-in</th><th>Anon</th><th>Bots</th><th>Errors</th><th>Avg ms</th></tr></thead>
-- <tbody>${svcs.map(s => {
-- const auth = s.authBreakdown || {};
-- return `
-- <tr>
-- <td><strong>${esc(s.label || s.name)}</strong></td>
-- <td>${fmtNum(s.total_pageviews)}</td>
-- <td>${fmtNum(s.total_api_calls)}</td>
-- <td>${fmtNum(s.total_unique_visitors)}</td>
-- <td style="color:var(--accent-light)">${fmtNum(auth.unique_authenticated_users)}</td>
-- <td style="color:var(--text-secondary)">${fmtNum(auth.anonymous)}</td>
-- <td>${fmtNum(s.total_bot_hits)}</td>
-- <td>${fmtNum(s.total_errors)}</td>
-- <td>${s.avg_response_ms || 0}ms</td>
-- </tr>`;
-- }).join('')}</tbody>
-- </table>` : '<p class="muted">No data yet</p>';
--
-- // Bot ratio donut
-- const totalHuman = (t.total_pageviews || 0) + (t.total_api_calls || 0);
-- const totalBots = t.total_bot_hits || 0;
-- if (totalHuman > 0 || totalBots > 0) {
-- createChart('chart-bot-ratio', {
-- type: 'doughnut',
-- data: {
-- labels: ['Human Traffic', 'Bot Traffic'],
-- datasets: [{ data: [totalHuman, totalBots], backgroundColor: [CHART_COLORS.success, CHART_COLORS.danger], borderWidth: 0 }],
-- },
-- options: { plugins: { legend: { position: 'bottom' } } },
-- });
-- }
--
-- // Bandwidth & usage card
-- const bwEl = document.getElementById('analytics-bandwidth');
-- bwEl.innerHTML = `
-- <div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;font-size:13px">
-- <div><strong style="color:var(--accent-light)">${fmtNum(bw.total_requests)}</strong><br><span class="muted">Total Requests</span></div>
-- <div><strong style="color:var(--accent-light)">${fmtBytes(bw.estimated_bytes)}</strong><br><span class="muted">Est. Transfer</span></div>
-- <div><strong style="color:var(--info)">${fmtNum(bw.page_requests)}</strong><br><span class="muted">Page Loads</span></div>
-- <div><strong style="color:var(--info)">${fmtNum(bw.api_requests)}</strong><br><span class="muted">API Requests</span></div>
-- <div><strong style="color:var(--success)">${fmtNum(ov.sessionCount || 0)}</strong><br><span class="muted">Unique Sessions</span></div>
-- <div><strong style="color:var(--success)">${fmtNum(t.total_unique_users || 0)}</strong><br><span class="muted">Logged-in Users</span></div>
-- </div>`;
--
-- } catch (e) {
-- statsEl.innerHTML = `<p class="muted">Error: ${esc(e.message)}</p>`;
-- }
--}
--
--async function loadServiceAnalytics(serviceName) {
-- const period = getAnalyticsPeriod();
-- const statsEl = document.getElementById('svc-analytics-stats');
-- statsEl.innerHTML = '<div class="loading">Loading...</div>';
-- try {
-- const data = await api(`/api/admin/analytics/service/${serviceName}?${period.qs}`);
-- const a = data.analytics;
-- const s = a.summary || {};
-- const bw = a.bandwidth || {};
-- const auth = a.authBreakdown || {};
-- const vis = a.visitorTypes || {};
-- const rp = a.responsePercentiles || {};
--
-- // Stat cards — more metrics
-- statsEl.innerHTML = [
-- { icon: 'fa-eye', label: 'Page Views', value: fmtNum(s.total_pageviews) },
-- { icon: 'fa-code', label: 'API Calls', value: fmtNum(s.total_api_calls) },
-- { icon: 'fa-users', label: 'Unique Visitors', value: fmtNum(s.total_unique_visitors) },
-- { icon: 'fa-user', label: 'Logged-in Users', value: fmtNum(s.total_unique_users) },
-- { icon: 'fa-id-badge', label: 'Sessions', value: fmtNum(a.sessionCount || 0) },
-- { icon: 'fa-robot', label: 'Bot Hits', value: fmtNum(s.total_bot_hits) },
-- { icon: 'fa-circle-exclamation', label: 'Errors', value: fmtNum(s.total_errors) },
-- { icon: 'fa-gauge-high', label: 'Avg Response', value: (s.avg_response_ms || 0) + 'ms' },
-- { icon: 'fa-arrow-up-from-bracket', label: 'Est. Bandwidth', value: fmtBytes(bw.estimated_bytes) },
-- // New vs returning needs a visitor identity that outlives a day; ADR-021 analytics keeps none.
-- vis.new_visitors != null ? { icon: 'fa-user-plus', label: 'New Visitors', value: fmtNum(vis.new_visitors) } : null,
-- ].filter(Boolean).map(s => `<div class="stat-card"><div class="value">${s.value}</div><div class="label"><i class="fa-solid ${s.icon}"></i> ${s.label}</div></div>`).join('');
--
-- // For sub-day ranges, use timeBuckets for the "daily" chart
-- const isSubDay = period.hours && period.hours < 24;
-- const buckets = a.timeBuckets || [];
--
-- // Daily / fine-grained traffic chart
-- if (isSubDay && buckets.length) {
-- document.getElementById('svc-hourly-title').textContent = 'Traffic (' + period.hours + 'h)';
-- createChart('chart-svc-daily', {
-- type: 'line',
-- data: {
-- labels: buckets.map(b => { const parts = b.bucket?.split(' '); return parts?.[1] || b.bucket; }),
-- datasets: [
-- { label: 'Page Views', data: buckets.map(b => b.pageviews), borderColor: CHART_COLORS.accent, backgroundColor: 'rgba(139,92,246,0.1)', fill: true, tension: 0.3, pointRadius: 2 },
-- { label: 'API Calls', data: buckets.map(b => b.api_calls), borderColor: CHART_COLORS.info, tension: 0.3, pointRadius: 2 },
-- { label: 'Visitors', data: buckets.map(b => b.unique_visitors), borderColor: CHART_COLORS.success, tension: 0.3, pointRadius: 2 },
-- { label: 'Bots', data: buckets.map(b => b.bot_hits), borderColor: CHART_COLORS.danger, borderDash: [5,5], tension: 0.3, pointRadius: 1 },
-- ],
-- },
-- options: { scales: { x: {}, y: { beginAtZero: true } } },
-- });
-- } else {
-- document.getElementById('svc-hourly-title').textContent = 'Hourly (24h)';
-- const daily = a.daily || [];
-- if (daily.length) {
-- createChart('chart-svc-daily', {
-- type: 'line',
-- data: {
-- labels: daily.map(d => d.date.slice(5)),
-- datasets: [
-- { label: 'Page Views', data: daily.map(d => d.pageviews), borderColor: CHART_COLORS.accent, backgroundColor: 'rgba(139,92,246,0.1)', fill: true, tension: 0.3, pointRadius: 2 },
-- { label: 'API Calls', data: daily.map(d => d.api_calls), borderColor: CHART_COLORS.info, tension: 0.3, pointRadius: 2 },
-- { label: 'Visitors', data: daily.map(d => d.unique_visitors), borderColor: CHART_COLORS.success, tension: 0.3, pointRadius: 2 },
-- { label: 'Bots', data: daily.map(d => d.bot_hits), borderColor: CHART_COLORS.danger, borderDash: [5,5], tension: 0.3, pointRadius: 1 },
-- ],
-- },
-- options: { scales: { x: {}, y: { beginAtZero: true } } },
-- });
-- }
-- }
--
-- // Hourly bar chart
-- const hourly = a.hourly || [];
-- if (hourly.length) {
-- createChart('chart-svc-hourly', {
-- type: 'bar',
-- data: {
-- labels: hourly.map(h => h.hour?.slice(11, 16) || ''),
-- datasets: [
-- { label: 'Page Views', data: hourly.map(h => h.pageviews), backgroundColor: 'rgba(139,92,246,0.7)' },
-- { label: 'API Calls', data: hourly.map(h => h.api_calls), backgroundColor: 'rgba(52,152,219,0.7)' },
-- ],
-- },
-- options: { scales: { x: {}, y: { beginAtZero: true } }, plugins: { legend: { position: 'bottom' } } },
-- });
-- }
--
-- // Response time percentiles
-- const rpEl = document.getElementById('svc-response-percentiles');
-- if (rp.p50 !== undefined) {
-- const pctBar = (label, val, max) => {
-- const pct = max > 0 ? Math.min(100, (val / max) * 100) : 0;
-- const color = val < 100 ? 'var(--success)' : val < 300 ? 'var(--accent-light)' : val < 1000 ? 'var(--warning)' : 'var(--danger)';
-- return `<div style="margin:6px 0"><div style="display:flex;justify-content:space-between;font-size:12px;margin-bottom:2px"><span class="muted">${label}</span><strong>${val}ms</strong></div><div class="storage-bar"><div class="storage-bar-fill" style="width:${pct}%;background:${color}"></div></div></div>`;
-- };
-- rpEl.innerHTML = pctBar('Median (p50)', rp.p50, rp.max) + pctBar('p90', rp.p90, rp.max) + pctBar('p95', rp.p95, rp.max) + pctBar('p99', rp.p99, rp.max) + pctBar('Max', rp.max, rp.max);
-- } else {
-- rpEl.innerHTML = '<p class="muted">No response time data</p>';
-- }
--
-- // Peak hours chart
-- const peakHours = a.peakHours || [];
-- if (peakHours.length) {
-- createChart('chart-svc-peak-hours', {
-- type: 'bar',
-- data: {
-- labels: peakHours.map(h => h.hour_of_day + ':00'),
-- datasets: [
-- { label: 'Hits', data: peakHours.map(h => h.hits), backgroundColor: 'rgba(139,92,246,0.7)' },
-- { label: 'Visitors', data: peakHours.map(h => h.visitors), backgroundColor: 'rgba(46,204,113,0.5)' },
-- ],
-- },
-- options: { scales: { x: {}, y: { beginAtZero: true } }, plugins: { legend: { position: 'bottom' } } },
-- });
-- }
--
-- // Devices pie
-- const devices = a.deviceBreakdown || [];
-- if (devices.length) {
-- createChart('chart-svc-devices', {
-- type: 'doughnut',
-- data: {
-- labels: devices.map(d => d.device_type || 'unknown'),
-- datasets: [{ data: devices.map(d => d.cnt), backgroundColor: PIE_COLORS.slice(0, devices.length), borderWidth: 0 }],
-- },
-- options: { plugins: { legend: { position: 'bottom' } } },
-- });
-- }
--
-- // Browsers pie
-- const browsers = a.browserBreakdown || [];
-- if (browsers.length) {
-- createChart('chart-svc-browsers', {
-- type: 'doughnut',
-- data: {
-- labels: browsers.map(d => d.browser || 'unknown'),
-- datasets: [{ data: browsers.map(d => d.cnt), backgroundColor: PIE_COLORS.slice(0, browsers.length), borderWidth: 0 }],
-- },
-- options: { plugins: { legend: { position: 'bottom' } } },
-- });
-- }
--
-- // OS pie
-- const oses = a.osBreakdown || [];
-- if (oses.length) {
-- createChart('chart-svc-os', {
-- type: 'doughnut',
-- data: {
-- labels: oses.map(d => d.os || 'unknown'),
-- datasets: [{ data: oses.map(d => d.cnt), backgroundColor: PIE_COLORS.slice(0, oses.length), borderWidth: 0 }],
-- },
-- options: { plugins: { legend: { position: 'bottom' } } },
-- });
-- }
--
-- // Bandwidth & sessions card
-- const bsEl = document.getElementById('svc-bandwidth-sessions');
-- bsEl.innerHTML = `
-- <div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;font-size:13px">
-- <div><strong style="color:var(--accent-light)">${fmtBytes(bw.estimated_bytes)}</strong><br><span class="muted">Est. Transfer</span></div>
-- <div><strong style="color:var(--accent-light)">${fmtNum(bw.total_requests)}</strong><br><span class="muted">Total Requests</span></div>
-- <div><strong style="color:var(--info)">${fmtNum(bw.page_requests)}</strong><br><span class="muted">Page Loads</span></div>
-- <div><strong style="color:var(--info)">${fmtNum(bw.api_requests)}</strong><br><span class="muted">API Requests</span></div>
-- <div><strong style="color:var(--success)">${fmtNum(a.sessionCount || 0)}</strong><br><span class="muted">Sessions</span></div>
-- ${vis.new_visitors != null
-- ? `<div><strong style="color:var(--success)">${fmtNum(vis.new_visitors)}</strong> new / <strong>${fmtNum(vis.returning_visitors)}</strong> returning<br><span class="muted">Visitors</span></div>`
-- : '<div><strong class="muted">Not measured</strong><br><span class="muted" title="ADR-021: no visitor identity outlives a day">New / returning</span></div>'}
-- </div>`;
--
-- // Auth vs anonymous chart
-- const authCount = auth.authenticated || 0;
-- const anonCount = auth.anonymous || 0;
-- if (authCount > 0 || anonCount > 0) {
-- createChart('chart-svc-auth', {
-- type: 'doughnut',
-- data: {
-- labels: ['Authenticated (' + fmtNum(auth.unique_authenticated_users) + ' users)', 'Anonymous'],
-- datasets: [{ data: [authCount, anonCount], backgroundColor: [CHART_COLORS.accent, '#4a4a5a'], borderWidth: 0 }],
-- },
-- options: { plugins: { legend: { position: 'bottom' } } },
-- });
-- }
--
-- // Top pages
-- const pages = a.topPages || [];
-- document.getElementById('svc-top-pages').innerHTML = pages.length ? `
-- <table class="admin-table">
-- <thead><tr><th>Path</th><th>Hits</th><th>Visitors</th></tr></thead>
-- <tbody>${pages.slice(0, 15).map(p => `
-- <tr><td style="max-width:240px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${esc(p.path)}">${esc(p.path)}</td><td>${fmtNum(p.hits)}</td><td>${fmtNum(p.visitors)}</td></tr>
-- `).join('')}</tbody>
-- </table>` : '<p class="muted">No data</p>';
--
-- // Top API endpoints
-- const apiEndpoints = a.topApiEndpoints || [];
-- document.getElementById('svc-top-api').innerHTML = apiEndpoints.length ? `
-- <table class="admin-table">
-- <thead><tr><th>Endpoint</th><th>Method</th><th>Hits</th><th>Avg ms</th><th>Errors</th></tr></thead>
-- <tbody>${apiEndpoints.slice(0, 15).map(e => `
-- <tr>
-- <td style="max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${esc(e.path)}">${esc(e.path)}</td>
-- <td><span class="badge badge-info">${esc(e.method)}</span></td>
-- <td>${fmtNum(e.hits)}</td>
-- <td>${e.avg_ms || 0}ms</td>
-- <td style="color:${e.errors > 0 ? 'var(--danger)' : 'inherit'}">${fmtNum(e.errors)}</td>
-- </tr>
-- `).join('')}</tbody>
-- </table>` : '<p class="muted">No API data</p>';
--
-- // Referers
-- const refs = a.topReferers || [];
-- document.getElementById('svc-top-referers').innerHTML = refs.length ? `
-- <table class="admin-table">
-- <thead><tr><th>Referer</th><th>Hits</th></tr></thead>
-- <tbody>${refs.slice(0, 10).map(r => `
-- <tr><td style="max-width:300px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${esc(r.referer)}">${esc(r.referer)}</td><td>${fmtNum(r.hits)}</td></tr>
-- `).join('')}</tbody>
-- </table>` : '<p class="muted">No referers tracked</p>';
--
-- // Slowest endpoints
-- const slow = a.slowestEndpoints || [];
-- document.getElementById('svc-slowest').innerHTML = slow.length ? `
-- <table class="admin-table">
-- <thead><tr><th>Endpoint</th><th>Method</th><th>Avg ms</th><th>Max ms</th><th>Hits</th></tr></thead>
-- <tbody>${slow.slice(0, 10).map(e => `
-- <tr>
-- <td style="max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${esc(e.path)}">${esc(e.path)}</td>
-- <td><span class="badge badge-warning">${esc(e.method)}</span></td>
-- <td style="color:${e.avg_ms > 500 ? 'var(--danger)' : e.avg_ms > 200 ? 'var(--warning)' : 'inherit'}">${e.avg_ms || 0}ms</td>
-- <td style="color:var(--danger)">${e.max_ms || 0}ms</td>
-- <td>${fmtNum(e.hits)}</td>
-- </tr>
-- `).join('')}</tbody>
-- </table>` : '<p class="muted">No data</p>';
--
-- // Status codes
-- const statuses = a.statusCodes || [];
-- if (statuses.length) {
-- const statusColors = { '2xx': CHART_COLORS.success, '3xx': CHART_COLORS.info, '4xx': CHART_COLORS.warning, '5xx': CHART_COLORS.danger, 'other': '#707080' };
-- createChart('chart-svc-status', {
-- type: 'bar',
-- data: {
-- labels: statuses.map(s => s.group_code),
-- datasets: [{ label: 'Count', data: statuses.map(s => s.cnt), backgroundColor: statuses.map(s => statusColors[s.group_code] || '#707080') }],
-- },
-- options: { scales: { x: {}, y: { beginAtZero: true } }, plugins: { legend: { display: false } } },
-- });
-- }
--
-- // Countries
-- const countries = a.countryBreakdown || [];
-- document.getElementById('svc-countries').innerHTML = countries.length ? `
-- <table class="admin-table">
-- <thead><tr><th>Country</th><th>Hits</th></tr></thead>
-- <tbody>${countries.slice(0, 15).map(c => `
-- <tr><td>${esc(c.country || 'Unknown')}</td><td>${fmtNum(c.cnt)}</td></tr>
-- `).join('')}</tbody>
-- </table>` : '<p class="muted">No country data (requires Cloudflare CF-IPCountry header)</p>';
-- } catch (e) {
-- statsEl.innerHTML = `<p class="muted">Error: ${esc(e.message)}</p>`;
-- }
--}
--
--async function loadBotAnalysis() {
-- const period = getAnalyticsPeriod();
-- const el = document.getElementById('bots-content');
-- el.innerHTML = '<div class="loading">Loading bot analysis...</div>';
-- try {
-- const data = await api(`/api/admin/analytics/bots?${period.qs}`);
-- const bots = data.bots || {};
-- let html = '';
--
-- for (const [svcName, svcBots] of Object.entries(bots)) {
-- if (!svcBots) continue;
-- const label = svcName === 'live' ? 'OpenVibe.Live' : svcName === 'openvibe-network' ? 'OpenVibe.Network' : svcName === 'tools' ? 'OpenVibe.Tools' : svcName === 'games' ? 'OpenVibe.Games' : svcName === 'media' ? 'OpenVibe.Media' : svcName;
--
-- html += `<div class="card"><h3><i class="fa-solid fa-robot"></i> ${esc(label)}</h3>`;
--
-- // Bot types
-- const types = svcBots.botTypes || [];
-- if (types.length) {
-- html += `<h4 style="font-size:12px;color:var(--text-muted);margin:12px 0 8px">Bot Types</h4>
-- <table class="admin-table"><thead><tr><th>Type</th><th>Hits</th><th>Sessions</th></tr></thead>
-- <tbody>${types.map(t => `<tr><td><span class="badge badge-danger">${esc(t.bot_type || 'unknown')}</span></td><td>${fmtNum(t.hits)}</td><td>${t.unique_sessions != null ? fmtNum(t.unique_sessions) : '-'}</td></tr>`).join('')}</tbody></table>`;
-- }
--
-- // Top bots by user-agent class (ADR-021: analytics stores no IP addresses)
-- const topBots = svcBots.topBotIPs || [];
-- if (topBots.length) {
-- html += `<h4 style="font-size:12px;color:var(--text-muted);margin:12px 0 8px">Top Bots (user-agent class)</h4>
-- <table class="admin-table"><thead><tr><th>User-agent class</th><th>Hits</th><th>Sessions</th><th>Type</th><th>First Seen</th><th>Last Seen</th></tr></thead>
-- <tbody>${topBots.slice(0, 15).map(b => `
-- <tr><td style="font-family:monospace;font-size:11px">${esc(b.ua_class || '-')}</td><td>${fmtNum(b.hits)}</td>
-- <td>${b.sessions != null ? fmtNum(b.sessions) : '-'}</td>
-- <td><span class="badge badge-warning">${esc(b.bot_type || '?')}</span></td>
-- <td style="font-size:11px">${b.first_seen ? timeAgo(b.first_seen) : '-'}</td>
-- <td style="font-size:11px">${b.last_seen ? timeAgo(b.last_seen) : '-'}</td></tr>
-- `).join('')}</tbody></table>`;
-- }
--
-- // High-volume sessions classified as human (rotating session ids, never an IP or user id)
-- const suspicious = svcBots.suspiciousIPs || [];
-- if (suspicious.length) {
-- html += `<h4 style="font-size:12px;color:var(--text-muted);margin:12px 0 8px">Suspicious Sessions (high volume, classified as human)</h4>
-- <table class="admin-table"><thead><tr><th>Session</th><th>Total Hits</th><th>Errors</th><th>Unique Paths</th><th>Period</th></tr></thead>
-- <tbody>${suspicious.map(s => `
-- <tr><td style="font-family:monospace;font-size:11px">${esc(s.session_id || '-')}</td><td>${fmtNum(s.total_hits)}</td>
-- <td style="color:var(--danger)">${fmtNum(s.error_hits)}</td><td>${fmtNum(s.unique_paths)}</td>
-- <td style="font-size:11px">${s.first_seen ? timeAgo(s.first_seen) : '-'} — ${s.last_seen ? timeAgo(s.last_seen) : '-'}</td></tr>
-- `).join('')}</tbody></table>`;
-- }
--
-- // Bot trend
-- const trend = svcBots.botTrend || [];
-- if (trend.length) {
-- const canvasId = `chart-bot-trend-${svcName}`;
-- html += `<div style="margin-top:12px"><canvas id="${canvasId}" height="180"></canvas></div>`;
-- // Queue chart creation after DOM update
-- setTimeout(() => {
-- createChart(canvasId, {
-- type: 'line',
-- data: {
-- labels: trend.map(d => d.date?.slice(5) || ''),
-- datasets: [
-- { label: 'Human', data: trend.map(d => d.human_hits || 0), borderColor: CHART_COLORS.success, tension: 0.3, fill: true, backgroundColor: 'rgba(46,204,113,0.1)' },
-- { label: 'Bots', data: trend.map(d => d.bot_hits || 0), borderColor: CHART_COLORS.danger, tension: 0.3, fill: true, backgroundColor: 'rgba(231,76,60,0.1)' },
-- ],
-- },
-- options: { scales: { x: {}, y: { beginAtZero: true } }, plugins: { legend: { position: 'bottom' } } },
-- });
-- }, 50);
-- }
--
-- html += '</div>';
-- }
--
-- el.innerHTML = html || '<p class="muted">No bot data available yet. Analytics data will appear once traffic is recorded.</p>';
-- } catch (e) {
-- el.innerHTML = `<p class="muted">Error: ${esc(e.message)}</p>`;
-- }
--}
--
--// ═══════════════════════════════════════════════════════════════
--// Audit Log
--// ═══════════════════════════════════════════════════════════════
--let auditOffset = 0;
--let _modlogNext = null;
--// The filters as a query: dates are whole days (until includes its day).
--function modLogQuery() {
-- const q = new URLSearchParams();
-- const val = (id) => (document.getElementById(id)?.value || '').trim();
-- for (const [k, id] of [['service', 'modlog-service'], ['action', 'modlog-action'], ['actor', 'modlog-actor'], ['target', 'modlog-target']]) if (val(id)) q.set(k, val(id));
-- if (val('modlog-since')) q.set('since', `${val('modlog-since')}T00:00:00Z`);
-- if (val('modlog-until')) { const d = new Date(`${val('modlog-until')}T00:00:00Z`); d.setUTCDate(d.getUTCDate() + 1); q.set('until', d.toISOString()); }
-- return q;
--}
--async function exportModLog() {
-- const q = modLogQuery();
-- q.set('format', 'csv');
-- try {
-- const headers = token ? { Authorization: 'Bearer ' + token } : {};
-- const res = await fetch(`${API}/api/v1/staff/moderation-audit?${q}`, { headers, credentials: 'include' });
-- if (!res.ok) throw new Error(`export failed (${res.status})`);
-- const url = URL.createObjectURL(await res.blob());
-- const a = document.createElement('a');
-- a.href = url; a.download = `moderation-audit-${new Date().toISOString().slice(0, 10)}.csv`;
-- document.body.appendChild(a); a.click(); a.remove();
-- setTimeout(() => URL.revokeObjectURL(url), 5000);
-- } catch (err) { alert(err.message || 'Export failed'); }
--}
--let _deliveries = [];
--async function loadDeliveries() {
-- const c = document.getElementById('dlq-content');
-- const q = new URLSearchParams({ status: document.getElementById('dlq-status').value, limit: '200' });
-- const sub = document.getElementById('dlq-sub').value.trim();
-- if (sub) q.set('subscription_id', sub);
-- c.innerHTML = '<div class="loading">Loading...</div>';
-- try {
-- const d = await api(`/api/admin/events/deliveries?${q}`);
-- _deliveries = d.deliveries || [];
-- const counts = d.counts || {};
-- document.getElementById('dlq-counts').textContent = Object.entries(counts).map(([k, v]) => `${k}: ${v}`).join(' · ');
-- const bySub = {};
-- for (const x of _deliveries) (bySub[x.subscription_id] = bySub[x.subscription_id] || []).push(x.event_id);
-- const replayAll = Object.entries(bySub).map(([s, ids]) => `<button class="btn btn-sm" data-sub="${esc(s)}" onclick="replayDeliveries(this.dataset.sub, null)">Replay ${ids.length} for ${esc(s.slice(0, 14))}…</button>`).join(' ');
-- c.innerHTML = _deliveries.length ? `<div style="margin-bottom:8px">${replayAll}</div><table class="admin-table"><thead><tr><th>Event</th><th>Subscription</th><th>Seq</th><th>Attempts</th><th>Last answer</th><th></th></tr></thead><tbody>${_deliveries.map(x => `
-- <tr><td style="font-size:11px"><code>${esc(x.event_id)}</code></td><td style="font-size:11px"><code>${esc(x.subscription_id)}</code></td><td>${esc(String(x.seq))}</td><td>${esc(String(x.attempt))}</td>
-- <td style="font-size:11px;max-width:320px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${esc(x.last_error || '')}">${esc(x.last_status ? String(x.last_status) : '-')} ${esc(x.last_error || '')}</td>
-- <td><button class="btn btn-sm" data-sub="${esc(x.subscription_id)}" data-evt="${esc(x.event_id)}" onclick="replayDeliveries(this.dataset.sub, [this.dataset.evt])">Replay</button></td></tr>`).join('')}</tbody></table>` : '<p class="muted">Nothing here.</p>';
-- } catch (err) {
-- c.innerHTML = `<p class="muted">Deliveries could not be loaded: ${esc(err.message || 'error')}</p>`;
-- }
--}
--async function replayDeliveries(sub, ids) {
-- const eventIds = ids || _deliveries.filter(x => x.subscription_id === sub).map(x => x.event_id);
-- if (!eventIds.length || !confirm(`Replay ${eventIds.length} deliver${eventIds.length === 1 ? 'y' : 'ies'} to ${sub}?`)) return;
-- try { const r = await api('/api/admin/events/replay', { method: 'POST', body: { subscription_id: sub, event_ids: eventIds } }); alert(`Queued ${r.queued}`); loadDeliveries(); }
-- catch (err) { alert(err.message || 'Replay failed'); }
--}
--async function loadOperatorChecklist() {
-- const c = document.getElementById('checklist-content');
-- let areas = [];
-- try { areas = (await api('/api/admin/operator-checklist')).areas || []; } catch (err) { c.textContent = 'Could not load: ' + err.message; return; }
-- c.replaceChildren();
-- const colour = { ready: 'var(--success, #3ee6b0)', degraded: 'var(--warning, #f5a524)', down: 'var(--danger, #ef4444)' };
-- for (const a of areas) {
-- const h = document.createElement('h3'); h.textContent = a.area; h.style.margin = '14px 0 6px'; c.appendChild(h);
-- for (const it of a.items) {
-- const row = document.createElement('div');
-- row.style.cssText = 'display:flex;gap:10px;align-items:flex-start;flex-wrap:wrap;padding:8px 0;border-top:1px solid var(--border)';
-- const dot = document.createElement('span'); dot.title = (it.service || '') + ': ' + it.status;
-- dot.style.cssText = 'width:10px;height:10px;border-radius:50%;margin-top:6px;flex:none'; dot.style.background = colour[it.status] || 'var(--text-muted, #888)';
-- const name = document.createElement('strong'); name.textContent = it.item; name.style.minWidth = '260px';
-- const places = document.createElement('div'); places.style.cssText = 'display:flex;gap:8px;flex-wrap:wrap;align-items:center';
-- for (const w of it.where) {
-- let el;
-- if (w.kind === 'tab') { el = document.createElement('button'); el.type = 'button'; el.className = 'btn'; el.textContent = 'Open ' + w.tab; el.onclick = () => showTab(w.tab, null); }
-- else if (w.kind === 'url') { el = document.createElement('a'); el.href = w.url; el.target = '_blank'; el.rel = 'noopener'; el.textContent = w.url.replace(/^https:\/\//, ''); }
-- else { el = document.createElement('code'); el.textContent = (w.kind === 'cli' ? '$ ' : 'API ') + (w.cli || w.api); }
-- places.appendChild(el);
-- if (w.note) { const n = document.createElement('span'); n.className = 'muted'; n.textContent = '(' + w.note + ')'; places.appendChild(n); }
-- }
-- if (!it.console) { const gap = document.createElement('span'); gap.className = 'muted'; gap.textContent = 'no console yet'; gap.style.color = 'var(--warning, #f5a524)'; places.appendChild(gap); }
-- row.append(dot, name, places);
-- c.appendChild(row);
-- }
-- }
--}
--
--async function loadThemeReview() {
-- const c = document.getElementById('themes-review-content');
-- let list = [];
-- try { list = (await api('/api/admin/themes?status=pending')).themes || []; } catch (err) { c.textContent = 'Could not load: ' + err.message; return; }
-- c.replaceChildren();
-- if (!list.length) { const p = document.createElement('p'); p.className = 'muted'; p.textContent = 'Nothing waiting for review.'; c.appendChild(p); return; }
-- for (const t of list) {
-- const card = document.createElement('div');
-- card.style.cssText = 'border:1px solid var(--border);border-radius:10px;padding:10px 12px;margin-bottom:8px;display:grid;gap:8px';
-- const head = document.createElement('div');
-- const n = document.createElement('strong'); n.textContent = t.name + ' ';
-- const meta = document.createElement('span'); meta.className = 'muted'; meta.textContent = `/${t.slug} · ${t.mode} · by ${t.author || 'unknown'}`;
-- head.append(n, meta);
-- const swatches = document.createElement('div'); swatches.style.cssText = 'display:flex;gap:4px;flex-wrap:wrap';
-- for (const [k, v] of Object.entries(t.variables || {}).filter(([, v]) => /^#|^rgb|^hsl/i.test(v)).slice(0, 14)) {
-- const sw = document.createElement('span'); sw.title = `${k}: ${v}`; sw.style.cssText = 'width:22px;height:22px;border-radius:6px;border:1px solid var(--border)'; sw.style.background = v; swatches.appendChild(sw);
-- }
-- const note = document.createElement('input'); note.placeholder = 'Reason (needed to reject)'; note.maxLength = 300; note.style.minWidth = '240px';
-- const act = (decision) => async () => {
-- try { await api(`/api/admin/themes/${encodeURIComponent(t.id)}/review`, { method: 'POST', body: JSON.stringify({ decision, note: note.value }) }); loadThemeReview(); }
-- catch (err) { alert(err.message); }
-- };
-- const ok = document.createElement('button'); ok.className = 'btn'; ok.textContent = 'Approve'; ok.onclick = act('approve');
-- const no = document.createElement('button'); no.className = 'btn'; no.textContent = 'Reject'; no.onclick = act('reject');
-- const row = document.createElement('div'); row.style.cssText = 'display:flex;gap:8px;flex-wrap:wrap;align-items:center'; row.append(note, ok, no);
-- card.append(head, swatches, row);
-- c.appendChild(card);
-- }
--}
--
--let _grants = [];
--async function loadGrants() {
-- const c = document.getElementById('grants-content');
-- try {
-- const [g, ch] = await Promise.all([api('/api/admin/grants'), api('/api/admin/grants/changes?limit=50')]);
-- _grants = g.grants || [];
-- renderGrants();
-- const rows = (ch.changes || []).map(x => `<tr><td style="white-space:nowrap;font-size:11px" title="${esc(x.at)}">${timeAgo(x.at)}</td><td><strong>${esc(x.change)}</strong></td><td><code>${esc(x.client_id)}</code></td><td><code>${esc(x.capability)}</code></td><td style="font-size:11px">${esc(x.reason || '-')}</td><td style="font-size:11px"><code>${esc(x.actor || 'system')}</code></td></tr>`).join('');
-- document.getElementById('grant-changes').innerHTML = rows ? `<table class="admin-table"><thead><tr><th>When</th><th>Change</th><th>Service</th><th>Capability</th><th>Reason</th><th>By</th></tr></thead><tbody>${rows}</tbody></table>` : '<p class="muted">No changes yet: every grant is still a default.</p>';
-- } catch (err) {
-- c.innerHTML = `<p class="muted">Grants could not be loaded: ${esc(err.message || 'error')}</p>`;
-- }
--}
--function renderGrants() {
-- const f = (document.getElementById('grant-filter').value || '').trim().toLowerCase();
-- const list = _grants.filter(g => !f || g.client_id.includes(f));
-- const badge = { active: 'badge-success', revoked: 'badge-danger', expired: 'badge-warning' };
-- document.getElementById('grants-content').innerHTML = list.length ? `<table class="admin-table"><thead><tr><th>Service</th><th>Capability</th><th>Audience</th><th>Namespaces</th><th>State</th><th>Expires</th><th>By</th><th></th></tr></thead><tbody>${list.map(g => `
-- <tr><td><code>${esc(g.client_id)}</code></td><td><code>${esc(g.capability)}</code></td><td style="font-size:11px">${esc(g.audience)}</td>
-- <td style="font-size:11px">${esc((g.namespaces || []).join(', ') || '-')}</td><td><span class="badge ${badge[g.state] || ''}">${esc(g.state)}</span></td>
-- <td style="font-size:11px">${g.expires_at ? esc(g.expires_at.slice(0, 10)) : '-'}</td><td style="font-size:11px">${esc(g.granted_by === 'default' ? 'default' : (g.granted_by || '-'))}</td>
-- <td>${g.state === 'active' ? `<button class="btn btn-sm" data-client="${esc(g.client_id)}" data-cap="${esc(g.capability)}" onclick="revokeGrant(this.dataset.client, this.dataset.cap)">Revoke</button>` : ''}</td></tr>`).join('')}</tbody></table>` : '<p class="muted">No grants match.</p>';
--}
--async function submitGrant() {
-- const v = (id) => document.getElementById(id).value.trim();
-- const body = { client_id: v('grant-client'), capability: v('grant-cap'), reason: v('grant-reason'), namespaces: v('grant-ns') ? v('grant-ns').split(',').map(x => x.trim()).filter(Boolean) : [] };
-- if (v('grant-exp')) body.expires_at = `${v('grant-exp')}T00:00:00Z`;
-- try { await api('/api/admin/grants', { method: 'POST', body }); document.getElementById('grant-reason').value = ''; loadGrants(); }
-- catch (err) { alert(err.message || 'Grant failed'); }
--}
--async function revokeGrant(client, capability) {
-- const reason = prompt(`Revoke ${capability} from ${client}? Reason:`);
-- if (!reason) return;
-- try { await api('/api/admin/grants/revoke', { method: 'POST', body: { client_id: client, capability, reason } }); loadGrants(); }
-- catch (err) { alert(err.message || 'Revoke failed'); }
--}
--async function loadModLog(reset) {
-- const c = document.getElementById('modlog-content');
-- const more = document.getElementById('modlog-more');
-- if (reset) { _modlogNext = null; c.innerHTML = '<div class="loading">Loading...</div>'; }
-- try {
-- const q = modLogQuery();
-- q.set('limit', '50');
-- if (!reset && _modlogNext) q.set('before', String(_modlogNext));
-- const data = await api(`/api/v1/staff/moderation-audit?${q}`);
-- const items = data.items || [];
-- const rowsHtml = items.map(e => `
-- <tr>
-- <td style="white-space:nowrap;font-size:11px" title="${esc(e.occurred_at)}">${e.occurred_at ? timeAgo(e.occurred_at) : '-'}</td>
-- <td><span class="badge badge-info">${esc(e.service)}</span></td>
-- <td><strong>${esc(e.action)}</strong>${e.scope ? `<div class="muted" style="font-size:11px">${esc(e.scope)}</div>` : ''}</td>
-- <td style="font-size:11px"><code>${esc(e.actor_subject || '-')}</code></td>
-- <td style="font-size:11px">${esc(e.target_type || '')} <code>${esc(e.target_id || '-')}</code>${e.target_subject && e.target_subject !== e.target_id ? `<div class="muted">${esc(e.target_subject)}</div>` : ''}</td>
-- <td style="font-size:11px;max-width:260px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${esc(e.reason || '')}">${esc(e.reason || '-')}</td>
-- </tr>`).join('');
-- if (reset) {
-- c.innerHTML = items.length ? `<table class="admin-table"><thead><tr><th>When</th><th>Service</th><th>Action</th><th>By</th><th>Target</th><th>Reason</th></tr></thead><tbody id="modlog-rows">${rowsHtml}</tbody></table>` : '<p class="muted">No staff actions recorded yet.</p>';
-- } else {
-- document.getElementById('modlog-rows')?.insertAdjacentHTML('beforeend', rowsHtml);
-- }
-- _modlogNext = data.next || null;
-- more.hidden = !_modlogNext;
-- } catch (err) {
-- c.innerHTML = `<p class="muted">The moderation log could not be loaded: ${esc(err.message || 'error')}</p>`;
-- }
--}
--
--async function loadAudit() {
-- const c = document.getElementById('audit-content');
-- const pager = document.getElementById('audit-pager');
-- c.innerHTML = '<div class="loading">Loading...</div>';
-- try {
-- const data = await api(`/api/admin/audit?limit=50&offset=${auditOffset}`);
-- const entries = data.entries || [];
-- if (!entries.length) { c.innerHTML = '<p class="muted">No audit entries</p>'; pager.innerHTML = ''; return; }
-- c.innerHTML = `
-- <table class="admin-table">
-- <thead><tr><th>Time</th><th>User</th><th>Action</th><th>Details</th></tr></thead>
-- <tbody>${entries.map(e => `
-- <tr>
-- <td style="white-space:nowrap;font-size:11px">${e.created_at ? timeAgo(e.created_at) : '-'}</td>
-- <td><strong>${esc(e.username || e.user_id || '-')}</strong></td>
-- <td><span class="badge badge-info">${esc(e.action)}</span></td>
-- <td style="font-size:11px;max-width:300px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${esc(e.details || '')}">${esc(e.details || '-')}</td>
-- </tr>
-- `).join('')}</tbody>
-- </table>`;
--
-- const hasMore = entries.length >= 50;
-- pager.innerHTML = `
-- <button class="btn btn-sm btn-outline" ${auditOffset<=0?'disabled':''} onclick="auditOffset=Math.max(0,auditOffset-50);loadAudit()"><i class="fa-solid fa-chevron-left"></i></button>
-- <span class="muted">Offset ${auditOffset}</span>
-- <button class="btn btn-sm btn-outline" ${!hasMore?'disabled':''} onclick="auditOffset+=50;loadAudit()"><i class="fa-solid fa-chevron-right"></i></button>
-- `;
-- } catch (e) { c.innerHTML = `<p class="muted">Error: ${esc(e.message)}</p>`; pager.innerHTML = ''; }
--}
--
--// ═══════════════════════════════════════════════════════════════
--// Discord Bot Settings
--// ═══════════════════════════════════════════════════════════════
--// A secret input for a provider secret: disabled when its environment variable provides it (server/secrets.js).
--function envSecretInput(sources, key, value, placeholder, style) {
-- const src = (sources || {})[key] || {};
-- if (src.source === 'env') return `<input type="password" data-key="${esc(key)}" value="" placeholder="Set in the environment (${esc(src.env || '')})" disabled style="${style}">`;
-- return `<input type="password" data-key="${esc(key)}" value="${esc(value || '')}" placeholder="${esc(placeholder)}" style="${style}">`
-- + (src.source === 'database' && src.env ? `<div class="muted" style="font-size:12px;margin-top:4px">Stored in the database; prefer <code>${esc(src.env)}</code> in the server's environment file.</div>` : '');
--}
--
--async function loadDiscordSettings() {
-- const c = document.getElementById('settings-discord');
-- c.innerHTML = '<p class="muted">Loading Discord settings...</p>';
-- try {
-- const data = await api('/api/admin/discord');
-- const s = data.settings || {};
-- const st = data.status || {};
-- const statusBadge = st.connected
-- ? `<span class="badge" style="background:var(--success-bg,#166534);color:var(--success-text,#4ade80)">● Connected as ${esc(st.botTag || '?')}</span>`
-- : `<span class="badge" style="background:var(--danger-bg,#7f1d1d);color:var(--danger-text,#f87171)">● Disconnected</span>`;
--
-- c.innerHTML = `
-- <div style="display:flex;align-items:center;gap:12px;margin-bottom:20px">
-- <h3 style="margin:0"><i class="fa-brands fa-discord"></i> Discord Bot</h3>
-- ${statusBadge}
-- </div>
-- <form id="discord-settings-form">
-- <div class="setting-row">
-- <label>Bot Token</label>
-- ${envSecretInput(data.sources, 'discord_bot_token', s.discord_bot_token, 'Bot token from Discord Developer Portal', 'width:100%;max-width:500px')}
-- </div>
-- <div class="setting-row">
-- <label>Guild / Server ID</label>
-- <input type="text" data-key="discord_guild_id" value="${esc(s.discord_guild_id || '')}" placeholder="Right-click server → Copy Server ID" style="width:240px">
-- </div>
-- <div class="setting-row">
-- <label>Live Alerts Channel ID</label>
-- <input type="text" data-key="discord_alerts_channel_id" value="${esc(s.discord_alerts_channel_id || '')}" placeholder="Channel for go-live alerts" style="width:240px">
-- </div>
-- <div class="setting-row">
-- <label>System Alerts Channel ID</label>
-- <input type="text" data-key="discord_system_channel_id" value="${esc(s.discord_system_channel_id || '')}" placeholder="Channel for system/admin alerts (optional)" style="width:240px">
-- </div>
-- <div class="setting-row">
-- <label>Dedupe Cooldown (minutes)</label>
-- <input type="number" data-key="discord_dedupe_minutes" value="${esc(s.discord_dedupe_minutes || '15')}" min="1" max="1440" style="width:100px">
-- <span class="muted" style="margin-left:8px">Prevent spam if a streamer flaps live/offline</span>
-- </div>
-- <div class="setting-row">
-- <label>Custom Alert Message (optional)</label>
-- <input type="text" data-key="discord_alert_message" value="${esc(s.discord_alert_message || '')}" placeholder="{display_name} went live: {title}" style="width:100%;max-width:500px">
-- <div class="muted" style="font-size:12px;margin-top:4px">Placeholders: {username}, {display_name}, {title}, {url}</div>
-- </div>
-- <h4 style="margin-top:24px"><i class="fa-brands fa-discord"></i> Account Linking (OAuth2)</h4>
-- <div class="setting-row">
-- <label>OAuth2 Client ID</label>
-- <input type="text" data-key="discord_oauth_client_id" value="${esc(s.discord_oauth_client_id || '')}" placeholder="Discord application client ID" style="width:300px">
-- </div>
-- <div class="setting-row">
-- <label>OAuth2 Client Secret</label>
-- ${envSecretInput(data.sources, 'discord_oauth_client_secret', s.discord_oauth_client_secret, 'Discord application client secret', 'width:300px')}
-- </div>
-- <div style="margin-top:16px;display:flex;gap:8px;flex-wrap:wrap">
-- <button type="button" class="btn btn-primary" onclick="saveDiscordSettings()"><i class="fa-solid fa-save"></i> Save Settings</button>
-- <button type="button" class="btn btn-outline" onclick="testDiscordAlert()"><i class="fa-solid fa-bell"></i> Test System Alert</button>
-- <button type="button" class="btn btn-outline" onclick="testDiscordLiveAlert()"><i class="fa-solid fa-broadcast-tower"></i> Test Live Alert</button>
-- <button type="button" class="btn btn-outline" onclick="reinitDiscordBot()"><i class="fa-solid fa-rotate"></i> Reconnect Bot</button>
-- </div>
-- </form>`;
-- } catch (e) {
-- c.innerHTML = `<p class="muted">Error loading Discord settings: ${esc(e.message)}</p>`;
-- }
--}
--
--// ═══════════════════════════════════════════════════════════════
--// GitHub integration (owner-only): the read-only token for the registry and Blog's changelog
--// ═══════════════════════════════════════════════════════════════
--async function loadGithubSettings() {
-- const c = document.getElementById('settings-github');
-- c.innerHTML = '<p class="muted">Loading…</p>';
-- try {
-- const g = (await api('/api/admin/integrations/github')).github || {};
-- const where = g.source === 'env' ? `set in the environment (<code>${esc(g.env)}</code>)` : g.source === 'database' ? 'saved here' : 'not set';
-- const badge = g.set ? `<span class="badge" style="background:var(--success-bg,#166534);color:var(--success-text,#4ade80)">● Token …${esc(g.last4 || '')} (${where})</span>` : `<span class="badge badge-warning">● No token: anonymous, 60 requests an hour for the whole host</span>`;
-- c.innerHTML = `
-- <div style="display:flex;align-items:center;gap:12px;margin-bottom:12px;flex-wrap:wrap">
-- <h3 style="margin:0"><i class="fa-brands fa-github"></i> GitHub</h3>${badge}
-- </div>
-- <p class="muted" style="max-width:720px">A read-only token for public repositories. With it, GitHub allows 5,000 requests an hour instead of the 60 an hour this host shares without one. Used by: ${(g.used_by || []).map(esc).join('; ')}. Create a fine-grained token with <b>public repositories, read-only</b> access and no other permissions. The token is never shown again after saving.</p>
-- ${g.source === 'env' ? `<p class="muted">It comes from <code>${esc(g.env)}</code> in the server's environment file; change it there.</p>` : `
-- <div class="setting-row"><label for="github-token-input">Token</label>
-- <input type="password" id="github-token-input" autocomplete="off" placeholder="${g.set ? 'Replace the saved token' : 'github_pat_… or ghp_…'}" style="width:100%;max-width:520px"></div>`}
-- <div style="margin-top:12px;display:flex;gap:8px;flex-wrap:wrap">
-- ${g.source === 'env' ? '' : '<button type="button" class="btn btn-primary" onclick="saveGithubToken()"><i class="fa-solid fa-save"></i> Save token</button>'}
-- <button type="button" class="btn btn-outline" onclick="testGithubToken()"><i class="fa-solid fa-plug"></i> Test</button>
-- ${g.source === 'database' ? '<button type="button" class="btn btn-outline" onclick="clearGithubToken()"><i class="fa-solid fa-trash"></i> Remove</button>' : ''}
-- </div>
-- <div id="github-test-result" class="muted" style="margin-top:10px"></div>`;
-- } catch (e) { c.innerHTML = `<p class="muted">GitHub settings could not be loaded: ${esc(e.message)}</p>`; }
--}
--async function saveGithubToken() {
-- const el = document.getElementById('github-token-input');
-- const token = (el && el.value || '').trim();
-- if (!token) return toast('Paste a token first', 'error');
-- try { await api('/api/admin/integrations/github', { method: 'PUT', body: { token } }); el.value = ''; toast('GitHub token saved'); await loadGithubSettings(); testGithubToken(); }
-- catch (e) { toast(e.message, 'error'); }
--}
--async function clearGithubToken() {
-- if (!confirm('Remove the saved GitHub token? GitHub calls go back to the shared anonymous limit.')) return;
-- try { await api('/api/admin/integrations/github', { method: 'DELETE' }); toast('GitHub token removed'); loadGithubSettings(); }
-- catch (e) { toast(e.message, 'error'); }
--}
--async function testGithubToken() {
-- const out = document.getElementById('github-test-result');
-- if (out) out.textContent = 'Asking GitHub…';
-- try {
-- const t = (await api('/api/admin/integrations/github/test', { method: 'POST' })).test || {};
-- if (out) out.innerHTML = t.ok ? `${t.authenticated ? '✅ Authenticated' : '⚠️ Anonymous'}: ${esc(String(t.remaining))} of ${esc(String(t.limit))} requests left this hour${t.reset ? ` (resets ${esc(new Date(t.reset).toLocaleTimeString())})` : ''}.` : `❌ GitHub refused it: ${esc(t.message || t.status)}`;
-- } catch (e) { if (out) out.textContent = e.message; }
--}
--
--async function saveDiscordSettings() {
-- try {
-- const form = document.getElementById('discord-settings-form');
-- const settings = {};
-- form.querySelectorAll('[data-key]').forEach(el => {
-- if (el.disabled) return; // provided by the environment
-- settings[el.dataset.key] = el.value;
-- });
-- await api('/api/admin/discord', { method: 'PUT', body: JSON.stringify({ settings }) });
-- toast('Discord settings saved');
-- loadDiscordSettings();
-- } catch (e) { toast(e.message, 'error'); }
--}
--
--async function testDiscordAlert() {
-- try {
-- const data = await api('/api/admin/discord/test', { method: 'POST' });
-- toast(data.sent ? 'Test alert sent!' : `Alert not sent: ${data.reason || 'unknown'}`, data.sent ? 'success' : 'error');
-- } catch (e) { toast(e.message, 'error'); }
--}
--
--async function testDiscordLiveAlert() {
-- try {
-- const data = await api('/api/admin/discord/test-live', { method: 'POST' });
-- toast(data.sent ? 'Test live alert sent!' : `Alert not sent: ${data.reason || 'unknown'}`, data.sent ? 'success' : 'error');
-- } catch (e) { toast(e.message, 'error'); }
--}
--
--async function reinitDiscordBot() {
-- try {
-- const data = await api('/api/admin/discord/reinit', { method: 'POST' });
-- toast(data.status?.connected ? `Bot reconnected as ${data.status.botTag}` : 'Bot reconnected (not logged in)', data.status?.connected ? 'success' : 'warning');
-- loadDiscordSettings();
-- } catch (e) { toast(e.message, 'error'); }
--}
--
--// ═══════════════════════════════════════════════════════════════
--// Initialization
--// ═══════════════════════════════════════════════════════════════
--function _adminLoginRedirect() {
-- window.location.href = '/login?return=' + encodeURIComponent(window.location.href);
--}
--
--// Silent sliding refresh: exchange the current (possibly just-expired) token for a
--// fresh one, same as index.html/my.html. Returns true if a new token was stored.
--async function _adminTryRefresh() {
-- try {
-- const res = await fetch(API + '/api/auth/refresh', {
-- method: 'POST',
-- headers: { Authorization: 'Bearer ' + (token || ''), 'Content-Type': 'application/json' },
-- credentials: 'include',
-- });
-- if (!res.ok) return false;
-- const data = await res.json().catch(() => ({}));
-- if (data && data.token) { token = data.token; localStorage.setItem('ov_token', data.token); return true; }
-- return false;
-- } catch { return false; }
--}
--
--async function _bootAdmin() {
-- const data = await api('/api/auth/me');
-- currentUser = data.user || data;
-- if (currentUser.role !== 'admin') {
-- document.querySelector('.page').innerHTML = '<div class="card" style="text-align:center;padding:60px"><h3 style="color:var(--live-red)"><i class="fa-solid fa-lock"></i> Access Denied</h3><p class="muted" style="margin-top:8px">Admin privileges required.</p></div>';
-- return;
-- }
-- // Owner-only UI (Deploy tab, etc.) — hide for plain admins.
-- if (!currentUser.is_owner) {
-- document.querySelectorAll('.owner-only').forEach(el => { el.style.display = 'none'; });
-- }
-- // Init navbar
-- if (window.OpenVibeNavbar) {
-- OpenVibeNavbar.init({ service: 'network', token, user: currentUser });
-- const bellMount = OpenVibeNavbar.getBellMount();
-- if (bellMount && window.OpenVibeNotifications) {
-- OpenVibeNotifications.init({ mountEl: bellMount, apiBase: API, token });
-- }
-- }
-- initMainTabsOverflow();
-- // Open the tab from the URL slug (or restore the last-visited location).
-- routeFromUrl();
--}
--
--async function init() {
-- if (!token) { _adminLoginRedirect(); return; }
-- try {
-- await _bootAdmin();
-- } catch (e) {
-- // Invalid/expired token → try one silent refresh, then retry; if that fails,
-- // send the user through the OAuth login instead of showing a dead-end error.
-- if (await _adminTryRefresh()) {
-- try { await _bootAdmin(); return; } catch { /* fall through to login */ }
-- }
-- _adminLoginRedirect();
-- }
--}
--
--init();
--
--// ═══════════════════════════════════════════════════════════════
--// SSH Access — owner provisions locked-down dev accounts; admins get
--// guided instructions + an agent prompt to connect and vibe-code.
--// ═══════════════════════════════════════════════════════════════
--let sshInfo = null;
--
--async function loadSSH() {
-- const el = document.getElementById('ssh-content');
-- if (!el) return;
-- if (!sshInfo) {
-- try {
-- const r = await fetch(`${API}/api/admin/ssh-info`, { headers: authHeaders() });
-- const d = await r.json();
-- if (!d.ok) throw new Error(d.error || 'Failed to load SSH info');
-- sshInfo = d;
-- } catch (e) {
-- el.innerHTML = `<div class="card"><p class="muted">Could not load SSH info: ${esc(e.message)}</p></div>`;
-- return;
-- }
-- }
-- if (sshInfo.is_owner) renderSSHOwner(el);
-- else renderSSHUser(el);
--}
--
--function _sshSetText(id, t) { const e = document.getElementById(id); if (e) e.textContent = t; }
--function _sshSanitizeUser(v) { return (v || '').trim().toLowerCase().replace(/[^a-z0-9_-]/g, ''); }
--
--function sshCopy(id, btn) {
-- const el = document.getElementById(id);
-- if (!el) return;
-- navigator.clipboard.writeText(el.textContent).then(() => {
-- if (typeof toast === 'function') toast('Prompt copied', 'success');
-- if (btn) { const o = btn.innerHTML; btn.innerHTML = '<i class="fa-solid fa-check"></i> Copied'; setTimeout(() => { btn.innerHTML = o; }, 1400); }
-- }).catch(() => { if (typeof toast === 'function') toast('Copy failed', 'error'); });
--}
--
--// ── Owner view ──────────────────────────────────────────────
--function renderSSHOwner(el) {
-- const hostWarn = sshInfo.host ? '' :
-- `<div class="card" style="border-color:var(--warning)"><p style="color:var(--warning);margin:0"><i class="fa-solid fa-triangle-exclamation"></i> No server host is configured yet. Set <code>SSH_SERVER_HOST</code> in the openvibe-network environment so the connection details fill in automatically.</p></div>`;
-- el.innerHTML = `
-- <div class="card">
-- <h3><i class="fa-solid fa-user-lock"></i> Provision an SSH dev account <span class="staff-pill">Owner</span></h3>
-- <p class="muted">Create a locked-down Linux account so a trusted admin can SSH in and help build the sites (vibe coding with Claude). They get their own home + group access to the project folders — <b>no root</b>. Fill in the fields, copy the prompt, and hand it to your agent.</p>
-- </div>
-- ${hostWarn}
-- <div class="card">
-- <div class="form-group" style="margin-bottom:14px">
-- <label>New Linux username</label>
-- <input type="text" id="ssh-o-user" placeholder="e.g. alex" oninput="sshOwnerUpdate()" style="max-width:280px">
-- </div>
-- <div class="form-group" style="margin-bottom:14px">
-- <label>Their SSH <b>public</b> key <span class="muted">— ask them to run the steps in their own SSH tab and paste their <code>.pub</code> here (recommended)</span></label>
-- <textarea id="ssh-o-pubkey" rows="3" placeholder="ssh-ed25519 AAAA... alex@openvibe" oninput="sshOwnerUpdate()" style="width:100%;font-family:ui-monospace,monospace;font-size:0.82rem"></textarea>
-- </div>
-- <div class="form-group">
-- <label>Access level</label>
-- <select id="ssh-o-access" onchange="sshOwnerUpdate()" style="max-width:440px">
-- <option value="code">Project code only (safest)</option>
-- <option value="restart">Project code + restart app services</option>
-- <option value="web">Full web + nginx — build &amp; run their own sites (high trust)</option>
-- </select>
-- </div>
-- <div id="ssh-o-webwarn" style="display:none;margin-top:12px;padding:10px 12px;border:1px solid var(--warning);border-radius:8px;background:color-mix(in srgb,var(--warning) 10%,transparent);color:var(--warning);font-size:0.84rem;line-height:1.5">
-- <i class="fa-solid fa-triangle-exclamation"></i> <b>High trust:</b> web + nginx access lets this admin edit any nginx site config and reload the web server — they can serve anything and a bad config can take <em>all</em> the sites down. It still stops short of root (no package installs or arbitrary sudo), but only grant it to admins you fully trust to run the web layer with you.
-- </div>
-- </div>
-- <div class="card">
-- <div style="display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap">
-- <h3 style="margin:0"><i class="fa-solid fa-robot"></i> Agent prompt</h3>
-- <button class="btn btn-primary" onclick="sshCopy('ssh-o-prompt', this)"><i class="fa-solid fa-copy"></i> Copy Prompt</button>
-- </div>
-- <p class="muted" style="margin:8px 0">Paste into Claude Code running on the server with sudo (or locally with root SSH) to create and lock down the account.</p>
-- <pre id="ssh-o-prompt" class="ssh-prompt"></pre>
-- </div>
-- <div class="card">
-- <h3><i class="fa-solid fa-shield-halved"></i> How it's locked down</h3>
-- <ul class="muted" style="line-height:1.7;margin:0;padding-left:20px">
-- <li>Key-only login — password auth is disabled for the account.</li>
-- <li>Member of a shared <code>devs</code> group that owns the project dirs (<code>${esc(sshInfo.projectsRoot)}</code>) with setgid, so they can edit code and new files stay group-shared.</li>
-- <li><b>Not</b> in <code>sudo</code> — no blanket root. TLS / packages / arbitrary system changes stay with you.</li>
-- <li><b>Access levels</b> are additive and each grants only a tightly-scoped sudoers rule (never general root):
-- <ul style="margin:4px 0 0;padding-left:18px">
-- <li><b>Project code only</b> — edit code in the project dirs, nothing privileged.</li>
-- <li><b>+ Restart app services</b> — adds sudo for <code>systemctl restart openvibe-network/openvibe-live</code> only.</li>
-- <li><b>Full web + nginx</b> — adds group ownership of the nginx site configs + a <code>/srv/www</code> web root, plus sudo limited to <code>nginx -t</code> and reload/restart of nginx &amp; php-fpm. Lets them build &amp; run their own sites — high trust, since a bad config affects every site.</li>
-- </ul>
-- </li>
-- <li>Revoke anytime: remove their key from <code>~/.ssh/authorized_keys</code>, or <code>sudo deluser --remove-home &lt;user&gt;</code>.</li>
-- </ul>
-- </div>`;
-- sshOwnerUpdate();
--}
--
--function sshOwnerUpdate() {
-- const user = _sshSanitizeUser(document.getElementById('ssh-o-user')?.value) || '<username>';
-- const pub = (document.getElementById('ssh-o-pubkey')?.value || '').trim();
-- const access = document.getElementById('ssh-o-access')?.value || 'code';
-- const host = sshInfo.host || '<server-host>';
-- const roots = sshInfo.projectsRoot;
-- const firstRoot = roots.split(/\s+/)[0];
-- const pubShown = pub || "<PASTE THE USER'S SSH PUBLIC KEY HERE>";
-- const isRestart = access === 'restart' || access === 'web';
-- const isWeb = access === 'web';
--
-- // Toggle the high-trust warning under the selector.
-- const ww = document.getElementById('ssh-o-webwarn');
-- if (ww) ww.style.display = isWeb ? 'block' : 'none';
--
-- const steps = [
-- `Create the user with no password login:\n sudo adduser --disabled-password --gecos "" ${user}`,
-- `Install their SSH public key:\n sudo install -d -m 700 -o ${user} -g ${user} /home/${user}/.ssh\n echo "${pubShown}" | sudo tee /home/${user}/.ssh/authorized_keys\n sudo chmod 600 /home/${user}/.ssh/authorized_keys\n sudo chown ${user}:${user} /home/${user}/.ssh/authorized_keys`,
-- `Create/ensure a shared dev group and add them:\n sudo groupadd -f devs\n sudo usermod -aG devs ${user}`,
-- `Give the group access to the project dirs (setgid so new files stay shared):\n sudo chgrp -R devs ${roots}\n sudo chmod -R g+rwX ${roots}\n sudo find ${roots} -type d -exec chmod g+s {} +`,
-- `Do NOT add them to the sudo group — they must not have blanket root.`,
-- ];
-- if (isWeb) {
-- steps.push(`Grant broad web + nginx access (edit any site config, run their own sites):\n sudo groupadd -f webdev\n sudo usermod -aG webdev ${user}\n # let the group own & edit nginx site configs (setgid keeps new files group-shared)\n sudo chgrp -R webdev /etc/nginx/sites-available /etc/nginx/sites-enabled\n sudo chmod -R g+rwX /etc/nginx/sites-available /etc/nginx/sites-enabled\n sudo find /etc/nginx/sites-available /etc/nginx/sites-enabled -type d -exec chmod g+s {} +\n # a shared web root for their own sites (2775 = group-writable + setgid)\n sudo install -d -m 2775 -o root -g webdev /srv/www`);
-- }
-- if (isRestart) {
-- const cmds = ['/usr/bin/systemctl restart openvibe-network', '/usr/bin/systemctl restart openvibe-live'];
-- if (isWeb) cmds.push('/usr/sbin/nginx -t', '/usr/bin/systemctl reload nginx', '/usr/bin/systemctl restart nginx', '/usr/bin/systemctl reload php*-fpm', '/usr/bin/systemctl restart php*-fpm');
-- steps.push(`Allow ONLY these exact root commands via sudo (nothing else):\n echo '${user} ALL=(root) NOPASSWD: ${cmds.join(', ')}' | sudo tee /etc/sudoers.d/${user}\n sudo chmod 440 /etc/sudoers.d/${user}\n sudo visudo -c`);
-- }
-- steps.push(`Verify:\n sudo -u ${user} whoami\n sudo -u ${user} ls -la ${firstRoot}${isWeb ? '\n sudo -u ' + user + ' test -w /etc/nginx/sites-enabled && echo "nginx: writable"' : ''}`);
-- const numbered = steps.map((s, i) => `${i + 1}. ${s}`).join('\n\n');
--
-- const purpose = isWeb
-- ? 'help develop our sites AND build/run their own nginx + PHP sites on the box'
-- : 'help develop the sites';
-- const webNote = isWeb
-- ? '\n\nNote: this tier gives them full control of the web layer (nginx configs + a /srv/www web root + reloading nginx/php-fpm) but still NOT blanket root — installing new system packages (PHP runtime, etc.) stays an owner action. If PHP-FPM is not installed yet, install it once before they start.'
-- : '';
--
-- const prompt =
--`You are provisioning a locked-down SSH account on my Linux server for a trusted OpenVibe admin so they can SSH in and ${purpose} (vibe coding with Claude Code). Be careful and idempotent, and do NOT grant this user root/sudo beyond exactly what is specified below.
--
--Server host: ${host}
--New username: ${user}
--Their SSH public key:
--${pubShown}
--
--Steps:
--${numbered}${webNote}
--
--Finally, tell me the exact command they should run to connect:
-- ssh ${user}@${host}`;
-- _sshSetText('ssh-o-prompt', prompt);
--}
--
--// ── Admin (user) view ───────────────────────────────────────
--function renderSSHUser(el) {
-- const defUser = _sshSanitizeUser(currentUser.username) || 'me';
-- el.innerHTML = `
-- <div class="card">
-- <h3><i class="fa-solid fa-terminal"></i> Connect to the dev server <span class="staff-pill user-pill">You</span></h3>
-- <p class="muted">Generate your key, send the owner (<b>${esc(sshInfo.ownerUsername)}</b>) your <b>public</b> key to authorize, then SSH in and help build the sites with Claude. Your private key never leaves your machine.</p>
-- </div>
-- <div class="card">
-- <div class="form-group">
-- <label>Your SSH username <span class="muted">— confirm the exact name with the owner</span></label>
-- <input type="text" id="ssh-u-user" value="${esc(defUser)}" oninput="sshUserUpdate()" style="max-width:280px">
-- </div>
-- </div>
-- <div class="card">
-- <h3><i class="fa-solid fa-list-ol"></i> Steps</h3>
-- <ol class="muted" style="line-height:1.9;padding-left:20px;margin:0">
-- <li>Generate a key pair (skip if you already have one you want to use):<br><code id="ssh-u-keygen"></code></li>
-- <li>Print your <b>public</b> key and send it to the owner to authorize:<br><code id="ssh-u-cat"></code></li>
-- <li>Once they confirm it's added, put this in your <code>~/.ssh/config</code>:<pre id="ssh-u-config" class="ssh-prompt" style="margin-top:6px"></pre></li>
-- <li>Connect: <code id="ssh-u-connect"></code></li>
-- </ol>
-- </div>
-- <div class="card">
-- <div style="display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap">
-- <h3 style="margin:0"><i class="fa-solid fa-robot"></i> Agent prompt</h3>
-- <button class="btn btn-primary" onclick="sshCopy('ssh-u-prompt', this)"><i class="fa-solid fa-copy"></i> Copy Prompt</button>
-- </div>
-- <p class="muted" style="margin:8px 0">Paste into Claude Code on your own machine — it sets up the connection and gives it full context about the server so you can start vibe coding.</p>
-- <pre id="ssh-u-prompt" class="ssh-prompt"></pre>
-- </div>`;
-- sshUserUpdate();
--}
--
--function sshUserUpdate() {
-- const host = sshInfo.host || '<server-host>';
-- const user = _sshSanitizeUser(document.getElementById('ssh-u-user')?.value) || '<username>';
-- const keygen = `ssh-keygen -t ed25519 -C "${user}@openvibe" -f ~/.ssh/openvibe_${user}`;
-- const cat = `cat ~/.ssh/openvibe_${user}.pub`;
-- const config = `Host openvibe\n HostName ${host}\n User ${user}\n IdentityFile ~/.ssh/openvibe_${user}\n ServerAliveInterval 60`;
-- const connect = `ssh openvibe`;
-- _sshSetText('ssh-u-keygen', keygen);
-- _sshSetText('ssh-u-cat', cat);
-- _sshSetText('ssh-u-config', config);
-- _sshSetText('ssh-u-connect', connect);
--
-- const prompt =
--`Help me connect to the OpenVibe dev server over SSH, then help me develop the sites (vibe coding with Claude Code).
--
--My SSH username: ${user}
--Server host: ${host}
--
--Do this:
--1. If I don't already have this key, generate one (leave the passphrase empty, or set one — my call):
-- ${keygen}
--2. Print my PUBLIC key so I can send it to the server owner (${sshInfo.ownerUsername}) to authorize. Nothing works until they add it:
-- ${cat}
--3. Add this block to my ~/.ssh/config (create the file if needed, then chmod 600 ~/.ssh/config):
--${config}
--4. Test the connection:
-- ${connect}
--
--Context about the server so you can help me once I'm connected:
--- Projects live under: ${sshInfo.projectsRoot}
-- (openvibe.network under /opt/openvibe.network, OpenVibe.Live under /opt/openvibe.live)
--- Services are systemd units: ${sshInfo.services.join(', ')}. Deploys are git-based —
-- cd into the project, git pull --ff-only, then restart the service (if I'm permitted).
--- I'm a OpenVibe admin, not root. nginx / TLS / packages / system-level changes go through the owner.
--- Other devs share the project group, so be careful with shared files and always pull before editing.
--Once I'm connected, cd into the relevant project and let's build.`;
-- _sshSetText('ssh-u-prompt', prompt);
--}
--
--// ═══════════════════════════════════════════════════════════════
--// Deploy Panel — TLS / Nginx / Infrastructure Control Plane
--// ═══════════════════════════════════════════════════════════════
--
--function showDeploySubTab(tab, btn) {
-- deployCurrentSubTab = tab;
-- document.querySelectorAll('#deploy-sub-tabs button').forEach(b => b.classList.remove('active'));
-- (btn || _subBtn('deploy-sub-tabs', tab))?.classList.add('active');
-- ['deploy-overview', 'deploy-certs-panel', 'deploy-nginx-panel', 'deploy-domains-panel', 'deploy-config-panel'].forEach(id => {
-- const el = document.getElementById(id);
-- if (el) el.style.display = 'none';
-- });
-- const map = {
-- overview: 'deploy-overview',
-- certs: 'deploy-certs-panel',
-- nginx: 'deploy-nginx-panel',
-- domains: 'deploy-domains-panel',
-- config: 'deploy-config-panel',
-- };
-- const target = document.getElementById(map[tab]);
-- if (target) target.style.display = 'block';
-- if (tab === 'overview') loadDeployOverview();
-- if (tab === 'certs') loadDeployCerts();
-- if (tab === 'domains') loadDeployDomains();
-- if (tab === 'config') loadDeployConfig();
-- _syncAdminUrl();
--}
--
--async function loadDeployOverview() {
-- try {
-- const r = await fetch(`${API}/api/admin/deploy/prerequisites`, { headers: authHeaders() });
-- const d = await r.json();
-- if (!d.ok) throw new Error(d.error);
-- const p = d.prerequisites;
-- const c = d.config;
-- document.getElementById('deploy-stats').innerHTML = `
-- <div class="stat-card"><div class="value" style="color:${p.certbotInstalled ? 'var(--success)' : 'var(--danger)'}"><i class="fa-solid fa-${p.certbotInstalled ? 'check' : 'xmark'}"></i></div><div class="label">Certbot</div></div>
-- <div class="stat-card"><div class="value" style="color:${p.cloudflarePluginInstalled ? 'var(--success)' : 'var(--warning)'}"><i class="fa-solid fa-${p.cloudflarePluginInstalled ? 'check' : 'minus'}"></i></div><div class="label">CF Plugin</div></div>
-- <div class="stat-card"><div class="value" style="color:${p.nginxInstalled ? 'var(--success)' : 'var(--danger)'}"><i class="fa-solid fa-${p.nginxInstalled ? 'check' : 'xmark'}"></i></div><div class="label">Nginx</div></div>
-- <div class="stat-card"><div class="value" style="color:${c.hasCloudflareToken ? 'var(--success)' : 'var(--text-muted)'}"><i class="fa-solid fa-${c.hasCloudflareToken ? 'check' : 'minus'}"></i></div><div class="label">CF Token</div></div>
-- <div class="stat-card"><div class="value">${c.domainsConfigured}</div><div class="label">Domains</div></div>
-- <div class="stat-card"><div class="value" style="font-size:14px">${esc(c.certMode)}</div><div class="label">Cert Mode</div></div>
-- <div class="stat-card"><div class="value" style="font-size:14px">${esc(c.nginxMode)}</div><div class="label">Nginx Mode</div></div>
-- `;
-- const prereqsEl = document.getElementById('deploy-prereqs');
-- const items = [];
-- items.push(prereqLine('Certbot installed', p.certbotInstalled));
-- items.push(prereqLine('Cloudflare DNS plugin', p.cloudflarePluginInstalled, 'Optional — needed for automated DNS-01'));
-- items.push(prereqLine('Let\'s Encrypt dir writable', p.letsencryptDirWritable, 'Run as root or with sudo'));
-- items.push(prereqLine('Nginx installed', p.nginxInstalled));
-- items.push(prereqLine('Cloudflare INI exists', p.cloudflareIniExists, 'Auto-created when token is saved'));
-- items.push(prereqLine('ACME email configured', !!c.acmeEmail, c.acmeEmail || 'Not set'));
-- prereqsEl.innerHTML = items.join('');
-- } catch (e) {
-- document.getElementById('deploy-stats').innerHTML = `<div class="muted" style="color:var(--danger)">${esc(e.message)}</div>`;
-- }
--}
--
--function prereqLine(label, ok, detail) {
-- const icon = ok ? '<i class="fa-solid fa-circle-check" style="color:var(--success)"></i>' : '<i class="fa-solid fa-circle-xmark" style="color:var(--danger)"></i>';
-- const detailHtml = detail ? ` <span class="muted" style="font-size:11px">— ${esc(detail)}</span>` : '';
-- return `<div style="padding:6px 0;display:flex;align-items:center;gap:8px">${icon} ${esc(label)}${detailHtml}</div>`;
--}
--
--async function loadDeployCerts() {
-- const el = document.getElementById('deploy-certs-list');
-- try {
-- const r = await fetch(`${API}/api/admin/deploy/certs`, { headers: authHeaders() });
-- const d = await r.json();
-- if (!d.ok && d.error) throw new Error(d.error);
-- if (!d.certs || d.certs.length === 0) {
-- el.innerHTML = '<div class="muted">No certificates found. Issue your first certificate below.</div>';
-- return;
-- }
-- el.innerHTML = d.certs.map(c => {
-- const isWild = c.domains?.some(d => d.startsWith('*.'));
-- const expColor = (c.daysRemaining || 0) < 14 ? 'var(--danger)' : (c.daysRemaining || 0) < 30 ? 'var(--warning)' : 'var(--success)';
-- return `<div class="card" style="margin-bottom:8px;padding:12px">
-- <div style="display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:8px">
-- <div>
-- <strong>${esc(c.name)}</strong>
-- ${isWild ? '<span style="font-size:10px;background:var(--accent);color:#000;padding:1px 6px;border-radius:3px;margin-left:6px">WILDCARD</span>' : ''}
-- <div class="muted" style="font-size:11px;margin-top:2px">${(c.domains || []).map(d => esc(d)).join(', ')}</div>
-- </div>
-- <div style="text-align:right">
-- <span style="color:${expColor};font-weight:600">${c.daysRemaining != null ? c.daysRemaining + ' days' : 'Unknown'}</span>
-- <div class="muted" style="font-size:11px">${c.expiry ? 'Expires ' + esc(c.expiry) : ''}</div>
-- </div>
-- </div>
-- </div>`;
-- }).join('');
-- } catch (e) {
-- el.innerHTML = `<div class="muted" style="color:var(--danger)">${esc(e.message)}</div>`;
-- }
--}
--
--async function issueCertCloudflare() {
-- const domain = document.getElementById('cert-issue-domain').value.trim();
-- if (!domain) return alert('Enter a domain');
-- const out = document.getElementById('cert-issue-output');
-- out.innerHTML = '<div class="loading">Issuing wildcard certificate via Cloudflare DNS... This may take 30-60 seconds.</div>';
-- try {
-- const r = await fetch(`${API}/api/admin/deploy/certs/issue-cloudflare`, {
-- method: 'POST', headers: { ...authHeaders(), 'Content-Type': 'application/json' },
-- body: JSON.stringify({ domain }),
-- });
-- const d = await r.json();
-- if (d.ok) {
-- out.innerHTML = `<div style="color:var(--success);padding:8px"><i class="fa-solid fa-check-circle"></i> Certificate issued: ${esc(d.certName || domain)}</div>
-- <pre style="background:var(--bg-input);padding:8px;border-radius:6px;font-size:11px;max-height:200px;overflow:auto;margin-top:8px">${esc(d.output || '')}</pre>`;
-- loadDeployCerts();
-- } else {
-- out.innerHTML = `<div style="color:var(--danger);padding:8px"><i class="fa-solid fa-xmark"></i> ${esc(d.error || 'Failed')}</div>
-- <pre style="background:var(--bg-input);padding:8px;border-radius:6px;font-size:11px;max-height:200px;overflow:auto;margin-top:8px">${esc(d.output || '')}</pre>`;
-- }
-- } catch (e) {
-- out.innerHTML = `<div style="color:var(--danger)">${esc(e.message)}</div>`;
-- }
--}
--
--async function showManualDnsInfo() {
-- const domain = document.getElementById('cert-issue-domain').value.trim();
-- if (!domain) return alert('Enter a domain');
-- const out = document.getElementById('cert-issue-output');
-- out.innerHTML = '<div class="loading">Getting manual DNS challenge info...</div>';
-- try {
-- const r = await fetch(`${API}/api/admin/deploy/certs/manual-info`, {
-- method: 'POST', headers: { ...authHeaders(), 'Content-Type': 'application/json' },
-- body: JSON.stringify({ domain }),
-- });
-- const d = await r.json();
-- const instructions = (d.instructions || []).map(i => esc(i)).join('<br>');
-- out.innerHTML = `<div class="card" style="padding:16px;margin-top:8px">
-- <h4 style="margin-bottom:8px"><i class="fa-solid fa-hand"></i> Manual DNS Challenge</h4>
-- <div style="font-size:13px;line-height:1.6">${instructions}</div>
-- <div style="margin-top:16px">
-- <button class="btn btn-primary" onclick="issueManualCert('${esc(domain)}')">
-- <i class="fa-solid fa-certificate"></i> DNS Records Created — Issue Certificate
-- </button>
-- <div class="muted" style="font-size:11px;margin-top:4px">Only click after creating the required DNS TXT records</div>
-- </div>
-- </div>`;
-- } catch (e) {
-- out.innerHTML = `<div style="color:var(--danger)">${esc(e.message)}</div>`;
-- }
--}
--
--async function issueManualCert(domain) {
-- const out = document.getElementById('cert-issue-output');
-- out.innerHTML = '<div class="loading">Issuing certificate with manual DNS verification... This may take 30-60 seconds.</div>';
-- try {
-- const r = await fetch(`${API}/api/admin/deploy/certs/issue-manual`, {
-- method: 'POST', headers: { ...authHeaders(), 'Content-Type': 'application/json' },
-- body: JSON.stringify({ domain }),
-- });
-- const d = await r.json();
-- if (d.ok) {
-- out.innerHTML = `<div style="color:var(--success);padding:8px"><i class="fa-solid fa-check-circle"></i> Certificate issued: ${esc(d.certName || domain)}</div>
-- <pre style="background:var(--bg-input);padding:8px;border-radius:6px;font-size:11px;max-height:200px;overflow:auto;margin-top:8px">${esc(d.output || '')}</pre>`;
-- loadDeployCerts();
-- } else {
-- out.innerHTML = `<div style="color:var(--danger);padding:8px"><i class="fa-solid fa-xmark"></i> ${esc(d.error || 'Failed')}</div>
-- <pre style="background:var(--bg-input);padding:8px;border-radius:6px;font-size:11px;max-height:200px;overflow:auto;margin-top:8px">${esc(d.output || '')}</pre>`;
-- }
-- } catch (e) {
-- out.innerHTML = `<div style="color:var(--danger)">${esc(e.message)}</div>`;
-- }
--}
--
--async function renewAllCerts() {
-- const out = document.getElementById('cert-issue-output');
-- out.innerHTML = '<div class="loading">Renewing all certificates...</div>';
-- try {
-- const r = await fetch(`${API}/api/admin/deploy/certs/renew`, {
-- method: 'POST', headers: authHeaders(),
-- });
-- const d = await r.json();
-- out.innerHTML = `<div style="color:${d.ok ? 'var(--success)' : 'var(--danger)'};padding:8px">
-- <i class="fa-solid fa-${d.ok ? 'check-circle' : 'xmark'}"></i> ${d.ok ? 'Renewal complete' : esc(d.error || 'Failed')}
-- </div>
-- <pre style="background:var(--bg-input);padding:8px;border-radius:6px;font-size:11px;max-height:200px;overflow:auto;margin-top:8px">${esc(d.output || '')}</pre>`;
-- if (d.ok) loadDeployCerts();
-- } catch (e) {
-- out.innerHTML = `<div style="color:var(--danger)">${esc(e.message)}</div>`;
-- }
--}
--
--async function previewNginxConfigs() {
-- const out = document.getElementById('nginx-preview-output');
-- out.innerHTML = '<div class="loading">Generating Nginx configs...</div>';
-- try {
-- const r = await fetch(`${API}/api/admin/deploy/nginx/preview`, { headers: authHeaders() });
-- const d = await r.json();
-- if (!d.ok) throw new Error(d.error);
-- out.innerHTML = d.configs.map(c => `
-- <div class="card" style="margin-bottom:12px">
-- <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">
-- <h4>${esc(c.serviceId)} <span class="muted" style="font-size:11px">${esc(c.filename)}</span></h4>
-- <span style="font-size:11px;color:${c.certFound ? 'var(--success)' : 'var(--warning)'}">${c.certFound ? '✓ cert found' : '⚠ no cert'}</span>
-- </div>
-- <pre style="background:var(--bg-input);padding:12px;border-radius:6px;font-size:11px;max-height:300px;overflow:auto;white-space:pre-wrap">${esc(c.content)}</pre>
-- </div>
-- `).join('');
-- } catch (e) {
-- out.innerHTML = `<div style="color:var(--danger)">${esc(e.message)}</div>`;
-- }
--}
--
--async function validateNginx() {
-- const out = document.getElementById('nginx-preview-output');
-- out.innerHTML = '<div class="loading">Validating Nginx config...</div>';
-- try {
-- const r = await fetch(`${API}/api/admin/deploy/nginx/validate`, {
-- method: 'POST', headers: authHeaders(),
-- });
-- const d = await r.json();
-- out.innerHTML = `<div class="card" style="padding:16px">
-- <div style="color:${d.ok ? 'var(--success)' : 'var(--danger)'}">
-- <i class="fa-solid fa-${d.ok ? 'check-circle' : 'times-circle'}"></i>
-- <strong>${d.ok ? 'Configuration valid' : 'Validation failed'}</strong>
-- </div>
-- <pre style="background:var(--bg-input);padding:8px;border-radius:6px;font-size:11px;margin-top:8px;max-height:200px;overflow:auto">${esc(d.output || '')}</pre>
-- </div>`;
-- } catch (e) {
-- out.innerHTML = `<div style="color:var(--danger)">${esc(e.message)}</div>`;
-- }
--}
--
--async function applyNginxConfigs() {
-- if (!confirm('Apply generated Nginx configs? This will backup existing configs, write new ones, validate, and reload Nginx.')) return;
-- const out = document.getElementById('nginx-preview-output');
-- out.innerHTML = '<div class="loading">Applying Nginx configs...</div>';
-- try {
-- const r = await fetch(`${API}/api/admin/deploy/nginx/apply`, {
-- method: 'POST', headers: { ...authHeaders(), 'Content-Type': 'application/json' },
-- body: JSON.stringify({ reload: true }),
-- });
-- const d = await r.json();
-- const lines = [];
-- lines.push(`<div style="color:${d.ok ? 'var(--success)' : 'var(--danger)'}"><i class="fa-solid fa-${d.ok ? 'check-circle' : 'times-circle'}"></i> <strong>${d.ok ? 'Applied successfully' : 'Apply failed'}</strong></div>`);
-- if (d.dryRun) lines.push('<div class="muted">Dry run — no files were written</div>');
-- if (d.applied?.length) lines.push(`<div>Applied: ${d.applied.map(f => esc(f)).join(', ')}</div>`);
-- if (d.backed_up?.length) lines.push(`<div class="muted">Backed up: ${d.backed_up.length} files</div>`);
-- if (d.validation) lines.push(`<div>Validation: ${d.validation.ok ? '✓ passed' : '✗ failed'}</div>`);
-- if (d.reload) lines.push(`<div>Reload: ${d.reload.ok ? '✓ success' : '✗ failed'}</div>`);
-- if (d.errors?.length) lines.push(`<div style="color:var(--danger)">Errors: ${d.errors.map(e => esc(e)).join('; ')}</div>`);
-- out.innerHTML = `<div class="card" style="padding:16px">${lines.join('')}</div>`;
-- } catch (e) {
-- out.innerHTML = `<div style="color:var(--danger)">${esc(e.message)}</div>`;
-- }
--}
--
--let deployDomainsList = [];
--
--async function loadDeployDomains() {
-- const el = document.getElementById('deploy-domains-list');
-- try {
-- const r = await fetch(`${API}/api/admin/deploy/domains`, { headers: authHeaders() });
-- const d = await r.json();
-- if (!d.ok) throw new Error(d.error);
-- deployDomainsList = d.domains || [];
-- if (deployDomainsList.length === 0) {
-- el.innerHTML = '<div class="muted">No domains configured. Add your first domain below.</div>';
-- return;
-- }
-- el.innerHTML = deployDomainsList.map((d, i) => `
-- <div class="card" style="margin-bottom:6px;padding:10px;display:flex;justify-content:space-between;align-items:center">
-- <div>
-- <strong>${esc(d.domain)}</strong>
-- ${d.wildcard ? '<span style="font-size:10px;background:var(--accent);color:#000;padding:1px 6px;border-radius:3px;margin-left:6px">WILDCARD</span>' : ''}
-- <span class="muted" style="font-size:11px;margin-left:8px">${d.certExists ? '🔒 cert found' : '⚠ no cert'}</span>
-- ${d.certName ? '<span class="muted" style="font-size:11px;margin-left:4px">(' + esc(d.certName) + ')</span>' : ''}
-- </div>
-- <button class="btn btn-danger btn-sm" onclick="removeManagedDomain(${i})" title="Remove">
-- <i class="fa-solid fa-trash"></i>
-- </button>
-- </div>
-- `).join('');
-- } catch (e) {
-- el.innerHTML = `<div class="muted" style="color:var(--danger)">${esc(e.message)}</div>`;
-- }
--}
--
--async function addManagedDomain() {
-- const domain = document.getElementById('domain-add-input').value.trim().toLowerCase();
-- if (!domain) return;
-- if (!/^[a-z0-9.-]+\.[a-z]{2,}$/.test(domain)) return alert('Invalid domain format');
-- if (deployDomainsList.find(d => d.domain === domain)) return alert('Domain already added');
-- const wildcard = document.getElementById('domain-add-wildcard').checked;
-- deployDomainsList.push({ domain, wildcard, certName: domain, services: [] });
-- await saveDomainsList();
-- document.getElementById('domain-add-input').value = '';
--}
--
--async function removeManagedDomain(idx) {
-- if (!confirm('Remove domain ' + deployDomainsList[idx]?.domain + '?')) return;
-- deployDomainsList.splice(idx, 1);
-- await saveDomainsList();
--}
--
--async function saveDomainsList() {
-- try {
-- const r = await fetch(`${API}/api/admin/deploy/domains`, {
-- method: 'PUT', headers: { ...authHeaders(), 'Content-Type': 'application/json' },
-- body: JSON.stringify({ domains: deployDomainsList }),
-- });
-- const d = await r.json();
-- if (!d.ok) throw new Error(d.error);
-- loadDeployDomains();
-- } catch (e) {
-- alert('Failed to save: ' + e.message);
-- }
--}
--
--async function loadDeployConfig() {
-- try {
-- const r = await fetch(`${API}/api/admin/deploy/config`, { headers: authHeaders() });
-- const d = await r.json();
-- if (!d.ok) throw new Error(d.error);
-- document.getElementById('deploy-acme-email').value = d.config.acmeEmail || '';
-- document.getElementById('deploy-cert-mode').value = d.config.certMode || 'manual';
-- document.getElementById('deploy-cf-token').value = d.config.cloudflareToken || '';
-- document.getElementById('deploy-nginx-mode').value = d.config.nginxMode || 'preview';
-- document.getElementById('deploy-nginx-path').value = d.config.nginxSitesPath || '/etc/nginx/sites-enabled';
-- } catch (e) {
-- document.getElementById('deploy-config-status').innerHTML = `<span style="color:var(--danger)">${esc(e.message)}</span>`;
-- }
--}
--
--async function saveDeployConfig() {
-- const status = document.getElementById('deploy-config-status');
-- try {
-- const body = {
-- acmeEmail: document.getElementById('deploy-acme-email').value.trim(),
-- certMode: document.getElementById('deploy-cert-mode').value,
-- cloudflareToken: document.getElementById('deploy-cf-token').value,
-- nginxMode: document.getElementById('deploy-nginx-mode').value,
-- nginxSitesPath: document.getElementById('deploy-nginx-path').value.trim(),
-- };
-- const r = await fetch(`${API}/api/admin/deploy/config`, {
-- method: 'PUT', headers: { ...authHeaders(), 'Content-Type': 'application/json' },
-- body: JSON.stringify(body),
-- });
-- const d = await r.json();
-- if (!d.ok) throw new Error(d.error);
-- status.innerHTML = '<span style="color:var(--success)"><i class="fa-solid fa-check"></i> Saved</span>';
-- setTimeout(() => status.innerHTML = '', 3000);
-- } catch (e) {
-- status.innerHTML = `<span style="color:var(--danger)">${esc(e.message)}</span>`;
-- }
--}
--</script>
++<script src="/js/admin/core.js" defer></script>
++<script src="/js/admin/dashboard.js" defer></script>
++<script src="/js/admin/users.js" defer></script>
++<script src="/js/admin/moderation.js" defer></script>
++<script src="/js/admin/settings.js" defer></script>
++<script src="/js/admin/payments.js" defer></script>
++<script src="/js/admin/ai.js" defer></script>
++<script src="/js/admin/content.js" defer></script>
++<script src="/js/admin/analytics.js" defer></script>
++<script src="/js/admin/deploy.js" defer></script>
++<script src="/js/admin/boot.js" defer></script>
<script src="https://openvibe.live/live-notify.js" async></script>
</body>
</html>
ddiff --git a/public/js/admin/ai.js b/public/js/admin/ai.js
nnew file mode 100644
iindex 00000000..05f74732
---- /dev/null
++++ b/public/js/admin/ai.js
@@@ -0,0 +1,501 @@
++/* ═══════════════════════════════════════════════════════════════
++ OpenVibe.Network — admin panel (/admin): AI, AI viewers, streamer media tools and TTS.
++ Split out of admin.html. Classic scripts, global scope: they rely on
++ admin/core.js's helpers (api, esc, toast, API, token) and on the
++ tabLoaders map in admin/boot.js (loaded last). No ES modules.
++ ═══════════════════════════════════════════════════════════════ */
++// ── AI: provider config + usage cost breakdown ──
++// Live's own provider settings are gone (WS-O task 2): OpenVibe.AI holds the key, the models and their prices.
++const AI_KEYS = ['ai_enabled',
++ 'ai_paste_analysis_enabled', 'ai_stream_memory_enabled', 'ai_stream_capture_interval_sec', 'ai_transcription_enabled', 'ai_timeline_enabled',
++ 'ai_max_cost_usd_per_day',
++ 'ai_viewers_enabled', 'ai_viewers_max_roster', 'ai_viewers_max_lines_per_min', 'ai_viewers_global_cap_usd_per_day', 'ai_viewers_default_settings_json'];
++
++async function loadAi() {
++ const c = document.getElementById('ai-content');
++ c.innerHTML = '<div class="loading">Loading...</div>';
++ try {
++ const [data, usageResp] = await Promise.all([
++ api('/api/admin/streamer/settings'),
++ api('/api/admin/streamer/ai/usage?days=30').catch(() => ({ usage: null })),
++ ]);
++ const byKey = {}; (data.settings || []).forEach(s => { byKey[s.key] = s; });
++ const field = (key) => {
++ const s = byKey[key]; if (!s) return '';
++ const sensitive = /(api_key|secret)/i.test(key);
++ // Server redacts API keys / money settings for non-owner admins — show locked.
++ if (s.redacted) return `
++ <div class="setting-row" style="flex-direction:column;align-items:stretch">
++ <label><strong>${esc(key)}</strong> <span style="color:var(--muted);font-size:11px;font-weight:600">🔒 Owner only</span><br><small>${esc(s.description||'')}</small></label>
++ <input type="text" value="${esc(s.value)}" disabled data-redacted="1"
++ style="margin-top:4px;padding:8px 10px;background:var(--bg-input);border:1px solid var(--border);border-radius:6px;color:var(--muted);font-size:13px;opacity:0.65"></div>`;
++ if (s.type === 'boolean') return `
++ <div class="setting-row"><label><strong>${esc(key)}</strong><br><small>${esc(s.description||'')}</small></label>
++ <input type="checkbox" data-key="${esc(key)}" data-type="boolean" ${s.value==='true'?'checked':''} style="width:18px;height:18px;cursor:pointer"></div>`;
++ if (s.type === 'json' || /_json$/.test(key)) return `<div class="setting-row" style="flex-direction:column;align-items:stretch">
++ <label><strong>${esc(key)}</strong><br><small>${esc(s.description||'')}</small></label>
++ <textarea data-key="${esc(key)}" data-type="json" rows="4" spellcheck="false" style="margin-top:4px;padding:8px 10px;background:var(--bg-input);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:12px;font-family:ui-monospace,monospace">${esc((() => { try { return JSON.stringify(JSON.parse(s.value), null, 2); } catch { return s.value; } })())}</textarea></div>`;
++ const type = s.type === 'number' ? 'number' : (sensitive ? 'password' : 'text');
++ return `<div class="setting-row" style="flex-direction:column;align-items:stretch">
++ <label><strong>${esc(key)}</strong><br><small>${esc(s.description||'')}</small></label>
++ <input type="${type}" data-key="${esc(key)}" data-type="${esc(s.type||'string')}" value="${esc(s.value)}"
++ style="margin-top:4px;padding:8px 10px;background:var(--bg-input);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:13px"></div>`;
++ };
++ const u = usageResp && usageResp.usage;
++ let costHtml = '<p class="muted">No AI usage recorded yet.</p>';
++ if (u && u.totals) {
++ const money = (n) => '$' + (Number(n||0)).toFixed(4);
++ costHtml = `
++ <div style="display:flex;gap:18px;flex-wrap:wrap;margin-bottom:10px">
++ <div><div style="font-size:22px;font-weight:700">${money(u.today)}</div><small class="muted">Today</small></div>
++ <div><div style="font-size:22px;font-weight:700">${money(u.totals.cost_usd)}</div><small class="muted">30 days</small></div>
++ <div><div style="font-size:22px;font-weight:700">${u.totals.calls||0}</div><small class="muted">Calls (30d)</small></div>
++ <div><div style="font-size:22px;font-weight:700">${((u.totals.input_tokens||0)+(u.totals.output_tokens||0)).toLocaleString()}</div><small class="muted">Tokens (30d)</small></div>
++ </div>
++ <div class="muted" style="font-size:12px;margin-bottom:8px">Prompt cache hit rate: <b>${Math.round((u.cachedShare||0)*100)}%</b> of input tokens (30d) · ${(u.totals.cached_tokens||0).toLocaleString()} cached tokens</div>
++ ${(u.byRole||[]).length ? `<table style="width:100%;font-size:13px;border-collapse:collapse;margin-bottom:10px"><tr><th style="text-align:left">Role</th><th style="text-align:right">Calls</th><th style="text-align:right">Cached</th><th style="text-align:right">Avg ms</th><th style="text-align:right">Cost</th></tr>
++ ${u.byRole.map(k=>`<tr><td>${esc(k.role||'—')}</td><td style="text-align:right">${k.calls}</td><td style="text-align:right">${k.input_tokens ? Math.round((k.cached_tokens||0)/k.input_tokens*100) : 0}%</td><td style="text-align:right">${Math.round(k.avg_latency_ms||0)}</td><td style="text-align:right">${money(k.cost_usd)}</td></tr>`).join('')}</table>` : ''}
++ ${(u.byKind||[]).length ? `<details><summary class="muted" style="cursor:pointer;font-size:12px">By feature</summary><table style="width:100%;font-size:13px;border-collapse:collapse"><tr><th style="text-align:left">Feature</th><th style="text-align:right">Calls</th><th style="text-align:right">Cost</th></tr>
++ ${u.byKind.map(k=>`<tr><td>${esc(k.kind||'—')}</td><td style="text-align:right">${k.calls}</td><td style="text-align:right">${money(k.cost_usd)}</td></tr>`).join('')}</table></details>` : ''}
++ ${(u.byOwner||[]).length ? `<details style="margin-top:8px"><summary class="muted" style="cursor:pointer;font-size:12px">By streamer (attributed spend)</summary><table style="width:100%;font-size:13px;border-collapse:collapse"><tr><th style="text-align:left">Streamer</th><th style="text-align:right">Calls</th><th style="text-align:right">Today</th><th style="text-align:right">30d</th><th style="text-align:right">of which BYO</th></tr>
++ ${u.byOwner.map(k=>`<tr><td>${esc(k.username||('#'+k.user_id))}</td><td style="text-align:right">${k.calls}</td><td style="text-align:right">${money(k.cost_today)}</td><td style="text-align:right">${money(k.cost_usd)}</td><td style="text-align:right">${money(k.cost_byo)}</td></tr>`).join('')}</table></details>` : ''}`;
++ }
++ c.innerHTML = `
++ <div style="max-width:760px">
++ <p class="muted" style="margin-bottom:12px">AI analysis for pastes, live-stream memories and AI viewers. Every call is a run on OpenVibe.AI, which holds the provider keys, models and prices (its console at ai.openvibe.network/console). Nothing runs until <strong>ai_enabled</strong> is on. Set <strong>ai_max_cost_usd_per_day</strong> to cap Live's daily spend.</p>
++ <fieldset style="border:1px solid var(--border);border-radius:8px;padding:12px 16px;margin-bottom:16px">
++ <legend style="padding:0 8px;font-weight:700">Estimated Cost</legend>
++ ${costHtml}
++ </fieldset>
++ <fieldset style="border:1px solid var(--border);border-radius:8px;padding:12px 16px;margin-bottom:16px">
++ <legend style="padding:0 8px;font-weight:700">AI Chat Viewers</legend>
++ <div id="ai-viewers-fleet"><span class="muted">Loading…</span></div>
++ </fieldset>
++ <fieldset style="border:1px solid var(--border);border-radius:8px;padding:12px 16px;margin-bottom:16px">
++ <legend style="padding:0 8px;font-weight:700">AI Status</legend>
++ <button type="button" class="btn" onclick="testAiStatus()"><i class="fa-solid fa-heart-pulse"></i> Run Status Check</button>
++ <div id="ai-status-out" style="margin-top:10px"></div>
++ </fieldset>
++ <fieldset style="border:1px solid var(--border);border-radius:8px;padding:12px 16px;margin-bottom:16px">
++ <legend style="padding:0 8px;font-weight:700">AI Explorer</legend>
++ <p class="muted" style="margin:0 0 8px">Browse a streamer's AI memory, analyzed pastes, and VODs — and generate a per-streamer overview aggregated across all of it.</p>
++ <div style="display:flex;gap:8px">
++ <input id="ai-explorer-search" placeholder="Search streamer username…" onkeydown="if(event.key==='Enter'){event.preventDefault();aiExplorerSearch();}"
++ style="flex:1;padding:8px 10px;background:var(--bg-input);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:13px">
++ <button type="button" class="btn" onclick="aiExplorerSearch()"><i class="fa-solid fa-magnifying-glass"></i></button>
++ </div>
++ <div id="ai-explorer-results" style="margin-top:8px"></div>
++ <div id="ai-explorer-detail" style="margin-top:12px"></div>
++ </fieldset>
++ ${currentUser.is_owner ? `
++ <form id="ai-form">
++ <fieldset style="border:1px solid var(--border);border-radius:8px;padding:12px 16px;margin-bottom:16px">
++ <legend style="padding:0 8px;font-weight:700">Configuration</legend>
++ ${AI_KEYS.map(field).join('')}
++ </fieldset>
++ <button type="button" class="btn btn-primary" onclick="saveAi()"><i class="fa-solid fa-floppy-disk"></i> Save AI Settings</button>
++ </form>` : `
++ <fieldset style="border:1px solid var(--border);border-radius:8px;padding:12px 16px;margin-bottom:16px;opacity:0.8">
++ <legend style="padding:0 8px;font-weight:700">Configuration</legend>
++ <p class="muted" style="margin:0"><i class="fa-solid fa-lock"></i> AI provider configuration and API keys are owner-only. You have full access to usage, costs, status, and the AI Explorer above.</p>
++ </fieldset>`}
++ </div>`;
++ loadAiViewersFleet();
++ } catch (e) { c.innerHTML = `<p class="muted">Error: ${esc(e.message)}</p>`; }
++}
++
++async function loadAiViewersFleet() {
++ const el = document.getElementById('ai-viewers-fleet'); if (!el) return;
++ try {
++ const d = await api('/api/admin/streamer/ai/viewers/status');
++ const money = (n) => '$' + (Number(n||0)).toFixed(4);
++ el.innerHTML = `
++ <div style="display:flex;gap:16px;flex-wrap:wrap;align-items:center;margin-bottom:10px">
++ <span>Default engine: <b>${esc(d.engine_default)}</b></span>
++ <span>Kill switch: <b style="color:${d.kill_switch ? '#f87171' : '#4ade80'}">${d.kill_switch ? 'ENGAGED (all channels silent)' : 'off'}</b></span>
++ <span>Shared-key viewer spend today: <b>${money(d.global_spend_today_usd)}</b></span>
++ <span class="muted" style="font-size:12px">The kill switch is ai_viewers_enabled in Configuration below.</span>
++ </div>
++ ${d.running.length ? `<table style="width:100%;font-size:13px;border-collapse:collapse;margin-bottom:10px"><tr><th style="text-align:left">Running now</th><th>Engine</th><th>Stream</th><th>Bots</th><th>Mode</th><th>Lines</th><th>Passes</th><th style="text-align:right">Cost</th><th></th></tr>
++ ${d.running.map(r=>`<tr><td>${esc(r.username||('#'+r.user_id))}</td><td>${esc(r.engine)}</td><td>${r.stream_id}</td><td>${r.bots}</td><td>${esc(r.paused ? 'paused' : (r.mode||''))}</td><td>${r.stats ? r.stats.lines : '—'}</td><td>${r.stats ? r.stats.ticks : '—'}</td><td style="text-align:right">${r.stats ? money(r.stats.cost) : '—'}</td>
++ <td style="white-space:nowrap"><button class="btn btn-sm btn-outline" onclick="aiViewersAdmin(${r.user_id},'${r.paused ? 'resume' : 'pause'}')">${r.paused ? 'Resume' : 'Pause'}</button> <button class="btn btn-sm btn-danger" onclick="aiViewersAdmin(${r.user_id},'stop')">Stop + disable</button></td></tr>`).join('')}</table>` : '<p class="muted" style="margin:0 0 8px">No AI viewer workers running right now.</p>'}
++ <details><summary class="muted" style="cursor:pointer;font-size:12px">Channels with AI viewers configured (${d.channels.length})</summary>
++ <table style="width:100%;font-size:13px;border-collapse:collapse"><tr><th style="text-align:left">Channel</th><th>Enabled</th><th>Activity</th><th>Bots</th><th>Key</th><th style="text-align:right">Today / cap</th></tr>
++ ${d.channels.map(c=>`<tr><td>${esc(c.username)}</td><td>${c.enabled ? '✔' : '—'}</td><td>${esc(c.activity||'—')}</td><td>${c.bots}</td><td>${c.shared_key ? 'shared' : 'BYO'}</td><td style="text-align:right">${money(c.spent_today_usd)} / ${c.shared_key ? '$'+c.cap_usd.toFixed(2) : '∞'}</td></tr>`).join('')}</table></details>`;
++ } catch (e) { el.innerHTML = `<span class="muted">Unavailable: ${esc(e.message)}</span>`; }
++}
++async function aiViewersAdmin(userId, action) {
++ if (action === 'stop' && !confirm('Stop this channel\'s AI viewers now and disable them?')) return;
++ try { const d = await api(`/api/admin/streamer/ai/viewers/${userId}/${action}`, { method: 'POST', body: {} }); toast(d.message || 'ok', 'success'); loadAiViewersFleet(); }
++ catch (e) { toast(e.message, 'error'); }
++}
++
++async function saveAi() {
++ const inputs = document.querySelectorAll('#ai-form [data-key]');
++ const settings = {};
++ for (const input of inputs) {
++ let v = input.dataset.type === 'boolean' ? (input.checked ? 'true' : 'false') : input.value;
++ if (input.dataset.type === 'json') { try { v = JSON.stringify(JSON.parse(input.value)); } catch (e) { return toast(`${input.dataset.key}: invalid JSON — ${e.message}`, 'error'); } }
++ settings[input.dataset.key] = v;
++ }
++ try {
++ await api('/api/admin/streamer/settings', { method: 'PUT', body: { settings } });
++ toast('AI settings saved', 'success');
++ } catch (e) { toast(e.message, 'error'); }
++}
++
++// ── AI Status + Explorer ─────────────────────────────────────
++async function testAiStatus() {
++ const out = document.getElementById('ai-status-out');
++ if (!out) return;
++ out.innerHTML = '<span class="muted">Checking… (this makes one tiny live call)</span>';
++ try {
++ const { status } = await api('/api/admin/streamer/ai/status');
++ const badge = status.ok ? '<span style="color:#4ade80">● OK</span>' : '<span style="color:#f87171">● Problem</span>';
++ out.innerHTML = `<div style="font-size:13px;line-height:1.7">
++ ${badge} &nbsp; via <b>${esc(status.service || 'openvibe-ai')}</b>${status.probed ? ` · reply "${esc(status.reply||'')}" · ${status.latency_ms}ms` : ''}
++ <br>enabled: <b>${status.enabled}</b> · paste analysis: ${status.paste_analysis} · stream memory: ${status.stream_memory}
++ <br>cost today: <b>$${Number(status.cost_today||0).toFixed(4)}</b> · daily cap: ${status.budget_cap_usd_per_day ? '$'+status.budget_cap_usd_per_day : 'none'}
++ ${status.error ? `<br><span style="color:#f87171">${esc(status.error)}</span>` : ''}
++ </div>`;
++ } catch (e) { out.innerHTML = `<span style="color:#f87171">${esc(e.message)}</span>`; }
++}
++
++async function aiExplorerSearch() {
++ const q = (document.getElementById('ai-explorer-search').value || '').trim();
++ const box = document.getElementById('ai-explorer-results');
++ box.innerHTML = '<span class="muted">Searching…</span>';
++ try {
++ const data = await api('/api/admin/streamer/users?search=' + encodeURIComponent(q) + '&limit=15');
++ const users = data.users || [];
++ box.innerHTML = users.length
++ ? users.map(u => `<button type="button" class="btn btn-sm" style="margin:2px" onclick="aiExplorerLoad(${u.id})">@${esc(u.username)}${u.display_name && u.display_name !== u.username ? ' ('+esc(u.display_name)+')' : ''}</button>`).join('')
++ : '<span class="muted">No matching users.</span>';
++ } catch (e) { box.innerHTML = `<span style="color:#f87171">${esc(e.message)}</span>`; }
++}
++
++async function aiExplorerLoad(userId) {
++ const box = document.getElementById('ai-explorer-detail');
++ box.innerHTML = '<div class="loading">Loading…</div>';
++ try {
++ const d = await api('/api/admin/streamer/ai/explorer/' + userId);
++ const ov = d.overview;
++ const tbl = (rows, head) => `<table style="width:100%;font-size:12px;border-collapse:collapse;margin-top:6px"><thead><tr>${head.map(h=>`<th style="text-align:left;border-bottom:1px solid var(--border);padding:3px 6px">${h}</th>`).join('')}</tr></thead><tbody>${rows||`<tr><td style="padding:6px" class="muted">None</td></tr>`}</tbody></table>`;
++ const scrollCell = (t) => t ? `<div style="max-height:90px;overflow:auto;white-space:pre-wrap;font-size:11px;line-height:1.45">${esc(t)}</div>` : '<span class="muted">—</span>';
++ const isHeard = (s) => /heard:/i.test(s||'');
++ const memRows = (d.memories||[]).map(m => `<tr><td style="padding:3px 6px;white-space:nowrap;color:var(--muted)">${esc((m.created_at||'').slice(0,16))}${isHeard(m.description)?' <span title="contains audio transcript" style="color:var(--accent)">🔊</span>':''}</td><td style="padding:3px 6px">${esc(m.description||'')}</td></tr>`).join('');
++ const pasteRows = (d.pastes||[]).map(p => `<tr><td style="padding:3px 6px">${esc(p.type||'')}</td><td style="padding:3px 6px">${esc(p.title||'')}</td><td style="padding:3px 6px">${esc(p.ai_summary||'—')}</td></tr>`).join('');
++ const vodRows = (d.vods||[]).map(v => `<tr><td style="padding:3px 6px">${esc(v.title||'')}</td><td style="padding:3px 6px">${esc(v.ai_overview||'—')}</td><td style="padding:3px 6px;min-width:180px">${scrollCell(v.ai_transcript)}</td></tr>`).join('');
++ const clipRows = (d.clips||[]).map(c => `<tr><td style="padding:3px 6px">${esc(c.title||'')}</td><td style="padding:3px 6px">${esc(c.ai_overview||'—')}</td><td style="padding:3px 6px;min-width:180px">${scrollCell(c.ai_transcript)}</td></tr>`).join('');
++ const txCount = (d.vods||[]).filter(v=>v.ai_transcript).length + (d.clips||[]).filter(c=>c.ai_transcript).length + (d.memories||[]).filter(m=>isHeard(m.description)).length;
++ box.innerHTML = `
++ <div style="display:flex;align-items:center;gap:10px;margin-bottom:8px">
++ <h3 style="margin:0">@${esc(d.user.username)}</h3>
++ <button type="button" class="btn btn-sm btn-primary" onclick="aiGenerateOverview(${userId})"><i class="fa-solid fa-wand-magic-sparkles"></i> Generate Overview</button>
++ </div>
++ <div style="padding:10px;background:var(--bg-input);border-radius:6px;margin-bottom:10px">
++ <strong>AI Overview</strong> ${ov?`<small class="muted">(${esc((ov.generated_at||'').slice(0,16))} · ${esc(ov.model||'')})</small>`:''}
++ <div style="margin-top:6px;white-space:pre-wrap;font-size:13px;line-height:1.5">${ov?esc(ov.overview):'<span class="muted">Not generated yet — click “Generate Overview”.</span>'}</div>
++ </div>
++ <p class="muted" style="font-size:12px;margin:0 0 6px">${d.counts.memories} memories · ${d.counts.pastes} pastes · ${d.counts.vods} vods · ${d.counts.clips||0} clips · <span style="color:var(--accent)">🔊 ${txCount} with transcript</span></p>
++ <details style="margin-bottom:6px"><summary style="cursor:pointer">Stream memories (${d.counts.memories}) — 🔊 = includes audio transcript</summary>${tbl(memRows,['When','Description'])}</details>
++ <details style="margin-bottom:6px"><summary style="cursor:pointer">Pastes (${d.counts.pastes})</summary>${tbl(pasteRows,['Type','Title','AI summary'])}</details>
++ <details style="margin-bottom:6px"><summary style="cursor:pointer">VODs (${d.counts.vods}) — overview + transcript</summary>${tbl(vodRows,['Title','AI overview','Transcript'])}</details>
++ <details><summary style="cursor:pointer">Clips (${d.counts.clips||0}) — overview + transcript</summary>${tbl(clipRows,['Title','AI overview','Transcript'])}</details>`;
++ } catch (e) { box.innerHTML = `<span style="color:#f87171">${esc(e.message)}</span>`; }
++}
++
++async function aiGenerateOverview(userId) {
++ toast('Generating overview… (one AI call)', 'info');
++ try {
++ await api('/api/admin/streamer/ai/streamer/' + userId + '/overview', { method: 'POST' });
++ toast('Overview generated', 'success');
++ aiExplorerLoad(userId);
++ } catch (e) { toast(e.message || 'Failed', 'error'); }
++}
++
++async function loadStreamerMediaTools() {
++ const c = document.getElementById('settings-media');
++ c.innerHTML = '<div class="loading">Loading...</div>';
++
++ let status = {};
++ try {
++ status = await api('/api/admin/streamer/media-tools/status');
++ } catch (e) {
++ c.innerHTML = `<p class="muted">Error: ${esc(e.message)}</p>`;
++ return;
++ }
++
++ c.innerHTML = `
++ <div style="display:grid;gap:16px;max-width:860px">
++ <div class="card">
++ <h3><i class="fa-solid fa-wrench"></i> OpenVibe.Live yt-dlp Status</h3>
++ <div style="display:grid;gap:8px;font-size:0.95rem">
++ <div><strong>yt-dlp available:</strong> ${status.ytdlp_available
++ ? '<span style="color:var(--success,#22c55e)"><i class="fa-solid fa-check-circle"></i> Yes</span>'
++ : '<span style="color:var(--danger,#ef4444)"><i class="fa-solid fa-xmark-circle"></i> No</span>'
++ }</div>
++ <div><strong>Path:</strong> <span style="font-family:monospace;font-size:0.88rem">${esc(status.ytdlp_path || '')}</span></div>
++ <div><strong>YouTube cookies:</strong> ${status.cookies_configured
++ ? `<span style="color:var(--success,#22c55e)"><i class="fa-solid fa-cookie"></i> Configured (${esc(String(status.cookies_size || 0))} bytes)</span>`
++ : '<span class="muted"><i class="fa-solid fa-cookie-bite"></i> Not configured</span>'
++ }</div>
++ ${!status.cookies_configured ? '<div style="color:var(--warn,#f59e0b);font-size:0.88rem"><i class="fa-solid fa-triangle-exclamation"></i> YouTube server-side downloads will fail until cookies.txt is added.</div>' : ''}
++ </div>
++ </div>
++
++ <div class="card">
++ <h3><i class="fa-solid fa-cookie"></i> YouTube Cookies</h3>
++ <p class="muted" style="margin-top:0">Paste a Netscape-format cookies.txt export from a browser session logged into YouTube. This is required when YouTube bot detection blocks server-side downloads.</p>
++ <textarea id="streamer-media-cookies-input" rows="10" placeholder="# Netscape HTTP Cookie File&#10;.youtube.com&#9;TRUE&#9;/&#9;TRUE&#9;0&#9;SID&#9;value..."
++ style="width:100%;background:var(--bg-input);color:var(--text);border:1px solid var(--border);padding:10px;border-radius:6px;font-family:monospace;font-size:12px;resize:vertical"></textarea>
++ <div style="display:flex;gap:8px;flex-wrap:wrap;margin-top:12px">
++ <button type="button" class="btn btn-primary" onclick="saveStreamerMediaCookies()"><i class="fa-solid fa-floppy-disk"></i> Save &amp; Test</button>
++ <button type="button" class="btn" onclick="checkStreamerMediaCookies()" ${status.cookies_configured ? '' : 'disabled'}><i class="fa-solid fa-stethoscope"></i> Test Current Cookies</button>
++ <button type="button" class="btn" onclick="deleteStreamerMediaCookies()" ${status.cookies_configured ? '' : 'disabled'}><i class="fa-solid fa-trash"></i> Remove Cookies</button>
++ </div>
++ <div id="streamer-media-cookie-verdict"></div>
++
++ <details style="margin-top:16px">
++ <summary style="cursor:pointer;font-weight:600"><i class="fa-solid fa-circle-question"></i> How to get cookies that actually work</summary>
++ <div style="margin-top:12px;font-size:0.9rem;line-height:1.65">
++ <p style="margin-top:0"><strong>Why this is needed.</strong> YouTube treats requests from datacenter IPs as suspicious and answers with
++ <em>&ldquo;Sign in to confirm you&rsquo;re not a bot&rdquo;</em>. A signed-in cookie jar is what gets past that. Some videos work without it;
++ age-restricted and heavily-flagged ones will not.</p>
++
++ <p><strong>The one thing that matters.</strong> The export must contain YouTube&rsquo;s <em>first-party</em> session cookies —
++ <code>SID</code>, <code>HSID</code>, <code>SSID</code>, <code>APISID</code>, <code>SAPISID</code>, <code>__Secure-1PSID</code>.
++ An export holding only <code>__Secure-3P*</code> cookies parses fine and is <em>not signed in</em>; it will fail exactly the same way.
++ Save &amp; Test now tells you which of these are present.</p>
++
++ <p><strong>Recommended method — a throwaway account in a separate browser profile:</strong></p>
++ <ol style="margin:6px 0 0 18px;padding:0">
++ <li>Create a <em>burner</em> Google account. Do not use your main one: this jar grants access to that account, and it lives on the server.</li>
++ <li>Open a brand-new browser profile (not incognito &mdash; incognito is what produces the third-party-only exports that fail).</li>
++ <li>Sign in to YouTube in that profile and watch a few seconds of any video so the session is fully established.</li>
++ <li>Install a cookies.txt exporter extension (&ldquo;Get cookies.txt LOCALLY&rdquo; or similar) and export for <code>youtube.com</code>.</li>
++ <li>Paste the file contents above and press <strong>Save &amp; Test</strong>. It validates the jar and runs a real extraction before accepting it.</li>
++ <li>Then <strong>close that browser profile without logging out</strong>. Logging out invalidates the cookies you just exported.</li>
++ </ol>
++
++ <p style="margin-top:12px"><strong>Keeping it working:</strong></p>
++ <ul style="margin:6px 0 0 18px;padding:0">
++ <li>Cookies expire. Press <strong>Test Current Cookies</strong> if requests start failing &mdash; it reports the earliest expiry.</li>
++ <li>Do not use that burner account in another browser afterwards; YouTube rotates the session and the saved jar goes stale.</li>
++ <li>The file must be tab-separated Netscape format. Copying from a viewer that converts tabs to spaces produces a file that parses to nothing &mdash; the validator flags this.</li>
++ <li>A residential proxy avoids the problem at its source, since the block is driven by the server&rsquo;s datacenter IP.</li>
++ </ul>
++
++ <p style="margin-top:12px;color:var(--warn,#f59e0b)"><i class="fa-solid fa-triangle-exclamation"></i>
++ Anyone with admin access to this panel can read these cookies, and they are stored unencrypted on the server. Treat the account as compromised-by-design &mdash; burner only.</p>
++ </div>
++ </details>
++ </div>
++
++ <div class="card">
++ <h3><i class="fa-solid fa-flask-vial"></i> Test Extraction</h3>
++ <p class="muted" style="margin-top:0">Test a YouTube URL against the live OpenVibe.Live server to confirm cookies and yt-dlp are working.</p>
++ <div class="input-row">
++ <input type="text" id="streamer-media-test-url" placeholder="https://www.youtube.com/watch?v=...">
++ <button type="button" class="btn btn-primary" onclick="testStreamerMediaExtraction()"><i class="fa-solid fa-play"></i> Test</button>
++ </div>
++ <div id="streamer-media-test-results" style="margin-top:12px"></div>
++ </div>
++ </div>`;
++}
++
++
++/**
++ * Render the cookie validation + live-test verdict returned by the server.
++ * The old flow just said "saved", so a jar that could never work looked identical to a
++ * good one — this shows exactly which check failed and what to do about it.
++ */
++function renderCookieVerdict(data) {
++ const el = document.getElementById('streamer-media-cookie-verdict');
++ if (!el) return;
++ if (!data) { el.innerHTML = ''; return; }
++ const c = data.check || {};
++ const t = data.test || {};
++ const ok = (v) => v
++ ? '<span style="color:var(--success,#22c55e)"><i class="fa-solid fa-circle-check"></i> '
++ : '<span style="color:var(--danger,#ef4444)"><i class="fa-solid fa-circle-xmark"></i> ';
++
++ const rows = [];
++ rows.push(`<div>${ok(c.ok)}Signed-in cookies${c.ok ? '' : ' — missing'}</span></div>`);
++ if (c.firstParty && c.firstParty.length) {
++ rows.push(`<div class="muted" style="font-size:.85rem">First-party session: ${esc(c.firstParty.join(', '))}</div>`);
++ }
++ if (c.missingFirstParty && c.missingFirstParty.length && !c.ok) {
++ rows.push(`<div class="muted" style="font-size:.85rem">Missing: ${esc(c.missingFirstParty.join(', '))}</div>`);
++ }
++ if (c.thirdParty && c.thirdParty.length && !c.firstParty?.length) {
++ rows.push(`<div class="muted" style="font-size:.85rem">Only third-party found: ${esc(c.thirdParty.join(', '))}</div>`);
++ }
++ if (c.cookieCount != null) {
++ rows.push(`<div class="muted" style="font-size:.85rem">${c.cookieCount} cookie(s)${c.expiresAt ? `, earliest expiry ${esc(new Date(c.expiresAt).toLocaleString())}` : ''}</div>`);
++ }
++ if (t.ran) {
++ rows.push(`<div style="margin-top:6px">${ok(t.ok)}Live extraction${t.ok ? `: ${esc(t.title || 'ok')}` : ' failed'}</span></div>`);
++ if (!t.ok && t.error) rows.push(`<div class="muted" style="font-size:.82rem;word-break:break-word">${esc(String(t.error).slice(0, 300))}</div>`);
++ }
++ (c.errors || []).forEach(e => rows.push(`<div style="color:var(--danger,#ef4444);font-size:.86rem;margin-top:6px">${esc(e)}</div>`));
++ (c.warnings || []).forEach(w => rows.push(`<div style="color:var(--warn,#f59e0b);font-size:.86rem">${esc(w)}</div>`));
++
++ el.innerHTML = `<div style="margin-top:12px;padding:12px;border:1px solid var(--border);border-radius:8px;background:var(--bg-input)">${rows.join('')}</div>`;
++}
++
++async function checkStreamerMediaCookies() {
++ const el = document.getElementById('streamer-media-cookie-verdict');
++ if (el) el.innerHTML = '<div class="muted" style="margin-top:10px"><i class="fa-solid fa-spinner fa-spin"></i> Validating and running a live extraction…</div>';
++ try {
++ const data = await api('/api/admin/streamer/media-tools/cookies/check', { method: 'POST', body: {} });
++ renderCookieVerdict(data);
++ } catch (e) {
++ if (el) el.innerHTML = `<div style="color:var(--danger,#ef4444);margin-top:10px">${esc(e.message)}</div>`;
++ }
++}
++
++async function saveStreamerMediaCookies() {
++ const ta = document.getElementById('streamer-media-cookies-input');
++ const cookies = ta?.value?.trim() || '';
++ if (!cookies) return toast('Paste cookies.txt content first', 'error');
++ const el = document.getElementById('streamer-media-cookie-verdict');
++ if (el) el.innerHTML = '<div class="muted" style="margin-top:10px"><i class="fa-solid fa-spinner fa-spin"></i> Validating and running a live extraction…</div>';
++ try {
++ const data = await api('/api/admin/streamer/media-tools/cookies', { method: 'PUT', body: { cookies } });
++ toast(data?.test?.ok ? 'Cookies saved — live extraction passed' : 'Cookies saved', 'success');
++ renderCookieVerdict(data);
++ } catch (e) {
++ // The server refuses a jar it can already tell is unusable and returns the reason.
++ toast(e.message, 'error');
++ if (e.body) renderCookieVerdict(e.body);
++ else if (el) el.innerHTML = `<div style="color:var(--danger,#ef4444);margin-top:10px">${esc(e.message)}</div>`;
++ }
++}
++
++async function deleteStreamerMediaCookies() {
++ if (!confirm('Remove OpenVibe.Live yt-dlp cookies?')) return;
++ try {
++ await api('/api/admin/streamer/media-tools/cookies', { method: 'DELETE' });
++ toast('OpenVibe.Live cookies removed', 'success');
++ loadStreamerMediaTools();
++ } catch (e) { toast(e.message, 'error'); }
++}
++
++async function testStreamerMediaExtraction() {
++ const url = document.getElementById('streamer-media-test-url')?.value?.trim() || '';
++ const results = document.getElementById('streamer-media-test-results');
++ if (!url) return toast('Enter a URL to test', 'error');
++ results.innerHTML = '<p class="muted"><i class="fa-solid fa-spinner fa-spin"></i> Testing live OpenVibe.Live extraction…</p>';
++ try {
++ const data = await api('/api/admin/streamer/media-tools/test', { method: 'POST', body: { url } });
++ results.innerHTML = `<div style="display:grid;gap:8px;font-size:0.9rem">${(data.steps || []).map(step => {
++ const icon = step.ok
++ ? '<i class="fa-solid fa-check-circle" style="color:var(--success,#22c55e)"></i>'
++ : '<i class="fa-solid fa-xmark-circle" style="color:var(--danger,#ef4444)"></i>';
++ const detail = step.error
++ ? `<span style="color:var(--danger,#ef4444)"> — ${esc(step.error)}</span>`
++ : step.data ? ` — ${esc(JSON.stringify(step.data))}` : '';
++ return `<div>${icon} <strong>${esc(step.name)}</strong>${detail}</div>`;
++ }).join('')}</div>`;
++ } catch (e) {
++ results.innerHTML = `<p style="color:var(--danger,#ef4444)">${esc(e.message)}</p>`;
++ }
++}
++
++// ═══════════════════════════════════════════════════════════════
++// TTS (via openvibelive TTS proxy)
++// ═══════════════════════════════════════════════════════════════
++async function loadTTS() {
++ const c = document.getElementById('tts-content');
++ c.innerHTML = '<div class="loading">Loading...</div>';
++ try {
++ const [settingsRes, voicesRes] = await Promise.all([
++ api('/api/admin/streamer-tts/admin/settings'),
++ api('/api/admin/streamer-tts/voices'),
++ ]);
++ const s = settingsRes.settings || {};
++ const voices = voicesRes.voices || [];
++ const byEngine = (engine) => voices.filter(v => v.engine === engine);
++ const voiceOptions = (list) => list.map(v => `<option value="${esc(v.id)}" ${v.id === s.defaultVoice ? 'selected' : ''}>${esc(v.name)} (${esc(v.rarity || 'standard')})${v.available ? '' : ' ⚠ unavailable'}</option>`).join('');
++
++ c.innerHTML = `
++ <form id="tts-form" style="display:grid;gap:16px;max-width:760px">
++ <div class="card">
++ <h3><i class="fa-solid fa-sliders"></i> TTS Configuration</h3>
++ <div class="setting-row"><label><strong>Enabled</strong><br><small>Allow TTS requests on OpenVibe.Live.</small></label><input type="checkbox" id="tts-enabled" ${s.enabled ? 'checked' : ''} style="width:18px;height:18px"></div>
++ <div class="form-field"><label>Provider</label><select id="tts-provider"><option value="espeak-ng" ${s.provider === 'espeak-ng' ? 'selected' : ''}>espeak-ng</option><option value="google-cloud" ${s.provider === 'google-cloud' ? 'selected' : ''}>Google Cloud</option><option value="amazon-polly" ${s.provider === 'amazon-polly' ? 'selected' : ''}>Amazon Polly</option></select></div>
++ <div class="form-field"><label>Default Voice</label><select id="tts-default-voice"><optgroup label="espeak-ng">${voiceOptions(byEngine('espeak-ng'))}</optgroup><optgroup label="Google Cloud">${voiceOptions(byEngine('google-cloud'))}</optgroup><optgroup label="Amazon Polly">${voiceOptions(byEngine('amazon-polly'))}</optgroup></select></div>
++ <div style="display:grid;grid-template-columns:repeat(3,minmax(0,1fr));gap:12px">
++ <div class="form-field"><label>Max Message Length</label><input type="number" id="tts-max-length" value="${esc(s.maxLength || 200)}"></div>
++ <div class="form-field"><label>Max Queue / User</label><input type="number" id="tts-max-per-user" value="${esc(s.maxQueuePerUser || 3)}"></div>
++ <div class="form-field"><label>Max Global Queue</label><input type="number" id="tts-max-global" value="${esc(s.maxQueueGlobal || 20)}"></div>
++ </div>
++ </div>
++
++ <div class="card">
++ <h3><i class="fa-brands fa-google"></i> Google Cloud</h3>
++ <div class="form-field"><label>API Key</label><input type="password" id="tts-google-api-key" value="${esc(s.googleApiKey || '')}" autocomplete="off"></div>
++ <div class="form-field"><label>Service Account JSON</label><textarea id="tts-google-sa" rows="4" style="resize:vertical">${esc(s.googleServiceAccount || '')}</textarea></div>
++ </div>
++
++ <div class="card">
++ <h3><i class="fa-brands fa-aws"></i> Amazon Polly</h3>
++ <div class="form-field"><label>AWS Access Key ID</label><input type="password" id="tts-aws-key" value="${esc(s.awsAccessKeyId || '')}" autocomplete="off"></div>
++ <div class="form-field"><label>AWS Secret Access Key</label><input type="password" id="tts-aws-secret" value="${esc(s.awsSecretAccessKey || '')}" autocomplete="off"></div>
++ <div class="form-field"><label>AWS Region</label><input type="text" id="tts-aws-region" value="${esc(s.awsRegion || 'us-east-1')}"></div>
++ </div>
++
++ <div style="display:flex;gap:10px;flex-wrap:wrap">
++ <button type="button" class="btn btn-primary" onclick="saveTTS()"><i class="fa-solid fa-floppy-disk"></i> Save TTS Settings</button>
++ <button type="button" class="btn btn-outline" onclick="testTTSVoice()"><i class="fa-solid fa-volume-high"></i> Test Voice</button>
++ </div>
++ </form>`;
++ } catch (e) { c.innerHTML = `<p class="muted">Error: ${esc(e.message)}</p>`; }
++}
++
++async function saveTTS() {
++ try {
++ await api('/api/admin/streamer-tts/admin/settings', {
++ method: 'PUT',
++ body: {
++ settings: {
++ tts_enabled: document.getElementById('tts-enabled').checked,
++ tts_provider: document.getElementById('tts-provider').value,
++ tts_default_voice: document.getElementById('tts-default-voice').value,
++ tts_max_length: parseInt(document.getElementById('tts-max-length').value, 10) || 200,
++ tts_max_queue_per_user: parseInt(document.getElementById('tts-max-per-user').value, 10) || 3,
++ tts_max_queue_global: parseInt(document.getElementById('tts-max-global').value, 10) || 20,
++ tts_google_api_key: document.getElementById('tts-google-api-key').value,
++ tts_google_service_account: document.getElementById('tts-google-sa').value,
++ tts_aws_access_key_id: document.getElementById('tts-aws-key').value,
++ tts_aws_secret_access_key: document.getElementById('tts-aws-secret').value,
++ tts_aws_region: document.getElementById('tts-aws-region').value || 'us-east-1',
++ },
++ },
++ });
++ toast('TTS settings saved', 'success');
++ } catch (e) { toast(e.message, 'error'); }
++}
++
++async function testTTSVoice() {
++ try {
++ const voiceId = document.getElementById('tts-default-voice').value;
++ const result = await api('/api/admin/streamer-tts/admin/test', {
++ method: 'POST',
++ body: { voiceId, text: 'Hello, this is a OpenVibe.Live voice test from the unified admin panel.' },
++ });
++ if (!result.audio || !result.mimeType) {
++ toast('No audio returned from the provider', 'warning');
++ return;
++ }
++ const binary = atob(result.audio);
++ const bytes = new Uint8Array(binary.length);
++ for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
++ const url = URL.createObjectURL(new Blob([bytes], { type: result.mimeType }));
++ const audio = new Audio(url);
++ audio.onended = () => URL.revokeObjectURL(url);
++ await audio.play();
++ toast(`Testing ${result.voiceName || voiceId}`, 'info');
++ } catch (e) { toast(e.message, 'error'); }
++}
ddiff --git a/public/js/admin/analytics.js b/public/js/admin/analytics.js
nnew file mode 100644
iindex 00000000..4b30c959
---- /dev/null
++++ b/public/js/admin/analytics.js
@@@ -0,0 +1,660 @@
++/* ═══════════════════════════════════════════════════════════════
++ OpenVibe.Network — admin panel (/admin): the analytics tab and its charts.
++ Split out of admin.html. Classic scripts, global scope: they rely on
++ admin/core.js's helpers (api, esc, toast, API, token) and on the
++ tabLoaders map in admin/boot.js (loaded last). No ES modules.
++ ═══════════════════════════════════════════════════════════════ */
++// ═══════════════════════════════════════════════════════════════
++// Analytics
++// ═══════════════════════════════════════════════════════════════
++let analyticsCurrentSubTab = 'overview';
++let analyticsCharts = {};
++const CHART_COLORS = {
++ accent: '#8b5cf6',
++ accentLight: '#a78bfa',
++ success: '#2ecc71',
++ danger: '#e74c3c',
++ warning: '#f59e0b',
++ info: '#3498db',
++ purple: '#9b59b6',
++ pink: '#e84393',
++ teal: '#00cec9',
++ navy: '#2d3436',
++};
++const PIE_COLORS = ['#8b5cf6', '#3498db', '#2ecc71', '#e74c3c', '#9b59b6', '#f59e0b', '#e84393', '#00cec9', '#1abc9c', '#fd79a8'];
++
++function getAnalyticsDays() {
++ return parseInt(document.getElementById('analytics-period')?.value) || 30;
++}
++
++/** Parse the period select value into {days, hours, qs} */
++function getAnalyticsPeriod() {
++ const val = document.getElementById('analytics-period')?.value || '30';
++ if (val.endsWith('h')) {
++ const hours = parseInt(val);
++ return { days: Math.ceil(hours / 24), hours, qs: `days=${Math.ceil(hours / 24)}&hours=${hours}` };
++ }
++ if (val.endsWith('d')) {
++ const d = parseInt(val);
++ const hours = d * 24;
++ return { days: d, hours, qs: `days=${d}&hours=${hours}` };
++ }
++ const days = parseInt(val) || 30;
++ return { days, hours: null, qs: `days=${days}` };
++}
++
++function showAnalyticsSubTab(tab, btn) {
++ analyticsCurrentSubTab = tab;
++ document.querySelectorAll('#analytics-sub-tabs button').forEach(b => b.classList.remove('active'));
++ btn?.classList.add('active');
++ document.getElementById('analytics-overview').style.display = tab === 'overview' ? 'block' : 'none';
++ const serviceNames = ['live','openvibe-network','tools','games','media'];
++ document.getElementById('analytics-service-detail').style.display = serviceNames.includes(tab) ? 'block' : 'none';
++ document.getElementById('analytics-bots-detail').style.display = tab === 'bots' ? 'block' : 'none';
++ if (tab === 'overview') loadAnalyticsOverview();
++ else if (tab === 'bots') loadBotAnalysis();
++ else loadServiceAnalytics(tab);
++ _syncAdminUrl();
++}
++
++function reloadAnalytics() {
++ if (analyticsCurrentSubTab === 'overview') loadAnalyticsOverview();
++ else if (analyticsCurrentSubTab === 'bots') loadBotAnalysis();
++ else loadServiceAnalytics(analyticsCurrentSubTab);
++ _syncAdminUrl(true); // period lives in ?last= — replace, don't stack history
++}
++
++async function loadAnalytics() {
++ loadAnalyticsOverview();
++}
++
++function destroyChart(id) {
++ if (analyticsCharts[id]) { analyticsCharts[id].destroy(); delete analyticsCharts[id]; }
++}
++
++function createChart(canvasId, config) {
++ destroyChart(canvasId);
++ const ctx = document.getElementById(canvasId);
++ if (!ctx) return null;
++ // Default dark theme
++ config.options = config.options || {};
++ config.options.responsive = true;
++ config.options.maintainAspectRatio = true;
++ config.options.plugins = config.options.plugins || {};
++ config.options.plugins.legend = config.options.plugins.legend || { labels: { color: '#b0b0b8', font: { size: 11 } } };
++ if (config.options.scales) {
++ for (const [, axis] of Object.entries(config.options.scales)) {
++ axis.ticks = axis.ticks || {};
++ axis.ticks.color = axis.ticks.color || '#707080';
++ axis.grid = axis.grid || {};
++ axis.grid.color = axis.grid.color || 'rgba(42,42,58,0.5)';
++ }
++ }
++ analyticsCharts[canvasId] = new Chart(ctx, config);
++ return analyticsCharts[canvasId];
++}
++
++function fmtNum(n) { return (n || 0).toLocaleString(); }
++
++async function loadAnalyticsOverview() {
++ const period = getAnalyticsPeriod();
++ const statsEl = document.getElementById('analytics-stats');
++ statsEl.innerHTML = '<div class="loading">Loading analytics...</div>';
++ try {
++ const data = await api(`/api/admin/analytics/overview?${period.qs}`);
++ const ov = data.overview;
++ const t = ov.totals || {};
++ const bw = ov.bandwidth || {};
++
++ statsEl.innerHTML = [
++ { icon: 'fa-eye', label: 'Page Views', value: fmtNum(t.total_pageviews) },
++ { icon: 'fa-code', label: 'API Calls', value: fmtNum(t.total_api_calls) },
++ { icon: 'fa-users', label: 'Unique Visitors', value: fmtNum(t.total_unique_visitors) },
++ { icon: 'fa-id-badge', label: 'Sessions', value: fmtNum(ov.sessionCount || 0) },
++ { icon: 'fa-robot', label: 'Bot Hits', value: fmtNum(t.total_bot_hits) },
++ { icon: 'fa-circle-exclamation', label: 'Errors', value: fmtNum(t.total_errors) },
++ { icon: 'fa-gauge-high', label: 'Avg Response', value: (t.avg_response_ms || 0) + 'ms' },
++ { icon: 'fa-arrow-up-from-bracket', label: 'Est. Bandwidth', value: fmtBytes(bw.estimated_bytes) },
++ ].map(s => `<div class="stat-card"><div class="value">${s.value}</div><div class="label"><i class="fa-solid ${s.icon}"></i> ${s.label}</div></div>`).join('');
++
++ // Real-time card
++ const rt = ov.realtime || {};
++ const rtCard = document.getElementById('analytics-realtime-card');
++ if (rt.requests > 0) {
++ rtCard.style.display = 'block';
++ document.getElementById('analytics-realtime').innerHTML = `
++ <div style="display:flex;gap:24px;font-size:14px">
++ <span><strong style="color:var(--accent-light)">${fmtNum(rt.requests)}</strong> requests</span>
++ <span><strong style="color:var(--success)">${fmtNum(rt.visitors)}</strong> visitors</span>
++ <span><strong style="color:var(--danger)">${fmtNum(rt.bots)}</strong> bots</span>
++ </div>`;
++ } else {
++ rtCard.style.display = 'none';
++ }
++
++ // Traffic trend chart — use timeBuckets for sub-day, dailyTrend for multi-day
++ const buckets = ov.timeBuckets || [];
++ const trend = ov.dailyTrend || [];
++ const useSubDay = period.hours && period.hours < 24 && buckets.length > 0;
++ const trendData = useSubDay ? buckets : trend;
++ const trendLabels = useSubDay
++ ? trendData.map(d => { const parts = d.bucket?.split(' '); return parts?.[1] || d.bucket; })
++ : trendData.map(d => d.date?.slice(5) || '');
++ if (trendData.length) {
++ createChart('chart-traffic-trend', {
++ type: 'line',
++ data: {
++ labels: trendLabels,
++ datasets: [
++ { label: 'Page Views', data: trendData.map(d => d.pageviews), borderColor: CHART_COLORS.accent, backgroundColor: 'rgba(139,92,246,0.1)', fill: true, tension: 0.3 },
++ { label: 'API Calls', data: trendData.map(d => d.api_calls), borderColor: CHART_COLORS.info, backgroundColor: 'rgba(52,152,219,0.1)', fill: true, tension: 0.3 },
++ { label: 'Visitors', data: trendData.map(d => d.unique_visitors), borderColor: CHART_COLORS.success, backgroundColor: 'rgba(46,204,113,0.1)', fill: true, tension: 0.3 },
++ ],
++ },
++ options: { scales: { x: {}, y: { beginAtZero: true } } },
++ });
++ }
++
++ // Service comparison bar chart
++ const svcs = ov.services || [];
++ if (svcs.length) {
++ createChart('chart-service-compare', {
++ type: 'bar',
++ data: {
++ labels: svcs.map(s => s.label || s.name),
++ datasets: [
++ { label: 'Page Views', data: svcs.map(s => s.total_pageviews || 0), backgroundColor: CHART_COLORS.accent },
++ { label: 'API Calls', data: svcs.map(s => s.total_api_calls || 0), backgroundColor: CHART_COLORS.info },
++ { label: 'Bot Hits', data: svcs.map(s => s.total_bot_hits || 0), backgroundColor: CHART_COLORS.danger },
++ ],
++ },
++ options: { scales: { x: {}, y: { beginAtZero: true } } },
++ });
++ }
++
++ // ── Unique Visitors Trend (dedicated chart) ──────────
++ if (trendData.length) {
++ createChart('chart-visitors-trend', {
++ type: 'line',
++ data: {
++ labels: trendLabels,
++ datasets: [
++ { label: 'Unique Visitors', data: trendData.map(d => d.unique_visitors || 0), borderColor: CHART_COLORS.success, backgroundColor: 'rgba(46,204,113,0.15)', fill: true, tension: 0.3, pointRadius: 3 },
++ ],
++ },
++ options: {
++ scales: { x: {}, y: { beginAtZero: true } },
++ plugins: { legend: { display: false } },
++ },
++ });
++ }
++
++ // ── Logged-in vs Anonymous Trend ─────────────────────
++ const authTrend = ov.authTrend || [];
++ if (authTrend.length) {
++ const authLabels = useSubDay
++ ? authTrend.map(d => { const parts = d.bucket?.split(' '); return parts?.[1] || d.bucket; })
++ : authTrend.map(d => d.bucket?.slice(5) || '');
++ createChart('chart-auth-trend', {
++ type: 'line',
++ data: {
++ labels: authLabels,
++ datasets: [
++ { label: 'Logged-in Visitors', data: authTrend.map(d => d.auth_visitors || 0), borderColor: CHART_COLORS.accent, backgroundColor: 'rgba(139,92,246,0.15)', fill: true, tension: 0.3, pointRadius: 2 },
++ { label: 'Anonymous Visitors', data: authTrend.map(d => d.anon_visitors || 0), borderColor: '#6c6c80', backgroundColor: 'rgba(108,108,128,0.15)', fill: true, tension: 0.3, pointRadius: 2 },
++ ],
++ },
++ options: { scales: { x: {}, y: { beginAtZero: true, stacked: true } }, plugins: { legend: { position: 'bottom' } } },
++ });
++ }
++
++ // ── Per-Service Traffic Share (doughnut) ─────────────
++ if (svcs.length) {
++ const svcTraffic = svcs.map(s => (s.total_pageviews || 0) + (s.total_api_calls || 0)).filter(v => v > 0);
++ const svcLabelsFiltered = svcs.filter((s, i) => svcTraffic.length > i && ((s.total_pageviews || 0) + (s.total_api_calls || 0)) > 0);
++ if (svcTraffic.length) {
++ createChart('chart-service-share', {
++ type: 'doughnut',
++ data: {
++ labels: svcLabelsFiltered.map(s => s.label || s.name),
++ datasets: [{ data: svcLabelsFiltered.map(s => (s.total_pageviews || 0) + (s.total_api_calls || 0)), backgroundColor: PIE_COLORS.slice(0, svcLabelsFiltered.length), borderWidth: 0 }],
++ },
++ options: { plugins: { legend: { position: 'bottom' } } },
++ });
++ }
++
++ // Per-service visitors doughnut
++ const svcVis = svcs.filter(s => (s.total_unique_visitors || 0) > 0);
++ if (svcVis.length) {
++ createChart('chart-service-visitors', {
++ type: 'doughnut',
++ data: {
++ labels: svcVis.map(s => s.label || s.name),
++ datasets: [{ data: svcVis.map(s => s.total_unique_visitors || 0), backgroundColor: PIE_COLORS.slice(0, svcVis.length), borderWidth: 0 }],
++ },
++ options: { plugins: { legend: { position: 'bottom' } } },
++ });
++ }
++ }
++
++ // ── Per-Service Traffic Over Time (stacked area) ─────
++ const perSvcTrend = ov.perServiceTrend || [];
++ if (perSvcTrend.length) {
++ // Build unified labels from all series
++ const labelSet = new Set();
++ for (const s of perSvcTrend) {
++ for (const d of s.data) labelSet.add(d.date || d.bucket);
++ }
++ const labels = Array.from(labelSet).sort();
++ const fmtLabel = (l) => useSubDay ? (l.split(' ')[1] || l) : (l.slice(5) || l);
++ const datasets = perSvcTrend.map((s, i) => {
++ const dataMap = new Map(s.data.map(d => [d.date || d.bucket, (d.pageviews || 0) + (d.api_calls || 0)]));
++ const color = PIE_COLORS[i % PIE_COLORS.length];
++ return {
++ label: s.label || s.name,
++ data: labels.map(l => dataMap.get(l) || 0),
++ borderColor: color,
++ backgroundColor: color + '33',
++ fill: true,
++ tension: 0.3,
++ pointRadius: 1,
++ };
++ });
++ createChart('chart-per-service-trend', {
++ type: 'line',
++ data: { labels: labels.map(fmtLabel), datasets },
++ options: { scales: { x: {}, y: { beginAtZero: true, stacked: true } }, plugins: { legend: { position: 'bottom' } } },
++ });
++ }
++
++ // ── Service table with auth breakdown columns ────────
++ const tblEl = document.getElementById('analytics-service-table');
++ tblEl.innerHTML = svcs.length ? `
++ <table class="admin-table">
++ <thead><tr><th>Service</th><th>Views</th><th>API</th><th>Visitors</th><th>Logged-in</th><th>Anon</th><th>Bots</th><th>Errors</th><th>Avg ms</th></tr></thead>
++ <tbody>${svcs.map(s => {
++ const auth = s.authBreakdown || {};
++ return `
++ <tr>
++ <td><strong>${esc(s.label || s.name)}</strong></td>
++ <td>${fmtNum(s.total_pageviews)}</td>
++ <td>${fmtNum(s.total_api_calls)}</td>
++ <td>${fmtNum(s.total_unique_visitors)}</td>
++ <td style="color:var(--accent-light)">${fmtNum(auth.unique_authenticated_users)}</td>
++ <td style="color:var(--text-secondary)">${fmtNum(auth.anonymous)}</td>
++ <td>${fmtNum(s.total_bot_hits)}</td>
++ <td>${fmtNum(s.total_errors)}</td>
++ <td>${s.avg_response_ms || 0}ms</td>
++ </tr>`;
++ }).join('')}</tbody>
++ </table>` : '<p class="muted">No data yet</p>';
++
++ // Bot ratio donut
++ const totalHuman = (t.total_pageviews || 0) + (t.total_api_calls || 0);
++ const totalBots = t.total_bot_hits || 0;
++ if (totalHuman > 0 || totalBots > 0) {
++ createChart('chart-bot-ratio', {
++ type: 'doughnut',
++ data: {
++ labels: ['Human Traffic', 'Bot Traffic'],
++ datasets: [{ data: [totalHuman, totalBots], backgroundColor: [CHART_COLORS.success, CHART_COLORS.danger], borderWidth: 0 }],
++ },
++ options: { plugins: { legend: { position: 'bottom' } } },
++ });
++ }
++
++ // Bandwidth & usage card
++ const bwEl = document.getElementById('analytics-bandwidth');
++ bwEl.innerHTML = `
++ <div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;font-size:13px">
++ <div><strong style="color:var(--accent-light)">${fmtNum(bw.total_requests)}</strong><br><span class="muted">Total Requests</span></div>
++ <div><strong style="color:var(--accent-light)">${fmtBytes(bw.estimated_bytes)}</strong><br><span class="muted">Est. Transfer</span></div>
++ <div><strong style="color:var(--info)">${fmtNum(bw.page_requests)}</strong><br><span class="muted">Page Loads</span></div>
++ <div><strong style="color:var(--info)">${fmtNum(bw.api_requests)}</strong><br><span class="muted">API Requests</span></div>
++ <div><strong style="color:var(--success)">${fmtNum(ov.sessionCount || 0)}</strong><br><span class="muted">Unique Sessions</span></div>
++ <div><strong style="color:var(--success)">${fmtNum(t.total_unique_users || 0)}</strong><br><span class="muted">Logged-in Users</span></div>
++ </div>`;
++
++ } catch (e) {
++ statsEl.innerHTML = `<p class="muted">Error: ${esc(e.message)}</p>`;
++ }
++}
++
++async function loadServiceAnalytics(serviceName) {
++ const period = getAnalyticsPeriod();
++ const statsEl = document.getElementById('svc-analytics-stats');
++ statsEl.innerHTML = '<div class="loading">Loading...</div>';
++ try {
++ const data = await api(`/api/admin/analytics/service/${serviceName}?${period.qs}`);
++ const a = data.analytics;
++ const s = a.summary || {};
++ const bw = a.bandwidth || {};
++ const auth = a.authBreakdown || {};
++ const vis = a.visitorTypes || {};
++ const rp = a.responsePercentiles || {};
++
++ // Stat cards — more metrics
++ statsEl.innerHTML = [
++ { icon: 'fa-eye', label: 'Page Views', value: fmtNum(s.total_pageviews) },
++ { icon: 'fa-code', label: 'API Calls', value: fmtNum(s.total_api_calls) },
++ { icon: 'fa-users', label: 'Unique Visitors', value: fmtNum(s.total_unique_visitors) },
++ { icon: 'fa-user', label: 'Logged-in Users', value: fmtNum(s.total_unique_users) },
++ { icon: 'fa-id-badge', label: 'Sessions', value: fmtNum(a.sessionCount || 0) },
++ { icon: 'fa-robot', label: 'Bot Hits', value: fmtNum(s.total_bot_hits) },
++ { icon: 'fa-circle-exclamation', label: 'Errors', value: fmtNum(s.total_errors) },
++ { icon: 'fa-gauge-high', label: 'Avg Response', value: (s.avg_response_ms || 0) + 'ms' },
++ { icon: 'fa-arrow-up-from-bracket', label: 'Est. Bandwidth', value: fmtBytes(bw.estimated_bytes) },
++ // New vs returning needs a visitor identity that outlives a day; ADR-021 analytics keeps none.
++ vis.new_visitors != null ? { icon: 'fa-user-plus', label: 'New Visitors', value: fmtNum(vis.new_visitors) } : null,
++ ].filter(Boolean).map(s => `<div class="stat-card"><div class="value">${s.value}</div><div class="label"><i class="fa-solid ${s.icon}"></i> ${s.label}</div></div>`).join('');
++
++ // For sub-day ranges, use timeBuckets for the "daily" chart
++ const isSubDay = period.hours && period.hours < 24;
++ const buckets = a.timeBuckets || [];
++
++ // Daily / fine-grained traffic chart
++ if (isSubDay && buckets.length) {
++ document.getElementById('svc-hourly-title').textContent = 'Traffic (' + period.hours + 'h)';
++ createChart('chart-svc-daily', {
++ type: 'line',
++ data: {
++ labels: buckets.map(b => { const parts = b.bucket?.split(' '); return parts?.[1] || b.bucket; }),
++ datasets: [
++ { label: 'Page Views', data: buckets.map(b => b.pageviews), borderColor: CHART_COLORS.accent, backgroundColor: 'rgba(139,92,246,0.1)', fill: true, tension: 0.3, pointRadius: 2 },
++ { label: 'API Calls', data: buckets.map(b => b.api_calls), borderColor: CHART_COLORS.info, tension: 0.3, pointRadius: 2 },
++ { label: 'Visitors', data: buckets.map(b => b.unique_visitors), borderColor: CHART_COLORS.success, tension: 0.3, pointRadius: 2 },
++ { label: 'Bots', data: buckets.map(b => b.bot_hits), borderColor: CHART_COLORS.danger, borderDash: [5,5], tension: 0.3, pointRadius: 1 },
++ ],
++ },
++ options: { scales: { x: {}, y: { beginAtZero: true } } },
++ });
++ } else {
++ document.getElementById('svc-hourly-title').textContent = 'Hourly (24h)';
++ const daily = a.daily || [];
++ if (daily.length) {
++ createChart('chart-svc-daily', {
++ type: 'line',
++ data: {
++ labels: daily.map(d => d.date.slice(5)),
++ datasets: [
++ { label: 'Page Views', data: daily.map(d => d.pageviews), borderColor: CHART_COLORS.accent, backgroundColor: 'rgba(139,92,246,0.1)', fill: true, tension: 0.3, pointRadius: 2 },
++ { label: 'API Calls', data: daily.map(d => d.api_calls), borderColor: CHART_COLORS.info, tension: 0.3, pointRadius: 2 },
++ { label: 'Visitors', data: daily.map(d => d.unique_visitors), borderColor: CHART_COLORS.success, tension: 0.3, pointRadius: 2 },
++ { label: 'Bots', data: daily.map(d => d.bot_hits), borderColor: CHART_COLORS.danger, borderDash: [5,5], tension: 0.3, pointRadius: 1 },
++ ],
++ },
++ options: { scales: { x: {}, y: { beginAtZero: true } } },
++ });
++ }
++ }
++
++ // Hourly bar chart
++ const hourly = a.hourly || [];
++ if (hourly.length) {
++ createChart('chart-svc-hourly', {
++ type: 'bar',
++ data: {
++ labels: hourly.map(h => h.hour?.slice(11, 16) || ''),
++ datasets: [
++ { label: 'Page Views', data: hourly.map(h => h.pageviews), backgroundColor: 'rgba(139,92,246,0.7)' },
++ { label: 'API Calls', data: hourly.map(h => h.api_calls), backgroundColor: 'rgba(52,152,219,0.7)' },
++ ],
++ },
++ options: { scales: { x: {}, y: { beginAtZero: true } }, plugins: { legend: { position: 'bottom' } } },
++ });
++ }
++
++ // Response time percentiles
++ const rpEl = document.getElementById('svc-response-percentiles');
++ if (rp.p50 !== undefined) {
++ const pctBar = (label, val, max) => {
++ const pct = max > 0 ? Math.min(100, (val / max) * 100) : 0;
++ const color = val < 100 ? 'var(--success)' : val < 300 ? 'var(--accent-light)' : val < 1000 ? 'var(--warning)' : 'var(--danger)';
++ return `<div style="margin:6px 0"><div style="display:flex;justify-content:space-between;font-size:12px;margin-bottom:2px"><span class="muted">${label}</span><strong>${val}ms</strong></div><div class="storage-bar"><div class="storage-bar-fill" style="width:${pct}%;background:${color}"></div></div></div>`;
++ };
++ rpEl.innerHTML = pctBar('Median (p50)', rp.p50, rp.max) + pctBar('p90', rp.p90, rp.max) + pctBar('p95', rp.p95, rp.max) + pctBar('p99', rp.p99, rp.max) + pctBar('Max', rp.max, rp.max);
++ } else {
++ rpEl.innerHTML = '<p class="muted">No response time data</p>';
++ }
++
++ // Peak hours chart
++ const peakHours = a.peakHours || [];
++ if (peakHours.length) {
++ createChart('chart-svc-peak-hours', {
++ type: 'bar',
++ data: {
++ labels: peakHours.map(h => h.hour_of_day + ':00'),
++ datasets: [
++ { label: 'Hits', data: peakHours.map(h => h.hits), backgroundColor: 'rgba(139,92,246,0.7)' },
++ { label: 'Visitors', data: peakHours.map(h => h.visitors), backgroundColor: 'rgba(46,204,113,0.5)' },
++ ],
++ },
++ options: { scales: { x: {}, y: { beginAtZero: true } }, plugins: { legend: { position: 'bottom' } } },
++ });
++ }
++
++ // Devices pie
++ const devices = a.deviceBreakdown || [];
++ if (devices.length) {
++ createChart('chart-svc-devices', {
++ type: 'doughnut',
++ data: {
++ labels: devices.map(d => d.device_type || 'unknown'),
++ datasets: [{ data: devices.map(d => d.cnt), backgroundColor: PIE_COLORS.slice(0, devices.length), borderWidth: 0 }],
++ },
++ options: { plugins: { legend: { position: 'bottom' } } },
++ });
++ }
++
++ // Browsers pie
++ const browsers = a.browserBreakdown || [];
++ if (browsers.length) {
++ createChart('chart-svc-browsers', {
++ type: 'doughnut',
++ data: {
++ labels: browsers.map(d => d.browser || 'unknown'),
++ datasets: [{ data: browsers.map(d => d.cnt), backgroundColor: PIE_COLORS.slice(0, browsers.length), borderWidth: 0 }],
++ },
++ options: { plugins: { legend: { position: 'bottom' } } },
++ });
++ }
++
++ // OS pie
++ const oses = a.osBreakdown || [];
++ if (oses.length) {
++ createChart('chart-svc-os', {
++ type: 'doughnut',
++ data: {
++ labels: oses.map(d => d.os || 'unknown'),
++ datasets: [{ data: oses.map(d => d.cnt), backgroundColor: PIE_COLORS.slice(0, oses.length), borderWidth: 0 }],
++ },
++ options: { plugins: { legend: { position: 'bottom' } } },
++ });
++ }
++
++ // Bandwidth & sessions card
++ const bsEl = document.getElementById('svc-bandwidth-sessions');
++ bsEl.innerHTML = `
++ <div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;font-size:13px">
++ <div><strong style="color:var(--accent-light)">${fmtBytes(bw.estimated_bytes)}</strong><br><span class="muted">Est. Transfer</span></div>
++ <div><strong style="color:var(--accent-light)">${fmtNum(bw.total_requests)}</strong><br><span class="muted">Total Requests</span></div>
++ <div><strong style="color:var(--info)">${fmtNum(bw.page_requests)}</strong><br><span class="muted">Page Loads</span></div>
++ <div><strong style="color:var(--info)">${fmtNum(bw.api_requests)}</strong><br><span class="muted">API Requests</span></div>
++ <div><strong style="color:var(--success)">${fmtNum(a.sessionCount || 0)}</strong><br><span class="muted">Sessions</span></div>
++ ${vis.new_visitors != null
++ ? `<div><strong style="color:var(--success)">${fmtNum(vis.new_visitors)}</strong> new / <strong>${fmtNum(vis.returning_visitors)}</strong> returning<br><span class="muted">Visitors</span></div>`
++ : '<div><strong class="muted">Not measured</strong><br><span class="muted" title="ADR-021: no visitor identity outlives a day">New / returning</span></div>'}
++ </div>`;
++
++ // Auth vs anonymous chart
++ const authCount = auth.authenticated || 0;
++ const anonCount = auth.anonymous || 0;
++ if (authCount > 0 || anonCount > 0) {
++ createChart('chart-svc-auth', {
++ type: 'doughnut',
++ data: {
++ labels: ['Authenticated (' + fmtNum(auth.unique_authenticated_users) + ' users)', 'Anonymous'],
++ datasets: [{ data: [authCount, anonCount], backgroundColor: [CHART_COLORS.accent, '#4a4a5a'], borderWidth: 0 }],
++ },
++ options: { plugins: { legend: { position: 'bottom' } } },
++ });
++ }
++
++ // Top pages
++ const pages = a.topPages || [];
++ document.getElementById('svc-top-pages').innerHTML = pages.length ? `
++ <table class="admin-table">
++ <thead><tr><th>Path</th><th>Hits</th><th>Visitors</th></tr></thead>
++ <tbody>${pages.slice(0, 15).map(p => `
++ <tr><td style="max-width:240px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${esc(p.path)}">${esc(p.path)}</td><td>${fmtNum(p.hits)}</td><td>${fmtNum(p.visitors)}</td></tr>
++ `).join('')}</tbody>
++ </table>` : '<p class="muted">No data</p>';
++
++ // Top API endpoints
++ const apiEndpoints = a.topApiEndpoints || [];
++ document.getElementById('svc-top-api').innerHTML = apiEndpoints.length ? `
++ <table class="admin-table">
++ <thead><tr><th>Endpoint</th><th>Method</th><th>Hits</th><th>Avg ms</th><th>Errors</th></tr></thead>
++ <tbody>${apiEndpoints.slice(0, 15).map(e => `
++ <tr>
++ <td style="max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${esc(e.path)}">${esc(e.path)}</td>
++ <td><span class="badge badge-info">${esc(e.method)}</span></td>
++ <td>${fmtNum(e.hits)}</td>
++ <td>${e.avg_ms || 0}ms</td>
++ <td style="color:${e.errors > 0 ? 'var(--danger)' : 'inherit'}">${fmtNum(e.errors)}</td>
++ </tr>
++ `).join('')}</tbody>
++ </table>` : '<p class="muted">No API data</p>';
++
++ // Referers
++ const refs = a.topReferers || [];
++ document.getElementById('svc-top-referers').innerHTML = refs.length ? `
++ <table class="admin-table">
++ <thead><tr><th>Referer</th><th>Hits</th></tr></thead>
++ <tbody>${refs.slice(0, 10).map(r => `
++ <tr><td style="max-width:300px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${esc(r.referer)}">${esc(r.referer)}</td><td>${fmtNum(r.hits)}</td></tr>
++ `).join('')}</tbody>
++ </table>` : '<p class="muted">No referers tracked</p>';
++
++ // Slowest endpoints
++ const slow = a.slowestEndpoints || [];
++ document.getElementById('svc-slowest').innerHTML = slow.length ? `
++ <table class="admin-table">
++ <thead><tr><th>Endpoint</th><th>Method</th><th>Avg ms</th><th>Max ms</th><th>Hits</th></tr></thead>
++ <tbody>${slow.slice(0, 10).map(e => `
++ <tr>
++ <td style="max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${esc(e.path)}">${esc(e.path)}</td>
++ <td><span class="badge badge-warning">${esc(e.method)}</span></td>
++ <td style="color:${e.avg_ms > 500 ? 'var(--danger)' : e.avg_ms > 200 ? 'var(--warning)' : 'inherit'}">${e.avg_ms || 0}ms</td>
++ <td style="color:var(--danger)">${e.max_ms || 0}ms</td>
++ <td>${fmtNum(e.hits)}</td>
++ </tr>
++ `).join('')}</tbody>
++ </table>` : '<p class="muted">No data</p>';
++
++ // Status codes
++ const statuses = a.statusCodes || [];
++ if (statuses.length) {
++ const statusColors = { '2xx': CHART_COLORS.success, '3xx': CHART_COLORS.info, '4xx': CHART_COLORS.warning, '5xx': CHART_COLORS.danger, 'other': '#707080' };
++ createChart('chart-svc-status', {
++ type: 'bar',
++ data: {
++ labels: statuses.map(s => s.group_code),
++ datasets: [{ label: 'Count', data: statuses.map(s => s.cnt), backgroundColor: statuses.map(s => statusColors[s.group_code] || '#707080') }],
++ },
++ options: { scales: { x: {}, y: { beginAtZero: true } }, plugins: { legend: { display: false } } },
++ });
++ }
++
++ // Countries
++ const countries = a.countryBreakdown || [];
++ document.getElementById('svc-countries').innerHTML = countries.length ? `
++ <table class="admin-table">
++ <thead><tr><th>Country</th><th>Hits</th></tr></thead>
++ <tbody>${countries.slice(0, 15).map(c => `
++ <tr><td>${esc(c.country || 'Unknown')}</td><td>${fmtNum(c.cnt)}</td></tr>
++ `).join('')}</tbody>
++ </table>` : '<p class="muted">No country data (requires Cloudflare CF-IPCountry header)</p>';
++ } catch (e) {
++ statsEl.innerHTML = `<p class="muted">Error: ${esc(e.message)}</p>`;
++ }
++}
++
++async function loadBotAnalysis() {
++ const period = getAnalyticsPeriod();
++ const el = document.getElementById('bots-content');
++ el.innerHTML = '<div class="loading">Loading bot analysis...</div>';
++ try {
++ const data = await api(`/api/admin/analytics/bots?${period.qs}`);
++ const bots = data.bots || {};
++ let html = '';
++
++ for (const [svcName, svcBots] of Object.entries(bots)) {
++ if (!svcBots) continue;
++ const label = svcName === 'live' ? 'OpenVibe.Live' : svcName === 'openvibe-network' ? 'OpenVibe.Network' : svcName === 'tools' ? 'OpenVibe.Tools' : svcName === 'games' ? 'OpenVibe.Games' : svcName === 'media' ? 'OpenVibe.Media' : svcName;
++
++ html += `<div class="card"><h3><i class="fa-solid fa-robot"></i> ${esc(label)}</h3>`;
++
++ // Bot types
++ const types = svcBots.botTypes || [];
++ if (types.length) {
++ html += `<h4 style="font-size:12px;color:var(--text-muted);margin:12px 0 8px">Bot Types</h4>
++ <table class="admin-table"><thead><tr><th>Type</th><th>Hits</th><th>Sessions</th></tr></thead>
++ <tbody>${types.map(t => `<tr><td><span class="badge badge-danger">${esc(t.bot_type || 'unknown')}</span></td><td>${fmtNum(t.hits)}</td><td>${t.unique_sessions != null ? fmtNum(t.unique_sessions) : '-'}</td></tr>`).join('')}</tbody></table>`;
++ }
++
++ // Top bots by user-agent class (ADR-021: analytics stores no IP addresses)
++ const topBots = svcBots.topBotIPs || [];
++ if (topBots.length) {
++ html += `<h4 style="font-size:12px;color:var(--text-muted);margin:12px 0 8px">Top Bots (user-agent class)</h4>
++ <table class="admin-table"><thead><tr><th>User-agent class</th><th>Hits</th><th>Sessions</th><th>Type</th><th>First Seen</th><th>Last Seen</th></tr></thead>
++ <tbody>${topBots.slice(0, 15).map(b => `
++ <tr><td style="font-family:monospace;font-size:11px">${esc(b.ua_class || '-')}</td><td>${fmtNum(b.hits)}</td>
++ <td>${b.sessions != null ? fmtNum(b.sessions) : '-'}</td>
++ <td><span class="badge badge-warning">${esc(b.bot_type || '?')}</span></td>
++ <td style="font-size:11px">${b.first_seen ? timeAgo(b.first_seen) : '-'}</td>
++ <td style="font-size:11px">${b.last_seen ? timeAgo(b.last_seen) : '-'}</td></tr>
++ `).join('')}</tbody></table>`;
++ }
++
++ // High-volume sessions classified as human (rotating session ids, never an IP or user id)
++ const suspicious = svcBots.suspiciousIPs || [];
++ if (suspicious.length) {
++ html += `<h4 style="font-size:12px;color:var(--text-muted);margin:12px 0 8px">Suspicious Sessions (high volume, classified as human)</h4>
++ <table class="admin-table"><thead><tr><th>Session</th><th>Total Hits</th><th>Errors</th><th>Unique Paths</th><th>Period</th></tr></thead>
++ <tbody>${suspicious.map(s => `
++ <tr><td style="font-family:monospace;font-size:11px">${esc(s.session_id || '-')}</td><td>${fmtNum(s.total_hits)}</td>
++ <td style="color:var(--danger)">${fmtNum(s.error_hits)}</td><td>${fmtNum(s.unique_paths)}</td>
++ <td style="font-size:11px">${s.first_seen ? timeAgo(s.first_seen) : '-'} — ${s.last_seen ? timeAgo(s.last_seen) : '-'}</td></tr>
++ `).join('')}</tbody></table>`;
++ }
++
++ // Bot trend
++ const trend = svcBots.botTrend || [];
++ if (trend.length) {
++ const canvasId = `chart-bot-trend-${svcName}`;
++ html += `<div style="margin-top:12px"><canvas id="${canvasId}" height="180"></canvas></div>`;
++ // Queue chart creation after DOM update
++ setTimeout(() => {
++ createChart(canvasId, {
++ type: 'line',
++ data: {
++ labels: trend.map(d => d.date?.slice(5) || ''),
++ datasets: [
++ { label: 'Human', data: trend.map(d => d.human_hits || 0), borderColor: CHART_COLORS.success, tension: 0.3, fill: true, backgroundColor: 'rgba(46,204,113,0.1)' },
++ { label: 'Bots', data: trend.map(d => d.bot_hits || 0), borderColor: CHART_COLORS.danger, tension: 0.3, fill: true, backgroundColor: 'rgba(231,76,60,0.1)' },
++ ],
++ },
++ options: { scales: { x: {}, y: { beginAtZero: true } }, plugins: { legend: { position: 'bottom' } } },
++ });
++ }, 50);
++ }
++
++ html += '</div>';
++ }
++
++ el.innerHTML = html || '<p class="muted">No bot data available yet. Analytics data will appear once traffic is recorded.</p>';
++ } catch (e) {
++ el.innerHTML = `<p class="muted">Error: ${esc(e.message)}</p>`;
++ }
++}
ddiff --git a/public/js/admin/boot.js b/public/js/admin/boot.js
nnew file mode 100644
iindex 00000000..2c8e2a48
---- /dev/null
++++ b/public/js/admin/boot.js
@@@ -0,0 +1,99 @@
++/* ═══════════════════════════════════════════════════════════════
++ OpenVibe.Network — admin panel (/admin): the tabLoaders map, sign-in bootstrap and init() (loaded last).
++ Split out of admin.html. Classic scripts, global scope: they rely on
++ admin/core.js's helpers (api, esc, toast, API, token) and on the
++ tabLoaders map in admin/boot.js (loaded last). No ES modules.
++ ═══════════════════════════════════════════════════════════════ */
++const tabLoaders = {
++ dashboard: loadDashboard,
++ users: loadUsers,
++ streams: loadStreams,
++ lineage: () => loadLineage(),
++ bans: loadBans,
++ 'chat-logs': () => {}, // manual search
++ moderators: loadModerators,
++ settings: loadSettings,
++ tts: loadTTS,
++ cashouts: loadCashouts,
++ payments: loadPayments,
++ ai: loadAi,
++ vpn: loadVPNQueue,
++ pastes: loadPastesAdmin,
++ storage: loadStorage,
++ notifications: () => {},
++ email: loadEmail,
++ vkeys: loadVKeys,
++ audit: loadAudit,
++ modlog: () => loadModLog(true),
++ grants: () => loadGrants(),
++ themes: () => loadThemeReview(),
++ checklist: () => loadOperatorChecklist(),
++ eventsops: () => loadDeliveries(),
++ analytics: loadAnalytics,
++ deploy: loadDeployOverview,
++ domains: loadDomains,
++ ssh: loadSSH,
++};
++
++// ═══════════════════════════════════════════════════════════════
++// Initialization
++// ═══════════════════════════════════════════════════════════════
++function _adminLoginRedirect() {
++ window.location.href = '/login?return=' + encodeURIComponent(window.location.href);
++}
++
++// Silent sliding refresh: exchange the current (possibly just-expired) token for a
++// fresh one, same as index.html/my.html. Returns true if a new token was stored.
++async function _adminTryRefresh() {
++ try {
++ const res = await fetch(API + '/api/auth/refresh', {
++ method: 'POST',
++ headers: { Authorization: 'Bearer ' + (token || ''), 'Content-Type': 'application/json' },
++ credentials: 'include',
++ });
++ if (!res.ok) return false;
++ const data = await res.json().catch(() => ({}));
++ if (data && data.token) { token = data.token; localStorage.setItem('ov_token', data.token); return true; }
++ return false;
++ } catch { return false; }
++}
++
++async function _bootAdmin() {
++ const data = await api('/api/auth/me');
++ currentUser = data.user || data;
++ if (currentUser.role !== 'admin') {
++ document.querySelector('.page').innerHTML = '<div class="card" style="text-align:center;padding:60px"><h3 style="color:var(--live-red)"><i class="fa-solid fa-lock"></i> Access Denied</h3><p class="muted" style="margin-top:8px">Admin privileges required.</p></div>';
++ return;
++ }
++ // Owner-only UI (Deploy tab, etc.) — hide for plain admins.
++ if (!currentUser.is_owner) {
++ document.querySelectorAll('.owner-only').forEach(el => { el.style.display = 'none'; });
++ }
++ // Init navbar
++ if (window.OpenVibeNavbar) {
++ OpenVibeNavbar.init({ service: 'network', token, user: currentUser });
++ const bellMount = OpenVibeNavbar.getBellMount();
++ if (bellMount && window.OpenVibeNotifications) {
++ OpenVibeNotifications.init({ mountEl: bellMount, apiBase: API, token });
++ }
++ }
++ initMainTabsOverflow();
++ // Open the tab from the URL slug (or restore the last-visited location).
++ routeFromUrl();
++}
++
++async function init() {
++ if (!token) { _adminLoginRedirect(); return; }
++ try {
++ await _bootAdmin();
++ } catch (e) {
++ // Invalid/expired token → try one silent refresh, then retry; if that fails,
++ // send the user through the OAuth login instead of showing a dead-end error.
++ if (await _adminTryRefresh()) {
++ try { await _bootAdmin(); return; } catch { /* fall through to login */ }
++ }
++ _adminLoginRedirect();
++ }
++}
++
++init();
ddiff --git a/public/js/admin/content.js b/public/js/admin/content.js
nnew file mode 100644
iindex 00000000..b44ad80f
---- /dev/null
++++ b/public/js/admin/content.js
@@@ -0,0 +1,483 @@
++/* ═══════════════════════════════════════════════════════════════
++ OpenVibe.Network — admin panel (/admin): VPN queue, pastes, storage, VODs, notifications broadcast and email.
++ Split out of admin.html. Classic scripts, global scope: they rely on
++ admin/core.js's helpers (api, esc, toast, API, token) and on the
++ tabLoaders map in admin/boot.js (loaded last). No ES modules.
++ ═══════════════════════════════════════════════════════════════ */
++// ═══════════════════════════════════════════════════════════════
++// VPN queue (via admin proxy)
++// ═══════════════════════════════════════════════════════════════
++async function loadVPNQueue() {
++ const c = document.getElementById('vpn-content');
++ c.innerHTML = '<div class="loading">Loading...</div>';
++ try {
++ const data = await api('/api/admin/streamer/vpn-queue');
++ const queue = data.queue || [];
++ if (!queue.length) { c.innerHTML = '<p class="muted">VPN approval queue empty</p>'; return; }
++ c.innerHTML = `
++ <table class="admin-table">
++ <thead><tr><th>User</th><th>IP</th><th>Reason</th><th>Status</th><th>Actions</th></tr></thead>
++ <tbody>${queue.map(item => `
++ <tr>
++ <td><strong>${esc(item.username || item.user_id || '-')}</strong></td>
++ <td>${esc(item.ip_address || '-')}</td>
++ <td>${esc(item.reason || '-')}</td>
++ <td><span class="badge badge-${item.status === 'approved' ? 'success' : item.status === 'denied' ? 'danger' : 'warning'}">${esc(item.status || 'pending')}</span></td>
++ <td>${item.status === 'pending' ? `
++ <div style="display:flex;gap:6px">
++ <button class="btn btn-sm btn-success" onclick="updateVPNStatus('${item.id}','approved')"><i class="fa-solid fa-check"></i> Approve</button>
++ <button class="btn btn-sm btn-danger" onclick="updateVPNStatus('${item.id}','denied')"><i class="fa-solid fa-xmark"></i> Deny</button>
++ </div>` : '<span class="muted">Handled</span>'}
++ </td>
++ </tr>
++ `).join('')}</tbody>
++ </table>`;
++ } catch (e) { c.innerHTML = `<p class="muted">Error: ${esc(e.message)}</p>`; }
++}
++
++async function updateVPNStatus(id, status) {
++ try {
++ await api(`/api/admin/streamer/vpn-queue/${id}`, { method: 'PUT', body: { status } });
++ toast(`VPN request ${status}`, status === 'approved' ? 'success' : 'info');
++ loadVPNQueue();
++ } catch (e) { toast(e.message, 'error'); }
++}
++
++// ═══════════════════════════════════════════════════════════════
++// Pastes (via pastes proxy + streamer settings)
++// ═══════════════════════════════════════════════════════════════
++async function loadPastesAdmin() {
++ const c = document.getElementById('pastes-content');
++ c.innerHTML = '<div class="loading">Loading...</div>';
++ try {
++ const [statsData, configData] = await Promise.all([
++ api('/api/admin/streamer-pastes/admin/stats'),
++ api('/api/admin/streamer-pastes/config'),
++ ]);
++ const s = statsData.stats || {};
++ const cfg = configData || {};
++ c.innerHTML = `
++ <div class="stats-grid">
++ <div class="stat-card"><div class="value">${Number(s.total || 0).toLocaleString()}</div><div class="label"><i class="fa-solid fa-paste"></i> Total Pastes</div></div>
++ <div class="stat-card"><div class="value">${Number(s.textPastes || 0).toLocaleString()}</div><div class="label"><i class="fa-solid fa-code"></i> Text Pastes</div></div>
++ <div class="stat-card"><div class="value">${Number(s.screenshots || 0).toLocaleString()}</div><div class="label"><i class="fa-solid fa-image"></i> Screenshots</div></div>
++ <div class="stat-card"><div class="value">${Number(s.forks || 0).toLocaleString()}</div><div class="label"><i class="fa-solid fa-code-fork"></i> Forks</div></div>
++ </div>
++ <div class="card">
++ <h3><i class="fa-solid fa-sliders"></i> Paste Limits</h3>
++ <div style="display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px">
++ <div class="form-field"><label>Max Paste Size (KB)</label><input type="number" id="paste-max-size-kb" value="${esc(cfg.maxSizeKb || 512)}"></div>
++ <div class="form-field"><label>Max Image Size (MB)</label><input type="number" id="paste-screenshot-max-mb" value="${esc(cfg.screenshotMaxSizeMb || 8)}"></div>
++ <div class="form-field"><label>Cooldown (seconds)</label><input type="number" id="paste-cooldown" value="${esc(cfg.cooldownSeconds || 30)}"></div>
++ <div class="form-field"><label>Max / User / Day</label><input type="number" id="paste-max-per-day" value="${esc(cfg.maxPerUserPerDay || 50)}"></div>
++ </div>
++ <div class="setting-row"><label><strong>Allow anonymous pastes</strong></label><input type="checkbox" id="paste-anon" ${cfg.anonAllowed !== false ? 'checked' : ''} style="width:18px;height:18px"></div>
++ <div class="setting-row"><label><strong>Allow image uploads</strong></label><input type="checkbox" id="paste-image-upload" ${cfg.imageUploadEnabled !== false ? 'checked' : ''} style="width:18px;height:18px"></div>
++ <div style="display:flex;gap:10px;flex-wrap:wrap;margin-top:10px">
++ <button class="btn btn-primary" onclick="savePasteAdminConfig()"><i class="fa-solid fa-floppy-disk"></i> Save Paste Settings</button>
++ <button class="btn btn-danger" onclick="deleteAllPasteForks()"><i class="fa-solid fa-trash-can"></i> Delete All Forks (${Number(s.forks || 0).toLocaleString()})</button>
++ </div>
++ </div>`;
++ } catch (e) { c.innerHTML = `<p class="muted">Error: ${esc(e.message)}</p>`; }
++}
++
++async function savePasteAdminConfig() {
++ try {
++ await api('/api/admin/streamer/settings', {
++ method: 'PUT',
++ body: {
++ settings: {
++ paste_max_size_kb: document.getElementById('paste-max-size-kb').value,
++ paste_screenshot_max_size_mb: document.getElementById('paste-screenshot-max-mb').value,
++ paste_cooldown_seconds: document.getElementById('paste-cooldown').value,
++ paste_max_per_user_per_day: document.getElementById('paste-max-per-day').value,
++ paste_anon_allowed: document.getElementById('paste-anon').checked ? 'true' : 'false',
++ paste_image_upload_enabled: document.getElementById('paste-image-upload').checked ? 'true' : 'false',
++ },
++ },
++ });
++ toast('Paste settings saved', 'success');
++ } catch (e) { toast(e.message, 'error'); }
++}
++
++async function deleteAllPasteForks() {
++ if (!confirm('Delete all forked pastes? This cannot be undone.')) return;
++ try {
++ const data = await api('/api/admin/streamer-pastes/admin/forks', { method: 'DELETE' });
++ toast(`Deleted ${Number(data.deleted || 0).toLocaleString()} forked paste(s)`, 'success');
++ loadPastesAdmin();
++ } catch (e) { toast(e.message, 'error'); }
++}
++
++// ═══════════════════════════════════════════════════════════════
++// Storage (via openvibelive proxy)
++// ═══════════════════════════════════════════════════════════════
++async function loadStorage() {
++ const c = document.getElementById('storage-content');
++ c.innerHTML = '<div class="loading"><i class="fa-solid fa-spinner fa-spin"></i> Analyzing storage...</div>';
++ try {
++ const [data, tierData] = await Promise.all([
++ api('/api/admin/streamer/storage'),
++ api('/api/admin/streamer/storage/tiers').catch(() => null),
++ ]);
++ const d = data.disk || {};
++ const usePct = d.total ? ((d.used / d.total) * 100).toFixed(1) : 0;
++ const dataPct = d.total ? ((data.dataTotal?.bytes || 0) / d.total * 100).toFixed(1) : 0;
++ const pctColor = parseFloat(usePct) >= 90 ? 'var(--danger)' : parseFloat(usePct) >= 75 ? 'var(--warning)' : 'var(--success)';
++ const breakdown = (data.breakdown || []).sort((a, b) => b.bytes - a.bytes);
++ const maxB = Math.max(...breakdown.map(b => b.bytes), 1);
++
++ c.innerHTML = `
++ <div style="display:grid;grid-template-columns:1fr 1fr;gap:16px;margin-bottom:24px">
++ <div class="card">
++ <h3><i class="fa-solid fa-hard-drive"></i> Disk Usage</h3>
++ <div style="display:flex;justify-content:space-between;margin-bottom:6px;font-size:14px">
++ <span>${fmtBytes(d.used)} used of ${fmtBytes(d.total)}</span>
++ <span style="font-weight:700;color:${pctColor}">${usePct}%</span>
++ </div>
++ <div class="storage-bar">
++ <div class="storage-bar-fill" style="background:${pctColor};width:${usePct}%"></div>
++ </div>
++ <div style="display:flex;justify-content:space-between;margin-top:8px;font-size:12px;color:var(--text-muted)">
++ <span>${fmtBytes(d.available)} available</span>
++ <span>Mount: ${esc(d.mount || '/')}</span>
++ </div>
++ </div>
++ <div class="card">
++ <h3><i class="fa-solid fa-database"></i> Data Summary</h3>
++ <div class="stats-grid" style="margin:0">
++ <div class="stat-card" style="padding:10px"><div class="value" style="font-size:18px">${fmtBytes(data.dataTotal?.bytes || 0)}</div><div class="label">Total Data</div></div>
++ <div class="stat-card" style="padding:10px"><div class="value" style="font-size:18px">${(data.dataTotal?.files || 0).toLocaleString()}</div><div class="label">Files</div></div>
++ <div class="stat-card" style="padding:10px"><div class="value" style="font-size:18px">${fmtBytes(data.database?.bytes || 0)}</div><div class="label">Database</div></div>
++ <div class="stat-card" style="padding:10px"><div class="value" style="font-size:18px">${dataPct}%</div><div class="label">% of Disk</div></div>
++ </div>
++ </div>
++ </div>
++
++ <h3 style="margin-bottom:12px"><i class="fa-solid fa-folder-tree"></i> Storage Breakdown</h3>
++ <div style="display:grid;gap:8px;margin-bottom:24px">
++ ${breakdown.map(b => `
++ <div style="display:grid;grid-template-columns:140px 1fr 100px 80px;align-items:center;gap:12px;padding:10px 14px;background:var(--bg-card);border:1px solid var(--border);border-radius:8px">
++ <span style="font-weight:600;font-size:13px"><i class="fa-solid ${esc(b.icon || 'fa-folder')}" style="width:18px;text-align:center;margin-right:6px;color:var(--accent)"></i>${esc(b.name)}</span>
++ <div class="storage-bar"><div class="storage-bar-fill" style="background:var(--accent);width:${(b.bytes/maxB*100).toFixed(1)}%"></div></div>
++ <span style="text-align:right;font-weight:600;font-size:12px">${fmtBytes(b.bytes)}</span>
++ <span style="text-align:right;font-size:11px;color:var(--text-muted)">${b.files?.toLocaleString() || 0} files</span>
++ </div>
++ `).join('')}
++ </div>
++
++ <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:12px">
++ <h3><i class="fa-solid fa-video"></i> VOD Management</h3>
++ <div style="display:flex;gap:8px">
++ <select id="vod-sort" onchange="loadVodTable()" style="padding:6px 10px;background:var(--bg-input);color:var(--text);border:1px solid var(--border);border-radius:6px;font-size:12px">
++ <option value="size">Sort by Size</option>
++ <option value="date">Sort by Date</option>
++ <option value="duration">Sort by Duration</option>
++ </select>
++ <button class="btn btn-sm btn-danger" id="vod-bulk-btn" disabled onclick="bulkDeleteVods()"><i class="fa-solid fa-trash-can"></i> Delete Selected</button>
++ </div>
++ </div>
++ <div id="vod-table"><div class="loading">Loading VODs...</div></div>
++ `;
++ loadVodTable();
++ loadStreamerStorageExtras(tierData);
++ } catch (e) { c.innerHTML = `<p class="muted">Error: ${esc(e.message)}</p>`; }
++}
++
++// ── Streamer storage tiers (Local/B2/R2) + cloud usage/cost (openvibe.network admin) ──
++function buildStreamerTierHtml(tierData) {
++ if (!tierData) return '';
++ const t = tierData.tiers || {};
++ const ct = tierData.clipTiers || {};
++ const prov = tierData.providers || {};
++ const ts = tierData.settings || {};
++ const L = t.local || { count: 0, bytes: 0 };
++ const B = t.b2 || { count: 0, bytes: 0 };
++ const R = t.r2 || { count: 0, bytes: 0 };
++ const totalBytes = (L.bytes || 0) + (B.bytes || 0) + (R.bytes || 0);
++ const totalVods = (L.count || 0) + (B.count || 0) + (R.count || 0);
++ const totalClips = ((ct.local && ct.local.count) || 0) + ((ct.b2 && ct.b2.count) || 0) + ((ct.r2 && ct.r2.count) || 0);
++ const offloaded = (B.bytes || 0) + (R.bytes || 0);
++ const offloadPct = totalBytes > 0 ? ((offloaded / totalBytes) * 100).toFixed(1) : '0.0';
++ const pctOf = (b) => totalBytes > 0 ? (b / totalBytes) * 100 : 0;
++ const provStatus = (p) => {
++ if (!p) return '<span style="color:var(--text-muted)">—</span>';
++ if (!p.configured) return '<span style="color:var(--text-muted)">not configured</span>';
++ return p.healthy
++ ? '<span style="color:var(--success,#22c55e)"><i class="fa-solid fa-circle-check"></i> healthy</span>' + (p.bucket ? ' <span style="color:var(--text-muted);font-size:11px">· ' + esc(p.bucket) + '</span>' : '')
++ : '<span style="color:var(--danger,#ef4444)"><i class="fa-solid fa-circle-xmark"></i> error</span>';
++ };
++ const rows = [
++ { label: 'Local (SSD)', icon: 'fa-hard-drive', color: '#f59e0b', v: L, clips: (ct.local && ct.local.count) || 0, status: '<span style="color:var(--text-muted)">primary disk</span>' },
++ { label: 'Backblaze B2', icon: 'fa-box-archive', color: '#e21e2b', v: B, clips: (ct.b2 && ct.b2.count) || 0, status: provStatus(prov.b2) },
++ { label: 'Cloudflare R2', icon: 'fa-cloud', color: '#f6821f', v: R, clips: (ct.r2 && ct.r2.count) || 0, status: provStatus(prov.r2) },
++ ];
++ const bar = '<div style="display:flex;height:16px;border-radius:8px;overflow:hidden;margin:6px 0 14px;background:var(--bg-tertiary)">'
++ + '<div title="Local ' + pctOf(L.bytes).toFixed(1) + '%" style="width:' + pctOf(L.bytes) + '%;background:#f59e0b"></div>'
++ + '<div title="B2 ' + pctOf(B.bytes).toFixed(1) + '%" style="width:' + pctOf(B.bytes) + '%;background:#e21e2b"></div>'
++ + '<div title="R2 ' + pctOf(R.bytes).toFixed(1) + '%" style="width:' + pctOf(R.bytes) + '%;background:#f6821f"></div></div>';
++ const rowHtml = rows.map(r => '<tr>'
++ + '<td style="padding:8px 10px"><i class="fa-solid ' + r.icon + '" style="color:' + r.color + '"></i> <strong>' + r.label + '</strong></td>'
++ + '<td style="padding:8px 10px;text-align:right">' + (r.v.count || 0).toLocaleString() + '</td>'
++ + '<td style="padding:8px 10px;text-align:right">' + fmtBytes(r.v.bytes || 0) + '</td>'
++ + '<td style="padding:8px 10px;text-align:right">' + pctOf(r.v.bytes).toFixed(1) + '%</td>'
++ + '<td style="padding:8px 10px;text-align:right">' + (r.clips || 0).toLocaleString() + '</td>'
++ + '<td style="padding:8px 10px">' + r.status + '</td></tr>').join('');
++ return '<div class="card" style="margin-bottom:24px">'
++ + '<h3 style="margin:0 0 6px"><i class="fa-solid fa-layer-group"></i> Storage Tiers <span style="color:var(--text-muted);font-size:13px;font-weight:400">— ' + offloadPct + '% of VOD data offloaded to cloud</span></h3>'
++ + bar
++ + '<div style="overflow-x:auto"><table style="width:100%;border-collapse:collapse;font-size:13px">'
++ + '<thead><tr style="text-align:left;color:var(--text-muted);border-bottom:1px solid var(--border)">'
++ + '<th style="padding:6px 10px">Tier</th><th style="padding:6px 10px;text-align:right">VODs</th><th style="padding:6px 10px;text-align:right">VOD Size</th><th style="padding:6px 10px;text-align:right">Share</th><th style="padding:6px 10px;text-align:right">Clips</th><th style="padding:6px 10px">Provider</th></tr></thead>'
++ + '<tbody>' + rowHtml
++ + '<tr style="border-top:2px solid var(--border);font-weight:700"><td style="padding:8px 10px">Total</td><td style="padding:8px 10px;text-align:right">' + totalVods.toLocaleString() + '</td><td style="padding:8px 10px;text-align:right">' + fmtBytes(totalBytes) + '</td><td style="padding:8px 10px;text-align:right">100%</td><td style="padding:8px 10px;text-align:right">' + totalClips.toLocaleString() + '</td><td style="padding:8px 10px">' + (tierData.sweepRunning ? '<span style="color:#f59e0b"><i class="fa-solid fa-spinner fa-spin"></i> sweeping</span>' : '<span style="color:var(--text-muted)">idle</span>') + '</td></tr>'
++ + '</tbody></table></div>'
++ + '<div style="display:flex;flex-wrap:wrap;gap:8px;font-size:12px;color:var(--text-muted);margin-top:12px">'
++ + '<span><i class="fa-solid ' + (ts.enabled ? 'fa-toggle-on' : 'fa-toggle-off') + '" style="color:' + (ts.enabled ? '#22c55e' : '#ef4444') + '"></i> Auto-offload: ' + (ts.enabled ? 'ON' : 'OFF') + '</span>'
++ + '<span>| Age &ge; ' + (ts.minAgeDays != null ? ts.minAgeDays : '?') + 'd</span><span>| Views &le; ' + (ts.maxViewsForCold != null ? ts.maxViewsForCold : '?') + '</span><span>| Idle &ge; ' + (ts.minLastAccessDays != null ? ts.minLastAccessDays : '?') + 'd</span>'
++ + '</div></div>';
++}
++
++function loadStreamerStorageExtras(tierData) {
++ const c = document.getElementById('storage-content');
++ if (!c) return;
++ c.insertAdjacentHTML('afterbegin', '<div id="streamer-cloud-storage" style="margin-bottom:24px"></div>');
++ c.insertAdjacentHTML('afterbegin', buildStreamerTierHtml(tierData));
++ loadStreamerCloudStorage();
++}
++
++async function loadStreamerCloudStorage(force) {
++ const el = document.getElementById('streamer-cloud-storage');
++ if (!el) return;
++ const header = '<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:12px">'
++ + '<h3 style="margin:0"><i class="fa-solid fa-cloud"></i> Cloud Storage — B2 &amp; R2 Usage &amp; Cost</h3>'
++ + '<button class="btn btn-sm btn-outline" onclick="loadStreamerCloudStorage(true)"><i class="fa-solid fa-rotate"></i> Rescan</button></div>';
++ el.innerHTML = header + '<div class="loading"><i class="fa-solid fa-spinner fa-spin"></i> Scanning cloud buckets…</div>';
++ try {
++ const data = await api('/api/admin/streamer/storage/buckets' + (force ? '?force=1' : ''));
++ const usage = data.usage || {};
++ const costs = data.costs || {};
++ const money = (n) => '$' + (Number(n) || 0).toFixed(2);
++ const card = (key, label, color) => {
++ const u = usage[key];
++ const cc = costs[key];
++ if (!u || u.configured === false) return '<div class="card" style="flex:1;min-width:260px"><strong style="color:' + color + '"><i class="fa-solid fa-cloud"></i> ' + label + '</strong><div style="color:var(--text-muted);margin-top:8px">Not configured</div></div>';
++ if (u.error) return '<div class="card" style="flex:1;min-width:260px"><strong style="color:' + color + '"><i class="fa-solid fa-cloud"></i> ' + label + '</strong><div style="color:var(--danger,#ef4444);margin-top:8px">Scan error: ' + esc(u.error) + '</div></div>';
++ const tops = Object.entries(u.prefixes || {}).sort((a, b) => b[1].bytes - a[1].bytes).slice(0, 8);
++ const prefixRows = tops.map(([p, v]) => '<div style="display:flex;justify-content:space-between;font-size:12px;color:var(--text-muted);padding:2px 0"><span>' + esc(p) + '</span><span>' + fmtBytes(v.bytes) + ' · ' + v.objects.toLocaleString() + '</span></div>').join('');
++ return '<div class="card" style="flex:1;min-width:260px">'
++ + '<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:8px"><strong style="color:' + color + '"><i class="fa-solid fa-cloud"></i> ' + label + '</strong><span style="color:var(--text-muted);font-size:12px">' + esc(u.bucket || '') + '</span></div>'
++ + '<div style="display:flex;gap:16px;margin-bottom:10px"><div><div style="font-size:20px;font-weight:700">' + fmtBytes(u.bytes) + '</div><div style="color:var(--text-muted);font-size:11px">' + u.objects.toLocaleString() + ' objects</div></div>'
++ + '<div><div style="font-size:20px;font-weight:700;color:' + color + '">' + (cc ? money(cc.storageMonthly) : '—') + '<span style="font-size:12px;font-weight:400;color:var(--text-muted)">/mo</span></div><div style="color:var(--text-muted);font-size:11px">storage @ $' + (cc ? cc.storagePerGbMonth : '?') + '/GB</div></div></div>'
++ + '<div style="color:var(--text-muted);font-size:11px;margin-bottom:6px">Egress: ' + (cc ? esc(cc.egressNote) : '—') + '</div>'
++ + (prefixRows ? '<div style="border-top:1px solid var(--border);padding-top:6px">' + prefixRows + '</div>' : '') + '</div>';
++ };
++ el.innerHTML = header
++ + '<div style="display:flex;gap:16px;flex-wrap:wrap;margin-bottom:12px">' + card('b2', 'Backblaze B2', '#e21e2b') + card('r2', 'Cloudflare R2', '#f6821f') + '</div>'
++ + '<div class="card" style="display:flex;align-items:center;justify-content:space-between"><span><i class="fa-solid fa-file-invoice-dollar" style="color:var(--accent)"></i> <strong>Estimated cloud storage cost</strong> <span style="color:var(--text-muted);font-size:12px">(list prices; excludes egress/operations)</span></span><span style="font-size:22px;font-weight:800;color:var(--accent)">' + money(costs.totalStorageMonthly) + '<span style="font-size:13px;font-weight:400;color:var(--text-muted)">/mo</span></span></div>';
++ } catch (e) {
++ el.innerHTML = header + '<div style="color:var(--danger,#ef4444)">Failed to scan buckets: ' + esc(e.message) + '</div>';
++ }
++}
++
++async function loadVodTable() {
++ const c = document.getElementById('vod-table');
++ if (!c) return;
++ const sort = document.getElementById('vod-sort')?.value || 'size';
++ c.innerHTML = '<div class="loading">Loading...</div>';
++ try {
++ const data = await api(`/api/admin/streamer/storage/vods?sort=${sort}&order=desc&limit=100`);
++ const vods = data.vods || [];
++ if (!vods.length) { c.innerHTML = '<p class="muted">No VODs found</p>'; return; }
++
++ c.innerHTML = `
++ <table class="admin-table">
++ <thead><tr>
++ <th style="width:32px"><input type="checkbox" onchange="toggleAllVods(this.checked)" style="cursor:pointer"></th>
++ <th>Title</th><th>User</th><th style="text-align:right">Size</th><th style="text-align:right">Duration</th><th>Date</th>
++ </tr></thead>
++ <tbody>${vods.map(v => `
++ <tr>
++ <td><input type="checkbox" class="vod-cb" value="${v.id}" onchange="updateVodSelection()" style="cursor:pointer"></td>
++ <td style="max-width:240px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${esc(v.title||'')}">${esc(v.title || '(untitled)')}</td>
++ <td>${esc(v.username || '-')}</td>
++ <td style="text-align:right;font-weight:600;font-variant-numeric:tabular-nums">${fmtBytes(v.diskSize || v.file_size || 0)}</td>
++ <td style="text-align:right;color:var(--text-secondary)">${fmtDuration(v.duration_seconds)}</td>
++ <td>${v.created_at ? timeAgo(v.created_at) : '-'}</td>
++ </tr>
++ `).join('')}</tbody>
++ </table>
++ ${data.total > 100 ? `<p class="muted" style="margin-top:8px;font-size:11px">Showing top 100 of ${data.total.toLocaleString()} VODs</p>` : ''}`;
++ } catch (e) { c.innerHTML = `<p class="muted">Error: ${esc(e.message)}</p>`; }
++}
++
++function toggleAllVods(checked) { document.querySelectorAll('.vod-cb').forEach(cb => cb.checked = checked); updateVodSelection(); }
++function updateVodSelection() {
++ const n = document.querySelectorAll('.vod-cb:checked').length;
++ const btn = document.getElementById('vod-bulk-btn');
++ if (btn) { btn.disabled = n === 0; btn.innerHTML = `<i class="fa-solid fa-trash-can"></i> Delete Selected${n ? ` (${n})` : ''}`; }
++}
++
++async function bulkDeleteVods() {
++ const ids = [...document.querySelectorAll('.vod-cb:checked')].map(cb => parseInt(cb.value));
++ if (!ids.length) return;
++ if (!confirm(`Permanently delete ${ids.length} VOD(s)?`)) return;
++ try {
++ const data = await api('/api/admin/streamer/storage/vods/bulk', { method: 'DELETE', body: { ids } });
++ toast(`Deleted ${data.deleted || 0} VOD(s), freed ${fmtBytes(data.freed || 0)}`, 'success');
++ loadStorage();
++ } catch (e) { toast(e.message, 'error'); }
++}
++
++// ═══════════════════════════════════════════════════════════════
++// Broadcast Notification
++// ═══════════════════════════════════════════════════════════════
++async function sendBroadcast() {
++ const title = document.getElementById('broadcast-title')?.value.trim();
++ const message = document.getElementById('broadcast-msg')?.value.trim();
++ const icon = document.getElementById('broadcast-icon')?.value.trim() || '📢';
++ const priority = document.getElementById('broadcast-priority')?.value || 'normal';
++ const url = document.getElementById('broadcast-url')?.value.trim();
++ if (!title) return toast('Title required', 'error');
++ if (!confirm(`Send broadcast "${title}" to all users?`)) return;
++ try {
++ const data = await api('/api/admin/broadcast', { method: 'POST', body: { title, message, icon, priority, url } });
++ toast(`Broadcast sent to ${data.sent || 0} users`, 'success');
++ document.getElementById('broadcast-title').value = '';
++ document.getElementById('broadcast-msg').value = '';
++ } catch (e) { toast(e.message, 'error'); }
++}
++
++// ═══════════════════════════════════════════════════════════════
++// Email Configuration
++// ═══════════════════════════════════════════════════════════════
++async function loadEmail() {
++ const c = document.getElementById('email-content');
++ c.innerHTML = '<div class="loading">Loading...</div>';
++ try {
++ const data = await api('/api/admin/email');
++ const email = data.email || {};
++ const metrics = data.metrics || {};
++ const summary = metrics.summary || {};
++ const byType = Array.isArray(metrics.by_type) ? metrics.by_type : [];
++ const recent = Array.isArray(metrics.recent) ? metrics.recent : [];
++ c.innerHTML = `
++ <div class="stats-grid" style="margin-bottom:16px">
++ <div class="stat-card"><div class="value">${Number(summary.sent || 0).toLocaleString()}</div><div class="label"><i class="fa-solid fa-paper-plane"></i> Emails Sent</div></div>
++ <div class="stat-card"><div class="value">${Number(summary.failed || 0).toLocaleString()}</div><div class="label"><i class="fa-solid fa-triangle-exclamation"></i> Failed</div></div>
++ <div class="stat-card"><div class="value">${Number(summary.sent_24h || 0).toLocaleString()}</div><div class="label"><i class="fa-solid fa-clock"></i> Sent · 24h</div></div>
++ <div class="stat-card"><div class="value">${Number(summary.password_resets_24h || 0).toLocaleString()}</div><div class="label"><i class="fa-solid fa-key"></i> Password Resets · 24h</div></div>
++ <div class="stat-card"><div class="value">${Number(summary.notifications_24h || 0).toLocaleString()}</div><div class="label"><i class="fa-solid fa-bell"></i> Notification Emails · 24h</div></div>
++ <div class="stat-card"><div class="value">${Number(summary.tests_24h || 0).toLocaleString()}</div><div class="label"><i class="fa-solid fa-vial"></i> Test Emails · 24h</div></div>
++ </div>
++
++ <div class="card" style="padding:16px;margin-bottom:16px">
++ <h3 style="margin-bottom:10px"><i class="fa-solid fa-chart-line"></i> Email Delivery Metrics</h3>
++ <div class="muted" style="display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:10px">
++ <div><strong>Total Logged:</strong> ${Number(summary.total || 0).toLocaleString()}</div>
++ <div><strong>Failed · 24h:</strong> ${Number(summary.failed_24h || 0).toLocaleString()}</div>
++ <div><strong>Last Sent:</strong> ${summary.last_sent_at ? esc(timeAgo(summary.last_sent_at)) : 'Never'}</div>
++ <div><strong>Last Failure:</strong> ${summary.last_failed_at ? esc(timeAgo(summary.last_failed_at)) : 'Never'}</div>
++ </div>
++ </div>
++
++ <form id="email-form" style="max-width:600px;margin-bottom:16px">
++ <div class="setting-row">
++ <label><strong>Email Enabled</strong></label>
++ <input type="checkbox" id="email-enabled" ${email.enabled ? 'checked' : ''} style="width:18px;height:18px;cursor:pointer">
++ </div>
++ ${email.issue ? `<div style="background:#e74c3c22;border:1px solid #e74c3c44;border-radius:8px;padding:10px 14px;margin:8px 0 12px;font-size:13px;color:#e74c3c"><i class="fa-solid fa-triangle-exclamation"></i> ${esc(email.issue)}</div>` : email.ready ? `<div style="background:#27ae6022;border:1px solid #27ae6044;border-radius:8px;padding:10px 14px;margin:8px 0 12px;font-size:13px;color:#27ae60"><i class="fa-solid fa-circle-check"></i> Resend is ready — emails will be delivered</div>` : ''}
++
++ <h4 style="margin:12px 0 10px;font-size:14px"><i class="fa-solid fa-key"></i> Resend API</h4>
++ ${email.api_key_source === 'env'
++ ? `<div class="form-field"><label>API Key</label><input type="password" id="email-api-key" value="" placeholder="Set in the environment (${esc(email.api_key_env || 'RESEND_API_KEY')})" disabled autocomplete="off"></div>
++ <p class="muted" style="margin:-4px 0 8px;font-size:11px">The key comes from <code>${esc(email.api_key_env || 'RESEND_API_KEY')}</code> in the server's environment file and cannot be changed here.</p>`
++ : `<div class="form-field"><label>API Key</label><input type="password" id="email-api-key" value="${esc(email.api_key || '')}" placeholder="re_xxxxxxxxxxxx" autocomplete="off"></div>
++ <p class="muted" style="margin:-4px 0 8px;font-size:11px">Get your API key from <a href="https://resend.com/api-keys" target="_blank" style="color:var(--accent)">resend.com/api-keys</a>. Key is masked after saving.${email.api_key_source === 'database' ? ` Stored in the database; prefer <code>${esc(email.api_key_env || 'RESEND_API_KEY')}</code> in the server's environment file.` : ''}</p>`}
++
++ <h4 style="margin:20px 0 10px;font-size:14px"><i class="fa-solid fa-envelope"></i> From Addresses</h4>
++ <div class="form-field"><label>Default From Email</label><input type="email" id="email-from-email" value="${esc(email.from_email || '')}" placeholder="[email protected]"></div>
++ <div class="form-field"><label>Default From Name</label><input type="text" id="email-from-name" value="${esc(email.from_name || '')}" placeholder="OpenVibe"></div>
++ <div class="form-field"><label>OpenVibe.Live From <span class="muted">(optional override)</span></label><input type="email" id="email-from-openvibelive" value="${esc(email.from_email_openvibelive || '')}" placeholder="[email protected]"></div>
++ <div class="form-field"><label>OpenVibe Quest From <span class="muted">(optional override)</span></label><input type="email" id="email-from-openvibegames" value="${esc(email.from_email_openvibegames || '')}" placeholder="[email protected]"></div>
++ <div class="form-field"><label>OpenVibe Tools From <span class="muted">(optional override)</span></label><input type="email" id="email-from-openvibenetwork" value="${esc(email.from_email_openvibenetwork || '')}" placeholder="[email protected]"></div>
++
++ <div style="display:flex;gap:8px;margin-top:16px">
++ <button type="button" class="btn btn-primary" onclick="saveEmail()"><i class="fa-solid fa-floppy-disk"></i> Save Email Config</button>
++ <button type="button" class="btn btn-outline" onclick="testEmail()"><i class="fa-solid fa-paper-plane"></i> Send Test Email</button>
++ </div>
++ <div style="background:#2a2a3822;border:1px solid #333340;border-radius:8px;padding:12px 14px;margin-top:16px;font-size:12px;color:var(--text-muted)">
++ <strong style="color:var(--text)"><i class="fa-solid fa-globe"></i> DNS Setup</strong><br>
++ Add your domain at <a href="https://resend.com/domains" target="_blank" style="color:var(--accent)">resend.com/domains</a> and add the SPF (TXT + MX) and DKIM records to your DNS provider. Once verified, emails will be sent from your domain.
++ </div>
++ </form>
++
++ <div class="card" style="padding:16px;margin-bottom:16px">
++ <h3 style="margin-bottom:10px"><i class="fa-solid fa-layer-group"></i> Delivery Breakdown · 30 days</h3>
++ ${byType.length ? `
++ <table class="admin-table">
++ <thead><tr><th>Email Type</th><th>Status</th><th>Count</th></tr></thead>
++ <tbody>
++ ${byType.map(row => `
++ <tr>
++ <td>${esc(row.email_type)}</td>
++ <td>${row.status === 'sent' ? '<span class="badge badge-success">Sent</span>' : '<span class="badge badge-danger">Failed</span>'}</td>
++ <td>${Number(row.count || 0).toLocaleString()}</td>
++ </tr>
++ `).join('')}
++ </tbody>
++ </table>`
++ : '<p class="muted">No email activity logged yet.</p>'}
++ </div>
++
++ <div class="card" style="padding:16px">
++ <h3 style="margin-bottom:10px"><i class="fa-solid fa-clock-rotate-left"></i> Recent Email Activity</h3>
++ ${recent.length ? `
++ <table class="admin-table">
++ <thead><tr><th>When</th><th>Type</th><th>Recipient</th><th>Status</th><th>Subject</th><th>Error</th></tr></thead>
++ <tbody>
++ ${recent.map(row => `
++ <tr>
++ <td style="white-space:nowrap">${esc(timeAgo(row.created_at))}</td>
++ <td>${esc(row.email_type)}</td>
++ <td>${esc(row.recipient)}</td>
++ <td>${row.status === 'sent' ? '<span class="badge badge-success">Sent</span>' : '<span class="badge badge-danger">Failed</span>'}</td>
++ <td>${esc(row.subject || '-')}</td>
++ <td style="font-size:11px;color:var(--text-muted)">${esc(row.error_message || '-')}</td>
++ </tr>
++ `).join('')}
++ </tbody>
++ </table>`
++ : '<p class="muted">No email activity logged yet.</p>'}
++ </div>`;
++ } catch (e) { c.innerHTML = `<p class="muted">Error: ${esc(e.message)}</p>`; }
++}
++
++async function saveEmail() {
++ try {
++ await api('/api/admin/email', { method: 'PUT', body: {
++ enabled: document.getElementById('email-enabled').checked,
++ api_key: document.getElementById('email-api-key').disabled ? undefined : document.getElementById('email-api-key').value,
++ from_email: document.getElementById('email-from-email').value,
++ from_name: document.getElementById('email-from-name').value,
++ from_email_openvibelive: document.getElementById('email-from-openvibelive').value,
++ from_email_openvibegames: document.getElementById('email-from-openvibegames').value,
++ from_email_openvibenetwork: document.getElementById('email-from-openvibenetwork').value,
++ }});
++ toast('Email configuration saved', 'success');
++ loadEmail();
++ } catch (e) { toast(e.message, 'error'); }
++}
++
++async function testEmail() {
++ const email = prompt('Send test email to:');
++ if (!email) return;
++ try {
++ const result = await api('/api/admin/email/test', { method: 'POST', body: { email } });
++ if (result.sent) {
++ toast('Test email sent successfully! Check your inbox.', 'success');
++ } else {
++ toast(result.error || 'Send failed — check delivery log', 'error');
++ }
++ } catch (e) { toast(e.message, 'error'); }
++}
ddiff --git a/public/js/admin/core.js b/public/js/admin/core.js
nnew file mode 100644
iindex 00000000..c35fbac3
---- /dev/null
++++ b/public/js/admin/core.js
@@@ -0,0 +1,221 @@
++/* ═══════════════════════════════════════════════════════════════
++ OpenVibe.Network — admin panel (/admin): state, helpers (api, esc, toast, fmtBytes), tabs and URL routing.
++ Split out of admin.html. Classic scripts, global scope: they rely on
++ admin/core.js's helpers (api, esc, toast, API, token) and on the
++ tabLoaders map in admin/boot.js (loaded last). No ES modules.
++ ═══════════════════════════════════════════════════════════════ */
++/* ═══════════════════════════════════════════════════════════════
++ OpenVibe — Unified Admin Panel
++ ═══════════════════════════════════════════════════════════════ */
++
++const API = window.location.origin;
++let token = localStorage.getItem('ov_token');
++let currentUser = null;
++
++// ── Helpers ──────────────────────────────────────────────────
++// Attribute-safe: also escapes quotes, otherwise any value containing '"' (e.g. JSON) is cut
++// short inside value="..." — which is how JSON settings got rendered as '{' and saved back as '{'.
++function esc(s) { return String(s ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;'); }
++
++function timeAgo(dateStr) {
++ const d = new Date(dateStr);
++ const s = Math.floor((Date.now() - d.getTime()) / 1000);
++ if (s < 60) return 'just now';
++ if (s < 3600) return Math.floor(s / 60) + 'm ago';
++ if (s < 86400) return Math.floor(s / 3600) + 'h ago';
++ if (s < 604800) return Math.floor(s / 86400) + 'd ago';
++ return d.toLocaleDateString();
++}
++
++function fmtBytes(bytes) {
++ if (!bytes || bytes < 0) return '0 B';
++ const units = ['B', 'KB', 'MB', 'GB', 'TB'];
++ let i = 0, val = bytes;
++ while (val >= 1024 && i < units.length - 1) { val /= 1024; i++; }
++ return `${val.toFixed(i === 0 ? 0 : 1)} ${units[i]}`;
++}
++
++function fmtDuration(sec) {
++ if (!sec) return '—';
++ const h = Math.floor(sec / 3600), m = Math.floor((sec % 3600) / 60), s = sec % 60;
++ return h > 0 ? `${h}h ${m}m` : `${m}m ${s}s`;
++}
++
++function toast(msg, type = 'info') {
++ const c = document.getElementById('toast-container');
++ const t = document.createElement('div');
++ t.className = `toast toast-${type}`;
++ t.textContent = msg;
++ c.appendChild(t);
++ setTimeout(() => { t.style.opacity = '0'; t.style.transition = 'opacity .3s'; setTimeout(() => t.remove(), 300); }, 3500);
++}
++
++async function api(path, opts = {}) {
++ const headers = { 'Content-Type': 'application/json', ...opts.headers };
++ if (token) headers.Authorization = 'Bearer ' + token;
++ const fetchOpts = { ...opts, headers, credentials: 'include' };
++ if (opts.body && typeof opts.body === 'object') fetchOpts.body = JSON.stringify(opts.body);
++ const res = await fetch(API + path, fetchOpts);
++ const data = await res.json();
++ if (!res.ok) {
++ // Keep the payload: endpoints that reject with structured detail (e.g. the cookie
++ // validator explaining WHICH check failed) are useless if only the message survives.
++ const err = new Error(data.error || 'Request failed');
++ err.body = data;
++ err.status = res.status;
++ throw err;
++ }
++ return data;
++}
++
++// Auth headers for raw fetch() calls (SSH tab, Deploy tab, etc.).
++function authHeaders() {
++ const h = {};
++ if (token) h.Authorization = 'Bearer ' + token;
++ return h;
++}
++
++// ── Tab switching ────────────────────────────────────────────
++let currentTab = 'dashboard';
++// Tabs restricted to the network owner (API keys / money / infrastructure / core settings).
++// NOTE: 'ai' is NOT here — admins may use its analytics/explorer tools; only the
++// AI configuration form inside it is owner-gated (see loadAi).
++const OWNER_ONLY_TABS = new Set(['deploy', 'domains', 'payments', 'cashouts', 'storage', 'email', 'settings', 'grants', 'eventsops']);
++
++function showTab(id, btn) {
++ if (!tabLoaders[id]) id = 'dashboard';
++ // Owner-only tabs (secrets / money / infra) — bounce non-owners to dashboard.
++ if (OWNER_ONLY_TABS.has(id) && currentUser && !currentUser.is_owner) id = 'dashboard';
++ currentTab = id;
++ btn = btn || _tabButton(id);
++ document.querySelectorAll('.section').forEach(s => s.classList.remove('active'));
++ document.querySelectorAll('#main-tabs .admin-tab-btn').forEach(b => b.classList.remove('active'));
++ document.getElementById('sec-' + id)?.classList.add('active');
++ btn?.classList.add('active');
++ btn?.scrollIntoView({ behavior: 'smooth', inline: 'center', block: 'nearest' });
++ setTimeout(checkMainTabsOverflow, 180);
++ tabLoaders[id]?.();
++ _syncAdminUrl();
++}
++
++/* ── Admin URL routing (History API + slugs + localStorage) ──────────
++ /admin/<tab>[/<subtab>][?last=30d] e.g. /admin/settings,
++ /admin/analytics/openvibelive?last=12h. On load a slug wins; otherwise the
++ last-visited location is restored from localStorage. */
++let deployCurrentSubTab = 'overview';
++let _applyingRoute = false;
++
++function _tabButton(id) {
++ return [...document.querySelectorAll('#main-tabs .admin-tab-btn')]
++ .find(b => (b.getAttribute('onclick') || '').includes(`showTab('${id}'`)) || null;
++}
++function _subBtn(containerId, tab) {
++ return [...document.querySelectorAll(`#${containerId} button`)]
++ .find(b => (b.getAttribute('onclick') || '').includes(`('${tab}'`)) || null;
++}
++
++// Analytics period ↔ URL param. Day options become "<n>d"; hour/3d pass through.
++function periodToUrlParam(v) {
++ if (v == null) return '';
++ v = String(v);
++ return /^\d+$/.test(v) ? v + 'd' : v; // 30 -> 30d ; 12h/3d stay
++}
++function urlParamToPeriod(p) {
++ if (!p) return null;
++ p = String(p).toLowerCase().trim();
++ const alias = { '1y': '365', '365d': '365', '1d': '24h', '24': '24h' };
++ if (alias[p]) return alias[p];
++ if (/^\d+d$/.test(p)) return p.slice(0, -1); // 30d -> 30
++ const valid = ['1h', '12h', '24h', '3d', '7', '14', '30', '90', '365'];
++ if (valid.includes(p)) return p;
++ if (/^\d+$/.test(p)) return p; // bare day count
++ return null;
++}
++
++function _adminPath() {
++ if (currentTab === 'analytics') return '/admin/analytics/' + (analyticsCurrentSubTab || 'overview');
++ if (currentTab === 'deploy') return '/admin/deploy/' + (deployCurrentSubTab || 'overview');
++ if (currentTab && currentTab !== 'dashboard') return '/admin/' + currentTab;
++ return '/admin';
++}
++function _syncAdminUrl(replace) {
++ if (_applyingRoute) return;
++ let url = _adminPath();
++ if (currentTab === 'analytics') {
++ const per = document.getElementById('analytics-period')?.value;
++ if (per) url += '?last=' + periodToUrlParam(per);
++ }
++ try { history[replace ? 'replaceState' : 'pushState']({ adminTab: currentTab }, '', url); } catch { /* */ }
++ try { localStorage.setItem('openvibe_admin_last_path', url); } catch { /* */ }
++}
++
++function routeFromUrl() {
++ _applyingRoute = true;
++ try {
++ let rest = location.pathname.replace(/^\/admin\/?/, '');
++ let parts = rest.split('/').filter(Boolean);
++ let search = location.search;
++ // Bare /admin → restore last-visited location (a real slug always wins).
++ if (parts.length === 0) {
++ const last = localStorage.getItem('openvibe_admin_last_path');
++ if (last && last !== '/admin' && last.startsWith('/admin')) {
++ try { history.replaceState({}, '', last); } catch { /* */ }
++ const u = new URL(last, location.origin);
++ parts = u.pathname.replace(/^\/admin\/?/, '').split('/').filter(Boolean);
++ search = u.search;
++ }
++ }
++ let tab = parts[0];
++ const sub = parts[1] || null;
++ if (!tab || !tabLoaders[tab]) tab = 'dashboard';
++ const per = urlParamToPeriod(new URLSearchParams(search).get('last'));
++ if (tab === 'analytics' && per) {
++ const sel = document.getElementById('analytics-period'); if (sel) sel.value = per;
++ }
++ showTab(tab, _tabButton(tab));
++ // Use the effective tab (showTab may bounce a non-owner off /admin/deploy).
++ if (currentTab === 'analytics' && sub) showAnalyticsSubTab(sub, _subBtn('analytics-sub-tabs', sub));
++ else if (currentTab === 'deploy' && sub) showDeploySubTab(sub, _subBtn('deploy-sub-tabs', sub));
++ } finally {
++ _applyingRoute = false;
++ _syncAdminUrl(true); // normalize (e.g. /admin/analytics -> /admin/analytics/overview)
++ }
++}
++
++window.addEventListener('popstate', () => routeFromUrl());
++
++function checkMainTabsOverflow() {
++ const tabs = document.getElementById('main-tabs');
++ if (!tabs) return;
++ const left = document.getElementById('main-tabs-left');
++ const right = document.getElementById('main-tabs-right');
++ const hasOverflow = tabs.scrollWidth > tabs.clientWidth + 2;
++ const atStart = tabs.scrollLeft <= 1;
++ const atEnd = tabs.scrollLeft >= tabs.scrollWidth - tabs.clientWidth - 1;
++ left?.classList.toggle('visible', hasOverflow && !atStart);
++ right?.classList.toggle('visible', hasOverflow && !atEnd);
++}
++
++function scrollMainTabs(dir) {
++ const tabs = document.getElementById('main-tabs');
++ if (!tabs) return;
++ tabs.scrollBy({ left: dir * 180, behavior: 'smooth' });
++ let checks = 0;
++ let lastPos = tabs.scrollLeft;
++ const poll = setInterval(() => {
++ checkMainTabsOverflow();
++ if (tabs.scrollLeft === lastPos || ++checks > 12) clearInterval(poll);
++ lastPos = tabs.scrollLeft;
++ }, 60);
++}
++
++function initMainTabsOverflow() {
++ const tabs = document.getElementById('main-tabs');
++ if (!tabs) return;
++ tabs.addEventListener('scroll', checkMainTabsOverflow, { passive: true });
++ if (typeof ResizeObserver !== 'undefined') {
++ new ResizeObserver(checkMainTabsOverflow).observe(tabs);
++ }
++ window.addEventListener('resize', checkMainTabsOverflow);
++ setTimeout(checkMainTabsOverflow, 100);
++}
ddiff --git a/public/js/admin/dashboard.js b/public/js/admin/dashboard.js
nnew file mode 100644
iindex 00000000..8c804e1b
---- /dev/null
++++ b/public/js/admin/dashboard.js
@@@ -0,0 +1,58 @@
++/* ═══════════════════════════════════════════════════════════════
++ OpenVibe.Network — admin panel (/admin): the dashboard tab.
++ Split out of admin.html. Classic scripts, global scope: they rely on
++ admin/core.js's helpers (api, esc, toast, API, token) and on the
++ tabLoaders map in admin/boot.js (loaded last). No ES modules.
++ ═══════════════════════════════════════════════════════════════ */
++// ═══════════════════════════════════════════════════════════════
++// Dashboard
++// ═══════════════════════════════════════════════════════════════
++async function loadDashboard() {
++ const statsEl = document.getElementById('dashboard-stats');
++ const healthEl = document.getElementById('health-info');
++
++ // Load both openvibe-network health and OpenVibe.Live stats in parallel
++ const [toolsHealth, streamerStats] = await Promise.allSettled([
++ api('/api/admin/health'),
++ api('/api/admin/streamer/stats'),
++ ]);
++
++ const th = toolsHealth.status === 'fulfilled' ? toolsHealth.value.health : null;
++ const ss = streamerStats.status === 'fulfilled' ? (streamerStats.value.stats || streamerStats.value) : null;
++
++ const stats = [
++ { label: 'Registered Users', value: th?.users || 0, icon: 'fa-users' },
++ { label: 'Active Sessions', value: th?.active_sessions || 0, icon: 'fa-plug' },
++ { label: 'Active Streams', value: ss?.streams?.live || ss?.activeStreams || 0, icon: 'fa-broadcast-tower' },
++ { label: 'Total Streams', value: ss?.streams?.total || ss?.totalStreams || 0, icon: 'fa-video' },
++ { label: 'Active Bans', value: ss?.users?.banned || ss?.activeBans || 0, icon: 'fa-ban' },
++ { label: 'Notifications', value: th?.total_notifications || 0, icon: 'fa-bell' },
++ { label: 'Unread Notifs', value: th?.unread_notifications || 0, icon: 'fa-bell-slash' },
++ { label: 'Anon Users', value: th?.anon_users || 0, icon: 'fa-ghost' },
++ ];
++
++ statsEl.innerHTML = stats.map(s => `
++ <div class="stat-card">
++ <div class="value">${typeof s.value === 'number' ? s.value.toLocaleString() : s.value}</div>
++ <div class="label"><i class="fa-solid ${s.icon}"></i> ${s.label}</div>
++ </div>
++ `).join('');
++
++ // Health details
++ if (th) {
++ const mem = th.memory;
++ const upH = Math.floor(th.uptime / 3600);
++ const upM = Math.floor((th.uptime % 3600) / 60);
++ healthEl.innerHTML = `
++ <div style="display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:12px;font-size:13px">
++ <div><strong>Uptime:</strong> ${upH}h ${upM}m</div>
++ <div><strong>Heap Used:</strong> ${fmtBytes(mem?.heapUsed)}</div>
++ <div><strong>Heap Total:</strong> ${fmtBytes(mem?.heapTotal)}</div>
++ <div><strong>RSS:</strong> ${fmtBytes(mem?.rss)}</div>
++ <div><strong>Email:</strong> ${th.ses_enabled ? '<span style="color:var(--success)">Enabled</span>' : '<span style="color:var(--text-muted)">Disabled</span>'}</div>
++ </div>
++ `;
++ } else {
++ healthEl.innerHTML = '<p class="muted">Could not load health data</p>';
++ }
++}
ddiff --git a/public/js/admin/deploy.js b/public/js/admin/deploy.js
nnew file mode 100644
iindex 00000000..89ff7443
---- /dev/null
++++ b/public/js/admin/deploy.js
@@@ -0,0 +1,629 @@
++/* ═══════════════════════════════════════════════════════════════
++ OpenVibe.Network — admin panel (/admin): domains, SSH access and the deploy tab.
++ Split out of admin.html. Classic scripts, global scope: they rely on
++ admin/core.js's helpers (api, esc, toast, API, token) and on the
++ tabLoaders map in admin/boot.js (loaded last). No ES modules.
++ ═══════════════════════════════════════════════════════════════ */
++// ── Tool domains (owner only; the server enforces it) ───────────────
++let _domCatalog = null;
++async function loadDomains() {
++ const el = document.getElementById('domains-content');
++ try {
++ const [list, cat] = await Promise.all([api('/api/admin/domains'), _domCatalog ? Promise.resolve(_domCatalog) : api('/api/admin/domains/catalog')]);
++ _domCatalog = cat;
++ const byTool = {}; list.domains.forEach(d => { (byTool[d.tool_id] = byTool[d.tool_id] || []).push(d); });
++ const opts = [...cat.families.map(f => ({ id: f.id, name: f.name + ' (family)' })), ...cat.tools.map(t => ({ id: t.id, name: t.name }))];
++ const roleBadge = r => `<span class="badge ${r === 'canonical' ? 'badge-success' : r === 'short' ? 'badge-info' : ''}">${esc(r)}</span>`;
++ el.innerHTML = `
++ <div class="card"><h3><i class="fa-solid fa-globe"></i> Tool domains</h3>
++ <p class="muted">Every tool has a <b>canonical</b> host (used in links, sitemaps and canonical tags), a <b>short</b> host people type, any number of <b>aliases</b> that redirect to the short one, and <b>mirrors</b> that serve the tool on another address while pointing search engines at the canonical host. Custom domains work the same way: point DNS at the server, add the host here, then run <code>add-custom-domain.sh</code> on the host for nginx and TLS. Tools picks changes up within a minute.${cat.source !== 'live' ? ' <br><span class="badge badge-warning">Tools catalog unreachable: showing the built-in list</span>' : ''}</p>
++ <div class="form-group" style="display:grid;grid-template-columns:repeat(auto-fit,minmax(170px,1fr));gap:10px;align-items:end">
++ <div class="form-field"><label for="dom-tool">Tool</label><select id="dom-tool">${opts.map(o => `<option value="${esc(o.id)}">${esc(o.name)}</option>`).join('')}</select></div>
++ <div class="form-field"><label for="dom-host">Host</label><input id="dom-host" placeholder="youtubedownloadonline.com" autocomplete="off" spellcheck="false"></div>
++ <div class="form-field"><label for="dom-role">Role</label><select id="dom-role"><option value="alias">alias (redirects to the short host)</option><option value="mirror">mirror (serves the tool, canonical tag points at the SEO host)</option><option value="short">short (what people type)</option><option value="canonical">canonical (SEO host)</option></select></div>
++ <div class="form-field"><label for="dom-note">Note</label><input id="dom-note" maxlength="500"></div>
++ <div><button class="btn btn-outline" onclick="checkDomainDns()">Check DNS</button> <button class="btn btn-primary" onclick="addDomain()">Add</button></div>
++ </div>
++ <div id="dom-check" class="muted" style="margin-top:8px"></div>
++ </div>
++ <div class="card"><h3>Overrides</h3>
++ ${list.domains.length ? `<div style="overflow-x:auto"><table class="table" style="width:100%"><thead><tr><th>Tool</th><th>Host</th><th>Role</th><th>On</th><th>Note</th><th></th></tr></thead><tbody>
++ ${list.domains.map(d => `<tr><td>${esc(d.tool_id)}${d.tool_known === false ? ' <span class="badge badge-warning">unknown tool</span>' : ''}</td>
++ <td><a href="https://${esc(d.host)}/" target="_blank" rel="noopener">${esc(d.host)}</a></td><td>${roleBadge(d.role)}</td>
++ <td><input type="checkbox" ${d.enabled ? 'checked' : ''} onchange="updateDomain(${d.id},{enabled:this.checked})" aria-label="Enabled"></td>
++ <td>${esc(d.note || '')}</td>
++ <td style="white-space:nowrap"><select onchange="updateDomain(${d.id},{role:this.value})" aria-label="Role">${['canonical','short','alias','mirror'].map(r => `<option ${r === d.role ? 'selected' : ''}>${r}</option>`).join('')}</select>
++ <button class="btn btn-sm btn-danger" onclick="deleteDomain(${d.id},'${esc(d.host)}')">Remove</button></td></tr>`).join('')}
++ </tbody></table></div>` : '<p class="muted">No overrides yet. Every tool is using its built-in hosts.</p>'}
++ </div>
++ <div class="card"><h3>Built-in hosts</h3><div style="overflow-x:auto"><table class="table" style="width:100%"><thead><tr><th>Tool</th><th>Canonical</th><th>Short</th><th>Aliases</th></tr></thead><tbody>
++ ${cat.tools.map(t => `<tr><td>${esc(t.name)} <small class="muted">${esc(t.id)}</small></td><td>${esc(t.hosts?.canonical || '')}</td><td>${esc(t.hosts?.short || '')}</td><td>${esc((t.hosts?.aliases || []).join(', '))}</td></tr>`).join('')}
++ </tbody></table></div></div>`;
++ } catch (e) { el.innerHTML = `<div class="card"><p class="muted">Could not load domains: ${esc(e.message)}</p></div>`; }
++}
++async function addDomain() {
++ const body = { tool_id: document.getElementById('dom-tool').value, host: document.getElementById('dom-host').value, role: document.getElementById('dom-role').value, note: document.getElementById('dom-note').value };
++ try { const r = await api('/api/admin/domains', { method: 'POST', body }); toast('Domain added', 'success'); (r.warnings || []).forEach(w => toast(w, 'warning')); loadDomains(); } catch (e) { toast(e.message, 'error'); }
++}
++async function updateDomain(id, patch) { try { const r = await api('/api/admin/domains/' + id, { method: 'PUT', body: patch }); (r.warnings || []).forEach(w => toast(w, 'warning')); toast('Saved', 'success'); } catch (e) { toast(e.message, 'error'); } loadDomains(); }
++async function deleteDomain(id, host) { if (!confirm(`Remove ${host}?`)) return; try { await api('/api/admin/domains/' + id, { method: 'DELETE' }); toast('Removed', 'success'); } catch (e) { toast(e.message, 'error'); } loadDomains(); }
++async function checkDomainDns() {
++ const out = document.getElementById('dom-check'); out.textContent = 'Checking…';
++ try { const r = await api('/api/admin/domains/check', { method: 'POST', body: { host: document.getElementById('dom-host').value } }); const c = r.check || {};
++ out.textContent = (c.status === 'ready' ? 'Ready. ' : '') + (c.message || c.status || ''); } catch (e) { out.textContent = e.message; }
++}
++
++// ═══════════════════════════════════════════════════════════════
++// SSH Access — owner provisions locked-down dev accounts; admins get
++// guided instructions + an agent prompt to connect and vibe-code.
++// ═══════════════════════════════════════════════════════════════
++let sshInfo = null;
++
++async function loadSSH() {
++ const el = document.getElementById('ssh-content');
++ if (!el) return;
++ if (!sshInfo) {
++ try {
++ const r = await fetch(`${API}/api/admin/ssh-info`, { headers: authHeaders() });
++ const d = await r.json();
++ if (!d.ok) throw new Error(d.error || 'Failed to load SSH info');
++ sshInfo = d;
++ } catch (e) {
++ el.innerHTML = `<div class="card"><p class="muted">Could not load SSH info: ${esc(e.message)}</p></div>`;
++ return;
++ }
++ }
++ if (sshInfo.is_owner) renderSSHOwner(el);
++ else renderSSHUser(el);
++}
++
++function _sshSetText(id, t) { const e = document.getElementById(id); if (e) e.textContent = t; }
++function _sshSanitizeUser(v) { return (v || '').trim().toLowerCase().replace(/[^a-z0-9_-]/g, ''); }
++
++function sshCopy(id, btn) {
++ const el = document.getElementById(id);
++ if (!el) return;
++ navigator.clipboard.writeText(el.textContent).then(() => {
++ if (typeof toast === 'function') toast('Prompt copied', 'success');
++ if (btn) { const o = btn.innerHTML; btn.innerHTML = '<i class="fa-solid fa-check"></i> Copied'; setTimeout(() => { btn.innerHTML = o; }, 1400); }
++ }).catch(() => { if (typeof toast === 'function') toast('Copy failed', 'error'); });
++}
++
++// ── Owner view ──────────────────────────────────────────────
++function renderSSHOwner(el) {
++ const hostWarn = sshInfo.host ? '' :
++ `<div class="card" style="border-color:var(--warning)"><p style="color:var(--warning);margin:0"><i class="fa-solid fa-triangle-exclamation"></i> No server host is configured yet. Set <code>SSH_SERVER_HOST</code> in the openvibe-network environment so the connection details fill in automatically.</p></div>`;
++ el.innerHTML = `
++ <div class="card">
++ <h3><i class="fa-solid fa-user-lock"></i> Provision an SSH dev account <span class="staff-pill">Owner</span></h3>
++ <p class="muted">Create a locked-down Linux account so a trusted admin can SSH in and help build the sites (vibe coding with Claude). They get their own home + group access to the project folders — <b>no root</b>. Fill in the fields, copy the prompt, and hand it to your agent.</p>
++ </div>
++ ${hostWarn}
++ <div class="card">
++ <div class="form-group" style="margin-bottom:14px">
++ <label>New Linux username</label>
++ <input type="text" id="ssh-o-user" placeholder="e.g. alex" oninput="sshOwnerUpdate()" style="max-width:280px">
++ </div>
++ <div class="form-group" style="margin-bottom:14px">
++ <label>Their SSH <b>public</b> key <span class="muted">— ask them to run the steps in their own SSH tab and paste their <code>.pub</code> here (recommended)</span></label>
++ <textarea id="ssh-o-pubkey" rows="3" placeholder="ssh-ed25519 AAAA... alex@openvibe" oninput="sshOwnerUpdate()" style="width:100%;font-family:ui-monospace,monospace;font-size:0.82rem"></textarea>
++ </div>
++ <div class="form-group">
++ <label>Access level</label>
++ <select id="ssh-o-access" onchange="sshOwnerUpdate()" style="max-width:440px">
++ <option value="code">Project code only (safest)</option>
++ <option value="restart">Project code + restart app services</option>
++ <option value="web">Full web + nginx — build &amp; run their own sites (high trust)</option>
++ </select>
++ </div>
++ <div id="ssh-o-webwarn" style="display:none;margin-top:12px;padding:10px 12px;border:1px solid var(--warning);border-radius:8px;background:color-mix(in srgb,var(--warning) 10%,transparent);color:var(--warning);font-size:0.84rem;line-height:1.5">
++ <i class="fa-solid fa-triangle-exclamation"></i> <b>High trust:</b> web + nginx access lets this admin edit any nginx site config and reload the web server — they can serve anything and a bad config can take <em>all</em> the sites down. It still stops short of root (no package installs or arbitrary sudo), but only grant it to admins you fully trust to run the web layer with you.
++ </div>
++ </div>
++ <div class="card">
++ <div style="display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap">
++ <h3 style="margin:0"><i class="fa-solid fa-robot"></i> Agent prompt</h3>
++ <button class="btn btn-primary" onclick="sshCopy('ssh-o-prompt', this)"><i class="fa-solid fa-copy"></i> Copy Prompt</button>
++ </div>
++ <p class="muted" style="margin:8px 0">Paste into Claude Code running on the server with sudo (or locally with root SSH) to create and lock down the account.</p>
++ <pre id="ssh-o-prompt" class="ssh-prompt"></pre>
++ </div>
++ <div class="card">
++ <h3><i class="fa-solid fa-shield-halved"></i> How it's locked down</h3>
++ <ul class="muted" style="line-height:1.7;margin:0;padding-left:20px">
++ <li>Key-only login — password auth is disabled for the account.</li>
++ <li>Member of a shared <code>devs</code> group that owns the project dirs (<code>${esc(sshInfo.projectsRoot)}</code>) with setgid, so they can edit code and new files stay group-shared.</li>
++ <li><b>Not</b> in <code>sudo</code> — no blanket root. TLS / packages / arbitrary system changes stay with you.</li>
++ <li><b>Access levels</b> are additive and each grants only a tightly-scoped sudoers rule (never general root):
++ <ul style="margin:4px 0 0;padding-left:18px">
++ <li><b>Project code only</b> — edit code in the project dirs, nothing privileged.</li>
++ <li><b>+ Restart app services</b> — adds sudo for <code>systemctl restart openvibe-network/openvibe-live</code> only.</li>
++ <li><b>Full web + nginx</b> — adds group ownership of the nginx site configs + a <code>/srv/www</code> web root, plus sudo limited to <code>nginx -t</code> and reload/restart of nginx &amp; php-fpm. Lets them build &amp; run their own sites — high trust, since a bad config affects every site.</li>
++ </ul>
++ </li>
++ <li>Revoke anytime: remove their key from <code>~/.ssh/authorized_keys</code>, or <code>sudo deluser --remove-home &lt;user&gt;</code>.</li>
++ </ul>
++ </div>`;
++ sshOwnerUpdate();
++}
++
++function sshOwnerUpdate() {
++ const user = _sshSanitizeUser(document.getElementById('ssh-o-user')?.value) || '<username>';
++ const pub = (document.getElementById('ssh-o-pubkey')?.value || '').trim();
++ const access = document.getElementById('ssh-o-access')?.value || 'code';
++ const host = sshInfo.host || '<server-host>';
++ const roots = sshInfo.projectsRoot;
++ const firstRoot = roots.split(/\s+/)[0];
++ const pubShown = pub || "<PASTE THE USER'S SSH PUBLIC KEY HERE>";
++ const isRestart = access === 'restart' || access === 'web';
++ const isWeb = access === 'web';
++
++ // Toggle the high-trust warning under the selector.
++ const ww = document.getElementById('ssh-o-webwarn');
++ if (ww) ww.style.display = isWeb ? 'block' : 'none';
++
++ const steps = [
++ `Create the user with no password login:\n sudo adduser --disabled-password --gecos "" ${user}`,
++ `Install their SSH public key:\n sudo install -d -m 700 -o ${user} -g ${user} /home/${user}/.ssh\n echo "${pubShown}" | sudo tee /home/${user}/.ssh/authorized_keys\n sudo chmod 600 /home/${user}/.ssh/authorized_keys\n sudo chown ${user}:${user} /home/${user}/.ssh/authorized_keys`,
++ `Create/ensure a shared dev group and add them:\n sudo groupadd -f devs\n sudo usermod -aG devs ${user}`,
++ `Give the group access to the project dirs (setgid so new files stay shared):\n sudo chgrp -R devs ${roots}\n sudo chmod -R g+rwX ${roots}\n sudo find ${roots} -type d -exec chmod g+s {} +`,
++ `Do NOT add them to the sudo group — they must not have blanket root.`,
++ ];
++ if (isWeb) {
++ steps.push(`Grant broad web + nginx access (edit any site config, run their own sites):\n sudo groupadd -f webdev\n sudo usermod -aG webdev ${user}\n # let the group own & edit nginx site configs (setgid keeps new files group-shared)\n sudo chgrp -R webdev /etc/nginx/sites-available /etc/nginx/sites-enabled\n sudo chmod -R g+rwX /etc/nginx/sites-available /etc/nginx/sites-enabled\n sudo find /etc/nginx/sites-available /etc/nginx/sites-enabled -type d -exec chmod g+s {} +\n # a shared web root for their own sites (2775 = group-writable + setgid)\n sudo install -d -m 2775 -o root -g webdev /srv/www`);
++ }
++ if (isRestart) {
++ const cmds = ['/usr/bin/systemctl restart openvibe-network', '/usr/bin/systemctl restart openvibe-live'];
++ if (isWeb) cmds.push('/usr/sbin/nginx -t', '/usr/bin/systemctl reload nginx', '/usr/bin/systemctl restart nginx', '/usr/bin/systemctl reload php*-fpm', '/usr/bin/systemctl restart php*-fpm');
++ steps.push(`Allow ONLY these exact root commands via sudo (nothing else):\n echo '${user} ALL=(root) NOPASSWD: ${cmds.join(', ')}' | sudo tee /etc/sudoers.d/${user}\n sudo chmod 440 /etc/sudoers.d/${user}\n sudo visudo -c`);
++ }
++ steps.push(`Verify:\n sudo -u ${user} whoami\n sudo -u ${user} ls -la ${firstRoot}${isWeb ? '\n sudo -u ' + user + ' test -w /etc/nginx/sites-enabled && echo "nginx: writable"' : ''}`);
++ const numbered = steps.map((s, i) => `${i + 1}. ${s}`).join('\n\n');
++
++ const purpose = isWeb
++ ? 'help develop our sites AND build/run their own nginx + PHP sites on the box'
++ : 'help develop the sites';
++ const webNote = isWeb
++ ? '\n\nNote: this tier gives them full control of the web layer (nginx configs + a /srv/www web root + reloading nginx/php-fpm) but still NOT blanket root — installing new system packages (PHP runtime, etc.) stays an owner action. If PHP-FPM is not installed yet, install it once before they start.'
++ : '';
++
++ const prompt =
++`You are provisioning a locked-down SSH account on my Linux server for a trusted OpenVibe admin so they can SSH in and ${purpose} (vibe coding with Claude Code). Be careful and idempotent, and do NOT grant this user root/sudo beyond exactly what is specified below.
++
++Server host: ${host}
++New username: ${user}
++Their SSH public key:
++${pubShown}
++
++Steps:
++${numbered}${webNote}
++
++Finally, tell me the exact command they should run to connect:
++ ssh ${user}@${host}`;
++ _sshSetText('ssh-o-prompt', prompt);
++}
++
++// ── Admin (user) view ───────────────────────────────────────
++function renderSSHUser(el) {
++ const defUser = _sshSanitizeUser(currentUser.username) || 'me';
++ el.innerHTML = `
++ <div class="card">
++ <h3><i class="fa-solid fa-terminal"></i> Connect to the dev server <span class="staff-pill user-pill">You</span></h3>
++ <p class="muted">Generate your key, send the owner (<b>${esc(sshInfo.ownerUsername)}</b>) your <b>public</b> key to authorize, then SSH in and help build the sites with Claude. Your private key never leaves your machine.</p>
++ </div>
++ <div class="card">
++ <div class="form-group">
++ <label>Your SSH username <span class="muted">— confirm the exact name with the owner</span></label>
++ <input type="text" id="ssh-u-user" value="${esc(defUser)}" oninput="sshUserUpdate()" style="max-width:280px">
++ </div>
++ </div>
++ <div class="card">
++ <h3><i class="fa-solid fa-list-ol"></i> Steps</h3>
++ <ol class="muted" style="line-height:1.9;padding-left:20px;margin:0">
++ <li>Generate a key pair (skip if you already have one you want to use):<br><code id="ssh-u-keygen"></code></li>
++ <li>Print your <b>public</b> key and send it to the owner to authorize:<br><code id="ssh-u-cat"></code></li>
++ <li>Once they confirm it's added, put this in your <code>~/.ssh/config</code>:<pre id="ssh-u-config" class="ssh-prompt" style="margin-top:6px"></pre></li>
++ <li>Connect: <code id="ssh-u-connect"></code></li>
++ </ol>
++ </div>
++ <div class="card">
++ <div style="display:flex;justify-content:space-between;align-items:center;gap:12px;flex-wrap:wrap">
++ <h3 style="margin:0"><i class="fa-solid fa-robot"></i> Agent prompt</h3>
++ <button class="btn btn-primary" onclick="sshCopy('ssh-u-prompt', this)"><i class="fa-solid fa-copy"></i> Copy Prompt</button>
++ </div>
++ <p class="muted" style="margin:8px 0">Paste into Claude Code on your own machine — it sets up the connection and gives it full context about the server so you can start vibe coding.</p>
++ <pre id="ssh-u-prompt" class="ssh-prompt"></pre>
++ </div>`;
++ sshUserUpdate();
++}
++
++function sshUserUpdate() {
++ const host = sshInfo.host || '<server-host>';
++ const user = _sshSanitizeUser(document.getElementById('ssh-u-user')?.value) || '<username>';
++ const keygen = `ssh-keygen -t ed25519 -C "${user}@openvibe" -f ~/.ssh/openvibe_${user}`;
++ const cat = `cat ~/.ssh/openvibe_${user}.pub`;
++ const config = `Host openvibe\n HostName ${host}\n User ${user}\n IdentityFile ~/.ssh/openvibe_${user}\n ServerAliveInterval 60`;
++ const connect = `ssh openvibe`;
++ _sshSetText('ssh-u-keygen', keygen);
++ _sshSetText('ssh-u-cat', cat);
++ _sshSetText('ssh-u-config', config);
++ _sshSetText('ssh-u-connect', connect);
++
++ const prompt =
++`Help me connect to the OpenVibe dev server over SSH, then help me develop the sites (vibe coding with Claude Code).
++
++My SSH username: ${user}
++Server host: ${host}
++
++Do this:
++1. If I don't already have this key, generate one (leave the passphrase empty, or set one — my call):
++ ${keygen}
++2. Print my PUBLIC key so I can send it to the server owner (${sshInfo.ownerUsername}) to authorize. Nothing works until they add it:
++ ${cat}
++3. Add this block to my ~/.ssh/config (create the file if needed, then chmod 600 ~/.ssh/config):
++${config}
++4. Test the connection:
++ ${connect}
++
++Context about the server so you can help me once I'm connected:
++- Projects live under: ${sshInfo.projectsRoot}
++ (openvibe.network under /opt/openvibe.network, OpenVibe.Live under /opt/openvibe.live)
++- Services are systemd units: ${sshInfo.services.join(', ')}. Deploys are git-based —
++ cd into the project, git pull --ff-only, then restart the service (if I'm permitted).
++- I'm a OpenVibe admin, not root. nginx / TLS / packages / system-level changes go through the owner.
++- Other devs share the project group, so be careful with shared files and always pull before editing.
++Once I'm connected, cd into the relevant project and let's build.`;
++ _sshSetText('ssh-u-prompt', prompt);
++}
++
++// ═══════════════════════════════════════════════════════════════
++// Deploy Panel — TLS / Nginx / Infrastructure Control Plane
++// ═══════════════════════════════════════════════════════════════
++
++function showDeploySubTab(tab, btn) {
++ deployCurrentSubTab = tab;
++ document.querySelectorAll('#deploy-sub-tabs button').forEach(b => b.classList.remove('active'));
++ (btn || _subBtn('deploy-sub-tabs', tab))?.classList.add('active');
++ ['deploy-overview', 'deploy-certs-panel', 'deploy-nginx-panel', 'deploy-domains-panel', 'deploy-config-panel'].forEach(id => {
++ const el = document.getElementById(id);
++ if (el) el.style.display = 'none';
++ });
++ const map = {
++ overview: 'deploy-overview',
++ certs: 'deploy-certs-panel',
++ nginx: 'deploy-nginx-panel',
++ domains: 'deploy-domains-panel',
++ config: 'deploy-config-panel',
++ };
++ const target = document.getElementById(map[tab]);
++ if (target) target.style.display = 'block';
++ if (tab === 'overview') loadDeployOverview();
++ if (tab === 'certs') loadDeployCerts();
++ if (tab === 'domains') loadDeployDomains();
++ if (tab === 'config') loadDeployConfig();
++ _syncAdminUrl();
++}
++
++async function loadDeployOverview() {
++ try {
++ const r = await fetch(`${API}/api/admin/deploy/prerequisites`, { headers: authHeaders() });
++ const d = await r.json();
++ if (!d.ok) throw new Error(d.error);
++ const p = d.prerequisites;
++ const c = d.config;
++ document.getElementById('deploy-stats').innerHTML = `
++ <div class="stat-card"><div class="value" style="color:${p.certbotInstalled ? 'var(--success)' : 'var(--danger)'}"><i class="fa-solid fa-${p.certbotInstalled ? 'check' : 'xmark'}"></i></div><div class="label">Certbot</div></div>
++ <div class="stat-card"><div class="value" style="color:${p.cloudflarePluginInstalled ? 'var(--success)' : 'var(--warning)'}"><i class="fa-solid fa-${p.cloudflarePluginInstalled ? 'check' : 'minus'}"></i></div><div class="label">CF Plugin</div></div>
++ <div class="stat-card"><div class="value" style="color:${p.nginxInstalled ? 'var(--success)' : 'var(--danger)'}"><i class="fa-solid fa-${p.nginxInstalled ? 'check' : 'xmark'}"></i></div><div class="label">Nginx</div></div>
++ <div class="stat-card"><div class="value" style="color:${c.hasCloudflareToken ? 'var(--success)' : 'var(--text-muted)'}"><i class="fa-solid fa-${c.hasCloudflareToken ? 'check' : 'minus'}"></i></div><div class="label">CF Token</div></div>
++ <div class="stat-card"><div class="value">${c.domainsConfigured}</div><div class="label">Domains</div></div>
++ <div class="stat-card"><div class="value" style="font-size:14px">${esc(c.certMode)}</div><div class="label">Cert Mode</div></div>
++ <div class="stat-card"><div class="value" style="font-size:14px">${esc(c.nginxMode)}</div><div class="label">Nginx Mode</div></div>
++ `;
++ const prereqsEl = document.getElementById('deploy-prereqs');
++ const items = [];
++ items.push(prereqLine('Certbot installed', p.certbotInstalled));
++ items.push(prereqLine('Cloudflare DNS plugin', p.cloudflarePluginInstalled, 'Optional — needed for automated DNS-01'));
++ items.push(prereqLine('Let\'s Encrypt dir writable', p.letsencryptDirWritable, 'Run as root or with sudo'));
++ items.push(prereqLine('Nginx installed', p.nginxInstalled));
++ items.push(prereqLine('Cloudflare INI exists', p.cloudflareIniExists, 'Auto-created when token is saved'));
++ items.push(prereqLine('ACME email configured', !!c.acmeEmail, c.acmeEmail || 'Not set'));
++ prereqsEl.innerHTML = items.join('');
++ } catch (e) {
++ document.getElementById('deploy-stats').innerHTML = `<div class="muted" style="color:var(--danger)">${esc(e.message)}</div>`;
++ }
++}
++
++function prereqLine(label, ok, detail) {
++ const icon = ok ? '<i class="fa-solid fa-circle-check" style="color:var(--success)"></i>' : '<i class="fa-solid fa-circle-xmark" style="color:var(--danger)"></i>';
++ const detailHtml = detail ? ` <span class="muted" style="font-size:11px">— ${esc(detail)}</span>` : '';
++ return `<div style="padding:6px 0;display:flex;align-items:center;gap:8px">${icon} ${esc(label)}${detailHtml}</div>`;
++}
++
++async function loadDeployCerts() {
++ const el = document.getElementById('deploy-certs-list');
++ try {
++ const r = await fetch(`${API}/api/admin/deploy/certs`, { headers: authHeaders() });
++ const d = await r.json();
++ if (!d.ok && d.error) throw new Error(d.error);
++ if (!d.certs || d.certs.length === 0) {
++ el.innerHTML = '<div class="muted">No certificates found. Issue your first certificate below.</div>';
++ return;
++ }
++ el.innerHTML = d.certs.map(c => {
++ const isWild = c.domains?.some(d => d.startsWith('*.'));
++ const expColor = (c.daysRemaining || 0) < 14 ? 'var(--danger)' : (c.daysRemaining || 0) < 30 ? 'var(--warning)' : 'var(--success)';
++ return `<div class="card" style="margin-bottom:8px;padding:12px">
++ <div style="display:flex;justify-content:space-between;align-items:center;flex-wrap:wrap;gap:8px">
++ <div>
++ <strong>${esc(c.name)}</strong>
++ ${isWild ? '<span style="font-size:10px;background:var(--accent);color:#000;padding:1px 6px;border-radius:3px;margin-left:6px">WILDCARD</span>' : ''}
++ <div class="muted" style="font-size:11px;margin-top:2px">${(c.domains || []).map(d => esc(d)).join(', ')}</div>
++ </div>
++ <div style="text-align:right">
++ <span style="color:${expColor};font-weight:600">${c.daysRemaining != null ? c.daysRemaining + ' days' : 'Unknown'}</span>
++ <div class="muted" style="font-size:11px">${c.expiry ? 'Expires ' + esc(c.expiry) : ''}</div>
++ </div>
++ </div>
++ </div>`;
++ }).join('');
++ } catch (e) {
++ el.innerHTML = `<div class="muted" style="color:var(--danger)">${esc(e.message)}</div>`;
++ }
++}
++
++async function issueCertCloudflare() {
++ const domain = document.getElementById('cert-issue-domain').value.trim();
++ if (!domain) return alert('Enter a domain');
++ const out = document.getElementById('cert-issue-output');
++ out.innerHTML = '<div class="loading">Issuing wildcard certificate via Cloudflare DNS... This may take 30-60 seconds.</div>';
++ try {
++ const r = await fetch(`${API}/api/admin/deploy/certs/issue-cloudflare`, {
++ method: 'POST', headers: { ...authHeaders(), 'Content-Type': 'application/json' },
++ body: JSON.stringify({ domain }),
++ });
++ const d = await r.json();
++ if (d.ok) {
++ out.innerHTML = `<div style="color:var(--success);padding:8px"><i class="fa-solid fa-check-circle"></i> Certificate issued: ${esc(d.certName || domain)}</div>
++ <pre style="background:var(--bg-input);padding:8px;border-radius:6px;font-size:11px;max-height:200px;overflow:auto;margin-top:8px">${esc(d.output || '')}</pre>`;
++ loadDeployCerts();
++ } else {
++ out.innerHTML = `<div style="color:var(--danger);padding:8px"><i class="fa-solid fa-xmark"></i> ${esc(d.error || 'Failed')}</div>
++ <pre style="background:var(--bg-input);padding:8px;border-radius:6px;font-size:11px;max-height:200px;overflow:auto;margin-top:8px">${esc(d.output || '')}</pre>`;
++ }
++ } catch (e) {
++ out.innerHTML = `<div style="color:var(--danger)">${esc(e.message)}</div>`;
++ }
++}
++
++async function showManualDnsInfo() {
++ const domain = document.getElementById('cert-issue-domain').value.trim();
++ if (!domain) return alert('Enter a domain');
++ const out = document.getElementById('cert-issue-output');
++ out.innerHTML = '<div class="loading">Getting manual DNS challenge info...</div>';
++ try {
++ const r = await fetch(`${API}/api/admin/deploy/certs/manual-info`, {
++ method: 'POST', headers: { ...authHeaders(), 'Content-Type': 'application/json' },
++ body: JSON.stringify({ domain }),
++ });
++ const d = await r.json();
++ const instructions = (d.instructions || []).map(i => esc(i)).join('<br>');
++ out.innerHTML = `<div class="card" style="padding:16px;margin-top:8px">
++ <h4 style="margin-bottom:8px"><i class="fa-solid fa-hand"></i> Manual DNS Challenge</h4>
++ <div style="font-size:13px;line-height:1.6">${instructions}</div>
++ <div style="margin-top:16px">
++ <button class="btn btn-primary" onclick="issueManualCert('${esc(domain)}')">
++ <i class="fa-solid fa-certificate"></i> DNS Records Created — Issue Certificate
++ </button>
++ <div class="muted" style="font-size:11px;margin-top:4px">Only click after creating the required DNS TXT records</div>
++ </div>
++ </div>`;
++ } catch (e) {
++ out.innerHTML = `<div style="color:var(--danger)">${esc(e.message)}</div>`;
++ }
++}
++
++async function issueManualCert(domain) {
++ const out = document.getElementById('cert-issue-output');
++ out.innerHTML = '<div class="loading">Issuing certificate with manual DNS verification... This may take 30-60 seconds.</div>';
++ try {
++ const r = await fetch(`${API}/api/admin/deploy/certs/issue-manual`, {
++ method: 'POST', headers: { ...authHeaders(), 'Content-Type': 'application/json' },
++ body: JSON.stringify({ domain }),
++ });
++ const d = await r.json();
++ if (d.ok) {
++ out.innerHTML = `<div style="color:var(--success);padding:8px"><i class="fa-solid fa-check-circle"></i> Certificate issued: ${esc(d.certName || domain)}</div>
++ <pre style="background:var(--bg-input);padding:8px;border-radius:6px;font-size:11px;max-height:200px;overflow:auto;margin-top:8px">${esc(d.output || '')}</pre>`;
++ loadDeployCerts();
++ } else {
++ out.innerHTML = `<div style="color:var(--danger);padding:8px"><i class="fa-solid fa-xmark"></i> ${esc(d.error || 'Failed')}</div>
++ <pre style="background:var(--bg-input);padding:8px;border-radius:6px;font-size:11px;max-height:200px;overflow:auto;margin-top:8px">${esc(d.output || '')}</pre>`;
++ }
++ } catch (e) {
++ out.innerHTML = `<div style="color:var(--danger)">${esc(e.message)}</div>`;
++ }
++}
++
++async function renewAllCerts() {
++ const out = document.getElementById('cert-issue-output');
++ out.innerHTML = '<div class="loading">Renewing all certificates...</div>';
++ try {
++ const r = await fetch(`${API}/api/admin/deploy/certs/renew`, {
++ method: 'POST', headers: authHeaders(),
++ });
++ const d = await r.json();
++ out.innerHTML = `<div style="color:${d.ok ? 'var(--success)' : 'var(--danger)'};padding:8px">
++ <i class="fa-solid fa-${d.ok ? 'check-circle' : 'xmark'}"></i> ${d.ok ? 'Renewal complete' : esc(d.error || 'Failed')}
++ </div>
++ <pre style="background:var(--bg-input);padding:8px;border-radius:6px;font-size:11px;max-height:200px;overflow:auto;margin-top:8px">${esc(d.output || '')}</pre>`;
++ if (d.ok) loadDeployCerts();
++ } catch (e) {
++ out.innerHTML = `<div style="color:var(--danger)">${esc(e.message)}</div>`;
++ }
++}
++
++async function previewNginxConfigs() {
++ const out = document.getElementById('nginx-preview-output');
++ out.innerHTML = '<div class="loading">Generating Nginx configs...</div>';
++ try {
++ const r = await fetch(`${API}/api/admin/deploy/nginx/preview`, { headers: authHeaders() });
++ const d = await r.json();
++ if (!d.ok) throw new Error(d.error);
++ out.innerHTML = d.configs.map(c => `
++ <div class="card" style="margin-bottom:12px">
++ <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:8px">
++ <h4>${esc(c.serviceId)} <span class="muted" style="font-size:11px">${esc(c.filename)}</span></h4>
++ <span style="font-size:11px;color:${c.certFound ? 'var(--success)' : 'var(--warning)'}">${c.certFound ? '✓ cert found' : '⚠ no cert'}</span>
++ </div>
++ <pre style="background:var(--bg-input);padding:12px;border-radius:6px;font-size:11px;max-height:300px;overflow:auto;white-space:pre-wrap">${esc(c.content)}</pre>
++ </div>
++ `).join('');
++ } catch (e) {
++ out.innerHTML = `<div style="color:var(--danger)">${esc(e.message)}</div>`;
++ }
++}
++
++async function validateNginx() {
++ const out = document.getElementById('nginx-preview-output');
++ out.innerHTML = '<div class="loading">Validating Nginx config...</div>';
++ try {
++ const r = await fetch(`${API}/api/admin/deploy/nginx/validate`, {
++ method: 'POST', headers: authHeaders(),
++ });
++ const d = await r.json();
++ out.innerHTML = `<div class="card" style="padding:16px">
++ <div style="color:${d.ok ? 'var(--success)' : 'var(--danger)'}">
++ <i class="fa-solid fa-${d.ok ? 'check-circle' : 'times-circle'}"></i>
++ <strong>${d.ok ? 'Configuration valid' : 'Validation failed'}</strong>
++ </div>
++ <pre style="background:var(--bg-input);padding:8px;border-radius:6px;font-size:11px;margin-top:8px;max-height:200px;overflow:auto">${esc(d.output || '')}</pre>
++ </div>`;
++ } catch (e) {
++ out.innerHTML = `<div style="color:var(--danger)">${esc(e.message)}</div>`;
++ }
++}
++
++async function applyNginxConfigs() {
++ if (!confirm('Apply generated Nginx configs? This will backup existing configs, write new ones, validate, and reload Nginx.')) return;
++ const out = document.getElementById('nginx-preview-output');
++ out.innerHTML = '<div class="loading">Applying Nginx configs...</div>';
++ try {
++ const r = await fetch(`${API}/api/admin/deploy/nginx/apply`, {
++ method: 'POST', headers: { ...authHeaders(), 'Content-Type': 'application/json' },
++ body: JSON.stringify({ reload: true }),
++ });
++ const d = await r.json();
++ const lines = [];
++ lines.push(`<div style="color:${d.ok ? 'var(--success)' : 'var(--danger)'}"><i class="fa-solid fa-${d.ok ? 'check-circle' : 'times-circle'}"></i> <strong>${d.ok ? 'Applied successfully' : 'Apply failed'}</strong></div>`);
++ if (d.dryRun) lines.push('<div class="muted">Dry run — no files were written</div>');
++ if (d.applied?.length) lines.push(`<div>Applied: ${d.applied.map(f => esc(f)).join(', ')}</div>`);
++ if (d.backed_up?.length) lines.push(`<div class="muted">Backed up: ${d.backed_up.length} files</div>`);
++ if (d.validation) lines.push(`<div>Validation: ${d.validation.ok ? '✓ passed' : '✗ failed'}</div>`);
++ if (d.reload) lines.push(`<div>Reload: ${d.reload.ok ? '✓ success' : '✗ failed'}</div>`);
++ if (d.errors?.length) lines.push(`<div style="color:var(--danger)">Errors: ${d.errors.map(e => esc(e)).join('; ')}</div>`);
++ out.innerHTML = `<div class="card" style="padding:16px">${lines.join('')}</div>`;
++ } catch (e) {
++ out.innerHTML = `<div style="color:var(--danger)">${esc(e.message)}</div>`;
++ }
++}
++
++let deployDomainsList = [];
++
++async function loadDeployDomains() {
++ const el = document.getElementById('deploy-domains-list');
++ try {
++ const r = await fetch(`${API}/api/admin/deploy/domains`, { headers: authHeaders() });
++ const d = await r.json();
++ if (!d.ok) throw new Error(d.error);
++ deployDomainsList = d.domains || [];
++ if (deployDomainsList.length === 0) {
++ el.innerHTML = '<div class="muted">No domains configured. Add your first domain below.</div>';
++ return;
++ }
++ el.innerHTML = deployDomainsList.map((d, i) => `
++ <div class="card" style="margin-bottom:6px;padding:10px;display:flex;justify-content:space-between;align-items:center">
++ <div>
++ <strong>${esc(d.domain)}</strong>
++ ${d.wildcard ? '<span style="font-size:10px;background:var(--accent);color:#000;padding:1px 6px;border-radius:3px;margin-left:6px">WILDCARD</span>' : ''}
++ <span class="muted" style="font-size:11px;margin-left:8px">${d.certExists ? '🔒 cert found' : '⚠ no cert'}</span>
++ ${d.certName ? '<span class="muted" style="font-size:11px;margin-left:4px">(' + esc(d.certName) + ')</span>' : ''}
++ </div>
++ <button class="btn btn-danger btn-sm" onclick="removeManagedDomain(${i})" title="Remove">
++ <i class="fa-solid fa-trash"></i>
++ </button>
++ </div>
++ `).join('');
++ } catch (e) {
++ el.innerHTML = `<div class="muted" style="color:var(--danger)">${esc(e.message)}</div>`;
++ }
++}
++
++async function addManagedDomain() {
++ const domain = document.getElementById('domain-add-input').value.trim().toLowerCase();
++ if (!domain) return;
++ if (!/^[a-z0-9.-]+\.[a-z]{2,}$/.test(domain)) return alert('Invalid domain format');
++ if (deployDomainsList.find(d => d.domain === domain)) return alert('Domain already added');
++ const wildcard = document.getElementById('domain-add-wildcard').checked;
++ deployDomainsList.push({ domain, wildcard, certName: domain, services: [] });
++ await saveDomainsList();
++ document.getElementById('domain-add-input').value = '';
++}
++
++async function removeManagedDomain(idx) {
++ if (!confirm('Remove domain ' + deployDomainsList[idx]?.domain + '?')) return;
++ deployDomainsList.splice(idx, 1);
++ await saveDomainsList();
++}
++
++async function saveDomainsList() {
++ try {
++ const r = await fetch(`${API}/api/admin/deploy/domains`, {
++ method: 'PUT', headers: { ...authHeaders(), 'Content-Type': 'application/json' },
++ body: JSON.stringify({ domains: deployDomainsList }),
++ });
++ const d = await r.json();
++ if (!d.ok) throw new Error(d.error);
++ loadDeployDomains();
++ } catch (e) {
++ alert('Failed to save: ' + e.message);
++ }
++}
++
++async function loadDeployConfig() {
++ try {
++ const r = await fetch(`${API}/api/admin/deploy/config`, { headers: authHeaders() });
++ const d = await r.json();
++ if (!d.ok) throw new Error(d.error);
++ document.getElementById('deploy-acme-email').value = d.config.acmeEmail || '';
++ document.getElementById('deploy-cert-mode').value = d.config.certMode || 'manual';
++ document.getElementById('deploy-cf-token').value = d.config.cloudflareToken || '';
++ document.getElementById('deploy-nginx-mode').value = d.config.nginxMode || 'preview';
++ document.getElementById('deploy-nginx-path').value = d.config.nginxSitesPath || '/etc/nginx/sites-enabled';
++ } catch (e) {
++ document.getElementById('deploy-config-status').innerHTML = `<span style="color:var(--danger)">${esc(e.message)}</span>`;
++ }
++}
++
++async function saveDeployConfig() {
++ const status = document.getElementById('deploy-config-status');
++ try {
++ const body = {
++ acmeEmail: document.getElementById('deploy-acme-email').value.trim(),
++ certMode: document.getElementById('deploy-cert-mode').value,
++ cloudflareToken: document.getElementById('deploy-cf-token').value,
++ nginxMode: document.getElementById('deploy-nginx-mode').value,
++ nginxSitesPath: document.getElementById('deploy-nginx-path').value.trim(),
++ };
++ const r = await fetch(`${API}/api/admin/deploy/config`, {
++ method: 'PUT', headers: { ...authHeaders(), 'Content-Type': 'application/json' },
++ body: JSON.stringify(body),
++ });
++ const d = await r.json();
++ if (!d.ok) throw new Error(d.error);
++ status.innerHTML = '<span style="color:var(--success)"><i class="fa-solid fa-check"></i> Saved</span>';
++ setTimeout(() => status.innerHTML = '', 3000);
++ } catch (e) {
++ status.innerHTML = `<span style="color:var(--danger)">${esc(e.message)}</span>`;
++ }
++}
ddiff --git a/public/js/admin/moderation.js b/public/js/admin/moderation.js
nnew file mode 100644
iindex 00000000..6844bdf9
---- /dev/null
++++ b/public/js/admin/moderation.js
@@@ -0,0 +1,424 @@
++/* ═══════════════════════════════════════════════════════════════
++ OpenVibe.Network — admin panel (/admin): streams, lineage, bans, chat logs, moderators, mod log, audit, deliveries, checklist, theme review and grants.
++ Split out of admin.html. Classic scripts, global scope: they rely on
++ admin/core.js's helpers (api, esc, toast, API, token) and on the
++ tabLoaders map in admin/boot.js (loaded last). No ES modules.
++ ═══════════════════════════════════════════════════════════════ */
++// ═══════════════════════════════════════════════════════════════
++// Streams (via openvibelive proxy)
++// ═══════════════════════════════════════════════════════════════
++async function loadStreams() {
++ const c = document.getElementById('streams-content');
++ c.innerHTML = '<div class="loading">Loading...</div>';
++ try {
++ const data = await api('/api/admin/streamer/streams');
++ const streams = data.streams || [];
++ if (!streams.length) { c.innerHTML = '<p class="muted">No active streams</p>'; return; }
++ c.innerHTML = `
++ <table class="admin-table">
++ <thead><tr>
++ <th>Title</th><th>Streamer</th><th>Protocol</th><th>Viewers</th><th>Started</th><th>Actions</th>
++ </tr></thead>
++ <tbody>${streams.map(s => `
++ <tr>
++ <td>${esc(s.title || 'Untitled')}</td>
++ <td><strong>${esc(s.username || '-')}</strong></td>
++ <td><span class="badge badge-info">${esc(s.protocol)}</span></td>
++ <td>${s.viewer_count || 0}</td>
++ <td>${s.started_at ? timeAgo(s.started_at) : '-'}</td>
++ <td>
++ <button class="btn btn-sm btn-danger" onclick="endStream('${s.id}')"><i class="fa-solid fa-stop"></i> End</button>
++ </td>
++ </tr>
++ `).join('')}</tbody>
++ </table>`;
++ } catch (e) { c.innerHTML = `<p class="muted">Error: ${esc(e.message)}</p>`; }
++}
++
++// Unresolved lineage references (Live GET /api/admin/lineage/unresolved through the /api/admin/streamer proxy).
++async function loadLineage(reason = '') {
++ const c = document.getElementById('lineage-content');
++ const tabs = document.getElementById('lineage-reasons');
++ c.innerHTML = '<div class="loading">Loading...</div>';
++ try {
++ const data = await api(`/api/admin/streamer/lineage/unresolved${reason ? `?reason=${encodeURIComponent(reason)}` : ''}`);
++ const by = data.by_reason || {};
++ tabs.innerHTML = [['', 'All']].concat(Object.keys(by).sort().map(r => [r, `${r.replace(/_/g, ' ')} (${by[r].refs})`]))
++ .map(([r, label]) => `<button class="${r === reason ? 'active' : ''}" onclick="loadLineage('${esc(r)}')">${esc(label)}</button>`).join('');
++ const rows = data.unresolved || [];
++ if (!rows.length) { c.innerHTML = '<p class="muted">Nothing unresolved in the last 30 days.</p>'; return; }
++ c.innerHTML = `
++ <div style="overflow-x:auto"><table class="admin-table">
++ <thead><tr><th>Reference</th><th>Reason</th><th>Detail</th><th>Last caller</th><th>Times</th><th>First seen</th><th>Last seen</th></tr></thead>
++ <tbody>${rows.map(u => `
++ <tr>
++ <td><code>${esc(u.ref)}</code></td>
++ <td><span class="badge badge-warning">${esc(u.reason)}</span></td>
++ <td>${esc(u.detail || '')}</td>
++ <td>${esc(u.last_caller || '-')}</td>
++ <td>${Number(u.count) || 0}</td>
++ <td>${u.first_at ? timeAgo(u.first_at) : '-'}</td>
++ <td>${u.last_at ? timeAgo(u.last_at) : '-'}</td>
++ </tr>`).join('')}</tbody>
++ </table></div>`;
++ } catch (e) { c.innerHTML = `<p class="muted">Error: ${esc(e.message)}</p>`; }
++}
++
++async function endStream(id) {
++ if (!confirm('Force end this stream?')) return;
++ try {
++ await api(`/api/admin/streamer/streams/${id}`, { method: 'DELETE' });
++ toast('Stream ended', 'success');
++ loadStreams();
++ } catch (e) { toast(e.message, 'error'); }
++}
++
++// ═══════════════════════════════════════════════════════════════
++// Bans (via openvibelive proxy)
++// ═══════════════════════════════════════════════════════════════
++async function loadBans() {
++ const c = document.getElementById('bans-content');
++ c.innerHTML = '<div class="loading">Loading...</div>';
++ try {
++ const data = await api('/api/admin/streamer/bans');
++ const bans = data.bans || [];
++ if (!bans.length) { c.innerHTML = '<p class="muted">No active bans</p>'; return; }
++ c.innerHTML = `
++ <table class="admin-table">
++ <thead><tr>
++ <th>User</th><th>Reason</th><th>Banned By</th><th>Banned At</th><th>Expires</th><th>Actions</th>
++ </tr></thead>
++ <tbody>${bans.map(b => `
++ <tr>
++ <td><strong>${esc(b.username || b.user_id)}</strong></td>
++ <td>${esc(b.reason || '-')}</td>
++ <td>${esc(b.banned_by_username || '-')}</td>
++ <td>${b.created_at ? timeAgo(b.created_at) : '-'}</td>
++ <td>${b.expires_at ? new Date(b.expires_at).toLocaleString() : 'Permanent'}</td>
++ <td>
++ <button class="btn btn-sm btn-outline" onclick="unbanStreamer('${b.user_id}')"><i class="fa-solid fa-user-check"></i> Unban</button>
++ </td>
++ </tr>
++ `).join('')}</tbody>
++ </table>`;
++ } catch (e) { c.innerHTML = `<p class="muted">Error: ${esc(e.message)}</p>`; }
++}
++
++async function unbanStreamer(userId) {
++ try {
++ await api(`/api/admin/streamer/users/${userId}/ban`, { method: 'DELETE' });
++ toast('User unbanned on OpenVibe.Live', 'success');
++ loadBans();
++ } catch (e) { toast(e.message, 'error'); }
++}
++
++// ═══════════════════════════════════════════════════════════════
++// Chat Logs (via openvibelive mod proxy)
++// ═══════════════════════════════════════════════════════════════
++let chatLogsOffset = 0;
++async function searchChatLogs() {
++ chatLogsOffset = 0;
++ await fetchChatLogs();
++}
++
++async function fetchChatLogs() {
++ const c = document.getElementById('chat-logs-content');
++ const pager = document.getElementById('chat-logs-pager');
++ const q = document.getElementById('chat-search-q')?.value.trim() || '';
++ const uid = document.getElementById('chat-search-uid')?.value.trim() || '';
++ if (!q && !uid) { c.innerHTML = '<p class="muted">Enter a search query or user ID</p>'; return; }
++ c.innerHTML = '<div class="loading">Loading...</div>';
++
++ try {
++ const params = new URLSearchParams({ limit: '50', offset: String(chatLogsOffset) });
++ if (q) params.set('q', q);
++ if (uid) params.set('user_id', uid);
++ const data = await api(`/api/admin/streamer-mod/chat/search?${params}`);
++ const msgs = data.messages || [];
++ const total = data.total || 0;
++
++ if (!msgs.length) { c.innerHTML = '<p class="muted">No messages found</p>'; pager.innerHTML = ''; return; }
++
++ c.innerHTML = `
++ <table class="admin-table">
++ <thead><tr><th>Time</th><th>User</th><th>Message</th><th>Stream</th></tr></thead>
++ <tbody>${msgs.map(m => `
++ <tr>
++ <td style="white-space:nowrap;font-size:11px">${m.timestamp ? timeAgo(m.timestamp) : '-'}</td>
++ <td style="white-space:nowrap;color:${esc(m.profile_color || (m.anon_id ? '#6b7280' : '#999'))}">${esc(m.display_name || m.username || m.anon_id || 'anon')}</td>
++ <td style="word-break:break-word">${esc(m.message)}</td>
++ <td style="font-size:11px">${m.stream_id || '-'}</td>
++ </tr>
++ `).join('')}</tbody>
++ </table>`;
++
++ const pages = Math.ceil(total / 50);
++ const curPage = Math.floor(chatLogsOffset / 50) + 1;
++ pager.innerHTML = pages > 1 ? `
++ <button class="btn btn-sm btn-outline" ${chatLogsOffset<=0?'disabled':''} onclick="chatLogsOffset=Math.max(0,chatLogsOffset-50);fetchChatLogs()"><i class="fa-solid fa-chevron-left"></i></button>
++ <span class="muted">Page ${curPage}/${pages} (${total} results)</span>
++ <button class="btn btn-sm btn-outline" ${curPage>=pages?'disabled':''} onclick="chatLogsOffset+=50;fetchChatLogs()"><i class="fa-solid fa-chevron-right"></i></button>
++ ` : `<span class="muted">${total} results</span>`;
++ } catch (e) { c.innerHTML = `<p class="muted">Error: ${esc(e.message)}</p>`; pager.innerHTML = ''; }
++}
++
++// ═══════════════════════════════════════════════════════════════
++// Moderators (via openvibelive proxy)
++// ═══════════════════════════════════════════════════════════════
++async function loadModerators() {
++ const c = document.getElementById('mods-content');
++ c.innerHTML = '<div class="loading">Loading...</div>';
++ try {
++ const data = await api('/api/admin/streamer/moderators');
++ const mods = data.moderators || [];
++ if (!mods.length) { c.innerHTML = '<p class="muted">No global moderators yet</p>'; return; }
++ c.innerHTML = `
++ <table class="admin-table">
++ <thead><tr><th>Username</th><th>Display Name</th><th>Last Seen</th><th>Actions</th></tr></thead>
++ <tbody>${mods.map(m => `
++ <tr>
++ <td><strong>${esc(m.username)}</strong></td>
++ <td>${esc(m.display_name || m.username)}</td>
++ <td>${m.last_seen ? timeAgo(m.last_seen) : 'Never'}</td>
++ <td>
++ <button class="btn btn-sm btn-danger" onclick="demoteMod('${m.id}','${esc(m.username)}')"><i class="fa-solid fa-user-minus"></i> Demote</button>
++ </td>
++ </tr>
++ `).join('')}</tbody>
++ </table>`;
++ } catch (e) { c.innerHTML = `<p class="muted">Error: ${esc(e.message)}</p>`; }
++}
++
++async function promoteMod() {
++ const username = document.getElementById('mod-username')?.value.trim();
++ if (!username) return toast('Enter a username', 'error');
++ try {
++ await api('/api/admin/streamer/moderators', { method: 'POST', body: { username } });
++ toast(`${username} promoted to global moderator`, 'success');
++ document.getElementById('mod-username').value = '';
++ loadModerators();
++ } catch (e) { toast(e.message, 'error'); }
++}
++
++async function demoteMod(id, username) {
++ if (!confirm(`Demote ${username} from global moderator?`)) return;
++ try {
++ await api(`/api/admin/streamer/moderators/${id}`, { method: 'DELETE' });
++ toast(`${username} demoted`, 'success');
++ loadModerators();
++ } catch (e) { toast(e.message, 'error'); }
++}
++
++// ═══════════════════════════════════════════════════════════════
++// Audit Log
++// ═══════════════════════════════════════════════════════════════
++let auditOffset = 0;
++let _modlogNext = null;
++// The filters as a query: dates are whole days (until includes its day).
++function modLogQuery() {
++ const q = new URLSearchParams();
++ const val = (id) => (document.getElementById(id)?.value || '').trim();
++ for (const [k, id] of [['service', 'modlog-service'], ['action', 'modlog-action'], ['actor', 'modlog-actor'], ['target', 'modlog-target']]) if (val(id)) q.set(k, val(id));
++ if (val('modlog-since')) q.set('since', `${val('modlog-since')}T00:00:00Z`);
++ if (val('modlog-until')) { const d = new Date(`${val('modlog-until')}T00:00:00Z`); d.setUTCDate(d.getUTCDate() + 1); q.set('until', d.toISOString()); }
++ return q;
++}
++async function exportModLog() {
++ const q = modLogQuery();
++ q.set('format', 'csv');
++ try {
++ const headers = token ? { Authorization: 'Bearer ' + token } : {};
++ const res = await fetch(`${API}/api/v1/staff/moderation-audit?${q}`, { headers, credentials: 'include' });
++ if (!res.ok) throw new Error(`export failed (${res.status})`);
++ const url = URL.createObjectURL(await res.blob());
++ const a = document.createElement('a');
++ a.href = url; a.download = `moderation-audit-${new Date().toISOString().slice(0, 10)}.csv`;
++ document.body.appendChild(a); a.click(); a.remove();
++ setTimeout(() => URL.revokeObjectURL(url), 5000);
++ } catch (err) { alert(err.message || 'Export failed'); }
++}
++let _deliveries = [];
++async function loadDeliveries() {
++ const c = document.getElementById('dlq-content');
++ const q = new URLSearchParams({ status: document.getElementById('dlq-status').value, limit: '200' });
++ const sub = document.getElementById('dlq-sub').value.trim();
++ if (sub) q.set('subscription_id', sub);
++ c.innerHTML = '<div class="loading">Loading...</div>';
++ try {
++ const d = await api(`/api/admin/events/deliveries?${q}`);
++ _deliveries = d.deliveries || [];
++ const counts = d.counts || {};
++ document.getElementById('dlq-counts').textContent = Object.entries(counts).map(([k, v]) => `${k}: ${v}`).join(' · ');
++ const bySub = {};
++ for (const x of _deliveries) (bySub[x.subscription_id] = bySub[x.subscription_id] || []).push(x.event_id);
++ const replayAll = Object.entries(bySub).map(([s, ids]) => `<button class="btn btn-sm" data-sub="${esc(s)}" onclick="replayDeliveries(this.dataset.sub, null)">Replay ${ids.length} for ${esc(s.slice(0, 14))}…</button>`).join(' ');
++ c.innerHTML = _deliveries.length ? `<div style="margin-bottom:8px">${replayAll}</div><table class="admin-table"><thead><tr><th>Event</th><th>Subscription</th><th>Seq</th><th>Attempts</th><th>Last answer</th><th></th></tr></thead><tbody>${_deliveries.map(x => `
++ <tr><td style="font-size:11px"><code>${esc(x.event_id)}</code></td><td style="font-size:11px"><code>${esc(x.subscription_id)}</code></td><td>${esc(String(x.seq))}</td><td>${esc(String(x.attempt))}</td>
++ <td style="font-size:11px;max-width:320px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${esc(x.last_error || '')}">${esc(x.last_status ? String(x.last_status) : '-')} ${esc(x.last_error || '')}</td>
++ <td><button class="btn btn-sm" data-sub="${esc(x.subscription_id)}" data-evt="${esc(x.event_id)}" onclick="replayDeliveries(this.dataset.sub, [this.dataset.evt])">Replay</button></td></tr>`).join('')}</tbody></table>` : '<p class="muted">Nothing here.</p>';
++ } catch (err) {
++ c.innerHTML = `<p class="muted">Deliveries could not be loaded: ${esc(err.message || 'error')}</p>`;
++ }
++}
++async function replayDeliveries(sub, ids) {
++ const eventIds = ids || _deliveries.filter(x => x.subscription_id === sub).map(x => x.event_id);
++ if (!eventIds.length || !confirm(`Replay ${eventIds.length} deliver${eventIds.length === 1 ? 'y' : 'ies'} to ${sub}?`)) return;
++ try { const r = await api('/api/admin/events/replay', { method: 'POST', body: { subscription_id: sub, event_ids: eventIds } }); alert(`Queued ${r.queued}`); loadDeliveries(); }
++ catch (err) { alert(err.message || 'Replay failed'); }
++}
++async function loadOperatorChecklist() {
++ const c = document.getElementById('checklist-content');
++ let areas = [];
++ try { areas = (await api('/api/admin/operator-checklist')).areas || []; } catch (err) { c.textContent = 'Could not load: ' + err.message; return; }
++ c.replaceChildren();
++ const colour = { ready: 'var(--success, #3ee6b0)', degraded: 'var(--warning, #f5a524)', down: 'var(--danger, #ef4444)' };
++ for (const a of areas) {
++ const h = document.createElement('h3'); h.textContent = a.area; h.style.margin = '14px 0 6px'; c.appendChild(h);
++ for (const it of a.items) {
++ const row = document.createElement('div');
++ row.style.cssText = 'display:flex;gap:10px;align-items:flex-start;flex-wrap:wrap;padding:8px 0;border-top:1px solid var(--border)';
++ const dot = document.createElement('span'); dot.title = (it.service || '') + ': ' + it.status;
++ dot.style.cssText = 'width:10px;height:10px;border-radius:50%;margin-top:6px;flex:none'; dot.style.background = colour[it.status] || 'var(--text-muted, #888)';
++ const name = document.createElement('strong'); name.textContent = it.item; name.style.minWidth = '260px';
++ const places = document.createElement('div'); places.style.cssText = 'display:flex;gap:8px;flex-wrap:wrap;align-items:center';
++ for (const w of it.where) {
++ let el;
++ if (w.kind === 'tab') { el = document.createElement('button'); el.type = 'button'; el.className = 'btn'; el.textContent = 'Open ' + w.tab; el.onclick = () => showTab(w.tab, null); }
++ else if (w.kind === 'url') { el = document.createElement('a'); el.href = w.url; el.target = '_blank'; el.rel = 'noopener'; el.textContent = w.url.replace(/^https:\/\//, ''); }
++ else { el = document.createElement('code'); el.textContent = (w.kind === 'cli' ? '$ ' : 'API ') + (w.cli || w.api); }
++ places.appendChild(el);
++ if (w.note) { const n = document.createElement('span'); n.className = 'muted'; n.textContent = '(' + w.note + ')'; places.appendChild(n); }
++ }
++ if (!it.console) { const gap = document.createElement('span'); gap.className = 'muted'; gap.textContent = 'no console yet'; gap.style.color = 'var(--warning, #f5a524)'; places.appendChild(gap); }
++ row.append(dot, name, places);
++ c.appendChild(row);
++ }
++ }
++}
++
++async function loadThemeReview() {
++ const c = document.getElementById('themes-review-content');
++ let list = [];
++ try { list = (await api('/api/admin/themes?status=pending')).themes || []; } catch (err) { c.textContent = 'Could not load: ' + err.message; return; }
++ c.replaceChildren();
++ if (!list.length) { const p = document.createElement('p'); p.className = 'muted'; p.textContent = 'Nothing waiting for review.'; c.appendChild(p); return; }
++ for (const t of list) {
++ const card = document.createElement('div');
++ card.style.cssText = 'border:1px solid var(--border);border-radius:10px;padding:10px 12px;margin-bottom:8px;display:grid;gap:8px';
++ const head = document.createElement('div');
++ const n = document.createElement('strong'); n.textContent = t.name + ' ';
++ const meta = document.createElement('span'); meta.className = 'muted'; meta.textContent = `/${t.slug} · ${t.mode} · by ${t.author || 'unknown'}`;
++ head.append(n, meta);
++ const swatches = document.createElement('div'); swatches.style.cssText = 'display:flex;gap:4px;flex-wrap:wrap';
++ for (const [k, v] of Object.entries(t.variables || {}).filter(([, v]) => /^#|^rgb|^hsl/i.test(v)).slice(0, 14)) {
++ const sw = document.createElement('span'); sw.title = `${k}: ${v}`; sw.style.cssText = 'width:22px;height:22px;border-radius:6px;border:1px solid var(--border)'; sw.style.background = v; swatches.appendChild(sw);
++ }
++ const note = document.createElement('input'); note.placeholder = 'Reason (needed to reject)'; note.maxLength = 300; note.style.minWidth = '240px';
++ const act = (decision) => async () => {
++ try { await api(`/api/admin/themes/${encodeURIComponent(t.id)}/review`, { method: 'POST', body: JSON.stringify({ decision, note: note.value }) }); loadThemeReview(); }
++ catch (err) { alert(err.message); }
++ };
++ const ok = document.createElement('button'); ok.className = 'btn'; ok.textContent = 'Approve'; ok.onclick = act('approve');
++ const no = document.createElement('button'); no.className = 'btn'; no.textContent = 'Reject'; no.onclick = act('reject');
++ const row = document.createElement('div'); row.style.cssText = 'display:flex;gap:8px;flex-wrap:wrap;align-items:center'; row.append(note, ok, no);
++ card.append(head, swatches, row);
++ c.appendChild(card);
++ }
++}
++
++let _grants = [];
++async function loadGrants() {
++ const c = document.getElementById('grants-content');
++ try {
++ const [g, ch] = await Promise.all([api('/api/admin/grants'), api('/api/admin/grants/changes?limit=50')]);
++ _grants = g.grants || [];
++ renderGrants();
++ const rows = (ch.changes || []).map(x => `<tr><td style="white-space:nowrap;font-size:11px" title="${esc(x.at)}">${timeAgo(x.at)}</td><td><strong>${esc(x.change)}</strong></td><td><code>${esc(x.client_id)}</code></td><td><code>${esc(x.capability)}</code></td><td style="font-size:11px">${esc(x.reason || '-')}</td><td style="font-size:11px"><code>${esc(x.actor || 'system')}</code></td></tr>`).join('');
++ document.getElementById('grant-changes').innerHTML = rows ? `<table class="admin-table"><thead><tr><th>When</th><th>Change</th><th>Service</th><th>Capability</th><th>Reason</th><th>By</th></tr></thead><tbody>${rows}</tbody></table>` : '<p class="muted">No changes yet: every grant is still a default.</p>';
++ } catch (err) {
++ c.innerHTML = `<p class="muted">Grants could not be loaded: ${esc(err.message || 'error')}</p>`;
++ }
++}
++function renderGrants() {
++ const f = (document.getElementById('grant-filter').value || '').trim().toLowerCase();
++ const list = _grants.filter(g => !f || g.client_id.includes(f));
++ const badge = { active: 'badge-success', revoked: 'badge-danger', expired: 'badge-warning' };
++ document.getElementById('grants-content').innerHTML = list.length ? `<table class="admin-table"><thead><tr><th>Service</th><th>Capability</th><th>Audience</th><th>Namespaces</th><th>State</th><th>Expires</th><th>By</th><th></th></tr></thead><tbody>${list.map(g => `
++ <tr><td><code>${esc(g.client_id)}</code></td><td><code>${esc(g.capability)}</code></td><td style="font-size:11px">${esc(g.audience)}</td>
++ <td style="font-size:11px">${esc((g.namespaces || []).join(', ') || '-')}</td><td><span class="badge ${badge[g.state] || ''}">${esc(g.state)}</span></td>
++ <td style="font-size:11px">${g.expires_at ? esc(g.expires_at.slice(0, 10)) : '-'}</td><td style="font-size:11px">${esc(g.granted_by === 'default' ? 'default' : (g.granted_by || '-'))}</td>
++ <td>${g.state === 'active' ? `<button class="btn btn-sm" data-client="${esc(g.client_id)}" data-cap="${esc(g.capability)}" onclick="revokeGrant(this.dataset.client, this.dataset.cap)">Revoke</button>` : ''}</td></tr>`).join('')}</tbody></table>` : '<p class="muted">No grants match.</p>';
++}
++async function submitGrant() {
++ const v = (id) => document.getElementById(id).value.trim();
++ const body = { client_id: v('grant-client'), capability: v('grant-cap'), reason: v('grant-reason'), namespaces: v('grant-ns') ? v('grant-ns').split(',').map(x => x.trim()).filter(Boolean) : [] };
++ if (v('grant-exp')) body.expires_at = `${v('grant-exp')}T00:00:00Z`;
++ try { await api('/api/admin/grants', { method: 'POST', body }); document.getElementById('grant-reason').value = ''; loadGrants(); }
++ catch (err) { alert(err.message || 'Grant failed'); }
++}
++async function revokeGrant(client, capability) {
++ const reason = prompt(`Revoke ${capability} from ${client}? Reason:`);
++ if (!reason) return;
++ try { await api('/api/admin/grants/revoke', { method: 'POST', body: { client_id: client, capability, reason } }); loadGrants(); }
++ catch (err) { alert(err.message || 'Revoke failed'); }
++}
++async function loadModLog(reset) {
++ const c = document.getElementById('modlog-content');
++ const more = document.getElementById('modlog-more');
++ if (reset) { _modlogNext = null; c.innerHTML = '<div class="loading">Loading...</div>'; }
++ try {
++ const q = modLogQuery();
++ q.set('limit', '50');
++ if (!reset && _modlogNext) q.set('before', String(_modlogNext));
++ const data = await api(`/api/v1/staff/moderation-audit?${q}`);
++ const items = data.items || [];
++ const rowsHtml = items.map(e => `
++ <tr>
++ <td style="white-space:nowrap;font-size:11px" title="${esc(e.occurred_at)}">${e.occurred_at ? timeAgo(e.occurred_at) : '-'}</td>
++ <td><span class="badge badge-info">${esc(e.service)}</span></td>
++ <td><strong>${esc(e.action)}</strong>${e.scope ? `<div class="muted" style="font-size:11px">${esc(e.scope)}</div>` : ''}</td>
++ <td style="font-size:11px"><code>${esc(e.actor_subject || '-')}</code></td>
++ <td style="font-size:11px">${esc(e.target_type || '')} <code>${esc(e.target_id || '-')}</code>${e.target_subject && e.target_subject !== e.target_id ? `<div class="muted">${esc(e.target_subject)}</div>` : ''}</td>
++ <td style="font-size:11px;max-width:260px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${esc(e.reason || '')}">${esc(e.reason || '-')}</td>
++ </tr>`).join('');
++ if (reset) {
++ c.innerHTML = items.length ? `<table class="admin-table"><thead><tr><th>When</th><th>Service</th><th>Action</th><th>By</th><th>Target</th><th>Reason</th></tr></thead><tbody id="modlog-rows">${rowsHtml}</tbody></table>` : '<p class="muted">No staff actions recorded yet.</p>';
++ } else {
++ document.getElementById('modlog-rows')?.insertAdjacentHTML('beforeend', rowsHtml);
++ }
++ _modlogNext = data.next || null;
++ more.hidden = !_modlogNext;
++ } catch (err) {
++ c.innerHTML = `<p class="muted">The moderation log could not be loaded: ${esc(err.message || 'error')}</p>`;
++ }
++}
++
++async function loadAudit() {
++ const c = document.getElementById('audit-content');
++ const pager = document.getElementById('audit-pager');
++ c.innerHTML = '<div class="loading">Loading...</div>';
++ try {
++ const data = await api(`/api/admin/audit?limit=50&offset=${auditOffset}`);
++ const entries = data.entries || [];
++ if (!entries.length) { c.innerHTML = '<p class="muted">No audit entries</p>'; pager.innerHTML = ''; return; }
++ c.innerHTML = `
++ <table class="admin-table">
++ <thead><tr><th>Time</th><th>User</th><th>Action</th><th>Details</th></tr></thead>
++ <tbody>${entries.map(e => `
++ <tr>
++ <td style="white-space:nowrap;font-size:11px">${e.created_at ? timeAgo(e.created_at) : '-'}</td>
++ <td><strong>${esc(e.username || e.user_id || '-')}</strong></td>
++ <td><span class="badge badge-info">${esc(e.action)}</span></td>
++ <td style="font-size:11px;max-width:300px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${esc(e.details || '')}">${esc(e.details || '-')}</td>
++ </tr>
++ `).join('')}</tbody>
++ </table>`;
++
++ const hasMore = entries.length >= 50;
++ pager.innerHTML = `
++ <button class="btn btn-sm btn-outline" ${auditOffset<=0?'disabled':''} onclick="auditOffset=Math.max(0,auditOffset-50);loadAudit()"><i class="fa-solid fa-chevron-left"></i></button>
++ <span class="muted">Offset ${auditOffset}</span>
++ <button class="btn btn-sm btn-outline" ${!hasMore?'disabled':''} onclick="auditOffset+=50;loadAudit()"><i class="fa-solid fa-chevron-right"></i></button>
++ `;
++ } catch (e) { c.innerHTML = `<p class="muted">Error: ${esc(e.message)}</p>`; pager.innerHTML = ''; }
++}
ddiff --git a/public/js/admin/payments.js b/public/js/admin/payments.js
nnew file mode 100644
iindex 00000000..bab57f0d
---- /dev/null
++++ b/public/js/admin/payments.js
@@@ -0,0 +1,192 @@
++/* ═══════════════════════════════════════════════════════════════
++ OpenVibe.Network — admin panel (/admin): payments, cashouts and verification keys.
++ Split out of admin.html. Classic scripts, global scope: they rely on
++ admin/core.js's helpers (api, esc, toast, API, token) and on the
++ tabLoaders map in admin/boot.js (loaded last). No ES modules.
++ ═══════════════════════════════════════════════════════════════ */
++// ── Payments: configure PayPal / Stripe / CCBill / crypto + subscriptions ──
++const PAYMENT_GROUPS = [
++ { title: 'General', keys: ['payments_enabled', 'bucks_per_usd', 'bucks_min_purchase_usd', 'sub_price_usd', 'sub_streamer_share_pct'] },
++ { title: 'PayPal', keys: ['paypal_enabled', 'paypal_mode', 'paypal_client_id', 'paypal_client_secret', 'paypal_webhook_id'] },
++ { title: 'Stripe', keys: ['stripe_enabled', 'stripe_secret_key', 'stripe_publishable_key', 'stripe_webhook_secret'] },
++ { title: 'CCBill', keys: ['ccbill_enabled', 'ccbill_client_account', 'ccbill_subaccount', 'ccbill_flexform_id', 'ccbill_salt', 'ccbill_webhook_secret'] },
++ { title: 'Crypto (NOWPayments)', keys: ['crypto_enabled', 'crypto_provider', 'crypto_api_key', 'crypto_ipn_secret'] },
++];
++const PAYMENT_WEBHOOKS = {
++ Stripe: 'https://openvibe.live/api/payments/webhook/stripe',
++ PayPal: 'https://openvibe.live/api/payments/webhook/paypal',
++ CCBill: 'https://openvibe.live/api/payments/webhook/ccbill?secret=YOUR_ccbill_webhook_secret',
++ 'Crypto (NOWPayments)': 'https://openvibe.live/api/payments/webhook/crypto',
++};
++
++async function loadPayments() {
++ const c = document.getElementById('payments-content');
++ c.innerHTML = '<div class="loading">Loading...</div>';
++ try {
++ const data = await api('/api/admin/streamer/settings');
++ const byKey = {}; (data.settings || []).forEach(s => { byKey[s.key] = s; });
++ const field = (s) => {
++ if (!s) return '';
++ const sensitive = /(secret|salt|api[_-]?key|client_secret|ipn)/i.test(s.key);
++ if (s.type === 'boolean') return `
++ <div class="setting-row">
++ <label><strong>${esc(s.key)}</strong><br><small>${esc(s.description||'')}</small></label>
++ <input type="checkbox" data-key="${esc(s.key)}" data-type="boolean" ${s.value==='true'?'checked':''} style="width:18px;height:18px;cursor:pointer">
++ </div>`;
++ const type = s.type === 'number' ? 'number' : (sensitive ? 'password' : 'text');
++ return `
++ <div class="setting-row" style="flex-direction:column;align-items:stretch">
++ <label><strong>${esc(s.key)}</strong><br><small>${esc(s.description||'')}</small></label>
++ <input type="${type}" data-key="${esc(s.key)}" data-type="${esc(s.type||'string')}" value="${esc(s.value)}"
++ style="margin-top:4px;padding:8px 10px;background:var(--bg-input);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:13px">
++ </div>`;
++ };
++ const groups = PAYMENT_GROUPS.map(g => `
++ <fieldset style="border:1px solid var(--border);border-radius:8px;padding:12px 16px;margin-bottom:16px">
++ <legend style="padding:0 8px;font-weight:700">${esc(g.title)}</legend>
++ ${g.keys.map(k => field(byKey[k])).join('')}
++ ${PAYMENT_WEBHOOKS[g.title] ? `<p class="muted" style="font-size:12px;margin-top:8px"><i class="fa-solid fa-link"></i> Webhook URL: <code>${esc(PAYMENT_WEBHOOKS[g.title])}</code></p>` : ''}
++ </fieldset>`).join('');
++ c.innerHTML = `
++ <div style="max-width:760px">
++ <p class="muted" style="margin-bottom:12px">Configure real-money payments for Vibes &amp; channel subscriptions. Nothing goes live until <strong>payments_enabled</strong> is on and the provider is enabled with valid keys. Set each provider's webhook to the URL shown, and add the redirect/return URLs from OpenVibe.Live.</p>
++ <form id="payments-form">${groups}
++ <button type="button" class="btn btn-primary" onclick="savePayments()"><i class="fa-solid fa-floppy-disk"></i> Save Payment Settings</button>
++ </form>
++ </div>`;
++ } catch (e) { c.innerHTML = `<p class="muted">Error: ${esc(e.message)}</p>`; }
++}
++
++async function savePayments() {
++ const inputs = document.querySelectorAll('#payments-form [data-key]');
++ const settings = {};
++ inputs.forEach(input => {
++ settings[input.dataset.key] = input.dataset.type === 'boolean' ? (input.checked ? 'true' : 'false') : input.value;
++ });
++ try {
++ await api('/api/admin/streamer/settings', { method: 'PUT', body: { settings } });
++ toast('Payment settings saved', 'success');
++ } catch (e) { toast(e.message, 'error'); }
++}
++
++// ═══════════════════════════════════════════════════════════════
++// Cashouts (via funds proxy)
++// ═══════════════════════════════════════════════════════════════
++async function loadCashouts() {
++ const c = document.getElementById('cashouts-content');
++ c.innerHTML = '<div class="loading">Loading...</div>';
++ try {
++ const data = await api('/api/admin/streamer-funds/cashouts/pending');
++ const cashouts = data.cashouts || [];
++ if (!cashouts.length) { c.innerHTML = '<p class="muted">No pending cashouts</p>'; return; }
++ c.innerHTML = `
++ <table class="admin-table">
++ <thead><tr><th>User</th><th>Amount</th><th>USD</th><th>PayPal</th><th>Requested</th><th>Actions</th></tr></thead>
++ <tbody>${cashouts.map(co => `
++ <tr>
++ <td><strong>${esc(co.username || '-')}</strong></td>
++ <td>${Number(co.amount || 0).toLocaleString()} CF</td>
++ <td>$${((Number(co.amount) || 0) * 0.01).toFixed(2)}</td>
++ <td>${esc(co.paypal_email || '-')}</td>
++ <td>${co.created_at ? timeAgo(co.created_at) : '-'}</td>
++ <td style="display:flex;gap:6px">
++ <button class="btn btn-sm btn-success" onclick="approveCashout('${co.id}')"><i class="fa-solid fa-check"></i> Approve</button>
++ <button class="btn btn-sm btn-danger" onclick="denyCashout('${co.id}')"><i class="fa-solid fa-xmark"></i> Deny</button>
++ </td>
++ </tr>
++ `).join('')}</tbody>
++ </table>`;
++ } catch (e) { c.innerHTML = `<p class="muted">Error: ${esc(e.message)}</p>`; }
++}
++
++async function approveCashout(id) {
++ try {
++ await api(`/api/admin/streamer-funds/cashout/${id}/approve`, { method: 'POST' });
++ toast('Cashout approved', 'success');
++ loadCashouts();
++ } catch (e) { toast(e.message, 'error'); }
++}
++
++async function denyCashout(id) {
++ const reason = prompt('Denial reason:');
++ if (reason === null) return;
++ try {
++ await api(`/api/admin/streamer-funds/cashout/${id}/deny`, { method: 'POST', body: { reason } });
++ toast('Cashout denied', 'info');
++ loadCashouts();
++ } catch (e) { toast(e.message, 'error'); }
++}
++
++// ═══════════════════════════════════════════════════════════════
++// Verification Keys
++// ═══════════════════════════════════════════════════════════════
++const secretStore = {};
++async function loadVKeys() {
++ const c = document.getElementById('vkeys-content');
++ c.innerHTML = '<div class="loading">Loading...</div>';
++ try {
++ const data = await api('/api/admin/verification-keys');
++ const keys = data.keys || [];
++ keys.forEach(k => { if (k.key) secretStore[`vk-${k.id}`] = k.key; });
++ if (!keys.length) { c.innerHTML = '<p class="muted">No verification keys generated yet</p>'; return; }
++ c.innerHTML = `
++ <table class="admin-table">
++ <thead><tr><th>Key</th><th>Reserved Username</th><th>Status</th><th>Note</th><th>Created</th><th>Actions</th></tr></thead>
++ <tbody>${keys.map(k => `
++ <tr>
++ <td><code id="vk-${k.id}" style="font-size:12px;color:var(--text-muted)">••••••••</code></td>
++ <td><strong>${esc(k.target_username)}</strong></td>
++ <td><span class="badge badge-${k.status==='active'?'success':k.status==='used'?'info':'danger'}">${esc(k.status)}</span></td>
++ <td style="font-size:12px">${esc(k.note || '-')}</td>
++ <td style="font-size:11px">${k.created_at ? new Date(k.created_at).toLocaleDateString() : '-'}</td>
++ <td style="display:flex;gap:4px">
++ ${k.status === 'active' ? `
++ <button class="btn btn-sm btn-outline" onclick="revealVKey('vk-${k.id}')"><i class="fa-solid fa-eye"></i></button>
++ <button class="btn btn-sm btn-outline" onclick="copyVKey('vk-${k.id}')"><i class="fa-solid fa-copy"></i></button>
++ <button class="btn btn-sm btn-danger" onclick="revokeVKey('${k.id}')"><i class="fa-solid fa-trash"></i></button>
++ ` : k.status === 'used' ? `<span class="muted">Used by ${esc(k.used_by_name || '?')}</span>` : '<span class="muted">Revoked</span>'}
++ </td>
++ </tr>
++ `).join('')}</tbody>
++ </table>`;
++ } catch (e) { c.innerHTML = `<p class="muted">Error: ${esc(e.message)}</p>`; }
++}
++
++function revealVKey(id) {
++ const el = document.getElementById(id);
++ if (!el) return;
++ const revealed = el.dataset.revealed === 'true';
++ el.textContent = revealed ? '••••••••' : (secretStore[id] || '••••••••');
++ el.dataset.revealed = revealed ? 'false' : 'true';
++ el.style.color = revealed ? 'var(--text-muted)' : 'var(--accent-light)';
++}
++
++function copyVKey(id) {
++ const val = secretStore[id];
++ if (!val) return toast('No key to copy', 'error');
++ navigator.clipboard.writeText(val).then(() => toast('Key copied', 'success')).catch(() => toast('Copy failed', 'error'));
++}
++
++async function generateVKey() {
++ const username = document.getElementById('vkey-username')?.value.trim();
++ const note = document.getElementById('vkey-note')?.value.trim();
++ if (!username) return toast('Enter a username', 'error');
++ try {
++ const data = await api('/api/admin/verification-keys', { method: 'POST', body: { target_username: username, note } });
++ const key = data.key;
++ if (key?.id && key?.key) secretStore[`vk-${key.id}`] = key.key;
++ toast('Verification key generated', 'success');
++ document.getElementById('vkey-username').value = '';
++ document.getElementById('vkey-note').value = '';
++ loadVKeys();
++ } catch (e) { toast(e.message, 'error'); }
++}
++
++async function revokeVKey(id) {
++ if (!confirm('Revoke this verification key?')) return;
++ try {
++ await api(`/api/admin/verification-keys/${id}`, { method: 'DELETE' });
++ toast('Key revoked', 'success');
++ loadVKeys();
++ } catch (e) { toast(e.message, 'error'); }
++}
ddiff --git a/public/js/admin/settings.js b/public/js/admin/settings.js
nnew file mode 100644
iindex 00000000..c88d0d7d
---- /dev/null
++++ b/public/js/admin/settings.js
@@@ -0,0 +1,517 @@
++/* ═══════════════════════════════════════════════════════════════
++ OpenVibe.Network — admin panel (/admin): settings sub-tabs, URL registry, streamer settings, Discord and GitHub.
++ Split out of admin.html. Classic scripts, global scope: they rely on
++ admin/core.js's helpers (api, esc, toast, API, token) and on the
++ tabLoaders map in admin/boot.js (loaded last). No ES modules.
++ ═══════════════════════════════════════════════════════════════ */
++// ═══════════════════════════════════════════════════════════════
++// Settings (dual: openvibe-network + OpenVibe.Live)
++// ═══════════════════════════════════════════════════════════════
++let currentSettingsSub = 'tools';
++function showSettingsSub(sub, btn) {
++ currentSettingsSub = sub;
++ document.querySelectorAll('#settings-sub-tabs button').forEach(b => b.classList.remove('active'));
++ btn?.classList.add('active');
++ document.getElementById('settings-tools').style.display = sub === 'tools' ? 'block' : 'none';
++ document.getElementById('settings-streamer').style.display = sub === 'streamer' ? 'block' : 'none';
++ document.getElementById('settings-media').style.display = sub === 'media' ? 'block' : 'none';
++ document.getElementById('settings-urls').style.display = sub === 'urls' ? 'block' : 'none';
++ document.getElementById('settings-discord').style.display = sub === 'discord' ? 'block' : 'none';
++ document.getElementById('settings-github').style.display = sub === 'github' ? 'block' : 'none';
++ if (sub === 'tools') loadToolsSettings();
++ else if (sub === 'streamer') loadStreamerSettings();
++ else if (sub === 'media') loadStreamerMediaTools();
++ else if (sub === 'urls') loadUrlRegistry();
++ else if (sub === 'discord') loadDiscordSettings();
++ else if (sub === 'github') loadGithubSettings();
++}
++
++async function loadSettings() {
++ loadToolsSettings();
++}
++
++async function loadToolsSettings() {
++ const c = document.getElementById('settings-tools');
++ c.innerHTML = '<div class="loading">Loading...</div>';
++ try {
++ const data = await api('/api/admin/settings');
++ const settings = data.settings || {};
++ const keys = Object.keys(settings);
++ if (!keys.length) { c.innerHTML = '<p class="muted">No settings configured</p>'; return; }
++ _toolsSettingsOriginal = {};
++ for (const key of keys) _toolsSettingsOriginal[key] = settings[key].type === 'boolean' ? (settings[key].value === 'true' ? 'true' : 'false') : String(settings[key].value ?? '');
++ c.innerHTML = `
++ <form id="tools-settings-form" style="max-width:700px">
++ ${keys.map(key => {
++ const s = settings[key];
++ const isSensitive = /(password|secret|token|private|credential|api[_-]?key|bearer)/i.test(key);
++ if (s.source === 'env') return `
++ <div class="setting-row" style="flex-direction:column;align-items:stretch">
++ <label for="ts-${esc(key)}"><strong>${esc(key)}</strong></label>
++ <input type="password" id="ts-${esc(key)}" data-key="${esc(key)}" data-type="${esc(s.type||'string')}" value="" disabled placeholder="Set in the environment (${esc(s.env || '')})"
++ style="margin-top:4px;padding:8px 10px;background:var(--bg-input);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:13px">
++ </div>`;
++ if (s.type === 'boolean') return `
++ <div class="setting-row">
++ <label for="ts-${esc(key)}"><strong>${esc(key)}</strong></label>
++ <input type="checkbox" id="ts-${esc(key)}" data-key="${esc(key)}" data-type="boolean" ${s.value==='true'?'checked':''} style="width:18px;height:18px;cursor:pointer">
++ </div>`;
++ return `
++ <div class="setting-row" style="flex-direction:column;align-items:stretch">
++ <label for="ts-${esc(key)}"><strong>${esc(key)}</strong></label>
++ <input type="${isSensitive?'password':'text'}" id="ts-${esc(key)}" data-key="${esc(key)}" data-type="${esc(s.type||'string')}" value="${esc(s.value)}"
++ style="margin-top:4px;padding:8px 10px;background:var(--bg-input);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:13px">
++ ${s.source === 'database' && s.env && s.secret ? `<span class="muted" style="font-size:12px;margin-top:4px">Stored in the database; prefer <code>${esc(s.env)}</code> in the server's environment file.</span>` : ''}
++ </div>`;
++ }).join('')}
++ <div style="margin-top:16px"><button type="button" class="btn btn-primary" onclick="saveToolsSettings()"><i class="fa-solid fa-floppy-disk"></i> Save Settings</button></div>
++ </form>`;
++ } catch (e) { c.innerHTML = `<p class="muted">Error: ${esc(e.message)}</p>`; }
++}
++
++let _toolsSettingsOriginal = {};
++async function saveToolsSettings() {
++ const inputs = document.querySelectorAll('#tools-settings-form [data-key]');
++ const changed = [];
++ for (const input of inputs) {
++ if (input.disabled) continue; // provided by the environment
++ const key = input.dataset.key;
++ const value = input.dataset.type === 'boolean' ? (input.checked ? 'true' : 'false') : input.value;
++ if (value.startsWith('••••')) continue; // masked secret, untouched
++ if (_toolsSettingsOriginal[key] !== undefined && _toolsSettingsOriginal[key] === value) continue;
++ if (input.dataset.type === 'number' && !Number.isFinite(Number(value))) { toast(`${key}: must be a number`, 'error'); return; }
++ changed.push({ key, value, type: input.dataset.type });
++ }
++ if (!changed.length) { toast('No changes to save', 'info'); return; }
++ try {
++ for (const { key, value, type } of changed) {
++ await api('/api/admin/settings', { method: 'PUT', body: { key, value, type } });
++ _toolsSettingsOriginal[key] = value;
++ }
++ toast(`Saved ${changed.length} setting${changed.length === 1 ? '' : 's'}: ${changed.map(c => c.key).join(', ')}`, 'success');
++ } catch (e) { toast(e.message, 'error'); }
++}
++
++async function loadUrlRegistry() {
++ const c = document.getElementById('settings-urls');
++ c.innerHTML = '<div class="loading">Loading URL registry...</div>';
++ try {
++ const data = await api('/api/admin/url-registry');
++ const entries = data.entries || [];
++ if (!entries.length) {
++ c.innerHTML = '<p class="muted">No URL registry overrides configured.</p>';
++ return;
++ }
++ c.innerHTML = `
++ <div style="max-width:900px;display:grid;gap:16px">
++ <div style="display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap">
++ <div>
++ <strong>Network registry</strong>
++ <div class="muted" style="font-size:13px">Edit first-party networking values and push refresh to services.</div>
++ </div>
++ <div style="display:flex;gap:8px;flex-wrap:wrap">
++ <button class="btn btn-sm btn-primary" onclick="refreshAllUrlRegistry()"><i class="fa-solid fa-arrows-rotate"></i> Refresh all services</button>
++ </div>
++ </div>
++ <div id="url-registry-refresh-status" class="muted" style="font-size:13px"></div>
++ ${entries.map(entry => `
++ <div class="card" style="padding:16px">
++ <div style="display:flex;align-items:center;justify-content:space-between;gap:12px;flex-wrap:wrap">
++ <div>
++ <div style="font-weight:700">${esc(entry.key)}</div>
++ <div class="muted" style="font-size:13px">${esc(entry.label)} — ${esc(entry.type)}</div>
++ </div>
++ <div style="display:flex;gap:8px;flex-wrap:wrap">
++ <button class="btn btn-sm btn-outline" onclick="resetUrlRegistryEntry('${esc(entry.key)}')"><i class="fa-solid fa-rotate-left"></i> Reset</button>
++ <button class="btn btn-sm btn-primary" onclick="saveUrlRegistryEntry('${esc(entry.key)}')"><i class="fa-solid fa-floppy-disk"></i> Save</button>
++ </div>
++ </div>
++ <div class="form-field" style="margin-top:12px">
++ <label for="url-registry-${esc(entry.key)}">Value</label>
++ ${entry.type === 'boolean' ? `
++ <div style="display:flex;align-items:center;gap:8px">
++ <input type="checkbox" id="url-registry-${esc(entry.key)}" ${entry.value ? 'checked' : ''}>
++ <span>${esc(entry.label)}</span>
++ </div>
++ ` : `
++ <input type="text" id="url-registry-${esc(entry.key)}" value="${esc(entry.value || '')}" placeholder="${esc(entry.placeholder || '')}">
++ `}
++ <div class="muted" style="margin-top:4px">Source: ${esc(entry.source)}</div>
++ ${entry.key === 'WHIP_PUBLIC_URL' ? `<div class="badge badge-warning" style="margin-top:8px;display:inline-block">Note</div><div class="muted" style="margin-top:4px">A dedicated WHIP hostname also requires explicit enablement via WHIP_PUBLIC_URL_ENABLED, plus DNS/vhost/TLS support.</div>` : ''}
++ ${entry.warning ? `<div class="badge badge-warning" style="margin-top:8px;display:inline-block">Warning</div><div class="muted" style="margin-top:4px">${esc(entry.warning)}</div>` : ''}
++ </div>
++ </div>
++ `).join('')}
++ </div>`;
++ } catch (e) {
++ c.innerHTML = `<p class="muted">Error: ${esc(e.message)}</p>`;
++ }
++}
++
++async function saveUrlRegistryEntry(key) {
++ const input = document.getElementById(`url-registry-${key}`);
++ if (!input) return toast('Missing registry field', 'error');
++ const value = input.type === 'checkbox' ? input.checked : input.value.trim();
++ try {
++ await api(`/api/admin/url-registry/${encodeURIComponent(key)}`, { method: 'PUT', body: { value } });
++ toast('URL registry entry saved', 'success');
++ loadUrlRegistry();
++ } catch (e) { toast(e.message, 'error'); }
++}
++
++async function resetUrlRegistryEntry(key) {
++ if (!confirm('Reset this URL registry entry to its default?')) return;
++ try {
++ await api(`/api/admin/url-registry/${encodeURIComponent(key)}/reset`, { method: 'POST' });
++ toast('URL registry entry reset', 'success');
++ loadUrlRegistry();
++ } catch (e) { toast(e.message, 'error'); }
++}
++
++async function refreshAllUrlRegistry() {
++ const statusEl = document.getElementById('url-registry-refresh-status');
++ statusEl.textContent = 'Refreshing all services...';
++ try {
++ const data = await api('/api/admin/url-registry/refresh-all', { method: 'POST' });
++ if (!data.ok) throw new Error(data.error || 'Refresh failed');
++ const results = data.refreshResults || [];
++ statusEl.innerHTML = results.map(service => {
++ const result = (service.results && service.results[0]) || {};
++ const mode = result.mode || (result.ok ? 'success' : 'failed');
++ const badge = result.mode === 'local'
++ ? '<span class="badge badge-info">Local</span>'
++ : result.mode === 'not_configured'
++ ? '<span class="badge badge-warning">Not configured</span>'
++ : result.mode === 'unsupported'
++ ? '<span class="badge badge-warning">Unsupported</span>'
++ : result.ok
++ ? '<span class="badge badge-success">Success</span>'
++ : '<span class="badge badge-danger">Failed</span>';
++ const message = result.error ? ` <span class="muted">${esc(result.error)}</span>` : (result.message ? ` <span class="muted">${esc(result.message)}</span>` : '');
++ return `<div><strong>${esc(service.service)}</strong>: ${badge}${message}</div>`;
++ }).join('');
++ toast('Refresh command sent to services', 'success');
++ } catch (e) {
++ statusEl.textContent = `Refresh failed: ${esc(e.message)}`;
++ toast(e.message, 'error');
++ }
++}
++
++// Grouped, icon'd sections for the (otherwise huge) OpenVibe.Live settings list.
++const STREAMER_SETTINGS_GROUPS = [
++ { title: 'Site & General', icon: 'fa-sliders', test: k => /^(site_|motd$|registration_open$|require_email$|nsfw_enabled$)/.test(k) },
++ { title: 'Payment Processors', icon: 'fa-credit-card', test: k => /^(payments_|bucks_|sub_|paypal_|stripe_|ccbill_|crypto_|min_cashout)/.test(k) },
++ { title: 'Kick / Twitch / YouTube', icon: 'fa-plug', test: k => /^(twitch_|kick_|google_client)/.test(k) },
++ { title: 'AI Config', icon: 'fa-robot', test: k => /^ai_/.test(k) },
++ { title: 'Cloud TTS', icon: 'fa-volume-high', test: k => /^tts_/.test(k) },
++ { title: 'Media & Uploads', icon: 'fa-photo-film', test: k => /^(gif_|soundboard_|paste_|storage_tier|max_emotes|max_clip|max_video|max_audio|max_vod)/.test(k) },
++ { title: 'Chat & Rewards', icon: 'fa-comments', test: k => /^(chat_|coins_per)/.test(k) },
++ { title: 'Other', icon: 'fa-ellipsis', test: () => true },
++];
++
++const REDACTED_MASK = '••••';
++function _looksJson(v) { return typeof v === 'string' && /^\s*[\[{]/.test(v); }
++function _isMultiline(s) {
++ return s.type === 'json' || _looksJson(s.value) || /\n/.test(String(s.value ?? '')) || String(s.value ?? '').length > 140;
++}
++function _streamerSettingField(s) {
++ const isSensitive = /(password|secret|token|private|credential|api[_-]?key|bearer|salt)/i.test(s.key);
++ const label = `<label for="ss-${esc(s.key)}"><strong>${esc(s.key)}</strong>${s.redacted ? ' <span class="badge badge-warning" style="font-size:10px">owner-only</span>' : ''}<br><small>${esc(s.description||'')}</small></label>`;
++ const base = 'margin-top:4px;padding:8px 10px;background:var(--bg-input);border:1px solid var(--border);border-radius:6px;color:var(--text);font-size:13px';
++ if (s.type === 'boolean') return `<div class="setting-row">${label}
++ <input type="checkbox" id="ss-${esc(s.key)}" data-key="${esc(s.key)}" data-type="boolean" ${s.value==='true'?'checked':''} style="width:18px;height:18px;cursor:pointer"></div>`;
++ if (!isSensitive && _isMultiline(s)) {
++ const kind = (s.type === 'json' || _looksJson(s.value)) ? 'json' : 'text';
++ let shown = String(s.value ?? '');
++ if (kind === 'json') { try { shown = JSON.stringify(JSON.parse(shown), null, 2); } catch { /* show as-is; save validation will flag it */ } }
++ const rows = Math.min(18, Math.max(3, shown.split('\n').length + 1));
++ return `<div class="setting-row" style="flex-direction:column;align-items:stretch">${label}
++ <textarea id="ss-${esc(s.key)}" data-key="${esc(s.key)}" data-type="${kind}" rows="${rows}" spellcheck="false"
++ style="${base};font-family:ui-monospace,SFMono-Regular,Menlo,monospace;resize:vertical;white-space:pre;overflow:auto">${esc(shown)}</textarea>
++ <div class="setting-err muted" data-err-for="${esc(s.key)}" style="display:none;color:var(--danger,#f87171);font-size:12px;margin-top:4px"></div></div>`;
++ }
++ const type = s.type === 'number' ? 'number' : (isSensitive ? 'password' : 'text');
++ const width = s.type === 'number' ? 'width:200px;' : '';
++ return `<div class="setting-row" style="flex-direction:column;align-items:stretch">${label}
++ <input type="${type}" id="ss-${esc(s.key)}" data-key="${esc(s.key)}" data-type="${esc(s.type||'string')}" value="${esc(s.value)}" ${type === 'number' ? 'step="any"' : ''}
++ style="${base};${width}">
++ <div class="setting-err muted" data-err-for="${esc(s.key)}" style="display:none;color:var(--danger,#f87171);font-size:12px;margin-top:4px"></div></div>`;
++}
++
++// Snapshot of what the server sent (keyed by setting key) so Save only submits real changes.
++let _streamerSettingsOriginal = {};
++function _streamerFieldValue(input) {
++ return input.dataset.type === 'boolean' ? (input.checked ? 'true' : 'false') : input.value;
++}
++// Normalize so cosmetic differences (pretty-printed JSON, trailing whitespace) don't count as edits.
++function _streamerNormalize(input, raw) {
++ if (input.dataset.type === 'json') { try { return JSON.stringify(JSON.parse(raw)); } catch { return raw; } }
++ if (input.dataset.type === 'number') return String(raw).trim();
++ return raw;
++}
++function _markDirty(input) {
++ const orig = _streamerSettingsOriginal[input.dataset.key];
++ const dirty = orig !== undefined && _streamerNormalize(input, _streamerFieldValue(input)) !== _streamerNormalize(input, orig);
++ input.style.borderColor = dirty ? 'var(--accent, #8b5cf6)' : '';
++ const btn = document.getElementById('streamer-settings-save');
++ if (btn) {
++ const n = document.querySelectorAll('#streamer-settings-form [data-key][data-dirty="1"]').length + (dirty && input.dataset.dirty !== '1' ? 1 : (!dirty && input.dataset.dirty === '1' ? -1 : 0));
++ input.dataset.dirty = dirty ? '1' : '0';
++ btn.innerHTML = `<i class="fa-solid fa-floppy-disk"></i> Save ${n ? n + ' change' + (n === 1 ? '' : 's') : 'Settings'}`;
++ }
++}
++
++async function loadStreamerSettings() {
++ const c = document.getElementById('settings-streamer');
++ c.innerHTML = '<div class="loading">Loading...</div>';
++ try {
++ const data = await api('/api/admin/streamer/settings');
++ const settings = data.settings || [];
++ if (!settings.length) { c.innerHTML = '<p class="muted">No OpenVibe.Live settings found</p>'; return; }
++ _streamerSettingsOriginal = {};
++ for (const s of settings) _streamerSettingsOriginal[s.key] = s.value == null ? '' : String(s.value);
++ // Bucket each setting into the first matching group.
++ const buckets = STREAMER_SETTINGS_GROUPS.map(() => []);
++ for (const s of settings) {
++ const gi = STREAMER_SETTINGS_GROUPS.findIndex(g => g.test(s.key));
++ buckets[gi === -1 ? buckets.length - 1 : gi].push(s);
++ }
++ const sections = STREAMER_SETTINGS_GROUPS.map((g, i) => {
++ const items = buckets[i].sort((a, b) => a.key.localeCompare(b.key));
++ if (!items.length) return '';
++ return `<details class="settings-group" ${i < 2 ? 'open' : ''} style="border:1px solid var(--border);border-radius:8px;margin-bottom:10px">
++ <summary style="cursor:pointer;padding:10px 14px;font-weight:700;list-style:none"><i class="fa-solid ${g.icon}"></i> ${esc(g.title)} <span style="opacity:.5;font-weight:400">(${items.length})</span></summary>
++ <div style="padding:4px 14px 12px">${items.map(_streamerSettingField).join('')}</div>
++ </details>`;
++ }).join('');
++ c.innerHTML = `
++ <form id="streamer-settings-form" style="max-width:760px">
++ ${sections}
++ <div style="margin-top:8px;display:flex;gap:10px;align-items:center;flex-wrap:wrap">
++ <button type="button" id="streamer-settings-save" class="btn btn-primary" onclick="saveStreamerSettings()"><i class="fa-solid fa-floppy-disk"></i> Save Settings</button>
++ <button type="button" class="btn btn-outline" onclick="loadStreamerSettings()"><i class="fa-solid fa-rotate-left"></i> Discard changes</button>
++ <span class="muted" style="font-size:12px">Only fields you changed are sent. JSON and numeric fields are validated first.</span>
++ </div>
++ </form>`;
++ c.querySelectorAll('#streamer-settings-form [data-key]').forEach(el => el.addEventListener('input', () => _markDirty(el)));
++ } catch (e) { c.innerHTML = `<p class="muted">Error: ${esc(e.message)}</p>`; }
++}
++
++function _setFieldError(key, msg) {
++ const el = document.querySelector(`#streamer-settings-form [data-err-for="${CSS.escape(key)}"]`);
++ if (el) { el.textContent = msg || ''; el.style.display = msg ? '' : 'none'; }
++ const input = document.getElementById(`ss-${key}`);
++ if (input) input.style.borderColor = msg ? 'var(--danger, #f87171)' : (input.dataset.dirty === '1' ? 'var(--accent, #8b5cf6)' : '');
++}
++
++async function saveStreamerSettings() {
++ const inputs = document.querySelectorAll('#streamer-settings-form [data-key]');
++ const settings = {};
++ const errors = [];
++ inputs.forEach(input => {
++ const key = input.dataset.key;
++ const raw = _streamerFieldValue(input);
++ _setFieldError(key, '');
++ // Not in the snapshot (shouldn't happen) or redacted mask untouched → never send.
++ const orig = _streamerSettingsOriginal[key];
++ if (orig === undefined) return;
++ if (raw.startsWith(REDACTED_MASK)) return;
++ if (_streamerNormalize(input, raw) === _streamerNormalize(input, orig)) return;
++ let value = raw;
++ if (input.dataset.type === 'json') {
++ try { value = JSON.stringify(JSON.parse(raw)); }
++ catch (e) { errors.push(key); _setFieldError(key, `Invalid JSON: ${e.message}`); return; }
++ } else if (input.dataset.type === 'number') {
++ if (raw.trim() === '' || !Number.isFinite(Number(raw))) { errors.push(key); _setFieldError(key, 'Must be a number'); return; }
++ value = String(Number(raw));
++ } else if (_looksJson(orig) && !_looksJson(raw)) {
++ // Previously JSON, now something that clearly isn't → almost certainly a truncation/mistake.
++ errors.push(key); _setFieldError(key, 'This setting was JSON; the new value is not. Clear the field completely if you really mean to blank it.'); return;
++ }
++ settings[key] = value;
++ });
++ if (errors.length) {
++ toast(`Not saved — fix ${errors.length} invalid field${errors.length === 1 ? '' : 's'}: ${errors.join(', ')}`, 'error');
++ const first = document.getElementById(`ss-${errors[0]}`);
++ if (first) { first.closest('details')?.setAttribute('open', ''); first.scrollIntoView({ behavior: 'smooth', block: 'center' }); }
++ return;
++ }
++ const keys = Object.keys(settings);
++ if (!keys.length) { toast('No changes to save', 'info'); return; }
++ try {
++ const out = await api('/api/admin/streamer/settings', { method: 'PUT', body: { settings } });
++ if (out && out.rejected && Object.keys(out.rejected).length) {
++ for (const [k, msg] of Object.entries(out.rejected)) _setFieldError(k, msg);
++ toast(`Server rejected: ${Object.keys(out.rejected).join(', ')}`, 'error');
++ return;
++ }
++ for (const k of keys) _streamerSettingsOriginal[k] = settings[k];
++ inputs.forEach(el => { el.dataset.dirty = '0'; el.style.borderColor = ''; });
++ const btn = document.getElementById('streamer-settings-save');
++ if (btn) btn.innerHTML = '<i class="fa-solid fa-floppy-disk"></i> Save Settings';
++ toast(`Saved ${keys.length} setting${keys.length === 1 ? '' : 's'}: ${keys.join(', ')}` + (out?.message && /skipped/.test(out.message) ? ` (${out.message})` : ''), 'success');
++ } catch (e) {
++ // Server-side validation failed: nothing was written; surface the per-field reasons.
++ const rejected = e.body && e.body.rejected;
++ if (rejected && Object.keys(rejected).length) for (const [k, msg] of Object.entries(rejected)) _setFieldError(k, msg);
++ toast(e.message, 'error');
++ }
++}
++
++// ═══════════════════════════════════════════════════════════════
++// Discord Bot Settings
++// ═══════════════════════════════════════════════════════════════
++// A secret input for a provider secret: disabled when its environment variable provides it (server/secrets.js).
++function envSecretInput(sources, key, value, placeholder, style) {
++ const src = (sources || {})[key] || {};
++ if (src.source === 'env') return `<input type="password" data-key="${esc(key)}" value="" placeholder="Set in the environment (${esc(src.env || '')})" disabled style="${style}">`;
++ return `<input type="password" data-key="${esc(key)}" value="${esc(value || '')}" placeholder="${esc(placeholder)}" style="${style}">`
++ + (src.source === 'database' && src.env ? `<div class="muted" style="font-size:12px;margin-top:4px">Stored in the database; prefer <code>${esc(src.env)}</code> in the server's environment file.</div>` : '');
++}
++
++async function loadDiscordSettings() {
++ const c = document.getElementById('settings-discord');
++ c.innerHTML = '<p class="muted">Loading Discord settings...</p>';
++ try {
++ const data = await api('/api/admin/discord');
++ const s = data.settings || {};
++ const st = data.status || {};
++ const statusBadge = st.connected
++ ? `<span class="badge" style="background:var(--success-bg,#166534);color:var(--success-text,#4ade80)">● Connected as ${esc(st.botTag || '?')}</span>`
++ : `<span class="badge" style="background:var(--danger-bg,#7f1d1d);color:var(--danger-text,#f87171)">● Disconnected</span>`;
++
++ c.innerHTML = `
++ <div style="display:flex;align-items:center;gap:12px;margin-bottom:20px">
++ <h3 style="margin:0"><i class="fa-brands fa-discord"></i> Discord Bot</h3>
++ ${statusBadge}
++ </div>
++ <form id="discord-settings-form">
++ <div class="setting-row">
++ <label>Bot Token</label>
++ ${envSecretInput(data.sources, 'discord_bot_token', s.discord_bot_token, 'Bot token from Discord Developer Portal', 'width:100%;max-width:500px')}
++ </div>
++ <div class="setting-row">
++ <label>Guild / Server ID</label>
++ <input type="text" data-key="discord_guild_id" value="${esc(s.discord_guild_id || '')}" placeholder="Right-click server → Copy Server ID" style="width:240px">
++ </div>
++ <div class="setting-row">
++ <label>Live Alerts Channel ID</label>
++ <input type="text" data-key="discord_alerts_channel_id" value="${esc(s.discord_alerts_channel_id || '')}" placeholder="Channel for go-live alerts" style="width:240px">
++ </div>
++ <div class="setting-row">
++ <label>System Alerts Channel ID</label>
++ <input type="text" data-key="discord_system_channel_id" value="${esc(s.discord_system_channel_id || '')}" placeholder="Channel for system/admin alerts (optional)" style="width:240px">
++ </div>
++ <div class="setting-row">
++ <label>Dedupe Cooldown (minutes)</label>
++ <input type="number" data-key="discord_dedupe_minutes" value="${esc(s.discord_dedupe_minutes || '15')}" min="1" max="1440" style="width:100px">
++ <span class="muted" style="margin-left:8px">Prevent spam if a streamer flaps live/offline</span>
++ </div>
++ <div class="setting-row">
++ <label>Custom Alert Message (optional)</label>
++ <input type="text" data-key="discord_alert_message" value="${esc(s.discord_alert_message || '')}" placeholder="{display_name} went live: {title}" style="width:100%;max-width:500px">
++ <div class="muted" style="font-size:12px;margin-top:4px">Placeholders: {username}, {display_name}, {title}, {url}</div>
++ </div>
++ <h4 style="margin-top:24px"><i class="fa-brands fa-discord"></i> Account Linking (OAuth2)</h4>
++ <div class="setting-row">
++ <label>OAuth2 Client ID</label>
++ <input type="text" data-key="discord_oauth_client_id" value="${esc(s.discord_oauth_client_id || '')}" placeholder="Discord application client ID" style="width:300px">
++ </div>
++ <div class="setting-row">
++ <label>OAuth2 Client Secret</label>
++ ${envSecretInput(data.sources, 'discord_oauth_client_secret', s.discord_oauth_client_secret, 'Discord application client secret', 'width:300px')}
++ </div>
++ <div style="margin-top:16px;display:flex;gap:8px;flex-wrap:wrap">
++ <button type="button" class="btn btn-primary" onclick="saveDiscordSettings()"><i class="fa-solid fa-save"></i> Save Settings</button>
++ <button type="button" class="btn btn-outline" onclick="testDiscordAlert()"><i class="fa-solid fa-bell"></i> Test System Alert</button>
++ <button type="button" class="btn btn-outline" onclick="testDiscordLiveAlert()"><i class="fa-solid fa-broadcast-tower"></i> Test Live Alert</button>
++ <button type="button" class="btn btn-outline" onclick="reinitDiscordBot()"><i class="fa-solid fa-rotate"></i> Reconnect Bot</button>
++ </div>
++ </form>`;
++ } catch (e) {
++ c.innerHTML = `<p class="muted">Error loading Discord settings: ${esc(e.message)}</p>`;
++ }
++}
++
++// ═══════════════════════════════════════════════════════════════
++// GitHub integration (owner-only): the read-only token for the registry and Blog's changelog
++// ═══════════════════════════════════════════════════════════════
++async function loadGithubSettings() {
++ const c = document.getElementById('settings-github');
++ c.innerHTML = '<p class="muted">Loading…</p>';
++ try {
++ const g = (await api('/api/admin/integrations/github')).github || {};
++ const where = g.source === 'env' ? `set in the environment (<code>${esc(g.env)}</code>)` : g.source === 'database' ? 'saved here' : 'not set';
++ const badge = g.set ? `<span class="badge" style="background:var(--success-bg,#166534);color:var(--success-text,#4ade80)">● Token …${esc(g.last4 || '')} (${where})</span>` : `<span class="badge badge-warning">● No token: anonymous, 60 requests an hour for the whole host</span>`;
++ c.innerHTML = `
++ <div style="display:flex;align-items:center;gap:12px;margin-bottom:12px;flex-wrap:wrap">
++ <h3 style="margin:0"><i class="fa-brands fa-github"></i> GitHub</h3>${badge}
++ </div>
++ <p class="muted" style="max-width:720px">A read-only token for public repositories. With it, GitHub allows 5,000 requests an hour instead of the 60 an hour this host shares without one. Used by: ${(g.used_by || []).map(esc).join('; ')}. Create a fine-grained token with <b>public repositories, read-only</b> access and no other permissions. The token is never shown again after saving.</p>
++ ${g.source === 'env' ? `<p class="muted">It comes from <code>${esc(g.env)}</code> in the server's environment file; change it there.</p>` : `
++ <div class="setting-row"><label for="github-token-input">Token</label>
++ <input type="password" id="github-token-input" autocomplete="off" placeholder="${g.set ? 'Replace the saved token' : 'github_pat_… or ghp_…'}" style="width:100%;max-width:520px"></div>`}
++ <div style="margin-top:12px;display:flex;gap:8px;flex-wrap:wrap">
++ ${g.source === 'env' ? '' : '<button type="button" class="btn btn-primary" onclick="saveGithubToken()"><i class="fa-solid fa-save"></i> Save token</button>'}
++ <button type="button" class="btn btn-outline" onclick="testGithubToken()"><i class="fa-solid fa-plug"></i> Test</button>
++ ${g.source === 'database' ? '<button type="button" class="btn btn-outline" onclick="clearGithubToken()"><i class="fa-solid fa-trash"></i> Remove</button>' : ''}
++ </div>
++ <div id="github-test-result" class="muted" style="margin-top:10px"></div>`;
++ } catch (e) { c.innerHTML = `<p class="muted">GitHub settings could not be loaded: ${esc(e.message)}</p>`; }
++}
++async function saveGithubToken() {
++ const el = document.getElementById('github-token-input');
++ const token = (el && el.value || '').trim();
++ if (!token) return toast('Paste a token first', 'error');
++ try { await api('/api/admin/integrations/github', { method: 'PUT', body: { token } }); el.value = ''; toast('GitHub token saved'); await loadGithubSettings(); testGithubToken(); }
++ catch (e) { toast(e.message, 'error'); }
++}
++async function clearGithubToken() {
++ if (!confirm('Remove the saved GitHub token? GitHub calls go back to the shared anonymous limit.')) return;
++ try { await api('/api/admin/integrations/github', { method: 'DELETE' }); toast('GitHub token removed'); loadGithubSettings(); }
++ catch (e) { toast(e.message, 'error'); }
++}
++async function testGithubToken() {
++ const out = document.getElementById('github-test-result');
++ if (out) out.textContent = 'Asking GitHub…';
++ try {
++ const t = (await api('/api/admin/integrations/github/test', { method: 'POST' })).test || {};
++ if (out) out.innerHTML = t.ok ? `${t.authenticated ? '✅ Authenticated' : '⚠️ Anonymous'}: ${esc(String(t.remaining))} of ${esc(String(t.limit))} requests left this hour${t.reset ? ` (resets ${esc(new Date(t.reset).toLocaleTimeString())})` : ''}.` : `❌ GitHub refused it: ${esc(t.message || t.status)}`;
++ } catch (e) { if (out) out.textContent = e.message; }
++}
++
++async function saveDiscordSettings() {
++ try {
++ const form = document.getElementById('discord-settings-form');
++ const settings = {};
++ form.querySelectorAll('[data-key]').forEach(el => {
++ if (el.disabled) return; // provided by the environment
++ settings[el.dataset.key] = el.value;
++ });
++ await api('/api/admin/discord', { method: 'PUT', body: JSON.stringify({ settings }) });
++ toast('Discord settings saved');
++ loadDiscordSettings();
++ } catch (e) { toast(e.message, 'error'); }
++}
++
++async function testDiscordAlert() {
++ try {
++ const data = await api('/api/admin/discord/test', { method: 'POST' });
++ toast(data.sent ? 'Test alert sent!' : `Alert not sent: ${data.reason || 'unknown'}`, data.sent ? 'success' : 'error');
++ } catch (e) { toast(e.message, 'error'); }
++}
++
++async function testDiscordLiveAlert() {
++ try {
++ const data = await api('/api/admin/discord/test-live', { method: 'POST' });
++ toast(data.sent ? 'Test live alert sent!' : `Alert not sent: ${data.reason || 'unknown'}`, data.sent ? 'success' : 'error');
++ } catch (e) { toast(e.message, 'error'); }
++}
++
++async function reinitDiscordBot() {
++ try {
++ const data = await api('/api/admin/discord/reinit', { method: 'POST' });
++ toast(data.status?.connected ? `Bot reconnected as ${data.status.botTag}` : 'Bot reconnected (not logged in)', data.status?.connected ? 'success' : 'warning');
++ loadDiscordSettings();
++ } catch (e) { toast(e.message, 'error'); }
++}
ddiff --git a/public/js/admin/users.js b/public/js/admin/users.js
nnew file mode 100644
iindex 00000000..3bcb27ec
---- /dev/null
++++ b/public/js/admin/users.js
@@@ -0,0 +1,139 @@
++/* ═══════════════════════════════════════════════════════════════
++ OpenVibe.Network — admin panel (/admin): the users tab.
++ Split out of admin.html. Classic scripts, global scope: they rely on
++ admin/core.js's helpers (api, esc, toast, API, token) and on the
++ tabLoaders map in admin/boot.js (loaded last). No ES modules.
++ ═══════════════════════════════════════════════════════════════ */
++// ═══════════════════════════════════════════════════════════════
++// Users (from the openvibe.network central user DB)
++// ═══════════════════════════════════════════════════════════════
++let usersOffset = 0;
++async function loadUsers() {
++ const c = document.getElementById('users-content');
++ const pager = document.getElementById('users-pager');
++ const search = document.getElementById('user-search')?.value.trim() || '';
++ c.innerHTML = '<div class="loading">Loading...</div>';
++
++ try {
++ const params = new URLSearchParams({ limit: '50', offset: String(usersOffset) });
++ if (search) params.set('search', search);
++ const data = await api(`/api/admin/users?${params}`);
++ const users = data.users || [];
++ const total = data.total || 0;
++
++ if (!users.length) { c.innerHTML = '<p class="muted">No users found</p>'; pager.innerHTML = ''; return; }
++
++ c.innerHTML = `
++ <table class="admin-table">
++ <thead><tr>
++ <th>Username</th><th>Display Name</th><th>Email</th><th>Role</th><th>Created</th><th>Last Seen</th><th>Actions</th>
++ </tr></thead>
++ <tbody>${users.map(u => `
++ <tr>
++ <td><strong>${esc(u.username)}</strong></td>
++ <td>${esc(u.display_name || '-')}</td>
++ <td style="font-size:11px">${esc(u.email || '-')}</td>
++ <td><span class="badge badge-${u.role}">${esc(u.role)}</span></td>
++ <td style="font-size:11px">${u.created_at ? new Date(u.created_at).toLocaleDateString() : '-'}</td>
++ <td style="font-size:11px">${u.last_seen ? timeAgo(u.last_seen) : 'Never'}</td>
++ <td style="display:flex;gap:4px;align-items:center">
++ <select onchange="changeUserRole('${u.id}',this.value)" style="padding:4px 6px;background:var(--bg-input);color:var(--text);border:1px solid var(--border);border-radius:4px;font-size:11px">
++ ${['user','streamer','global_mod','admin'].map(r =>
++ `<option value="${r}" ${r===u.role?'selected':''}>${r}</option>`
++ ).join('')}
++ </select>
++ ${u.is_banned ?
++ `<button class="btn btn-sm btn-success" onclick="toggleBan('${u.id}','${esc(u.username)}',false)"><i class="fa-solid fa-user-check"></i></button>` :
++ `<button class="btn btn-sm btn-danger" onclick="toggleBan('${u.id}','${esc(u.username)}',true)"><i class="fa-solid fa-ban"></i></button>`
++ }
++ <button class="btn btn-sm btn-outline" title="Rename (the old name redirects on Live and stays reserved)" onclick="renameUser('${u.id}','${esc(u.username)}')"><i class="fa-solid fa-signature"></i></button>
++ <button class="btn btn-sm btn-outline" title="Sign out on every device and site" onclick="signOutUser('${u.id}','${esc(u.username)}')"><i class="fa-solid fa-right-from-bracket"></i></button>
++ ${currentUser?.is_owner ? `
++ <button class="btn btn-sm btn-outline" title="Edit email address"
++ onclick="editUserEmail('${u.id}','${esc(u.username)}','${esc(u.email || '')}')"><i class="fa-solid fa-envelope"></i></button>
++ <button class="btn btn-sm btn-outline" title="${u.email ? 'Send a password reset link' : 'No email address on file'}"
++ ${u.email ? '' : 'disabled'}
++ onclick="sendUserReset('${u.id}','${esc(u.username)}')"><i class="fa-solid fa-key"></i></button>
++ ` : ''}
++ </td>
++ </tr>
++ `).join('')}</tbody>
++ </table>`;
++
++ const pages = Math.ceil(total / 50);
++ const curPage = Math.floor(usersOffset / 50) + 1;
++ pager.innerHTML = pages > 1 ? `
++ <button class="btn btn-sm btn-outline" ${usersOffset <= 0 ? 'disabled' : ''} onclick="usersOffset=Math.max(0,usersOffset-50);loadUsers()"><i class="fa-solid fa-chevron-left"></i></button>
++ <span class="muted">Page ${curPage} / ${pages} (${total} total)</span>
++ <button class="btn btn-sm btn-outline" ${curPage >= pages ? 'disabled' : ''} onclick="usersOffset+=50;loadUsers()"><i class="fa-solid fa-chevron-right"></i></button>
++ ` : `<span class="muted">${total} users</span>`;
++ } catch (e) { c.innerHTML = `<p class="muted">Error: ${esc(e.message)}</p>`; pager.innerHTML = ''; }
++}
++
++async function changeUserRole(userId, role) {
++ try {
++ await api(`/api/admin/users/${userId}/role`, { method: 'PUT', body: { role } });
++ toast(`Role updated to ${role}`, 'success');
++ } catch (e) { toast(e.message, 'error'); loadUsers(); }
++}
++
++// ── Owner-only account recovery ──────────────────────────────────────────────
++// The server enforces owner-only on both of these (requireOwner); hiding the buttons
++// for non-owners is purely so admins are not shown controls they cannot use.
++async function editUserEmail(userId, username, current) {
++ const next = prompt(`Email address for @${username}\n\nLeave blank to remove it.`, current || '');
++ if (next === null) return; // cancelled
++ const email = next.trim();
++ if (email === (current || '').trim()) return; // unchanged
++ try {
++ const r = await api(`/api/admin/users/${userId}/email`, { method: 'PUT', body: { email } });
++ toast(r.email ? `Email for @${username} set to ${r.email}` : `Email removed from @${username}`, 'success');
++ loadUsers();
++ } catch (e) { toast(e.message, 'error'); }
++}
++
++async function sendUserReset(userId, username) {
++ // The link is emailed to the account holder and never shown here — that is what keeps
++ // this a recovery tool rather than a way to take over an account.
++ if (!confirm(`Send a password reset link to @${username}?\n\nIt goes to their email address on file and expires in 1 hour. Any previous reset link for this account stops working.`)) return;
++ try {
++ const r = await api(`/api/admin/users/${userId}/send-reset`, { method: 'POST' });
++ toast(`Reset link sent to ${r.sentTo} (expires in ${r.expiresMinutes} min)`, 'success');
++ } catch (e) { toast(e.message, 'error'); }
++}
++
++async function renameUser(userId, username) {
++ const next = prompt(`New username for ${username}? (3-24 letters, numbers, underscores. /@${username} will redirect to it on Live, and "${username}" stays reserved for them.)`, username);
++ if (next === null || next.trim() === username) return;
++ try {
++ const r = await api(`/api/admin/users/${userId}/username`, { method: 'PUT', body: { username: next.trim() } });
++ toast(`${r.from} is now ${r.to}`, 'success');
++ loadUsers();
++ } catch (e) { toast(e.message, 'error'); }
++}
++
++async function signOutUser(userId, username) {
++ if (!confirm(`Sign ${username} out on every device and every OpenVibe site?`)) return;
++ try {
++ await api(`/api/admin/users/${userId}/sign-out`, { method: 'POST' });
++ toast(`${username} signed out everywhere`, 'success');
++ } catch (e) { toast(e.message, 'error'); }
++}
++
++async function toggleBan(userId, username, ban) {
++ if (ban) {
++ const reason = prompt(`Ban ${username}? Enter reason:`);
++ if (reason === null) return;
++ try {
++ await api(`/api/admin/users/${userId}/ban`, { method: 'PUT', body: { banned: true, reason } });
++ toast(`${username} banned`, 'success');
++ loadUsers();
++ } catch (e) { toast(e.message, 'error'); }
++ } else {
++ try {
++ await api(`/api/admin/users/${userId}/ban`, { method: 'PUT', body: { banned: false } });
++ toast(`${username} unbanned`, 'success');
++ loadUsers();
++ } catch (e) { toast(e.message, 'error'); }
++ }
++}
ddiff --git a/public/js/my/boot.js b/public/js/my/boot.js
nnew file mode 100644
iindex 00000000..d749a3b9
---- /dev/null
++++ b/public/js/my/boot.js
@@@ -0,0 +1,11 @@
++/* ═══════════════════════════════════════════════════════════════
++ OpenVibe.Network — account hub (/my): starts the page (loaded last).
++ Split out of my.html. Classic scripts, global scope: they rely on
++ my/core.js's helpers (apiFetch, getAuthToken, showSection, API) and on
++ my/boot.js (loaded last), which starts the page. No ES modules.
++ ═══════════════════════════════════════════════════════════════ */
++// Init
++OpenVibeAccountSwitcher.init({ apiBase: API });
++loadUser();
++buildDashboardShowcase();
++openInitialSection();
ddiff --git a/public/js/my/core.js b/public/js/my/core.js
nnew file mode 100644
iindex 00000000..1a252bb4
---- /dev/null
++++ b/public/js/my/core.js
@@@ -0,0 +1,229 @@
++/* ═══════════════════════════════════════════════════════════════
++ OpenVibe.Network — account hub (/my): accounts and tokens, fetch helpers, section routing and anonymous mode.
++ Split out of my.html. Classic scripts, global scope: they rely on
++ my/core.js's helpers (apiFetch, getAuthToken, showSection, API) and on
++ my/boot.js (loaded last), which starts the page. No ES modules.
++ ═══════════════════════════════════════════════════════════════ */
++const API = window.location.origin;
++let currentUser = null;
++let availableThemes = [];
++let activeThemeId = 'vibe';
++const DEFAULT_EMAIL_HINT = 'Used for password resets and any notification emails you enable below.';
++
++const LOCAL_THEME_MAP = {
++ '--bg': '--bg-primary',
++ '--bg-secondary': '--bg-secondary',
++ '--bg-card': '--bg-card',
++ '--bg-input': '--bg-input',
++ '--border': '--border',
++ '--accent': '--accent',
++ '--accent-light': '--accent-light',
++ '--accent-dark': '--accent-dark',
++ '--text': '--text-primary',
++ '--text-secondary': '--text-secondary',
++ '--text-muted': '--text-muted',
++ '--on-accent': '--on-accent',
++};
++
++function getStoredAccounts() {
++ try { return JSON.parse(localStorage.getItem('openvibe_accounts') || '[]'); }
++ catch { return []; }
++}
++
++function getActiveAccount() {
++ const activeId = localStorage.getItem('openvibe_active_account');
++ const accounts = getStoredAccounts();
++ if (activeId === 'anon') return accounts.find(a => a?.is_anon) || null;
++ return accounts.find(a => String(a?.id) === String(activeId)) || null;
++}
++
++function isAnonSession() {
++ return !!getActiveAccount()?.is_anon || localStorage.getItem('openvibe_active_account') === 'anon';
++}
++
++/** Read ov_token from the host-only cookie (no Domain attribute — CONTRACTS.md) */
++function _getCookieToken() {
++ const m = document.cookie.match(/(?:^|; )ov_token=([^;]*)/);
++ return m ? decodeURIComponent(m[1]) : null;
++}
++
++function getAuthToken() {
++ if (isAnonSession()) return null;
++ return localStorage.getItem('ov_token') || getActiveAccount()?.token || _getCookieToken() || null;
++}
++
++function getAnonToken() {
++ return localStorage.getItem('openvibe_anon_token') || (getActiveAccount()?.is_anon ? getActiveAccount()?.token : null) || null;
++}
++
++/**
++ * If localStorage has no token but the cross-domain cookie does,
++ * hydrate localStorage so future getAuthToken() calls are instant.
++ * Also bootstraps the account list from /api/auth/me.
++ * If the token is expired, attempts a silent refresh.
++ */
++async function _bootstrapFromCookie() {
++ if (localStorage.getItem('ov_token')) return; // already have it
++ const cookieToken = _getCookieToken();
++ if (!cookieToken) return;
++ // Store the token in localStorage for this origin
++ localStorage.setItem('ov_token', cookieToken);
++ // Try to populate the account list
++ try {
++ const res = await fetchWithTimeout(API + '/api/auth/me', {
++ headers: { Authorization: 'Bearer ' + cookieToken, 'Content-Type': 'application/json' },
++ credentials: 'include',
++ });
++ if (res.ok) {
++ const data = await res.json();
++ const user = data.user || data;
++ // Bootstrap openvibe_accounts + openvibe_active_account
++ const accounts = [{ id: user.id, username: user.username, display_name: user.display_name || user.username, avatar_url: user.avatar_url || null, email: user.email || null, is_anon: false, token: cookieToken, added_at: Date.now() }];
++ localStorage.setItem('openvibe_accounts', JSON.stringify(accounts));
++ localStorage.setItem('openvibe_active_account', String(user.id));
++ } else if (res.status === 401) {
++ // Token expired — try refresh
++ const refreshRes = await fetchWithTimeout(API + '/api/auth/refresh', {
++ method: 'POST',
++ headers: { Authorization: 'Bearer ' + cookieToken, 'Content-Type': 'application/json' },
++ credentials: 'include',
++ });
++ if (refreshRes.ok) {
++ const refreshData = await refreshRes.json();
++ if (refreshData.token) {
++ localStorage.setItem('ov_token', refreshData.token);
++ document.cookie = `ov_token=${refreshData.token};path=/;max-age=${60*60*24*30};SameSite=Lax${location.protocol === 'https:' ? ';Secure' : ''}`;
++ const user = refreshData.user;
++ if (user) {
++ const accounts = [{ id: user.id, username: user.username, display_name: user.display_name || user.username, avatar_url: user.avatar_url || null, email: user.email || null, is_anon: false, token: refreshData.token, added_at: Date.now() }];
++ localStorage.setItem('openvibe_accounts', JSON.stringify(accounts));
++ localStorage.setItem('openvibe_active_account', String(user.id));
++ }
++ }
++ } else {
++ // Refresh failed — clear stale cookie
++ localStorage.removeItem('ov_token');
++ document.cookie = 'ov_token=;path=/;max-age=0';
++ }
++ }
++ } catch { /* cookie may be expired — loadUser will handle redirect */ }
++}
++
++// fetch with an abort timeout so a weak connection can't hang the page forever.
++async function fetchWithTimeout(url, opts = {}, ms = 15000) {
++ const ctrl = new AbortController();
++ const timer = setTimeout(() => ctrl.abort(), ms);
++ try {
++ return await fetch(url, { ...opts, signal: ctrl.signal });
++ } catch (e) {
++ const err = new Error(e && e.name === 'AbortError'
++ ? 'Request timed out — check your connection'
++ : 'Network error — check your connection');
++ err.network = true;
++ throw err;
++ } finally {
++ clearTimeout(timer);
++ }
++}
++
++async function apiFetch(path, opts = {}) {
++ const headers = { 'Content-Type':'application/json', ...opts.headers };
++ const authToken = getAuthToken();
++ if (authToken) headers.Authorization = 'Bearer ' + authToken;
++ const res = await fetchWithTimeout(API + path, { ...opts, headers, credentials: 'include' });
++ // Sliding sessions: the server hands back a renewed token when the old one is due.
++ try { const fresh = res.headers.get('X-OV-Token'); if (fresh) { localStorage.setItem('ov_token', fresh); document.cookie = `ov_token=${fresh};path=/;max-age=${60*60*24*90};SameSite=Lax${location.protocol === 'https:' ? ';Secure' : ''}`; } } catch { /* */ }
++ let data = {};
++ try { data = await res.json(); } catch { /* non-JSON (e.g. proxy error page) */ }
++ if (!res.ok) { const err = new Error(data.error || res.statusText || 'Request failed'); err.status = res.status; throw err; }
++ return data;
++}
++
++async function anonFetch(path, opts = {}) {
++ const headers = { 'Content-Type':'application/json', ...opts.headers };
++ const res = await fetchWithTimeout(API + path, { ...opts, headers, credentials: 'include' });
++ let data = {};
++ try { data = await res.json(); } catch { /* non-JSON */ }
++ if (!res.ok) { const err = new Error(data.error || res.statusText || 'Request failed'); err.status = res.status; throw err; }
++ return data;
++}
++
++// Section switching
++function showSection(id, btn) {
++ document.querySelectorAll('.section').forEach(s => s.classList.remove('active'));
++ document.querySelectorAll('.section-tabs button').forEach(b => b.classList.remove('active'));
++ document.getElementById('sec-' + id).classList.add('active');
++ const targetBtn = btn || [...document.querySelectorAll('.section-tabs button')].find((button) => button.getAttribute('onclick')?.includes(`'${id}'`));
++ targetBtn?.classList.add('active');
++ history.replaceState(null, '', id === 'profile' ? window.location.pathname : `${window.location.pathname}#${id}`);
++ if (id === 'history' && typeof loadHistory === 'function') loadHistory();
++ if (id === 'data') loadDataSection();
++}
++
++function openInitialSection() {
++ const pathSection = { '/themes': 'themes', '/notifications': 'notifications', '/verify-email': 'notifications', '/linked': 'linked', '/security': 'security', '/profile': 'profile', '/history': 'history' }[window.location.pathname];
++ const routeSection = pathSection || window.location.hash.replace(/^#/, '') || 'profile';
++ if (window.location.pathname === '/verify-email') setTimeout(handleVerifyEmailToken, 50);
++ if (isAnonSession() && ['notifications','linked','security','data'].includes(routeSection)) {
++ return showSection('anon');
++ }
++ if (document.getElementById('sec-' + routeSection)) {
++ showSection(routeSection);
++ }
++}
++
++function setSectionDisabled(sectionId, message) {
++ const section = document.getElementById('sec-' + sectionId);
++ if (!section) return;
++ section.innerHTML = `
++ <div class="card">
++ <h3><span class="icon"><i class="fa-solid fa-user-secret"></i></span> Anonymous Mode</h3>
++ <p>${message}</p>
++ </div>
++ `;
++}
++
++function applyAnonModeUI(user) {
++ document.getElementById('pf-bio').disabled = true;
++ document.getElementById('pf-email').disabled = true;
++ document.getElementById('pf-color').disabled = true;
++ document.getElementById('pf-bio').value = '';
++ document.getElementById('pf-email').value = '';
++ document.getElementById('pf-bio').placeholder = 'Anonymous mode does not store bios.';
++ document.getElementById('pf-email').placeholder = 'Anonymous mode does not use email addresses';
++ const emailHint = document.querySelector('#sec-profile .hint');
++ if (emailHint) emailHint.textContent = 'Anonymous mode only stores lightweight preferences like your display name.';
++ const saveButton = document.querySelector('#profile-form .btn-primary');
++ if (saveButton) saveButton.textContent = 'Save Anon Preferences';
++
++ const notifTab = [...document.querySelectorAll('.section-tabs button')].find((button) => button.getAttribute('onclick')?.includes("'notifications'"));
++ const linkedTab = [...document.querySelectorAll('.section-tabs button')].find((button) => button.getAttribute('onclick')?.includes("'linked'"));
++ const securityTab = [...document.querySelectorAll('.section-tabs button')].find((button) => button.getAttribute('onclick')?.includes("'security'"));
++ [notifTab, linkedTab, securityTab].forEach((button) => {
++ if (!button) return;
++ button.style.opacity = '.45';
++ button.title = 'Unavailable in anonymous mode';
++ });
++
++ setSectionDisabled('notifications', 'Notification inbox, email preferences, and alert delivery are only available for full accounts.');
++ setSectionDisabled('linked', 'Linked services are only available once you sign in with a full OpenVibe account.');
++ setSectionDisabled('security', 'Anonymous identities do not have passwords or device sessions to manage.');
++
++ const themeGrid = document.getElementById('theme-grid');
++ if (themeGrid) {
++ themeGrid.innerHTML = '<div class="theme-empty">Theme syncing is unavailable for anonymous sessions right now.</div>';
++ }
++ const currentName = document.getElementById('theme-current-name');
++ if (currentName) currentName.textContent = 'Anon sessions do not sync themes';
++}
++
++// Small helpers (my.html has no global toast/esc of its own).
++if (typeof window.esc !== 'function') window.esc = (v) => String(v ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
++if (typeof window.toast !== 'function') window.toast = (msg, type = 'info') => {
++ let c = document.getElementById('my-toasts');
++ if (!c) { c = document.createElement('div'); c.id = 'my-toasts'; c.style.cssText = 'position:fixed;top:78px;right:16px;z-index:100001;display:flex;flex-direction:column;gap:8px;max-width:360px'; document.body.appendChild(c); }
++ const t = document.createElement('div');
++ t.style.cssText = `padding:10px 14px;border-radius:10px;font-size:13px;color:#fff;box-shadow:0 8px 24px rgba(0,0,0,.35);background:${type === 'error' ? '#dc2626' : type === 'success' ? '#16a34a' : '#4c4c5e'}`;
++ t.textContent = msg; c.appendChild(t);
++ setTimeout(() => { t.style.transition = 'opacity .3s'; t.style.opacity = '0'; setTimeout(() => t.remove(), 300); }, 4500);
++};
ddiff --git a/public/js/my/profile.js b/public/js/my/profile.js
nnew file mode 100644
iindex 00000000..347877f0
---- /dev/null
++++ b/public/js/my/profile.js
@@@ -0,0 +1,407 @@
++/* ═══════════════════════════════════════════════════════════════
++ OpenVibe.Network — account hub (/my): the account, profile, avatar, merge, export and deletion.
++ Split out of my.html. Classic scripts, global scope: they rely on
++ my/core.js's helpers (apiFetch, getAuthToken, showSection, API) and on
++ my/boot.js (loaded last), which starts the page. No ES modules.
++ ═══════════════════════════════════════════════════════════════ */
++// Load user data
++async function loadUser() {
++ // Hydrate localStorage from the host-only cookie if needed (e.g. cookie set by the login page)
++ await _bootstrapFromCookie();
++ const authToken = getAuthToken();
++ const anonToken = getAnonToken();
++ if (!authToken && !anonToken) { window.location.href = '/login'; return; }
++ try {
++ if (isAnonSession()) {
++ const data = await anonFetch('/api/auth/anon/' + encodeURIComponent(anonToken));
++ currentUser = {
++ ...data.user,
++ id: 'anon',
++ role: 'anon',
++ email: null,
++ avatar_url: null,
++ profile_color: '#8b5cf6',
++ bio: '',
++ is_anon: true,
++ };
++ renderHeader(currentUser);
++ renderProfile(currentUser);
++ applyAnonModeUI(currentUser);
++ loadAccounts();
++ loadAnonInfo();
++ OpenVibeNavbar.init({ service: 'network', notificationsRealtime: true, token: null, user: currentUser });
++ } else {
++ const data = await apiFetch('/api/auth/me');
++ currentUser = data.user;
++ renderHeader(data.user);
++ renderProfile(data.user);
++ loadThemes(); loadThemeSubmissions();
++ loadAccounts();
++ renderMerge();
++ renderExport(); renderDeletion();
++ if (pendingMerge() && String(pendingMerge().into_id) !== String(currentUser.id)) showSection('accounts');
++ loadNotifPrefs();
++ loadLinked();
++ loadSessions();
++ loadBlocks();
++ loadAnonInfo();
++ loadEmailStatus();
++ loadRecentTools();
++
++ // Init navbar
++ OpenVibeNavbar.init({ service: 'network', notificationsRealtime: true, token: authToken, user: currentUser });
++ const bellMount = OpenVibeNavbar.getBellMount();
++ if (bellMount && window.OpenVibeNotifications && authToken) {
++ OpenVibeNotifications.init({ token: authToken, apiBase: API, swPath: '/openvibe-sw.js' });
++ const bell = OpenVibeNotifications.createBell();
++ bellMount.replaceChildren(bell);
++ }
++ renderNotifInbox();
++ }
++ } catch (err) {
++ console.error(err);
++ // A genuine auth failure → send them to log in. Everything else (offline, weak signal,
++ // timeout, server hiccup) → show a clear reason + retry instead of a stuck "Loading…".
++ if (err.status === 401 || /401|Authentication|not found|revoked|Unauthorized/i.test(err.message || '')) {
++ // One refresh attempt before giving up on the session (a stale localStorage token
++ // with a still-good cookie used to bounce people to the login page for nothing).
++ if (!window._ovRefreshTried) {
++ window._ovRefreshTried = true;
++ try {
++ const r = await fetchWithTimeout(API + '/api/auth/refresh', { method: 'POST', headers: { 'Content-Type': 'application/json', ...(getAuthToken() ? { Authorization: 'Bearer ' + getAuthToken() } : {}) }, credentials: 'include' });
++ if (r.ok) { const d = await r.json(); if (d.token) { localStorage.setItem('ov_token', d.token); document.cookie = `ov_token=${d.token};path=/;max-age=${60*60*24*90};SameSite=Lax${location.protocol === 'https:' ? ';Secure' : ''}`; location.reload(); return; } }
++ } catch { /* fall through */ }
++ }
++ window.location.href = '/login?return=' + encodeURIComponent(window.location.pathname);
++ return;
++ }
++ showAccountLoadError(err);
++ }
++}
++
++// Replace the stuck "Loading…" with a readable reason + a Retry button.
++function showAccountLoadError(err) {
++ const nameEl = document.getElementById('user-name');
++ const subEl = document.getElementById('user-subtitle');
++ if (nameEl) nameEl.textContent = "Couldn't load your account";
++ if (subEl) {
++ const offline = (typeof navigator !== 'undefined' && navigator.onLine === false);
++ subEl.textContent = (offline ? "You appear to be offline" : (err && err.message) ? err.message : 'Something went wrong') + ' · ';
++ const btn = document.createElement('button');
++ btn.type = 'button';
++ btn.className = 'acct-retry-btn';
++ btn.textContent = 'Retry';
++ btn.onclick = () => { if (nameEl) nameEl.textContent = 'Loading…'; if (subEl) subEl.textContent = ''; loadUser(); };
++ subEl.appendChild(btn);
++ }
++}
++
++function renderHeader(user) {
++ const avatar = document.getElementById('user-avatar');
++ const fallback = avatarPlaceholder(user, 96);
++ avatar.src = avatarSrc(user, 96);
++ avatar.onerror = () => { avatar.src = fallback; };
++ document.getElementById('user-name').textContent = user.display_name || user.username;
++ document.getElementById('user-subtitle').textContent = user.is_anon
++ ? `Anonymous identity${user.anon_number ? ' · #' + user.anon_number : ''}`
++ : '@' + user.username + (user.email ? ' · ' + user.email : '');
++ const badges = document.getElementById('user-badges');
++ badges.innerHTML = user.is_anon
++ ? `<span class="badge badge-anon">anonymous</span>`
++ : `<span class="badge badge-${user.role}">${user.role}</span>`;
++ if (user.anon_number) badges.innerHTML += `<span class="badge badge-anon">Anon #${user.anon_number}</span>`;
++}
++
++function renderProfile(user) {
++ document.getElementById('pf-display-name').value = user.display_name || '';
++ document.getElementById('pf-bio').value = user.bio || '';
++ document.getElementById('pf-email').value = user.email || '';
++ document.getElementById('pf-color').value = user.profile_color || '#8b5cf6';
++ if (!user.is_anon) {
++ document.getElementById('pf-bio').disabled = false;
++ document.getElementById('pf-email').disabled = false;
++ document.getElementById('pf-color').disabled = false;
++ const emailHint = document.querySelector('#sec-profile .hint');
++ if (emailHint) emailHint.textContent = DEFAULT_EMAIL_HINT;
++ const saveButton = document.querySelector('#profile-form .btn-primary');
++ if (saveButton) saveButton.textContent = 'Save Changes';
++ }
++}
++
++// Profile form
++
++// ── Avatar: one picture for every site (PUT/DELETE /api/profile/avatar) ─────
++(function () {
++ const save = document.getElementById('avatar-save'), clear = document.getElementById('avatar-clear'), src = document.getElementById('avatar-source'), msg = document.getElementById('avatar-msg'), prev = document.getElementById('avatar-preview');
++ if (!save) return;
++ const paint = () => { if (typeof currentUser !== 'undefined' && currentUser) { prev.src = avatarSrc(currentUser, 96); prev.onerror = () => { prev.src = avatarPlaceholder(currentUser, 96); }; } };
++ const apply = async (method, body) => {
++ save.disabled = clear.disabled = true; msg.textContent = method === 'PUT' ? 'Checking the picture…' : '';
++ try {
++ const data = await apiFetch('/api/profile/avatar', { method, body: body ? JSON.stringify(body) : undefined });
++ currentUser = Object.assign({}, currentUser, { avatar_url: data.avatar_url });
++ renderHeader(currentUser); paint(); src.value = '';
++ msg.textContent = data.avatar_url ? 'Saved. Every OpenVibe site will show it within a few minutes.' : 'Removed. Sites will show your initial.';
++ } catch (err) { msg.textContent = err.message || 'That did not work'; }
++ save.disabled = clear.disabled = false;
++ };
++ save.addEventListener('click', () => { if (src.value.trim()) apply('PUT', { source: src.value.trim() }); else msg.textContent = 'Paste a link first.'; });
++ clear.addEventListener('click', () => apply('DELETE'));
++ src.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); save.click(); } });
++ document.addEventListener('ov:account-ready', paint); setTimeout(paint, 1500);
++})();
++
++document.getElementById('profile-form').addEventListener('submit', async (e) => {
++ e.preventDefault();
++ try {
++ if (isAnonSession()) {
++ const anonToken = getAnonToken();
++ const data = await anonFetch('/api/auth/anon/' + encodeURIComponent(anonToken) + '/preferences', {
++ method: 'PUT',
++ body: JSON.stringify({
++ display_name: document.getElementById('pf-display-name').value,
++ }),
++ });
++ currentUser = {
++ ...currentUser,
++ display_name: data.preferences?.display_name || document.getElementById('pf-display-name').value || currentUser.display_name,
++ };
++ const accounts = getStoredAccounts().filter(a => !a.is_anon);
++ accounts.push({
++ id: 'anon',
++ username: currentUser.username,
++ display_name: currentUser.display_name,
++ avatar_url: null,
++ email: null,
++ is_anon: true,
++ anon_number: currentUser.anon_number,
++ token: getAnonToken(),
++ });
++ localStorage.setItem('openvibe_accounts', JSON.stringify(accounts));
++ renderHeader(currentUser);
++ alert('Anonymous preferences updated!');
++ return;
++ }
++
++ // Display name may only re-case the username, not rename it.
++ const _dn = (document.getElementById('pf-display-name').value || '').trim();
++ if (currentUser?.username && _dn.toLowerCase() !== String(currentUser.username).toLowerCase()) {
++ alert('Your display name can only change the capitalization of your username (e.g. "' + currentUser.username + '" → "' + currentUser.username.charAt(0).toUpperCase() + currentUser.username.slice(1) + '").');
++ return;
++ }
++ const data = await apiFetch('/api/auth/profile', { method: 'PUT', body: JSON.stringify({
++ display_name: _dn,
++ bio: document.getElementById('pf-bio').value,
++ email: document.getElementById('pf-email').value || undefined,
++ profile_color: document.getElementById('pf-color').value,
++ })});
++ currentUser = data.user;
++ renderHeader(data.user);
++ alert('Profile updated!');
++ } catch (err) { alert('Error: ' + err.message); }
++});
++
++// Accounts
++function loadAccounts() {
++ const accounts = JSON.parse(localStorage.getItem('openvibe_accounts') || '[]');
++ const activeId = localStorage.getItem('openvibe_active_account');
++ const list = document.getElementById('account-list');
++ list.innerHTML = accounts.map(a => `
++ <div class="account-item ${String(a.id) === String(activeId) ? 'active' : ''}">
++ <img src="${avatarSrc(a, 64)}" alt="${a.display_name || a.username}" onerror="this.onerror=null;this.src='${avatarPlaceholder({ display_name: a.display_name, username: a.username, profile_color: a.profile_color }, 64)}'">
++ <div>
++ <div class="name">${a.display_name || a.username}${a.is_anon ? ' <i class="fa-solid fa-user-secret" style="font-size:12px"></i>' : ''}</div>
++ <div class="meta">${a.is_anon ? 'Anonymous #' + (a.anon_number || '?') : '@' + a.username}</div>
++ </div>
++ <div class="actions">
++ ${String(a.id) !== String(activeId) ? `<button class="btn btn-outline" onclick="switchAccount('${a.id}')">Switch</button>` : '<span style="font-size:11px;color:var(--success);font-weight:600">Active</span>'}
++ ${String(a.id) !== String(activeId) ? `<button class="btn btn-danger" onclick="removeAccount('${a.id}')"><i class="fa-solid fa-xmark"></i></button>` : ''}
++ </div>
++ </div>
++ `).join('');
++}
++
++// ── Account merge (ADR-029): 1) the account you keep opens a 10-minute request, 2) you sign in to the other
++// account ("Add another account"), 3) it confirms and folds itself in. Both sign-ins within 10 minutes.
++const MERGE_KEY = 'ov_merge_intent';
++function pendingMerge() {
++ try { const m = JSON.parse(localStorage.getItem(MERGE_KEY) || 'null'); return m && Date.parse(m.expires_at) > Date.now() ? m : null; } catch { return null; }
++}
++function renderMerge() {
++ const body = document.getElementById('merge-body');
++ if (!body || !currentUser) return;
++ body.textContent = '';
++ const el = (tag, text, cls) => { const n = document.createElement(tag); if (text) n.textContent = text; if (cls) n.className = cls; return n; };
++ const row = el('div', '', 'btn-row');
++ const pending = pendingMerge();
++ if (currentUser.is_anon) { body.appendChild(el('p', 'Guests convert by signing up; merging is for two accounts.')); return; }
++ if (!pending) {
++ const b = el('button', `Keep @${currentUser.username} and merge another account into it`, 'btn btn-primary');
++ b.type = 'button';
++ b.onclick = async () => {
++ try {
++ const r = await apiFetch('/api/v1/account/merge/intents', { method: 'POST', body: '{}' });
++ localStorage.setItem(MERGE_KEY, JSON.stringify({ intent: r.intent, expires_at: r.expires_at, into_id: currentUser.id, into_username: currentUser.username }));
++ addAccount();
++ } catch (err) { toast('Could not start the merge: ' + err.message, 'error'); }
++ };
++ row.appendChild(b); body.appendChild(row); return;
++ }
++ const mins = Math.max(1, Math.round((Date.parse(pending.expires_at) - Date.now()) / 60000));
++ const cancel = el('button', 'Cancel', 'btn btn-outline');
++ cancel.type = 'button';
++ cancel.onclick = () => { localStorage.removeItem(MERGE_KEY); renderMerge(); };
++ if (String(currentUser.id) === String(pending.into_id)) {
++ body.appendChild(el('p', `Now sign in to the account you want to fold into @${pending.into_username} (within ${mins} min).`));
++ const go = el('button', 'Sign in to the other account', 'btn btn-primary');
++ go.type = 'button'; go.onclick = addAccount;
++ row.append(go, cancel); body.appendChild(row); return;
++ }
++ body.appendChild(el('p', `Fold @${currentUser.username} into @${pending.into_username}? Everything above moves to @${pending.into_username}; @${currentUser.username} then signs in as @${pending.into_username}.`));
++ const doIt = el('button', `Merge @${currentUser.username} into @${pending.into_username}`, 'btn btn-danger');
++ doIt.type = 'button';
++ doIt.onclick = async () => {
++ doIt.disabled = true;
++ try {
++ const r = await apiFetch('/api/v1/account/merge', { method: 'POST', body: JSON.stringify({ intent: pending.intent }) });
++ localStorage.removeItem(MERGE_KEY);
++ toast(`Merged: @${currentUser.username} is now part of @${pending.into_username} (${r.moved.coins} OpenCoins moved).`, 'success');
++ const folded = currentUser.id;
++ try { OpenVibeAccountSwitcher.removeAccount(folded); } catch { /* */ }
++ OpenVibeAccountSwitcher.switchTo(pending.into_id);
++ } catch (err) {
++ doIt.disabled = false;
++ if (err.status === 401) toast(`Sign in to @${currentUser.username} again (a fresh sign-in is needed), then merge.`, 'error');
++ else if (err.status === 410) { localStorage.removeItem(MERGE_KEY); toast('That merge request expired. Start again from the account you keep.', 'error'); renderMerge(); }
++ else toast('The merge did not happen: ' + err.message, 'error');
++ }
++ };
++ row.append(doIt, cancel); body.appendChild(row);
++}
++
++// ── Your data (ADR-033): the export job (each service adds its part; ready within 30 minutes, kept 7 days) and
++// the scheduled deletion (30 days of grace, cancellable; scheduling it needs a fresh sign-in and the username).
++async function accountApi(path, opts = {}) {
++ const headers = { 'Content-Type': 'application/json', ...(opts.headers || {}) };
++ const t = getAuthToken();
++ if (t) headers.Authorization = 'Bearer ' + t;
++ const res = await fetchWithTimeout(API + path, { ...opts, headers, credentials: 'include' });
++ let body = {};
++ try { body = await res.json(); } catch { /* */ }
++ return { status: res.status, ok: res.ok, body };
++}
++const ovEl = (tag, text, cls) => { const n = document.createElement(tag); if (text) n.textContent = text; if (cls) n.className = cls; return n; };
++const whenText = (s) => new Date(s).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' });
++const sizeText = (n) => (n >= 1048576 ? `${(n / 1048576).toFixed(1)} MB` : `${Math.max(1, Math.round(n / 1024))} KB`);
++let exportPoll = null;
++async function renderExport() {
++ const body = document.getElementById('export-body');
++ if (!body || !currentUser) return;
++ clearTimeout(exportPoll);
++ body.textContent = '';
++ if (currentUser.is_anon) { body.appendChild(ovEl('p', 'Guests have no account to export.')); return; }
++ const r = await accountApi('/api/v1/account/export');
++ const latest = ((r.body && r.body.exports) || [])[0];
++ const row = ovEl('div', '', 'btn-row');
++ if (latest && latest.status === 'pending') {
++ const waiting = latest.services.filter((s) => s.status === 'waiting').map((s) => s.service);
++ body.appendChild(ovEl('p', `Gathering your data since ${whenText(latest.requested_at)}${waiting.length ? `; waiting for ${waiting.join(', ')}` : ''}. It is ready by ${whenText(latest.deadline)} at the latest, and you get a notification.`));
++ exportPoll = setTimeout(renderExport, 15000);
++ return;
++ }
++ if (latest && (latest.status === 'ready' || latest.status === 'partial')) {
++ const missing = latest.services.filter((s) => s.status === 'missing').map((s) => s.service);
++ body.appendChild(ovEl('p', `Ready since ${whenText(latest.ready_at)} (${sizeText(latest.size_bytes || 0)}), until ${whenText(latest.expires_at)}.${missing.length ? ` Not included, because they did not answer in time: ${missing.join(', ')}. Ask again tomorrow for a complete copy.` : ''}`));
++ const dl = ovEl('button', 'Download (.zip)', 'btn btn-primary');
++ dl.type = 'button';
++ dl.onclick = () => downloadExport(latest.export_id, dl);
++ row.appendChild(dl);
++ } else if (latest && latest.status === 'failed') {
++ body.appendChild(ovEl('p', 'The last export could not be built. Ask again.'));
++ }
++ const since = latest && latest.status !== 'failed' ? Date.now() - Date.parse(latest.requested_at) : Infinity;
++ if (since >= 86400000) {
++ const start = ovEl('button', latest ? 'Request a new copy' : 'Request my data', latest ? 'btn btn-outline' : 'btn btn-primary');
++ start.type = 'button';
++ start.onclick = async () => {
++ start.disabled = true;
++ const s = await accountApi('/api/v1/account/export', { method: 'POST', body: '{}' });
++ if (!s.ok) { start.disabled = false; toast(s.body.detail || 'Could not start the export', 'error'); return; }
++ toast('Gathering your data. You get a notification when it is ready.', 'success');
++ renderExport();
++ };
++ row.appendChild(start);
++ } else if (latest) {
++ body.appendChild(ovEl('p', `You can ask for a new copy from ${whenText(Date.parse(latest.requested_at) + 86400000)}.`));
++ }
++ if (row.children.length) body.appendChild(row);
++}
++async function downloadExport(id, btn) {
++ btn.disabled = true;
++ try {
++ const headers = {};
++ const t = getAuthToken();
++ if (t) headers.Authorization = 'Bearer ' + t;
++ const res = await fetch(API + `/api/v1/account/export/${encodeURIComponent(id)}/download`, { headers, credentials: 'include' });
++ if (!res.ok) { toast('That copy is no longer available.', 'error'); renderExport(); return; }
++ const blob = await res.blob();
++ const name = (/filename="([^"]+)"/.exec(res.headers.get('Content-Disposition') || '') || [])[1] || 'openvibe-data.zip';
++ const a = document.createElement('a');
++ a.href = URL.createObjectURL(blob);
++ a.download = name;
++ document.body.appendChild(a); a.click(); a.remove();
++ setTimeout(() => URL.revokeObjectURL(a.href), 60000);
++ } catch { toast('The download failed. Try again.', 'error'); } finally { btn.disabled = false; }
++}
++async function renderDeletion() {
++ const body = document.getElementById('delete-body');
++ if (!body || !currentUser) return;
++ body.textContent = '';
++ if (currentUser.is_anon) { body.appendChild(ovEl('p', 'Guests have no account to delete.')); return; }
++ const r = await accountApi('/api/v1/account/deletion');
++ const d = r.body && r.body.deletion;
++ const row = ovEl('div', '', 'btn-row');
++ if (d) {
++ body.appendChild(ovEl('p', `Your account will be deleted on ${whenText(d.delete_after)}. Download your data before then if you want a copy.`));
++ const keep = ovEl('button', 'Keep my account', 'btn btn-primary');
++ keep.type = 'button';
++ keep.onclick = async () => {
++ keep.disabled = true;
++ const c = await accountApi('/api/v1/account/deletion', { method: 'DELETE' });
++ if (c.ok) toast('Deletion cancelled. Your account stays.', 'success');
++ renderDeletion();
++ };
++ row.appendChild(keep); body.appendChild(row);
++ return;
++ }
++ const field = ovEl('div', '', 'form-field');
++ const label = ovEl('label', `Type your username, ${currentUser.username}, to confirm`);
++ const input = ovEl('input');
++ input.type = 'text'; input.autocomplete = 'off'; input.spellcheck = false; input.id = 'delete-confirm';
++ label.htmlFor = 'delete-confirm';
++ field.append(label, input);
++ const go = ovEl('button', 'Delete my account in 30 days', 'btn btn-danger');
++ go.type = 'button';
++ go.onclick = async () => {
++ go.disabled = true;
++ const s = await accountApi('/api/v1/account/deletion', { method: 'POST', body: JSON.stringify({ confirm_username: input.value }) });
++ go.disabled = false;
++ if (s.status === 401 && s.body.error === 'deletion.sign_in_again') {
++ toast('To make sure it is you, sign in again, then come back here and confirm.', 'error');
++ const again = ovEl('button', 'Sign in again', 'btn btn-outline');
++ again.type = 'button'; again.onclick = addAccount;
++ if (!row.querySelector('.btn-outline')) row.appendChild(again);
++ return;
++ }
++ if (!s.ok) { toast(s.body.detail || 'That did not work', 'error'); return; }
++ toast(`Scheduled: your account will be deleted on ${whenText(s.body.delete_after)}. You can cancel until then.`, 'success');
++ renderDeletion();
++ };
++ row.appendChild(go);
++ body.append(field, row);
++}
++
++function switchAccount(id) { OpenVibeAccountSwitcher.switchTo(id); }
++function removeAccount(id) { OpenVibeAccountSwitcher.removeAccount(id); loadAccounts(); }
++function addAccount() { window.location.href = '/login?add_account=1&return=' + encodeURIComponent(window.location.pathname); }
++function signOutAll() { OpenVibeAccountSwitcher.logoutAll(); }
ddiff --git a/public/js/my/security.js b/public/js/my/security.js
nnew file mode 100644
iindex 00000000..6dd693d2
---- /dev/null
++++ b/public/js/my/security.js
@@@ -0,0 +1,372 @@
++/* ═══════════════════════════════════════════════════════════════
++ OpenVibe.Network — account hub (/my): email status, notifications, linked accounts, history, sessions, blocks, anonymous identities and password.
++ Split out of my.html. Classic scripts, global scope: they rely on
++ my/core.js's helpers (apiFetch, getAuthToken, showSection, API) and on
++ my/boot.js (loaded last), which starts the page. No ES modules.
++ ═══════════════════════════════════════════════════════════════ */
++// ── Email verification status (gates opt-in email like go-live alerts) ──
++async function loadEmailStatus() {
++ if (isAnonSession()) return;
++ const card = document.getElementById('notif-verify-card'), body = document.getElementById('notif-verify-body');
++ if (!card || !body) return;
++ try {
++ const st = await apiFetch('/api/auth/email/status');
++ card.style.display = '';
++ if (!st.email) {
++ body.innerHTML = `<p>No email address on your account, so you can't receive go-live emails. Add one in <a href="#" onclick="showSection('profile');return false">Profile</a> — we'll send a confirmation link.</p>`;
++ } else if (st.bounced) {
++ body.innerHTML = `<p><span style="color:var(--danger,#ef4444)"><i class="fa-solid fa-triangle-exclamation"></i> Mail to <strong>${esc(st.email)}</strong> is bouncing</span>${st.bounce_reason ? ` (${esc(st.bounce_reason)})` : ''}. Emails are paused until you update the address in <a href="#" onclick="showSection('profile');return false">Profile</a> and confirm it.</p>`;
++ } else if (st.verified) {
++ body.innerHTML = `<p><span style="color:var(--success,#22c55e)"><i class="fa-solid fa-circle-check"></i> <strong>${esc(st.email)}</strong> is verified.</span> You'll get an email when a streamer you follow goes live (turn this off under Streams → Email below).</p>`;
++ } else {
++ body.innerHTML = `<p><i class="fa-solid fa-envelope"></i> <strong>${esc(st.email)}</strong> isn't confirmed yet — go-live and other opt-in emails stay off until it is.</p>
++ <button class="btn btn-primary" id="notif-verify-btn" onclick="resendVerification(this)" ${st.can_resend && st.email_enabled ? '' : 'disabled'}><i class="fa-solid fa-paper-plane"></i> ${st.email_enabled ? 'Send verification email' : 'Email delivery not configured'}</button>
++ ${!st.can_resend && st.email_enabled ? '<div class="muted" style="font-size:12px;margin-top:6px">A link was sent moments ago — check your inbox and spam folder.</div>' : ''}`;
++ }
++ } catch (e) { card.style.display = 'none'; }
++}
++async function resendVerification(btn) {
++ if (btn) { btn.disabled = true; btn.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> Sending…'; }
++ try { const r = await apiFetch('/api/auth/email/send-verification', { method: 'POST' }); toast(r.message || 'Sent', 'success'); }
++ catch (e) { toast(e.message, 'error'); }
++ loadEmailStatus();
++}
++async function handleVerifyEmailToken() {
++ const token = new URLSearchParams(window.location.search).get('token');
++ if (!token) return;
++ history.replaceState(null, '', '/notifications');
++ try {
++ const r = await fetchWithTimeout(API + '/api/auth/email/verify', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token }) });
++ const data = await r.json().catch(() => ({}));
++ if (r.ok) toast(`${data.email || 'Your email'} is verified — go-live alerts will reach your inbox.`, 'success');
++ else toast(data.error || 'Verification failed', 'error');
++ } catch { toast('Verification failed — try again from your account page.', 'error'); }
++ loadEmailStatus();
++}
++let _inboxRendered = false;
++function renderNotifInbox() {
++ if (_inboxRendered || isAnonSession() || !window.OpenVibeNotifications) return;
++ const el = document.getElementById('notif-inbox'); if (!el) return;
++ _inboxRendered = true;
++ OpenVibeNotifications.renderInbox(el, { tab: 'all' });
++}
++
++// Notification preferences
++const CATEGORIES = ['social','chat','game','stream','economy','achievement','moderation','system','service','admin'];
++
++async function loadNotifPrefs() {
++ if (isAnonSession()) return;
++ try {
++ const data = await apiFetch('/api/notifications/preferences');
++ const prefsMap = {};
++ for (const p of data.preferences) prefsMap[p.category] = p;
++ const grid = document.getElementById('notif-prefs');
++ // Clear existing rows
++ grid.querySelectorAll('.pref-row').forEach(r => r.remove());
++ for (const cat of CATEGORIES) {
++ const p = prefsMap[cat] || { enabled: 1, sound: 1, toasts: 1, email: null };
++ // email NULL = no explicit choice: go-live (stream) alerts email by default.
++ const emailOn = (p.email === null || p.email === undefined) ? cat === 'stream' : !!p.email;
++ p.email = emailOn ? 1 : 0;
++ const label = { social: 'Social', chat: 'Chat', game: 'Game', stream: 'Streams (go-live)', economy: 'Economy', achievement: 'Achievements', moderation: 'Moderation', system: 'System', service: 'Services', admin: 'Announcements' }[cat] || cat;
++ const html = `
++ <div class="cat pref-row" title="${cat}">${label}</div>
++ <button class="toggle pref-row ${p.enabled ? 'on' : 'off'}" data-cat="${cat}" data-field="enabled" onclick="togglePref(this)"></button>
++ <button class="toggle pref-row ${p.sound ? 'on' : 'off'}" data-cat="${cat}" data-field="sound" onclick="togglePref(this)"></button>
++ <button class="toggle pref-row col-toast ${p.toasts ? 'on' : 'off'}" data-cat="${cat}" data-field="toasts" onclick="togglePref(this)"></button>
++ <button class="toggle pref-row col-email ${p.email ? 'on' : 'off'}" data-cat="${cat}" data-field="email" onclick="togglePref(this)"></button>
++ `;
++ grid.insertAdjacentHTML('beforeend', html);
++ }
++ } catch (err) { console.error('Failed to load notif prefs:', err); }
++}
++
++async function togglePref(btn) {
++ const cat = btn.dataset.cat;
++ const field = btn.dataset.field;
++ const isOn = btn.classList.contains('on');
++ btn.classList.toggle('on');
++ btn.classList.toggle('off');
++ try {
++ await apiFetch('/api/notifications/preferences', { method: 'PUT', body: JSON.stringify({ category: cat, [field]: !isOn }) });
++ } catch (err) { btn.classList.toggle('on'); btn.classList.toggle('off'); }
++}
++
++// Linked services
++async function loadLinked() {
++ if (isAnonSession()) return;
++ const list = document.getElementById('linked-list');
++ const SITES = {
++ live: { name: 'OpenVibe.Live', icon: 'fa-tower-broadcast', url: 'https://openvibe.live', color: '#ef4444' },
++ tools: { name: 'OpenVibe.Tools', icon: 'fa-screwdriver-wrench', url: 'https://openvibe.tools', color: '#8b5cf6' },
++ games: { name: 'OpenVibe.Games', icon: 'fa-gamepad', url: 'https://openvibe.games', color: '#22c55e' },
++ media: { name: 'OpenVibe.Media', icon: 'fa-photo-film', url: 'https://openvibe.media', color: '#22d3ee' },
++ community: { name: 'OpenVibe.Community', icon: 'fa-people-group', url: 'https://openvibe.community', color: '#f59e0b' },
++ discord: { name: 'Discord', icon: 'fa-brands fa-discord', url: 'https://discord.gg/M6MuRUaeJj', color: '#5865f2' },
++ network: { name: 'OpenVibe.Network', icon: 'fa-circle-nodes', url: '/', color: '#3b82f6' },
++ };
++ const ago = (iso) => { if (!iso) return ''; const d = (Date.now() - new Date(String(iso).replace(' ', 'T') + (String(iso).endsWith('Z') ? '' : 'Z')).getTime()) / 1000; if (d < 90) return 'just now'; if (d < 3600) return Math.round(d / 60) + 'm ago'; if (d < 86400) return Math.round(d / 3600) + 'h ago'; return Math.round(d / 86400) + 'd ago'; };
++ try {
++ const card = await apiFetch('/api/auth/users/' + currentUser.id + '/card');
++ const linked = card.user?.linked_services || [];
++ const known = new Set(linked.map(l => l.service));
++ const rows = linked.map(l => {
++ const site = SITES[l.service] || { name: l.service, icon: 'fa-link', url: null };
++ const who = l.service_username && !String(l.service_user_id || '').startsWith('network:') ? '@' + l.service_username : (l.service_username ? '@' + l.service_username : 'this account');
++ const inner = `<span class="svc-icon" style="color:${site.color || 'var(--accent-light)'}"><i class="fa-solid ${site.icon}"></i></span>
++ <div><div class="svc-name">${esc(site.name)}</div><div class="svc-user">${esc(who)}</div></div>
++ <div class="svc-meta">${l.last_used_at ? 'used ' + ago(l.last_used_at) : (l.linked_at ? 'linked ' + ago(l.linked_at) : '')}<br><span class="svc-status" style="color:var(--success)"><i class="fa-solid fa-check"></i> Linked</span></div>`;
++ return site.url ? `<a class="linked-item" href="${site.url}" target="_blank" rel="noopener">${inner}</a>` : `<div class="linked-item">${inner}</div>`;
++ }).join('');
++ const rest = Object.entries(SITES).filter(([id]) => !known.has(id) && id !== 'network' && id !== 'discord');
++ list.innerHTML = (rows || '<p style="color:var(--text-muted);font-size:13px">Nothing linked yet — the first sign-in on any OpenVibe site links it here automatically.</p>') +
++ (rest.length ? `<div class="linked-grid">${rest.map(([id, site]) => `<a href="${site.url}" target="_blank" rel="noopener" title="Open ${site.name}"><i class="fa-solid ${site.icon}" style="color:${site.color}"></i> ${esc(site.name.replace('OpenVibe.', ''))}<span style="margin-left:auto;font-size:10px;opacity:.7">not yet</span></a>`).join('')}</div>` : '');
++ } catch (err) { console.error('Failed to load linked:', err); }
++}
++
++// ── History across the network ──
++let _histOffset = 0, _histService = '', _histTimer = null, _histLoaded = false;
++function histDebounce() { clearTimeout(_histTimer); _histTimer = setTimeout(() => loadHistory(false), 250); }
++function histFilter(svc, btn) { _histService = svc; document.querySelectorAll('#hist-chips button').forEach(b => b.classList.toggle('active', b === btn)); loadHistory(false); }
++async function loadHistory(more) {
++ if (isAnonSession()) { document.getElementById('hist-list').innerHTML = '<div class="hist-empty">History is only kept for signed-in accounts.</div>'; return; }
++ if (!more) _histOffset = 0;
++ const listEl = document.getElementById('hist-list');
++ if (!more) listEl.innerHTML = '<div class="loading-spinner">Loading history…</div>';
++ try {
++ const q = new URLSearchParams({ limit: '40', offset: String(_histOffset) });
++ if (_histService) q.set('service', _histService);
++ const term = document.getElementById('hist-q').value.trim(); if (term) q.set('q', term);
++ const data = await apiFetch('/api/history?' + q);
++ document.getElementById('hist-pause').checked = !!data.paused;
++ if (!_histLoaded || !more) {
++ const chips = document.getElementById('hist-chips');
++ chips.innerHTML = `<button class="${_histService ? '' : 'active'}" onclick="histFilter('', this)">All</button>` + (data.services || []).map(s => `<button class="${_histService === s.service ? 'active' : ''}" onclick="histFilter('${esc(s.service)}', this)">${esc(s.label || s.service)} <span style="opacity:.6">${s.count}</span></button>`).join('');
++ _histLoaded = true;
++ }
++ const items = data.items || [];
++ if (!more && !items.length) { listEl.innerHTML = `<div class="hist-empty"><i class="fa-solid fa-clock-rotate-left" style="font-size:22px;display:block;margin-bottom:8px;opacity:.5"></i>${term || _histService ? 'Nothing matches.' : 'Nothing yet — open a tool, watch a stream or read a paste and it shows up here.'}</div>`; document.getElementById('hist-more-row').style.display = 'none'; return; }
++ const dayOf = (iso) => { const d = new Date(String(iso).replace(' ', 'T') + 'Z'); const today = new Date(); const y = new Date(); y.setDate(today.getDate() - 1); if (d.toDateString() === today.toDateString()) return 'Today'; if (d.toDateString() === y.toDateString()) return 'Yesterday'; return d.toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' }); };
++ const timeOf = (iso) => new Date(String(iso).replace(' ', 'T') + 'Z').toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
++ let html = '', lastDay = more ? (listEl.dataset.lastDay || '') : '';
++ for (const h of items) {
++ const day = dayOf(h.updated_at);
++ if (day !== lastDay) { html += `<div class="hist-day">${esc(day)}</div>`; lastDay = day; }
++ let host = ''; try { host = new URL(h.url).hostname; } catch {}
++ html += `<a class="hist-item" href="${esc(h.url)}" data-id="${h.id}"><span class="hi"><i class="fa-solid ${esc(h.icon || 'fa-file-lines')}"></i></span><div style="min-width:0"><div class="ht">${esc(h.title)}</div><div class="hs">${esc(h.service_label || host)} · ${esc(host)}${h.hits > 1 ? ` · ${h.hits}×` : ''}</div></div><div class="hx"><span>${esc(timeOf(h.updated_at))}</span><button title="Remove" onclick="removeHistoryItem(event, ${h.id})"><i class="fa-solid fa-xmark"></i></button></div></a>`;
++ }
++ listEl.dataset.lastDay = lastDay;
++ if (more) listEl.insertAdjacentHTML('beforeend', html); else listEl.innerHTML = html;
++ _histOffset += items.length;
++ document.getElementById('hist-more-row').style.display = _histOffset < (data.total || 0) ? '' : 'none';
++ } catch (err) { listEl.innerHTML = `<div class="hist-empty">Could not load history: ${esc(err.message)}</div>`; }
++}
++async function toggleHistoryPause(cb) {
++ try { await apiFetch('/api/history/settings', { method: 'PUT', body: JSON.stringify({ paused: cb.checked }) }); toast(cb.checked ? 'History paused — nothing new is recorded.' : 'History recording resumed.'); }
++ catch (e) { cb.checked = !cb.checked; toast(e.message, 'error'); }
++}
++async function clearHistory() {
++ if (!confirm('Delete your whole OpenVibe history? This cannot be undone.')) return;
++ try { await apiFetch('/api/history', { method: 'DELETE' }); _histLoaded = false; loadHistory(false); toast('History cleared.', 'success'); } catch (e) { toast(e.message, 'error'); }
++}
++async function removeHistoryItem(ev, id) {
++ ev.preventDefault(); ev.stopPropagation();
++ const el = document.querySelector(`.hist-item[data-id="${id}"]`);
++ try { await apiFetch('/api/history/' + id, { method: 'DELETE' }); if (el) { el.style.transition = 'opacity .2s'; el.style.opacity = '0'; setTimeout(() => el.remove(), 200); } } catch (e) { toast(e.message, 'error'); }
++}
++
++// Sessions
++async function loadSessions() {
++ if (isAnonSession()) {
++ document.getElementById('sessions-list').innerHTML = '<p style="color:var(--text-muted);font-size:13px">Anonymous mode does not create device sessions.</p>';
++ return;
++ }
++ try {
++ const data = await apiFetch('/api/auth/sessions');
++ const list = document.getElementById('sessions-list');
++ if (data.sessions.length === 0) {
++ list.innerHTML = '<p style="color:var(--text-muted);font-size:13px">No active sessions.</p>';
++ } else {
++ list.innerHTML = data.sessions.map(s => `
++ <div class="linked-item">
++ <span class="svc-icon"><i class="fa-solid fa-mobile-screen"></i></span>
++ <div>
++ <div class="svc-name">${s.device_name || 'Unknown Device'}</div>
++ <div class="svc-user">IP: ${s.ip || '?'} · Last used: ${new Date(s.last_used).toLocaleDateString()}</div>
++ </div>
++ <button class="btn btn-danger" onclick="revokeSession(${s.id})">Revoke</button>
++ </div>
++ `).join('');
++ }
++ } catch (err) { document.getElementById('sessions-list').innerHTML = '<p style="color:var(--text-muted)">Unable to load sessions.</p>'; }
++}
++
++// Blocked people (platform blocks: /api/v1/me/blocks), built from DOM nodes.
++async function loadBlocks() {
++ const box = document.getElementById('blocks-list');
++ if (!box) return;
++ if (isAnonSession()) { box.textContent = 'Anonymous identities cannot block people.'; return; }
++ try {
++ const data = await apiFetch('/api/v1/me/blocks');
++ box.replaceChildren();
++ if (!(data.blocks || []).length) {
++ const p = document.createElement('p'); p.style.cssText = 'color:var(--text-muted);font-size:13px'; p.textContent = 'You have not blocked anyone.';
++ box.appendChild(p); return;
++ }
++ for (const b of data.blocks) {
++ const row = document.createElement('div'); row.className = 'module-row';
++ const main = document.createElement('div'); main.className = 'module-row-main';
++ const title = document.createElement('strong'); title.textContent = b.display_name || b.username || 'Deleted account';
++ const info = document.createElement('small');
++ info.textContent = `${b.username ? '@' + b.username + ' · ' : ''}blocked ${b.blocked_at ? new Date(b.blocked_at).toLocaleDateString() : 'recently'}`;
++ main.append(title, info);
++ const btn = document.createElement('button'); btn.type = 'button'; btn.className = 'btn btn-small';
++ btn.textContent = 'Unblock'; btn.setAttribute('aria-label', 'Unblock ' + (b.username || b.subject));
++ btn.addEventListener('click', () => unblockPerson(b, btn));
++ row.append(main, btn);
++ box.appendChild(row);
++ }
++ } catch { box.textContent = 'Could not load the people you blocked. Try again later.'; }
++}
++async function blockPerson(e) {
++ e.preventDefault();
++ const input = document.getElementById('block-username');
++ const status = document.getElementById('blocks-status');
++ const name = input.value.trim().replace(/^@/, '');
++ if (!name) return;
++ try {
++ await apiFetch('/api/v1/me/blocks/' + encodeURIComponent(name), { method: 'PUT' });
++ if (status) status.textContent = `Blocked @${name}.`;
++ input.value = '';
++ } catch (err) {
++ if (status) status.textContent = err.status === 404 ? 'Nobody has that username.' : err.status === 400 ? 'You cannot block that account.' : 'Could not block. Try again.';
++ }
++ loadBlocks();
++}
++async function unblockPerson(b, btn) {
++ const status = document.getElementById('blocks-status');
++ btn.disabled = true;
++ try {
++ await apiFetch('/api/v1/me/blocks/' + encodeURIComponent(b.subject), { method: 'DELETE' });
++ if (status) status.textContent = `Unblocked ${b.username ? '@' + b.username : 'that account'}.`;
++ } catch (e) {
++ if (status) status.textContent = e.status === 404 ? 'Already unblocked.' : 'Could not unblock. Try again.';
++ }
++ loadBlocks();
++}
++
++async function revokeSession(id) {
++ try { await apiFetch('/api/auth/sessions/' + id, { method: 'DELETE' }); loadSessions(); }
++ catch (err) { alert('Error: ' + err.message); }
++}
++
++async function revokeAllSessions() {
++ if (!confirm('Sign out every other device? They will need to sign in again. You stay signed in here.')) return;
++ try {
++ const data = await apiFetch('/api/auth/sessions', { method: 'DELETE' });
++ if (data.token) {
++ localStorage.setItem('ov_token', data.token);
++ document.cookie = `ov_token=${data.token};path=/;max-age=${60*60*24*30};SameSite=Lax${location.protocol === 'https:' ? ';Secure' : ''}`;
++ }
++ loadSessions();
++ alert('Signed out everywhere else.');
++ } catch (err) { alert('Error: ' + err.message); }
++}
++
++async function signOutEverywhere() {
++ if (!confirm('Sign out on every device and every OpenVibe site, this one included?')) return;
++ try { await apiFetch('/api/auth/sign-out-everywhere', { method: 'POST' }); }
++ catch (err) { alert('Error: ' + err.message); return; }
++ try { localStorage.removeItem('ov_token'); } catch { /* storage may be blocked */ }
++ location.href = '/sso/fanout?action=logout&next=%2Flogin';
++}
++
++// Anon info
++async function loadAnonInfo() {
++ const anonToken = localStorage.getItem('openvibe_anon_token');
++ const container = document.getElementById('anon-info');
++ if (!anonToken && !currentUser?.anon_number) {
++ container.innerHTML = `
++ <p style="color:var(--text-muted);font-size:14px"><i class="fa-solid fa-user-secret"></i></p>
++ <p style="color:var(--text-muted);font-size:13px;margin-top:8px">You don't have an anonymous identity yet.</p>
++ <button class="btn btn-outline" onclick="createAnon()" style="margin-top:12px">Create Anonymous Identity</button>
++ `;
++ return;
++ }
++
++ if (currentUser?.is_anon) {
++ container.innerHTML = `
++ <p style="font-size:11px;color:var(--text-muted);text-transform:uppercase;letter-spacing:1px">Current Anonymous Session</p>
++ <div class="number">#${currentUser.anon_number}</div>
++ <p style="font-size:12px;color:var(--text-muted)">You're currently browsing as an anonymous identity.</p>
++ `;
++ return;
++ }
++
++ if (currentUser?.anon_number) {
++ container.innerHTML = `
++ <p style="font-size:11px;color:var(--text-muted);text-transform:uppercase;letter-spacing:1px">Your Anonymous Number</p>
++ <div class="number">#${currentUser.anon_number}</div>
++ <p style="font-size:12px;color:var(--text-muted)">This number is permanently linked to your account.</p>
++ `;
++ return;
++ }
++
++ try {
++ const data = await fetch(API + '/api/auth/anon/' + anonToken).then(r => r.json());
++ if (data.user) {
++ container.innerHTML = `
++ <p style="font-size:11px;color:var(--text-muted);text-transform:uppercase;letter-spacing:1px">Your Anonymous Number</p>
++ <div class="number">#${data.user.anon_number}</div>
++ <div class="label">First seen ${new Date(data.user.first_seen).toLocaleDateString()}</div>
++ <div class="stats">
++ <div class="stat"><div class="val">${data.user.total_messages}</div><div class="key">Messages</div></div>
++ <div class="stat"><div class="val">${data.user.total_commands}</div><div class="key">Commands</div></div>
++ </div>
++ `;
++ }
++ } catch (err) {
++ container.innerHTML = '<p style="color:var(--text-muted)">Unable to load anonymous info.</p>';
++ }
++}
++
++async function createAnon() {
++ try {
++ const res = await fetch(API + '/api/auth/anon-session', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' });
++ const data = await res.json();
++ localStorage.setItem('openvibe_anon_token', data.token);
++ const accounts = getStoredAccounts().filter(a => !a.is_anon);
++ accounts.push({ id: 'anon', username: data.user.username, display_name: data.user.display_name, is_anon: true, anon_number: data.user.anon_number, token: data.token, avatar_url: null, email: null });
++ localStorage.setItem('openvibe_accounts', JSON.stringify(accounts));
++ loadAnonInfo();
++ } catch (err) { alert('Error: ' + err.message); }
++}
++
++async function linkAnon() {
++ if (currentUser?.is_anon) { alert('Switch to a real account before linking this anonymous identity.'); return; }
++ const anonToken = localStorage.getItem('openvibe_anon_token');
++ if (!anonToken) { alert('No anonymous session to link.'); return; }
++ try {
++ await apiFetch('/api/auth/anon/' + anonToken + '/link', { method: 'POST' });
++ alert('Anonymous identity linked!');
++ location.reload();
++ } catch (err) { alert('Error: ' + err.message); }
++}
++
++// Password form
++document.getElementById('password-form').addEventListener('submit', async (e) => {
++ e.preventDefault();
++ if (isAnonSession()) { alert('Anonymous identities do not have passwords.'); return; }
++ const newPw = document.getElementById('sec-new-pw').value;
++ if (newPw !== document.getElementById('sec-confirm-pw').value) { alert('Passwords do not match'); return; }
++ try {
++ const data = await apiFetch('/api/auth/change-password', { method: 'POST', body: JSON.stringify({
++ current_password: document.getElementById('sec-current-pw').value,
++ new_password: newPw,
++ })});
++ localStorage.setItem('ov_token', data.token);
++ document.cookie = `ov_token=${data.token};path=/;max-age=${60*60*24*30};SameSite=Lax${location.protocol === 'https:' ? ';Secure' : ''}`;
++ alert(data.message || 'Password changed!');
++ document.getElementById('password-form').reset();
++ } catch (err) { alert('Error: ' + err.message); }
++});
ddiff --git a/public/js/my/themes.js b/public/js/my/themes.js
nnew file mode 100644
iindex 00000000..abc5458c
---- /dev/null
++++ b/public/js/my/themes.js
@@@ -0,0 +1,254 @@
++/* ═══════════════════════════════════════════════════════════════
++ OpenVibe.Network — account hub (/my): themes, the theme customizer and theme submissions.
++ Split out of my.html. Classic scripts, global scope: they rely on
++ my/core.js's helpers (apiFetch, getAuthToken, showSection, API) and on
++ my/boot.js (loaded last), which starts the page. No ES modules.
++ ═══════════════════════════════════════════════════════════════ */
++function avatarPlaceholder(user, size = 96) {
++ const label = (user?.display_name || user?.username || 'H').trim();
++ const initial = label.charAt(0).toUpperCase() || 'H';
++ const bg = user?.profile_color || '#8b5cf6';
++ const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 ${size} ${size}"><rect width="100%" height="100%" rx="${Math.round(size / 2)}" fill="${bg}"/><text x="50%" y="54%" dominant-baseline="middle" text-anchor="middle" font-family="Inter, Arial, sans-serif" font-size="${Math.round(size * 0.42)}" font-weight="700" fill="#fff">${initial}</text></svg>`;
++ return `data:image/svg+xml;charset=UTF-8,${encodeURIComponent(svg)}`;
++}
++
++function avatarSrc(user, size = 96) {
++ return user?.avatar_url || avatarPlaceholder(user, size);
++}
++
++function themePreviewColors(theme) {
++ const vars = theme?.variables || {};
++ const preview = Array.isArray(theme?.preview_colors) && theme.preview_colors.length
++ ? theme.preview_colors
++ : [vars['--bg-primary'], vars['--accent'], vars['--bg-card']].filter(Boolean);
++ return [preview[0] || '#202331', preview[1] || '#8b5cf6', preview[2] || '#181a24'];
++}
++
++function applyThemeToPage(theme) {
++ const vars = theme?.variables || {};
++ // The shared loader applies every token (the canvas behind the page, --on-accent, the browser UI);
++ // this page's own short names follow below.
++ if (typeof OpenVibeThemeLoader !== 'undefined' && OpenVibeThemeLoader.applyVars) OpenVibeThemeLoader.applyVars(vars);
++ Object.entries(LOCAL_THEME_MAP).forEach(([localVar, themeVar]) => {
++ const value = vars[themeVar];
++ if (value) document.documentElement.style.setProperty(localVar, value);
++ });
++}
++
++function updateThemeSummary(theme) {
++ const currentName = document.getElementById('theme-current-name');
++ const swatches = document.querySelectorAll('#theme-current-swatch span');
++ if (!currentName || swatches.length < 3) return;
++ currentName.textContent = theme?.name || 'Vibe';
++ themePreviewColors(theme).forEach((color, index) => {
++ if (swatches[index]) swatches[index].style.background = color;
++ });
++}
++
++function renderThemes() {
++ const grid = document.getElementById('theme-grid');
++ if (!grid) return;
++ if (!availableThemes.length) {
++ grid.innerHTML = '<div class="theme-empty">No themes available right now.</div>';
++ return;
++ }
++ grid.innerHTML = availableThemes.map((theme) => {
++ const [bg1, accent, bg2] = themePreviewColors(theme);
++ const isActive = String(theme.id) === String(activeThemeId) || String(theme.slug) === String(activeThemeId);
++ return `
++ <article class="theme-card ${isActive ? 'active' : ''}">
++ <div class="theme-preview" style="--theme-bg-1:${bg1};--theme-bg-2:${bg2}">
++ <div class="theme-preview-top">
++ <div class="theme-preview-swatch"><span style="background:${bg1}"></span><span style="background:${accent}"></span><span style="background:${bg2}"></span></div>
++ <span class="theme-preview-chip">${theme.mode || 'dark'}</span>
++ </div>
++ <div class="theme-preview-bars"><span style="background:${accent}"></span><span></span></div>
++ </div>
++ <div>
++ <h4>${theme.name}</h4>
++ <p>${theme.description || 'A synced network theme.'}</p>
++ </div>
++ <div class="theme-card-footer">
++ <div class="theme-badges">
++ ${theme.is_builtin ? '<span class="theme-badge">Built-in</span>' : '<span class="theme-badge">Community</span>'}
++ </div>
++ <button class="btn ${isActive ? 'btn-outline' : 'btn-primary'}" onclick="setTheme('${theme.id}')">${isActive ? 'Active' : 'Use theme'}</button>
++ </div>
++ </article>
++ `;
++ }).join('');
++ updateThemeSummary(availableThemes.find((theme) => String(theme.id) === String(activeThemeId) || String(theme.slug) === String(activeThemeId)) || availableThemes[0]);
++}
++
++async function loadThemes() {
++ const grid = document.getElementById('theme-grid');
++ if (!grid) return;
++ try {
++ const [themesData, activeData] = await Promise.all([
++ apiFetch('/api/themes?limit=100'),
++ apiFetch('/api/themes/me/active'),
++ ]);
++ availableThemes = themesData.themes || [];
++ activeThemeId = activeData.theme_id || 'vibe';
++ activeCustomVars = (activeData.custom_variables && typeof activeData.custom_variables === 'object') ? activeData.custom_variables : {};
++ const activeTheme = availableThemes.find((theme) => String(theme.id) === String(activeThemeId) || String(theme.slug) === String(activeThemeId));
++ activeThemeVars = (activeTheme && activeTheme.variables) || {};
++ if (activeTheme) applyThemeToPage(activeTheme);
++ if (Object.keys(activeCustomVars).length) applyThemeToPage({ variables: Object.assign({}, activeThemeVars, activeCustomVars) });
++ renderThemes();
++ renderThemeCustomizer();
++ } catch (err) {
++ console.error('Failed to load themes:', err);
++ grid.innerHTML = '<div class="theme-empty">Unable to load themes right now.</div>';
++ }
++}
++
++async function setTheme(themeId) {
++ try {
++ await apiFetch('/api/themes/me', { method: 'PUT', body: JSON.stringify({ theme_id: themeId }) });
++ activeThemeId = themeId;
++ activeCustomVars = {}; // picking a preset clears custom overrides
++ const activeTheme = availableThemes.find((theme) => String(theme.id) === String(themeId) || String(theme.slug) === String(themeId));
++ if (activeTheme) {
++ activeThemeVars = activeTheme.variables || {};
++ renderThemeCustomizer();
++ applyThemeToPage(activeTheme);
++ localStorage.setItem('ov_theme', JSON.stringify({ id: activeTheme.id, slug: activeTheme.slug, name: activeTheme.name, variables: activeTheme.variables || {} }));
++ // Sync theme choice via cookie + OpenVibeThemeLoader (host-only cookie here)
++ if (typeof OpenVibeThemeLoader !== 'undefined') OpenVibeThemeLoader.save(activeTheme.slug || activeTheme.id, activeTheme.variables);
++ else document.cookie = `ov_theme_id=${encodeURIComponent(activeTheme.slug || activeTheme.id)};path=/;max-age=${365*24*60*60};SameSite=Lax${location.protocol === 'https:' ? ';Secure' : ''}`;
++ }
++ renderThemes();
++ } catch (err) {
++ alert('Error: ' + err.message);
++ }
++}
++
++// ── Custom color editor ──────────────────────────────────────
++let activeCustomVars = {};
++let activeThemeVars = {};
++const THEME_EDITABLE = [
++ ['--bg-primary', 'Background'], ['--bg-secondary', 'Surface'], ['--bg-card', 'Card'],
++ ['--bg-hover', 'Hover'], ['--accent', 'Accent'], ['--accent-light', 'Accent light'],
++ ['--text-primary', 'Text'], ['--text-secondary', 'Muted text'], ['--border', 'Border'],
++];
++function _hexOrDefault(v) {
++ if (typeof v === 'string' && /^#[0-9a-fA-F]{6}$/.test(v.trim())) return v.trim();
++ if (typeof v === 'string' && /^#[0-9a-fA-F]{3}$/.test(v.trim())) {
++ const h = v.trim().slice(1); return '#' + h.split('').map(c => c + c).join('');
++ }
++ return '#888888';
++}
++function renderThemeCustomizer() {
++ const grid = document.getElementById('theme-custom-grid');
++ if (!grid) return;
++ grid.innerHTML = THEME_EDITABLE.map(([varName, label]) => {
++ const val = _hexOrDefault(activeCustomVars[varName] || activeThemeVars[varName]);
++ return `<label class="theme-custom-row">
++ <input type="color" value="${val}" data-var="${varName}" oninput="_onCustomColorInput(this)">
++ <span>${label}</span>
++ </label>`;
++ }).join('');
++}
++function toggleThemeCustomizer() {
++ const box = document.getElementById('theme-customizer');
++ const btn = document.getElementById('theme-custom-toggle');
++ if (!box) return;
++ const show = box.style.display === 'none';
++ box.style.display = show ? '' : 'none';
++ if (btn) btn.innerHTML = show ? '<i class="fa-solid fa-chevron-up"></i> Hide' : '<i class="fa-solid fa-pen"></i> Customize';
++ if (show) renderThemeCustomizer();
++}
++function _onCustomColorInput(input) {
++ const v = input.getAttribute('data-var');
++ activeCustomVars[v] = input.value;
++ // Live preview on this page.
++ applyThemeToPage({ variables: Object.assign({}, activeThemeVars, activeCustomVars) });
++}
++async function saveCustomColors(btn) {
++ if (btn) btn.disabled = true;
++ try {
++ await apiFetch('/api/themes/me', { method: 'PUT', body: JSON.stringify({ theme_id: activeThemeId, custom_variables: activeCustomVars }) });
++ const merged = Object.assign({}, activeThemeVars, activeCustomVars);
++ try { localStorage.setItem('ov_theme', JSON.stringify({ id: activeThemeId, variables: merged })); } catch {}
++ if (typeof OpenVibeThemeLoader !== 'undefined') OpenVibeThemeLoader.save(activeThemeId, merged);
++ alert('Custom theme saved — it will sync across the network.');
++ } catch (err) { alert('Error: ' + err.message); }
++ finally { if (btn) btn.disabled = false; }
++}
++// ── Share a theme (export, import, submit for review; WS-E task 2) ──
++async function exportActiveTheme(btn) {
++ if (btn) btn.disabled = true;
++ try {
++ const res = await fetch('/api/themes/' + encodeURIComponent(activeThemeId) + '/export', { credentials: 'include' });
++ if (!res.ok) throw new Error('This theme cannot be exported');
++ const file = await res.json();
++ if (activeCustomVars && Object.keys(activeCustomVars).length) file.variables = Object.assign({}, file.variables, activeCustomVars);
++ const a = document.createElement('a');
++ a.href = URL.createObjectURL(new Blob([JSON.stringify(file, null, 2)], { type: 'application/json' }));
++ a.download = (file.slug || 'theme') + '.openvibe-theme.json';
++ document.body.appendChild(a); a.click(); a.remove();
++ setTimeout(() => URL.revokeObjectURL(a.href), 1000);
++ } catch (err) { alert('Error: ' + err.message); }
++ finally { if (btn) btn.disabled = false; }
++}
++async function importThemeFile(input) {
++ const f = input.files && input.files[0];
++ input.value = '';
++ if (!f) return;
++ try {
++ const file = JSON.parse(await f.text());
++ const out = await apiFetch('/api/themes/import', { method: 'POST', body: JSON.stringify(file) });
++ alert('Imported "' + out.theme.name + '". It is waiting for review; you can use it now from your submissions.');
++ loadThemeSubmissions();
++ } catch (err) { alert('Import failed: ' + err.message); }
++}
++async function submitMyTheme(ev) {
++ ev.preventDefault();
++ const name = document.getElementById('theme-submit-name').value.trim();
++ const slug = document.getElementById('theme-submit-slug').value.trim();
++ const variables = Object.assign({}, activeThemeVars, activeCustomVars);
++ try {
++ await apiFetch('/api/themes', { method: 'POST', body: JSON.stringify({ name, slug, variables, mode: document.documentElement.dataset.mode === 'light' ? 'light' : 'dark' }) });
++ ev.target.reset();
++ loadThemeSubmissions();
++ } catch (err) { alert('Not submitted: ' + err.message); }
++}
++async function loadThemeSubmissions() {
++ const box = document.getElementById('theme-submissions');
++ if (!box) return;
++ let list = [];
++ try { list = (await apiFetch('/api/themes/me/submissions')).themes || []; } catch { return; }
++ box.replaceChildren();
++ if (!list.length) return;
++ const h = document.createElement('div');
++ h.textContent = 'Your submissions';
++ h.style.cssText = 'font-weight:600;margin-bottom:6px';
++ box.appendChild(h);
++ const labels = { pending: 'Waiting for review', approved: 'Approved', rejected: 'Not accepted' };
++ for (const t of list) {
++ const row = document.createElement('div');
++ row.style.cssText = 'display:flex;gap:10px;align-items:center;flex-wrap:wrap;padding:6px 0;border-top:1px solid var(--border)';
++ const n = document.createElement('strong'); n.textContent = t.name;
++ const st = document.createElement('span'); st.textContent = labels[t.review_status] || t.review_status;
++ st.style.cssText = 'font-size:0.8rem;color:var(--text-secondary)';
++ row.append(n, st);
++ if (t.review_note) { const note = document.createElement('span'); note.textContent = '— ' + t.review_note; note.style.cssText = 'font-size:0.8rem;color:var(--text-muted)'; row.appendChild(note); }
++ if (t.review_status !== 'rejected') {
++ const use = document.createElement('button'); use.type = 'button'; use.className = 'btn btn-outline'; use.textContent = 'Use';
++ use.onclick = async () => { try { await apiFetch('/api/themes/me', { method: 'PUT', body: JSON.stringify({ theme_id: t.id }) }); location.reload(); } catch (err) { alert('Error: ' + err.message); } };
++ row.appendChild(use);
++ }
++ box.appendChild(row);
++ }
++}
++async function resetCustomColors(btn) {
++ if (btn) btn.disabled = true;
++ try {
++ await apiFetch('/api/themes/me', { method: 'PUT', body: JSON.stringify({ theme_id: activeThemeId, custom_variables: null }) });
++ activeCustomVars = {};
++ applyThemeToPage({ variables: activeThemeVars });
++ renderThemeCustomizer();
++ } catch (err) { alert('Error: ' + err.message); }
++ finally { if (btn) btn.disabled = false; }
++}
ddiff --git a/public/js/my/tools.js b/public/js/my/tools.js
nnew file mode 100644
iindex 00000000..7c281f0d
---- /dev/null
++++ b/public/js/my/tools.js
@@@ -0,0 +1,167 @@
++/* ═══════════════════════════════════════════════════════════════
++ OpenVibe.Network — account hub (/my): the dashboard showcase, your tools, data and modules.
++ Split out of my.html. Classic scripts, global scope: they rely on
++ my/core.js's helpers (apiFetch, getAuthToken, showSection, API) and on
++ my/boot.js (loaded last), which starts the page. No ES modules.
++ ═══════════════════════════════════════════════════════════════ */
++// Build dashboard showcase
++function buildDashboardShowcase() {
++ const grid = document.getElementById('my-showcase-grid');
++ const quickAccess = document.getElementById('my-quick-access');
++ if (!grid || !quickAccess) return;
++ const popular = [
++ { name: 'Live', icon: 'fa-tower-broadcast', url: 'https://openvibe.live' },
++ { name: 'Tools', icon: 'fa-screwdriver-wrench', url: 'https://openvibe.tools' },
++ { name: 'Games', icon: 'fa-gamepad', url: 'https://openvibe.games' },
++ { name: 'Media', icon: 'fa-photo-film', url: 'https://openvibe.media' },
++ ];
++ grid.innerHTML = popular.map(t => `<a href="${t.url}" class="showcase-card" title="${t.name}" target="_blank" rel="noopener"><i class="fa-solid ${t.icon} showcase-card-icon"></i><span class="showcase-card-label">${t.name}</span></a>`).join('');
++ const quickLinks = [
++ { label: 'Themes', icon: 'fa-palette', onclick: "showSection('themes')" },
++ { label: 'Profile', icon: 'fa-user', onclick: "showSection('profile')" },
++ { label: 'Accounts', icon: 'fa-arrows-rotate', onclick: "showSection('accounts')" },
++ { label: 'Linked', icon: 'fa-link', onclick: "showSection('linked')" },
++ { label: 'Security', icon: 'fa-lock', onclick: "showSection('security')" },
++ { label: 'Notifications', icon: 'fa-bell', onclick: "showSection('notifications')" },
++ ];
++ quickAccess.innerHTML = quickLinks.map(l => `<a class="quick-link" onclick="${l.onclick}"><i class="fa-solid ${l.icon}"></i>${l.label}</a>`).join('');
++}
++
++// Your tools: the tools.usage module (contracts 0.41.0, v2). OpenVibe.Tools keeps `recent`; `favorites`
++// is the person's own (starred here or in the Tools launcher). Names come from the public Tools
++// registry; a tool that is gone from it is skipped. Writes name the revision read (If-Match).
++let toolsUsage = null, toolsRegistry = null;
++async function saveToolFavorites(next) {
++ for (let attempt = 0; attempt < 2; attempt++) {
++ const data = { ...(toolsUsage ? toolsUsage.data : {}), favorites: next };
++ try {
++ toolsUsage = await apiFetch('/api/modules/tools.usage', { method: 'PUT', headers: { 'If-Match': String(toolsUsage ? toolsUsage.revision : 0) }, body: JSON.stringify({ data }) });
++ return true;
++ } catch (e) {
++ if (e.status !== 412) return false;
++ toolsUsage = await apiFetch('/api/modules/tools.usage').catch(() => null); // moved: read again, then retry
++ }
++ }
++ return false;
++}
++function renderYourTools() {
++ const box = document.getElementById('my-recent-tools');
++ const list = document.getElementById('my-recent-tools-list');
++ if (!box || !list || !toolsRegistry) return;
++ const data = toolsUsage && toolsUsage.data ? toolsUsage.data : {};
++ const favorites = Array.isArray(data.favorites) ? data.favorites : [];
++ const recent = Array.isArray(data.recent) ? data.recent : [];
++ const entries = [...favorites.map(tool => ({ tool, fav: true })), ...recent.filter(e => !favorites.includes(e.tool)).map(e => ({ ...e, fav: false }))];
++ list.replaceChildren();
++ for (const e of entries) {
++ const t = toolsRegistry.get(e.tool);
++ if (!t || list.childElementCount >= 16) continue;
++ const item = document.createElement('span');
++ item.className = 'recent-tool-item';
++ const a = document.createElement('a');
++ a.className = 'recent-tool';
++ a.href = t.hosts && t.hosts[0] ? 'https://' + t.hosts[0] : 'https://openvibe.tools/tool/' + encodeURIComponent(t.id);
++ a.target = '_blank'; a.rel = 'noopener';
++ a.textContent = t.name;
++ if (e.at) a.title = 'Last used ' + new Date(e.at).toLocaleString();
++ const star = document.createElement('button');
++ star.type = 'button';
++ star.className = 'recent-tool-star';
++ star.setAttribute('aria-pressed', String(e.fav));
++ star.setAttribute('aria-label', (e.fav ? 'Remove ' : 'Add ') + t.name + (e.fav ? ' from favourites' : ' to favourites'));
++ star.innerHTML = `<i class="fa-${e.fav ? 'solid' : 'regular'} fa-star"></i>`;
++ star.addEventListener('click', async () => {
++ star.disabled = true;
++ const next = e.fav ? favorites.filter(id => id !== e.tool) : [e.tool, ...favorites].slice(0, 24);
++ const ok = await saveToolFavorites(next);
++ const status = document.getElementById('my-recent-tools-status');
++ if (status) status.textContent = ok ? '' : 'Could not save your favourites. Try again.';
++ renderYourTools();
++ });
++ item.append(a, star);
++ list.appendChild(item);
++ }
++ box.hidden = list.childElementCount === 0;
++}
++async function loadRecentTools() {
++ try {
++ toolsUsage = await apiFetch('/api/modules/tools.usage').catch(() => null);
++ const d = toolsUsage && toolsUsage.data ? toolsUsage.data : {};
++ if (!(d.recent || []).length && !(d.favorites || []).length) return;
++ const reg = await fetchWithTimeout('https://openvibe.tools/api/v1/tools', {}, 8000).then(r => (r.ok ? r.json() : { tools: [] }));
++ toolsRegistry = new Map((reg.tools || []).map(t => [t.id, t]));
++ renderYourTools();
++ } catch { /* optional */ }
++}
++
++// AI & Data: the ai.preferences user module (read by OpenVibe.AI for runs made on your behalf) and the list
++// of every module record kept with this account (GET /api/modules), each deletable, all downloadable.
++let aiPrefsRecord = null, modulesExport = null;
++async function loadDataSection() {
++ const status = document.getElementById('ai-prefs-status');
++ try {
++ aiPrefsRecord = await apiFetch('/api/modules/ai.preferences').catch((e) => { if (e.status === 404) return { revision: 0, data: {} }; throw e; });
++ const d = aiPrefsRecord.data || {};
++ document.getElementById('ai-style').value = d.style || '';
++ document.getElementById('ai-length').value = d.length || '';
++ document.getElementById('ai-perspective').value = d.perspective || '';
++ document.getElementById('ai-history').checked = d.history !== false;
++ } catch { if (status) status.textContent = 'Could not load your AI preferences.'; }
++ loadModules();
++}
++async function saveAiPrefs(event) {
++ event.preventDefault();
++ const status = document.getElementById('ai-prefs-status');
++ const data = { ...((aiPrefsRecord && aiPrefsRecord.data) || {}) };
++ for (const k of ['style', 'length', 'perspective']) {
++ const v = document.getElementById('ai-' + k).value.trim();
++ if (v) data[k] = v; else delete data[k];
++ }
++ if (document.getElementById('ai-history').checked) delete data.history; else data.history = false;
++ try {
++ aiPrefsRecord = await apiFetch('/api/modules/ai.preferences', { method: 'PUT', headers: { 'If-Match': String(aiPrefsRecord ? aiPrefsRecord.revision : 0) }, body: JSON.stringify({ data }) });
++ status.textContent = 'Saved.';
++ loadModules();
++ } catch (e) {
++ status.textContent = e.status === 412 ? 'Changed somewhere else: reloaded. Save again.' : 'Could not save. Try again.';
++ if (e.status === 412) loadDataSection();
++ }
++}
++async function loadModules() {
++ const box = document.getElementById('modules-list');
++ if (!box) return;
++ try {
++ modulesExport = await apiFetch('/api/modules');
++ const about = new Map((modulesExport.namespaces || []).map((n) => [n.namespace, n]));
++ box.replaceChildren();
++ if (!(modulesExport.modules || []).length) { const p = document.createElement('p'); p.textContent = 'Nothing yet.'; box.appendChild(p); return; }
++ for (const m of modulesExport.modules) {
++ const row = document.createElement('div'); row.className = 'module-row';
++ const main = document.createElement('div'); main.className = 'module-row-main';
++ const title = document.createElement('strong'); title.textContent = m.namespace;
++ const info = document.createElement('small');
++ const n = about.get(m.namespace);
++ const keeper = n && n.owner ? 'OpenVibe.' + n.owner.charAt(0).toUpperCase() + n.owner.slice(1) : 'OpenVibe';
++ info.textContent = `${n && n.description ? n.description + ' ' : ''}Kept by ${keeper}; updated ${m.updated_at ? new Date(String(m.updated_at).replace(' ', 'T') + (String(m.updated_at).includes('Z') ? '' : 'Z')).toLocaleString() : 'recently'}.`;
++ const code = document.createElement('code'); code.textContent = JSON.stringify(m.data);
++ main.append(title, info, code);
++ const del = document.createElement('button'); del.type = 'button'; del.className = 'btn btn-small btn-danger';
++ del.textContent = 'Delete'; del.setAttribute('aria-label', 'Delete ' + m.namespace);
++ del.addEventListener('click', async () => {
++ if (!confirm(`Delete ${m.namespace}? A site may write its summary again the next time you use it.`)) return;
++ try { await apiFetch('/api/modules/' + encodeURIComponent(m.namespace), { method: 'DELETE' }); } catch { /* shown by the reload */ }
++ if (m.namespace === 'ai.preferences') loadDataSection(); else loadModules();
++ });
++ row.append(main, del);
++ box.appendChild(row);
++ }
++ } catch { box.textContent = 'Could not load your data. Try again later.'; }
++}
++function exportModules() {
++ if (!modulesExport) return;
++ const blob = new Blob([JSON.stringify({ exported_at: new Date().toISOString(), subject: modulesExport.subject, modules: modulesExport.modules }, null, 2)], { type: 'application/json' });
++ const a = document.createElement('a');
++ a.href = URL.createObjectURL(blob); a.download = 'openvibe-account-modules.json';
++ document.body.appendChild(a); a.click(); a.remove();
++ setTimeout(() => URL.revokeObjectURL(a.href), 1000);
++}
ddiff --git a/public/my.html b/public/my.html
iindex 77e82aa4..ac825a71 100644
---- a/public/my.html
++++ b/public/my.html
@@@ -581,1417 +581,12 @@ a.linked-item:hover{background:var(--bg-hover,var(--bg-card));transform:translat
<script src="/shared/notification-ui.js"></script>
<script src="/shared/account-switcher.js"></script>
--<script>
--const API = window.location.origin;
--let currentUser = null;
--let availableThemes = [];
--let activeThemeId = 'vibe';
--const DEFAULT_EMAIL_HINT = 'Used for password resets and any notification emails you enable below.';
--
--const LOCAL_THEME_MAP = {
-- '--bg': '--bg-primary',
-- '--bg-secondary': '--bg-secondary',
-- '--bg-card': '--bg-card',
-- '--bg-input': '--bg-input',
-- '--border': '--border',
-- '--accent': '--accent',
-- '--accent-light': '--accent-light',
-- '--accent-dark': '--accent-dark',
-- '--text': '--text-primary',
-- '--text-secondary': '--text-secondary',
-- '--text-muted': '--text-muted',
-- '--on-accent': '--on-accent',
--};
--
--function getStoredAccounts() {
-- try { return JSON.parse(localStorage.getItem('openvibe_accounts') || '[]'); }
-- catch { return []; }
--}
--
--function getActiveAccount() {
-- const activeId = localStorage.getItem('openvibe_active_account');
-- const accounts = getStoredAccounts();
-- if (activeId === 'anon') return accounts.find(a => a?.is_anon) || null;
-- return accounts.find(a => String(a?.id) === String(activeId)) || null;
--}
--
--function isAnonSession() {
-- return !!getActiveAccount()?.is_anon || localStorage.getItem('openvibe_active_account') === 'anon';
--}
--
--/** Read ov_token from the host-only cookie (no Domain attribute — CONTRACTS.md) */
--function _getCookieToken() {
-- const m = document.cookie.match(/(?:^|; )ov_token=([^;]*)/);
-- return m ? decodeURIComponent(m[1]) : null;
--}
--
--function getAuthToken() {
-- if (isAnonSession()) return null;
-- return localStorage.getItem('ov_token') || getActiveAccount()?.token || _getCookieToken() || null;
--}
--
--function getAnonToken() {
-- return localStorage.getItem('openvibe_anon_token') || (getActiveAccount()?.is_anon ? getActiveAccount()?.token : null) || null;
--}
--
--/**
-- * If localStorage has no token but the cross-domain cookie does,
-- * hydrate localStorage so future getAuthToken() calls are instant.
-- * Also bootstraps the account list from /api/auth/me.
-- * If the token is expired, attempts a silent refresh.
-- */
--async function _bootstrapFromCookie() {
-- if (localStorage.getItem('ov_token')) return; // already have it
-- const cookieToken = _getCookieToken();
-- if (!cookieToken) return;
-- // Store the token in localStorage for this origin
-- localStorage.setItem('ov_token', cookieToken);
-- // Try to populate the account list
-- try {
-- const res = await fetchWithTimeout(API + '/api/auth/me', {
-- headers: { Authorization: 'Bearer ' + cookieToken, 'Content-Type': 'application/json' },
-- credentials: 'include',
-- });
-- if (res.ok) {
-- const data = await res.json();
-- const user = data.user || data;
-- // Bootstrap openvibe_accounts + openvibe_active_account
-- const accounts = [{ id: user.id, username: user.username, display_name: user.display_name || user.username, avatar_url: user.avatar_url || null, email: user.email || null, is_anon: false, token: cookieToken, added_at: Date.now() }];
-- localStorage.setItem('openvibe_accounts', JSON.stringify(accounts));
-- localStorage.setItem('openvibe_active_account', String(user.id));
-- } else if (res.status === 401) {
-- // Token expired — try refresh
-- const refreshRes = await fetchWithTimeout(API + '/api/auth/refresh', {
-- method: 'POST',
-- headers: { Authorization: 'Bearer ' + cookieToken, 'Content-Type': 'application/json' },
-- credentials: 'include',
-- });
-- if (refreshRes.ok) {
-- const refreshData = await refreshRes.json();
-- if (refreshData.token) {
-- localStorage.setItem('ov_token', refreshData.token);
-- document.cookie = `ov_token=${refreshData.token};path=/;max-age=${60*60*24*30};SameSite=Lax${location.protocol === 'https:' ? ';Secure' : ''}`;
-- const user = refreshData.user;
-- if (user) {
-- const accounts = [{ id: user.id, username: user.username, display_name: user.display_name || user.username, avatar_url: user.avatar_url || null, email: user.email || null, is_anon: false, token: refreshData.token, added_at: Date.now() }];
-- localStorage.setItem('openvibe_accounts', JSON.stringify(accounts));
-- localStorage.setItem('openvibe_active_account', String(user.id));
-- }
-- }
-- } else {
-- // Refresh failed — clear stale cookie
-- localStorage.removeItem('ov_token');
-- document.cookie = 'ov_token=;path=/;max-age=0';
-- }
-- }
-- } catch { /* cookie may be expired — loadUser will handle redirect */ }
--}
--
--// fetch with an abort timeout so a weak connection can't hang the page forever.
--async function fetchWithTimeout(url, opts = {}, ms = 15000) {
-- const ctrl = new AbortController();
-- const timer = setTimeout(() => ctrl.abort(), ms);
-- try {
-- return await fetch(url, { ...opts, signal: ctrl.signal });
-- } catch (e) {
-- const err = new Error(e && e.name === 'AbortError'
-- ? 'Request timed out — check your connection'
-- : 'Network error — check your connection');
-- err.network = true;
-- throw err;
-- } finally {
-- clearTimeout(timer);
-- }
--}
--
--async function apiFetch(path, opts = {}) {
-- const headers = { 'Content-Type':'application/json', ...opts.headers };
-- const authToken = getAuthToken();
-- if (authToken) headers.Authorization = 'Bearer ' + authToken;
-- const res = await fetchWithTimeout(API + path, { ...opts, headers, credentials: 'include' });
-- // Sliding sessions: the server hands back a renewed token when the old one is due.
-- try { const fresh = res.headers.get('X-OV-Token'); if (fresh) { localStorage.setItem('ov_token', fresh); document.cookie = `ov_token=${fresh};path=/;max-age=${60*60*24*90};SameSite=Lax${location.protocol === 'https:' ? ';Secure' : ''}`; } } catch { /* */ }
-- let data = {};
-- try { data = await res.json(); } catch { /* non-JSON (e.g. proxy error page) */ }
-- if (!res.ok) { const err = new Error(data.error || res.statusText || 'Request failed'); err.status = res.status; throw err; }
-- return data;
--}
--
--async function anonFetch(path, opts = {}) {
-- const headers = { 'Content-Type':'application/json', ...opts.headers };
-- const res = await fetchWithTimeout(API + path, { ...opts, headers, credentials: 'include' });
-- let data = {};
-- try { data = await res.json(); } catch { /* non-JSON */ }
-- if (!res.ok) { const err = new Error(data.error || res.statusText || 'Request failed'); err.status = res.status; throw err; }
-- return data;
--}
--
--// Build dashboard showcase
--function buildDashboardShowcase() {
-- const grid = document.getElementById('my-showcase-grid');
-- const quickAccess = document.getElementById('my-quick-access');
-- if (!grid || !quickAccess) return;
-- const popular = [
-- { name: 'Live', icon: 'fa-tower-broadcast', url: 'https://openvibe.live' },
-- { name: 'Tools', icon: 'fa-screwdriver-wrench', url: 'https://openvibe.tools' },
-- { name: 'Games', icon: 'fa-gamepad', url: 'https://openvibe.games' },
-- { name: 'Media', icon: 'fa-photo-film', url: 'https://openvibe.media' },
-- ];
-- grid.innerHTML = popular.map(t => `<a href="${t.url}" class="showcase-card" title="${t.name}" target="_blank" rel="noopener"><i class="fa-solid ${t.icon} showcase-card-icon"></i><span class="showcase-card-label">${t.name}</span></a>`).join('');
-- const quickLinks = [
-- { label: 'Themes', icon: 'fa-palette', onclick: "showSection('themes')" },
-- { label: 'Profile', icon: 'fa-user', onclick: "showSection('profile')" },
-- { label: 'Accounts', icon: 'fa-arrows-rotate', onclick: "showSection('accounts')" },
-- { label: 'Linked', icon: 'fa-link', onclick: "showSection('linked')" },
-- { label: 'Security', icon: 'fa-lock', onclick: "showSection('security')" },
-- { label: 'Notifications', icon: 'fa-bell', onclick: "showSection('notifications')" },
-- ];
-- quickAccess.innerHTML = quickLinks.map(l => `<a class="quick-link" onclick="${l.onclick}"><i class="fa-solid ${l.icon}"></i>${l.label}</a>`).join('');
--}
--
--// Your tools: the tools.usage module (contracts 0.41.0, v2). OpenVibe.Tools keeps `recent`; `favorites`
--// is the person's own (starred here or in the Tools launcher). Names come from the public Tools
--// registry; a tool that is gone from it is skipped. Writes name the revision read (If-Match).
--let toolsUsage = null, toolsRegistry = null;
--async function saveToolFavorites(next) {
-- for (let attempt = 0; attempt < 2; attempt++) {
-- const data = { ...(toolsUsage ? toolsUsage.data : {}), favorites: next };
-- try {
-- toolsUsage = await apiFetch('/api/modules/tools.usage', { method: 'PUT', headers: { 'If-Match': String(toolsUsage ? toolsUsage.revision : 0) }, body: JSON.stringify({ data }) });
-- return true;
-- } catch (e) {
-- if (e.status !== 412) return false;
-- toolsUsage = await apiFetch('/api/modules/tools.usage').catch(() => null); // moved: read again, then retry
-- }
-- }
-- return false;
--}
--function renderYourTools() {
-- const box = document.getElementById('my-recent-tools');
-- const list = document.getElementById('my-recent-tools-list');
-- if (!box || !list || !toolsRegistry) return;
-- const data = toolsUsage && toolsUsage.data ? toolsUsage.data : {};
-- const favorites = Array.isArray(data.favorites) ? data.favorites : [];
-- const recent = Array.isArray(data.recent) ? data.recent : [];
-- const entries = [...favorites.map(tool => ({ tool, fav: true })), ...recent.filter(e => !favorites.includes(e.tool)).map(e => ({ ...e, fav: false }))];
-- list.replaceChildren();
-- for (const e of entries) {
-- const t = toolsRegistry.get(e.tool);
-- if (!t || list.childElementCount >= 16) continue;
-- const item = document.createElement('span');
-- item.className = 'recent-tool-item';
-- const a = document.createElement('a');
-- a.className = 'recent-tool';
-- a.href = t.hosts && t.hosts[0] ? 'https://' + t.hosts[0] : 'https://openvibe.tools/tool/' + encodeURIComponent(t.id);
-- a.target = '_blank'; a.rel = 'noopener';
-- a.textContent = t.name;
-- if (e.at) a.title = 'Last used ' + new Date(e.at).toLocaleString();
-- const star = document.createElement('button');
-- star.type = 'button';
-- star.className = 'recent-tool-star';
-- star.setAttribute('aria-pressed', String(e.fav));
-- star.setAttribute('aria-label', (e.fav ? 'Remove ' : 'Add ') + t.name + (e.fav ? ' from favourites' : ' to favourites'));
-- star.innerHTML = `<i class="fa-${e.fav ? 'solid' : 'regular'} fa-star"></i>`;
-- star.addEventListener('click', async () => {
-- star.disabled = true;
-- const next = e.fav ? favorites.filter(id => id !== e.tool) : [e.tool, ...favorites].slice(0, 24);
-- const ok = await saveToolFavorites(next);
-- const status = document.getElementById('my-recent-tools-status');
-- if (status) status.textContent = ok ? '' : 'Could not save your favourites. Try again.';
-- renderYourTools();
-- });
-- item.append(a, star);
-- list.appendChild(item);
-- }
-- box.hidden = list.childElementCount === 0;
--}
--async function loadRecentTools() {
-- try {
-- toolsUsage = await apiFetch('/api/modules/tools.usage').catch(() => null);
-- const d = toolsUsage && toolsUsage.data ? toolsUsage.data : {};
-- if (!(d.recent || []).length && !(d.favorites || []).length) return;
-- const reg = await fetchWithTimeout('https://openvibe.tools/api/v1/tools', {}, 8000).then(r => (r.ok ? r.json() : { tools: [] }));
-- toolsRegistry = new Map((reg.tools || []).map(t => [t.id, t]));
-- renderYourTools();
-- } catch { /* optional */ }
--}
--
--// AI & Data: the ai.preferences user module (read by OpenVibe.AI for runs made on your behalf) and the list
--// of every module record kept with this account (GET /api/modules), each deletable, all downloadable.
--let aiPrefsRecord = null, modulesExport = null;
--async function loadDataSection() {
-- const status = document.getElementById('ai-prefs-status');
-- try {
-- aiPrefsRecord = await apiFetch('/api/modules/ai.preferences').catch((e) => { if (e.status === 404) return { revision: 0, data: {} }; throw e; });
-- const d = aiPrefsRecord.data || {};
-- document.getElementById('ai-style').value = d.style || '';
-- document.getElementById('ai-length').value = d.length || '';
-- document.getElementById('ai-perspective').value = d.perspective || '';
-- document.getElementById('ai-history').checked = d.history !== false;
-- } catch { if (status) status.textContent = 'Could not load your AI preferences.'; }
-- loadModules();
--}
--async function saveAiPrefs(event) {
-- event.preventDefault();
-- const status = document.getElementById('ai-prefs-status');
-- const data = { ...((aiPrefsRecord && aiPrefsRecord.data) || {}) };
-- for (const k of ['style', 'length', 'perspective']) {
-- const v = document.getElementById('ai-' + k).value.trim();
-- if (v) data[k] = v; else delete data[k];
-- }
-- if (document.getElementById('ai-history').checked) delete data.history; else data.history = false;
-- try {
-- aiPrefsRecord = await apiFetch('/api/modules/ai.preferences', { method: 'PUT', headers: { 'If-Match': String(aiPrefsRecord ? aiPrefsRecord.revision : 0) }, body: JSON.stringify({ data }) });
-- status.textContent = 'Saved.';
-- loadModules();
-- } catch (e) {
-- status.textContent = e.status === 412 ? 'Changed somewhere else: reloaded. Save again.' : 'Could not save. Try again.';
-- if (e.status === 412) loadDataSection();
-- }
--}
--async function loadModules() {
-- const box = document.getElementById('modules-list');
-- if (!box) return;
-- try {
-- modulesExport = await apiFetch('/api/modules');
-- const about = new Map((modulesExport.namespaces || []).map((n) => [n.namespace, n]));
-- box.replaceChildren();
-- if (!(modulesExport.modules || []).length) { const p = document.createElement('p'); p.textContent = 'Nothing yet.'; box.appendChild(p); return; }
-- for (const m of modulesExport.modules) {
-- const row = document.createElement('div'); row.className = 'module-row';
-- const main = document.createElement('div'); main.className = 'module-row-main';
-- const title = document.createElement('strong'); title.textContent = m.namespace;
-- const info = document.createElement('small');
-- const n = about.get(m.namespace);
-- const keeper = n && n.owner ? 'OpenVibe.' + n.owner.charAt(0).toUpperCase() + n.owner.slice(1) : 'OpenVibe';
-- info.textContent = `${n && n.description ? n.description + ' ' : ''}Kept by ${keeper}; updated ${m.updated_at ? new Date(String(m.updated_at).replace(' ', 'T') + (String(m.updated_at).includes('Z') ? '' : 'Z')).toLocaleString() : 'recently'}.`;
-- const code = document.createElement('code'); code.textContent = JSON.stringify(m.data);
-- main.append(title, info, code);
-- const del = document.createElement('button'); del.type = 'button'; del.className = 'btn btn-small btn-danger';
-- del.textContent = 'Delete'; del.setAttribute('aria-label', 'Delete ' + m.namespace);
-- del.addEventListener('click', async () => {
-- if (!confirm(`Delete ${m.namespace}? A site may write its summary again the next time you use it.`)) return;
-- try { await apiFetch('/api/modules/' + encodeURIComponent(m.namespace), { method: 'DELETE' }); } catch { /* shown by the reload */ }
-- if (m.namespace === 'ai.preferences') loadDataSection(); else loadModules();
-- });
-- row.append(main, del);
-- box.appendChild(row);
-- }
-- } catch { box.textContent = 'Could not load your data. Try again later.'; }
--}
--function exportModules() {
-- if (!modulesExport) return;
-- const blob = new Blob([JSON.stringify({ exported_at: new Date().toISOString(), subject: modulesExport.subject, modules: modulesExport.modules }, null, 2)], { type: 'application/json' });
-- const a = document.createElement('a');
-- a.href = URL.createObjectURL(blob); a.download = 'openvibe-account-modules.json';
-- document.body.appendChild(a); a.click(); a.remove();
-- setTimeout(() => URL.revokeObjectURL(a.href), 1000);
--}
--
--// Section switching
--function showSection(id, btn) {
-- document.querySelectorAll('.section').forEach(s => s.classList.remove('active'));
-- document.querySelectorAll('.section-tabs button').forEach(b => b.classList.remove('active'));
-- document.getElementById('sec-' + id).classList.add('active');
-- const targetBtn = btn || [...document.querySelectorAll('.section-tabs button')].find((button) => button.getAttribute('onclick')?.includes(`'${id}'`));
-- targetBtn?.classList.add('active');
-- history.replaceState(null, '', id === 'profile' ? window.location.pathname : `${window.location.pathname}#${id}`);
-- if (id === 'history' && typeof loadHistory === 'function') loadHistory();
-- if (id === 'data') loadDataSection();
--}
--
--function avatarPlaceholder(user, size = 96) {
-- const label = (user?.display_name || user?.username || 'H').trim();
-- const initial = label.charAt(0).toUpperCase() || 'H';
-- const bg = user?.profile_color || '#8b5cf6';
-- const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 ${size} ${size}"><rect width="100%" height="100%" rx="${Math.round(size / 2)}" fill="${bg}"/><text x="50%" y="54%" dominant-baseline="middle" text-anchor="middle" font-family="Inter, Arial, sans-serif" font-size="${Math.round(size * 0.42)}" font-weight="700" fill="#fff">${initial}</text></svg>`;
-- return `data:image/svg+xml;charset=UTF-8,${encodeURIComponent(svg)}`;
--}
--
--function avatarSrc(user, size = 96) {
-- return user?.avatar_url || avatarPlaceholder(user, size);
--}
--
--function themePreviewColors(theme) {
-- const vars = theme?.variables || {};
-- const preview = Array.isArray(theme?.preview_colors) && theme.preview_colors.length
-- ? theme.preview_colors
-- : [vars['--bg-primary'], vars['--accent'], vars['--bg-card']].filter(Boolean);
-- return [preview[0] || '#202331', preview[1] || '#8b5cf6', preview[2] || '#181a24'];
--}
--
--function applyThemeToPage(theme) {
-- const vars = theme?.variables || {};
-- // The shared loader applies every token (the canvas behind the page, --on-accent, the browser UI);
-- // this page's own short names follow below.
-- if (typeof OpenVibeThemeLoader !== 'undefined' && OpenVibeThemeLoader.applyVars) OpenVibeThemeLoader.applyVars(vars);
-- Object.entries(LOCAL_THEME_MAP).forEach(([localVar, themeVar]) => {
-- const value = vars[themeVar];
-- if (value) document.documentElement.style.setProperty(localVar, value);
-- });
--}
--
--function updateThemeSummary(theme) {
-- const currentName = document.getElementById('theme-current-name');
-- const swatches = document.querySelectorAll('#theme-current-swatch span');
-- if (!currentName || swatches.length < 3) return;
-- currentName.textContent = theme?.name || 'Vibe';
-- themePreviewColors(theme).forEach((color, index) => {
-- if (swatches[index]) swatches[index].style.background = color;
-- });
--}
--
--function renderThemes() {
-- const grid = document.getElementById('theme-grid');
-- if (!grid) return;
-- if (!availableThemes.length) {
-- grid.innerHTML = '<div class="theme-empty">No themes available right now.</div>';
-- return;
-- }
-- grid.innerHTML = availableThemes.map((theme) => {
-- const [bg1, accent, bg2] = themePreviewColors(theme);
-- const isActive = String(theme.id) === String(activeThemeId) || String(theme.slug) === String(activeThemeId);
-- return `
-- <article class="theme-card ${isActive ? 'active' : ''}">
-- <div class="theme-preview" style="--theme-bg-1:${bg1};--theme-bg-2:${bg2}">
-- <div class="theme-preview-top">
-- <div class="theme-preview-swatch"><span style="background:${bg1}"></span><span style="background:${accent}"></span><span style="background:${bg2}"></span></div>
-- <span class="theme-preview-chip">${theme.mode || 'dark'}</span>
-- </div>
-- <div class="theme-preview-bars"><span style="background:${accent}"></span><span></span></div>
-- </div>
-- <div>
-- <h4>${theme.name}</h4>
-- <p>${theme.description || 'A synced network theme.'}</p>
-- </div>
-- <div class="theme-card-footer">
-- <div class="theme-badges">
-- ${theme.is_builtin ? '<span class="theme-badge">Built-in</span>' : '<span class="theme-badge">Community</span>'}
-- </div>
-- <button class="btn ${isActive ? 'btn-outline' : 'btn-primary'}" onclick="setTheme('${theme.id}')">${isActive ? 'Active' : 'Use theme'}</button>
-- </div>
-- </article>
-- `;
-- }).join('');
-- updateThemeSummary(availableThemes.find((theme) => String(theme.id) === String(activeThemeId) || String(theme.slug) === String(activeThemeId)) || availableThemes[0]);
--}
--
--async function loadThemes() {
-- const grid = document.getElementById('theme-grid');
-- if (!grid) return;
-- try {
-- const [themesData, activeData] = await Promise.all([
-- apiFetch('/api/themes?limit=100'),
-- apiFetch('/api/themes/me/active'),
-- ]);
-- availableThemes = themesData.themes || [];
-- activeThemeId = activeData.theme_id || 'vibe';
-- activeCustomVars = (activeData.custom_variables && typeof activeData.custom_variables === 'object') ? activeData.custom_variables : {};
-- const activeTheme = availableThemes.find((theme) => String(theme.id) === String(activeThemeId) || String(theme.slug) === String(activeThemeId));
-- activeThemeVars = (activeTheme && activeTheme.variables) || {};
-- if (activeTheme) applyThemeToPage(activeTheme);
-- if (Object.keys(activeCustomVars).length) applyThemeToPage({ variables: Object.assign({}, activeThemeVars, activeCustomVars) });
-- renderThemes();
-- renderThemeCustomizer();
-- } catch (err) {
-- console.error('Failed to load themes:', err);
-- grid.innerHTML = '<div class="theme-empty">Unable to load themes right now.</div>';
-- }
--}
--
--async function setTheme(themeId) {
-- try {
-- await apiFetch('/api/themes/me', { method: 'PUT', body: JSON.stringify({ theme_id: themeId }) });
-- activeThemeId = themeId;
-- activeCustomVars = {}; // picking a preset clears custom overrides
-- const activeTheme = availableThemes.find((theme) => String(theme.id) === String(themeId) || String(theme.slug) === String(themeId));
-- if (activeTheme) {
-- activeThemeVars = activeTheme.variables || {};
-- renderThemeCustomizer();
-- applyThemeToPage(activeTheme);
-- localStorage.setItem('ov_theme', JSON.stringify({ id: activeTheme.id, slug: activeTheme.slug, name: activeTheme.name, variables: activeTheme.variables || {} }));
-- // Sync theme choice via cookie + OpenVibeThemeLoader (host-only cookie here)
-- if (typeof OpenVibeThemeLoader !== 'undefined') OpenVibeThemeLoader.save(activeTheme.slug || activeTheme.id, activeTheme.variables);
-- else document.cookie = `ov_theme_id=${encodeURIComponent(activeTheme.slug || activeTheme.id)};path=/;max-age=${365*24*60*60};SameSite=Lax${location.protocol === 'https:' ? ';Secure' : ''}`;
-- }
-- renderThemes();
-- } catch (err) {
-- alert('Error: ' + err.message);
-- }
--}
--
--// ── Custom color editor ──────────────────────────────────────
--let activeCustomVars = {};
--let activeThemeVars = {};
--const THEME_EDITABLE = [
-- ['--bg-primary', 'Background'], ['--bg-secondary', 'Surface'], ['--bg-card', 'Card'],
-- ['--bg-hover', 'Hover'], ['--accent', 'Accent'], ['--accent-light', 'Accent light'],
-- ['--text-primary', 'Text'], ['--text-secondary', 'Muted text'], ['--border', 'Border'],
--];
--function _hexOrDefault(v) {
-- if (typeof v === 'string' && /^#[0-9a-fA-F]{6}$/.test(v.trim())) return v.trim();
-- if (typeof v === 'string' && /^#[0-9a-fA-F]{3}$/.test(v.trim())) {
-- const h = v.trim().slice(1); return '#' + h.split('').map(c => c + c).join('');
-- }
-- return '#888888';
--}
--function renderThemeCustomizer() {
-- const grid = document.getElementById('theme-custom-grid');
-- if (!grid) return;
-- grid.innerHTML = THEME_EDITABLE.map(([varName, label]) => {
-- const val = _hexOrDefault(activeCustomVars[varName] || activeThemeVars[varName]);
-- return `<label class="theme-custom-row">
-- <input type="color" value="${val}" data-var="${varName}" oninput="_onCustomColorInput(this)">
-- <span>${label}</span>
-- </label>`;
-- }).join('');
--}
--function toggleThemeCustomizer() {
-- const box = document.getElementById('theme-customizer');
-- const btn = document.getElementById('theme-custom-toggle');
-- if (!box) return;
-- const show = box.style.display === 'none';
-- box.style.display = show ? '' : 'none';
-- if (btn) btn.innerHTML = show ? '<i class="fa-solid fa-chevron-up"></i> Hide' : '<i class="fa-solid fa-pen"></i> Customize';
-- if (show) renderThemeCustomizer();
--}
--function _onCustomColorInput(input) {
-- const v = input.getAttribute('data-var');
-- activeCustomVars[v] = input.value;
-- // Live preview on this page.
-- applyThemeToPage({ variables: Object.assign({}, activeThemeVars, activeCustomVars) });
--}
--async function saveCustomColors(btn) {
-- if (btn) btn.disabled = true;
-- try {
-- await apiFetch('/api/themes/me', { method: 'PUT', body: JSON.stringify({ theme_id: activeThemeId, custom_variables: activeCustomVars }) });
-- const merged = Object.assign({}, activeThemeVars, activeCustomVars);
-- try { localStorage.setItem('ov_theme', JSON.stringify({ id: activeThemeId, variables: merged })); } catch {}
-- if (typeof OpenVibeThemeLoader !== 'undefined') OpenVibeThemeLoader.save(activeThemeId, merged);
-- alert('Custom theme saved — it will sync across the network.');
-- } catch (err) { alert('Error: ' + err.message); }
-- finally { if (btn) btn.disabled = false; }
--}
--// ── Share a theme (export, import, submit for review; WS-E task 2) ──
--async function exportActiveTheme(btn) {
-- if (btn) btn.disabled = true;
-- try {
-- const res = await fetch('/api/themes/' + encodeURIComponent(activeThemeId) + '/export', { credentials: 'include' });
-- if (!res.ok) throw new Error('This theme cannot be exported');
-- const file = await res.json();
-- if (activeCustomVars && Object.keys(activeCustomVars).length) file.variables = Object.assign({}, file.variables, activeCustomVars);
-- const a = document.createElement('a');
-- a.href = URL.createObjectURL(new Blob([JSON.stringify(file, null, 2)], { type: 'application/json' }));
-- a.download = (file.slug || 'theme') + '.openvibe-theme.json';
-- document.body.appendChild(a); a.click(); a.remove();
-- setTimeout(() => URL.revokeObjectURL(a.href), 1000);
-- } catch (err) { alert('Error: ' + err.message); }
-- finally { if (btn) btn.disabled = false; }
--}
--async function importThemeFile(input) {
-- const f = input.files && input.files[0];
-- input.value = '';
-- if (!f) return;
-- try {
-- const file = JSON.parse(await f.text());
-- const out = await apiFetch('/api/themes/import', { method: 'POST', body: JSON.stringify(file) });
-- alert('Imported "' + out.theme.name + '". It is waiting for review; you can use it now from your submissions.');
-- loadThemeSubmissions();
-- } catch (err) { alert('Import failed: ' + err.message); }
--}
--async function submitMyTheme(ev) {
-- ev.preventDefault();
-- const name = document.getElementById('theme-submit-name').value.trim();
-- const slug = document.getElementById('theme-submit-slug').value.trim();
-- const variables = Object.assign({}, activeThemeVars, activeCustomVars);
-- try {
-- await apiFetch('/api/themes', { method: 'POST', body: JSON.stringify({ name, slug, variables, mode: document.documentElement.dataset.mode === 'light' ? 'light' : 'dark' }) });
-- ev.target.reset();
-- loadThemeSubmissions();
-- } catch (err) { alert('Not submitted: ' + err.message); }
--}
--async function loadThemeSubmissions() {
-- const box = document.getElementById('theme-submissions');
-- if (!box) return;
-- let list = [];
-- try { list = (await apiFetch('/api/themes/me/submissions')).themes || []; } catch { return; }
-- box.replaceChildren();
-- if (!list.length) return;
-- const h = document.createElement('div');
-- h.textContent = 'Your submissions';
-- h.style.cssText = 'font-weight:600;margin-bottom:6px';
-- box.appendChild(h);
-- const labels = { pending: 'Waiting for review', approved: 'Approved', rejected: 'Not accepted' };
-- for (const t of list) {
-- const row = document.createElement('div');
-- row.style.cssText = 'display:flex;gap:10px;align-items:center;flex-wrap:wrap;padding:6px 0;border-top:1px solid var(--border)';
-- const n = document.createElement('strong'); n.textContent = t.name;
-- const st = document.createElement('span'); st.textContent = labels[t.review_status] || t.review_status;
-- st.style.cssText = 'font-size:0.8rem;color:var(--text-secondary)';
-- row.append(n, st);
-- if (t.review_note) { const note = document.createElement('span'); note.textContent = '— ' + t.review_note; note.style.cssText = 'font-size:0.8rem;color:var(--text-muted)'; row.appendChild(note); }
-- if (t.review_status !== 'rejected') {
-- const use = document.createElement('button'); use.type = 'button'; use.className = 'btn btn-outline'; use.textContent = 'Use';
-- use.onclick = async () => { try { await apiFetch('/api/themes/me', { method: 'PUT', body: JSON.stringify({ theme_id: t.id }) }); location.reload(); } catch (err) { alert('Error: ' + err.message); } };
-- row.appendChild(use);
-- }
-- box.appendChild(row);
-- }
--}
--async function resetCustomColors(btn) {
-- if (btn) btn.disabled = true;
-- try {
-- await apiFetch('/api/themes/me', { method: 'PUT', body: JSON.stringify({ theme_id: activeThemeId, custom_variables: null }) });
-- activeCustomVars = {};
-- applyThemeToPage({ variables: activeThemeVars });
-- renderThemeCustomizer();
-- } catch (err) { alert('Error: ' + err.message); }
-- finally { if (btn) btn.disabled = false; }
--}
--
--function openInitialSection() {
-- const pathSection = { '/themes': 'themes', '/notifications': 'notifications', '/verify-email': 'notifications', '/linked': 'linked', '/security': 'security', '/profile': 'profile', '/history': 'history' }[window.location.pathname];
-- const routeSection = pathSection || window.location.hash.replace(/^#/, '') || 'profile';
-- if (window.location.pathname === '/verify-email') setTimeout(handleVerifyEmailToken, 50);
-- if (isAnonSession() && ['notifications','linked','security','data'].includes(routeSection)) {
-- return showSection('anon');
-- }
-- if (document.getElementById('sec-' + routeSection)) {
-- showSection(routeSection);
-- }
--}
--
--function setSectionDisabled(sectionId, message) {
-- const section = document.getElementById('sec-' + sectionId);
-- if (!section) return;
-- section.innerHTML = `
-- <div class="card">
-- <h3><span class="icon"><i class="fa-solid fa-user-secret"></i></span> Anonymous Mode</h3>
-- <p>${message}</p>
-- </div>
-- `;
--}
--
--function applyAnonModeUI(user) {
-- document.getElementById('pf-bio').disabled = true;
-- document.getElementById('pf-email').disabled = true;
-- document.getElementById('pf-color').disabled = true;
-- document.getElementById('pf-bio').value = '';
-- document.getElementById('pf-email').value = '';
-- document.getElementById('pf-bio').placeholder = 'Anonymous mode does not store bios.';
-- document.getElementById('pf-email').placeholder = 'Anonymous mode does not use email addresses';
-- const emailHint = document.querySelector('#sec-profile .hint');
-- if (emailHint) emailHint.textContent = 'Anonymous mode only stores lightweight preferences like your display name.';
-- const saveButton = document.querySelector('#profile-form .btn-primary');
-- if (saveButton) saveButton.textContent = 'Save Anon Preferences';
--
-- const notifTab = [...document.querySelectorAll('.section-tabs button')].find((button) => button.getAttribute('onclick')?.includes("'notifications'"));
-- const linkedTab = [...document.querySelectorAll('.section-tabs button')].find((button) => button.getAttribute('onclick')?.includes("'linked'"));
-- const securityTab = [...document.querySelectorAll('.section-tabs button')].find((button) => button.getAttribute('onclick')?.includes("'security'"));
-- [notifTab, linkedTab, securityTab].forEach((button) => {
-- if (!button) return;
-- button.style.opacity = '.45';
-- button.title = 'Unavailable in anonymous mode';
-- });
--
-- setSectionDisabled('notifications', 'Notification inbox, email preferences, and alert delivery are only available for full accounts.');
-- setSectionDisabled('linked', 'Linked services are only available once you sign in with a full OpenVibe account.');
-- setSectionDisabled('security', 'Anonymous identities do not have passwords or device sessions to manage.');
--
-- const themeGrid = document.getElementById('theme-grid');
-- if (themeGrid) {
-- themeGrid.innerHTML = '<div class="theme-empty">Theme syncing is unavailable for anonymous sessions right now.</div>';
-- }
-- const currentName = document.getElementById('theme-current-name');
-- if (currentName) currentName.textContent = 'Anon sessions do not sync themes';
--}
--
--// Load user data
--async function loadUser() {
-- // Hydrate localStorage from the host-only cookie if needed (e.g. cookie set by the login page)
-- await _bootstrapFromCookie();
-- const authToken = getAuthToken();
-- const anonToken = getAnonToken();
-- if (!authToken && !anonToken) { window.location.href = '/login'; return; }
-- try {
-- if (isAnonSession()) {
-- const data = await anonFetch('/api/auth/anon/' + encodeURIComponent(anonToken));
-- currentUser = {
-- ...data.user,
-- id: 'anon',
-- role: 'anon',
-- email: null,
-- avatar_url: null,
-- profile_color: '#8b5cf6',
-- bio: '',
-- is_anon: true,
-- };
-- renderHeader(currentUser);
-- renderProfile(currentUser);
-- applyAnonModeUI(currentUser);
-- loadAccounts();
-- loadAnonInfo();
-- OpenVibeNavbar.init({ service: 'network', notificationsRealtime: true, token: null, user: currentUser });
-- } else {
-- const data = await apiFetch('/api/auth/me');
-- currentUser = data.user;
-- renderHeader(data.user);
-- renderProfile(data.user);
-- loadThemes(); loadThemeSubmissions();
-- loadAccounts();
-- renderMerge();
-- renderExport(); renderDeletion();
-- if (pendingMerge() && String(pendingMerge().into_id) !== String(currentUser.id)) showSection('accounts');
-- loadNotifPrefs();
-- loadLinked();
-- loadSessions();
-- loadBlocks();
-- loadAnonInfo();
-- loadEmailStatus();
-- loadRecentTools();
--
-- // Init navbar
-- OpenVibeNavbar.init({ service: 'network', notificationsRealtime: true, token: authToken, user: currentUser });
-- const bellMount = OpenVibeNavbar.getBellMount();
-- if (bellMount && window.OpenVibeNotifications && authToken) {
-- OpenVibeNotifications.init({ token: authToken, apiBase: API, swPath: '/openvibe-sw.js' });
-- const bell = OpenVibeNotifications.createBell();
-- bellMount.replaceChildren(bell);
-- }
-- renderNotifInbox();
-- }
-- } catch (err) {
-- console.error(err);
-- // A genuine auth failure → send them to log in. Everything else (offline, weak signal,
-- // timeout, server hiccup) → show a clear reason + retry instead of a stuck "Loading…".
-- if (err.status === 401 || /401|Authentication|not found|revoked|Unauthorized/i.test(err.message || '')) {
-- // One refresh attempt before giving up on the session (a stale localStorage token
-- // with a still-good cookie used to bounce people to the login page for nothing).
-- if (!window._ovRefreshTried) {
-- window._ovRefreshTried = true;
-- try {
-- const r = await fetchWithTimeout(API + '/api/auth/refresh', { method: 'POST', headers: { 'Content-Type': 'application/json', ...(getAuthToken() ? { Authorization: 'Bearer ' + getAuthToken() } : {}) }, credentials: 'include' });
-- if (r.ok) { const d = await r.json(); if (d.token) { localStorage.setItem('ov_token', d.token); document.cookie = `ov_token=${d.token};path=/;max-age=${60*60*24*90};SameSite=Lax${location.protocol === 'https:' ? ';Secure' : ''}`; location.reload(); return; } }
-- } catch { /* fall through */ }
-- }
-- window.location.href = '/login?return=' + encodeURIComponent(window.location.pathname);
-- return;
-- }
-- showAccountLoadError(err);
-- }
--}
--
--// Replace the stuck "Loading…" with a readable reason + a Retry button.
--function showAccountLoadError(err) {
-- const nameEl = document.getElementById('user-name');
-- const subEl = document.getElementById('user-subtitle');
-- if (nameEl) nameEl.textContent = "Couldn't load your account";
-- if (subEl) {
-- const offline = (typeof navigator !== 'undefined' && navigator.onLine === false);
-- subEl.textContent = (offline ? "You appear to be offline" : (err && err.message) ? err.message : 'Something went wrong') + ' · ';
-- const btn = document.createElement('button');
-- btn.type = 'button';
-- btn.className = 'acct-retry-btn';
-- btn.textContent = 'Retry';
-- btn.onclick = () => { if (nameEl) nameEl.textContent = 'Loading…'; if (subEl) subEl.textContent = ''; loadUser(); };
-- subEl.appendChild(btn);
-- }
--}
--
--function renderHeader(user) {
-- const avatar = document.getElementById('user-avatar');
-- const fallback = avatarPlaceholder(user, 96);
-- avatar.src = avatarSrc(user, 96);
-- avatar.onerror = () => { avatar.src = fallback; };
-- document.getElementById('user-name').textContent = user.display_name || user.username;
-- document.getElementById('user-subtitle').textContent = user.is_anon
-- ? `Anonymous identity${user.anon_number ? ' · #' + user.anon_number : ''}`
-- : '@' + user.username + (user.email ? ' · ' + user.email : '');
-- const badges = document.getElementById('user-badges');
-- badges.innerHTML = user.is_anon
-- ? `<span class="badge badge-anon">anonymous</span>`
-- : `<span class="badge badge-${user.role}">${user.role}</span>`;
-- if (user.anon_number) badges.innerHTML += `<span class="badge badge-anon">Anon #${user.anon_number}</span>`;
--}
--
--function renderProfile(user) {
-- document.getElementById('pf-display-name').value = user.display_name || '';
-- document.getElementById('pf-bio').value = user.bio || '';
-- document.getElementById('pf-email').value = user.email || '';
-- document.getElementById('pf-color').value = user.profile_color || '#8b5cf6';
-- if (!user.is_anon) {
-- document.getElementById('pf-bio').disabled = false;
-- document.getElementById('pf-email').disabled = false;
-- document.getElementById('pf-color').disabled = false;
-- const emailHint = document.querySelector('#sec-profile .hint');
-- if (emailHint) emailHint.textContent = DEFAULT_EMAIL_HINT;
-- const saveButton = document.querySelector('#profile-form .btn-primary');
-- if (saveButton) saveButton.textContent = 'Save Changes';
-- }
--}
--
--// Profile form
--
--// ── Avatar: one picture for every site (PUT/DELETE /api/profile/avatar) ─────
--(function () {
-- const save = document.getElementById('avatar-save'), clear = document.getElementById('avatar-clear'), src = document.getElementById('avatar-source'), msg = document.getElementById('avatar-msg'), prev = document.getElementById('avatar-preview');
-- if (!save) return;
-- const paint = () => { if (typeof currentUser !== 'undefined' && currentUser) { prev.src = avatarSrc(currentUser, 96); prev.onerror = () => { prev.src = avatarPlaceholder(currentUser, 96); }; } };
-- const apply = async (method, body) => {
-- save.disabled = clear.disabled = true; msg.textContent = method === 'PUT' ? 'Checking the picture…' : '';
-- try {
-- const data = await apiFetch('/api/profile/avatar', { method, body: body ? JSON.stringify(body) : undefined });
-- currentUser = Object.assign({}, currentUser, { avatar_url: data.avatar_url });
-- renderHeader(currentUser); paint(); src.value = '';
-- msg.textContent = data.avatar_url ? 'Saved. Every OpenVibe site will show it within a few minutes.' : 'Removed. Sites will show your initial.';
-- } catch (err) { msg.textContent = err.message || 'That did not work'; }
-- save.disabled = clear.disabled = false;
-- };
-- save.addEventListener('click', () => { if (src.value.trim()) apply('PUT', { source: src.value.trim() }); else msg.textContent = 'Paste a link first.'; });
-- clear.addEventListener('click', () => apply('DELETE'));
-- src.addEventListener('keydown', (e) => { if (e.key === 'Enter') { e.preventDefault(); save.click(); } });
-- document.addEventListener('ov:account-ready', paint); setTimeout(paint, 1500);
--})();
--
--document.getElementById('profile-form').addEventListener('submit', async (e) => {
-- e.preventDefault();
-- try {
-- if (isAnonSession()) {
-- const anonToken = getAnonToken();
-- const data = await anonFetch('/api/auth/anon/' + encodeURIComponent(anonToken) + '/preferences', {
-- method: 'PUT',
-- body: JSON.stringify({
-- display_name: document.getElementById('pf-display-name').value,
-- }),
-- });
-- currentUser = {
-- ...currentUser,
-- display_name: data.preferences?.display_name || document.getElementById('pf-display-name').value || currentUser.display_name,
-- };
-- const accounts = getStoredAccounts().filter(a => !a.is_anon);
-- accounts.push({
-- id: 'anon',
-- username: currentUser.username,
-- display_name: currentUser.display_name,
-- avatar_url: null,
-- email: null,
-- is_anon: true,
-- anon_number: currentUser.anon_number,
-- token: getAnonToken(),
-- });
-- localStorage.setItem('openvibe_accounts', JSON.stringify(accounts));
-- renderHeader(currentUser);
-- alert('Anonymous preferences updated!');
-- return;
-- }
--
-- // Display name may only re-case the username, not rename it.
-- const _dn = (document.getElementById('pf-display-name').value || '').trim();
-- if (currentUser?.username && _dn.toLowerCase() !== String(currentUser.username).toLowerCase()) {
-- alert('Your display name can only change the capitalization of your username (e.g. "' + currentUser.username + '" → "' + currentUser.username.charAt(0).toUpperCase() + currentUser.username.slice(1) + '").');
-- return;
-- }
-- const data = await apiFetch('/api/auth/profile', { method: 'PUT', body: JSON.stringify({
-- display_name: _dn,
-- bio: document.getElementById('pf-bio').value,
-- email: document.getElementById('pf-email').value || undefined,
-- profile_color: document.getElementById('pf-color').value,
-- })});
-- currentUser = data.user;
-- renderHeader(data.user);
-- alert('Profile updated!');
-- } catch (err) { alert('Error: ' + err.message); }
--});
--
--// Accounts
--function loadAccounts() {
-- const accounts = JSON.parse(localStorage.getItem('openvibe_accounts') || '[]');
-- const activeId = localStorage.getItem('openvibe_active_account');
-- const list = document.getElementById('account-list');
-- list.innerHTML = accounts.map(a => `
-- <div class="account-item ${String(a.id) === String(activeId) ? 'active' : ''}">
-- <img src="${avatarSrc(a, 64)}" alt="${a.display_name || a.username}" onerror="this.onerror=null;this.src='${avatarPlaceholder({ display_name: a.display_name, username: a.username, profile_color: a.profile_color }, 64)}'">
-- <div>
-- <div class="name">${a.display_name || a.username}${a.is_anon ? ' <i class="fa-solid fa-user-secret" style="font-size:12px"></i>' : ''}</div>
-- <div class="meta">${a.is_anon ? 'Anonymous #' + (a.anon_number || '?') : '@' + a.username}</div>
-- </div>
-- <div class="actions">
-- ${String(a.id) !== String(activeId) ? `<button class="btn btn-outline" onclick="switchAccount('${a.id}')">Switch</button>` : '<span style="font-size:11px;color:var(--success);font-weight:600">Active</span>'}
-- ${String(a.id) !== String(activeId) ? `<button class="btn btn-danger" onclick="removeAccount('${a.id}')"><i class="fa-solid fa-xmark"></i></button>` : ''}
-- </div>
-- </div>
-- `).join('');
--}
--
--// ── Account merge (ADR-029): 1) the account you keep opens a 10-minute request, 2) you sign in to the other
--// account ("Add another account"), 3) it confirms and folds itself in. Both sign-ins within 10 minutes.
--const MERGE_KEY = 'ov_merge_intent';
--function pendingMerge() {
-- try { const m = JSON.parse(localStorage.getItem(MERGE_KEY) || 'null'); return m && Date.parse(m.expires_at) > Date.now() ? m : null; } catch { return null; }
--}
--function renderMerge() {
-- const body = document.getElementById('merge-body');
-- if (!body || !currentUser) return;
-- body.textContent = '';
-- const el = (tag, text, cls) => { const n = document.createElement(tag); if (text) n.textContent = text; if (cls) n.className = cls; return n; };
-- const row = el('div', '', 'btn-row');
-- const pending = pendingMerge();
-- if (currentUser.is_anon) { body.appendChild(el('p', 'Guests convert by signing up; merging is for two accounts.')); return; }
-- if (!pending) {
-- const b = el('button', `Keep @${currentUser.username} and merge another account into it`, 'btn btn-primary');
-- b.type = 'button';
-- b.onclick = async () => {
-- try {
-- const r = await apiFetch('/api/v1/account/merge/intents', { method: 'POST', body: '{}' });
-- localStorage.setItem(MERGE_KEY, JSON.stringify({ intent: r.intent, expires_at: r.expires_at, into_id: currentUser.id, into_username: currentUser.username }));
-- addAccount();
-- } catch (err) { toast('Could not start the merge: ' + err.message, 'error'); }
-- };
-- row.appendChild(b); body.appendChild(row); return;
-- }
-- const mins = Math.max(1, Math.round((Date.parse(pending.expires_at) - Date.now()) / 60000));
-- const cancel = el('button', 'Cancel', 'btn btn-outline');
-- cancel.type = 'button';
-- cancel.onclick = () => { localStorage.removeItem(MERGE_KEY); renderMerge(); };
-- if (String(currentUser.id) === String(pending.into_id)) {
-- body.appendChild(el('p', `Now sign in to the account you want to fold into @${pending.into_username} (within ${mins} min).`));
-- const go = el('button', 'Sign in to the other account', 'btn btn-primary');
-- go.type = 'button'; go.onclick = addAccount;
-- row.append(go, cancel); body.appendChild(row); return;
-- }
-- body.appendChild(el('p', `Fold @${currentUser.username} into @${pending.into_username}? Everything above moves to @${pending.into_username}; @${currentUser.username} then signs in as @${pending.into_username}.`));
-- const doIt = el('button', `Merge @${currentUser.username} into @${pending.into_username}`, 'btn btn-danger');
-- doIt.type = 'button';
-- doIt.onclick = async () => {
-- doIt.disabled = true;
-- try {
-- const r = await apiFetch('/api/v1/account/merge', { method: 'POST', body: JSON.stringify({ intent: pending.intent }) });
-- localStorage.removeItem(MERGE_KEY);
-- toast(`Merged: @${currentUser.username} is now part of @${pending.into_username} (${r.moved.coins} OpenCoins moved).`, 'success');
-- const folded = currentUser.id;
-- try { OpenVibeAccountSwitcher.removeAccount(folded); } catch { /* */ }
-- OpenVibeAccountSwitcher.switchTo(pending.into_id);
-- } catch (err) {
-- doIt.disabled = false;
-- if (err.status === 401) toast(`Sign in to @${currentUser.username} again (a fresh sign-in is needed), then merge.`, 'error');
-- else if (err.status === 410) { localStorage.removeItem(MERGE_KEY); toast('That merge request expired. Start again from the account you keep.', 'error'); renderMerge(); }
-- else toast('The merge did not happen: ' + err.message, 'error');
-- }
-- };
-- row.append(doIt, cancel); body.appendChild(row);
--}
--
--// ── Your data (ADR-033): the export job (each service adds its part; ready within 30 minutes, kept 7 days) and
--// the scheduled deletion (30 days of grace, cancellable; scheduling it needs a fresh sign-in and the username).
--async function accountApi(path, opts = {}) {
-- const headers = { 'Content-Type': 'application/json', ...(opts.headers || {}) };
-- const t = getAuthToken();
-- if (t) headers.Authorization = 'Bearer ' + t;
-- const res = await fetchWithTimeout(API + path, { ...opts, headers, credentials: 'include' });
-- let body = {};
-- try { body = await res.json(); } catch { /* */ }
-- return { status: res.status, ok: res.ok, body };
--}
--const ovEl = (tag, text, cls) => { const n = document.createElement(tag); if (text) n.textContent = text; if (cls) n.className = cls; return n; };
--const whenText = (s) => new Date(s).toLocaleString(undefined, { dateStyle: 'medium', timeStyle: 'short' });
--const sizeText = (n) => (n >= 1048576 ? `${(n / 1048576).toFixed(1)} MB` : `${Math.max(1, Math.round(n / 1024))} KB`);
--let exportPoll = null;
--async function renderExport() {
-- const body = document.getElementById('export-body');
-- if (!body || !currentUser) return;
-- clearTimeout(exportPoll);
-- body.textContent = '';
-- if (currentUser.is_anon) { body.appendChild(ovEl('p', 'Guests have no account to export.')); return; }
-- const r = await accountApi('/api/v1/account/export');
-- const latest = ((r.body && r.body.exports) || [])[0];
-- const row = ovEl('div', '', 'btn-row');
-- if (latest && latest.status === 'pending') {
-- const waiting = latest.services.filter((s) => s.status === 'waiting').map((s) => s.service);
-- body.appendChild(ovEl('p', `Gathering your data since ${whenText(latest.requested_at)}${waiting.length ? `; waiting for ${waiting.join(', ')}` : ''}. It is ready by ${whenText(latest.deadline)} at the latest, and you get a notification.`));
-- exportPoll = setTimeout(renderExport, 15000);
-- return;
-- }
-- if (latest && (latest.status === 'ready' || latest.status === 'partial')) {
-- const missing = latest.services.filter((s) => s.status === 'missing').map((s) => s.service);
-- body.appendChild(ovEl('p', `Ready since ${whenText(latest.ready_at)} (${sizeText(latest.size_bytes || 0)}), until ${whenText(latest.expires_at)}.${missing.length ? ` Not included, because they did not answer in time: ${missing.join(', ')}. Ask again tomorrow for a complete copy.` : ''}`));
-- const dl = ovEl('button', 'Download (.zip)', 'btn btn-primary');
-- dl.type = 'button';
-- dl.onclick = () => downloadExport(latest.export_id, dl);
-- row.appendChild(dl);
-- } else if (latest && latest.status === 'failed') {
-- body.appendChild(ovEl('p', 'The last export could not be built. Ask again.'));
-- }
-- const since = latest && latest.status !== 'failed' ? Date.now() - Date.parse(latest.requested_at) : Infinity;
-- if (since >= 86400000) {
-- const start = ovEl('button', latest ? 'Request a new copy' : 'Request my data', latest ? 'btn btn-outline' : 'btn btn-primary');
-- start.type = 'button';
-- start.onclick = async () => {
-- start.disabled = true;
-- const s = await accountApi('/api/v1/account/export', { method: 'POST', body: '{}' });
-- if (!s.ok) { start.disabled = false; toast(s.body.detail || 'Could not start the export', 'error'); return; }
-- toast('Gathering your data. You get a notification when it is ready.', 'success');
-- renderExport();
-- };
-- row.appendChild(start);
-- } else if (latest) {
-- body.appendChild(ovEl('p', `You can ask for a new copy from ${whenText(Date.parse(latest.requested_at) + 86400000)}.`));
-- }
-- if (row.children.length) body.appendChild(row);
--}
--async function downloadExport(id, btn) {
-- btn.disabled = true;
-- try {
-- const headers = {};
-- const t = getAuthToken();
-- if (t) headers.Authorization = 'Bearer ' + t;
-- const res = await fetch(API + `/api/v1/account/export/${encodeURIComponent(id)}/download`, { headers, credentials: 'include' });
-- if (!res.ok) { toast('That copy is no longer available.', 'error'); renderExport(); return; }
-- const blob = await res.blob();
-- const name = (/filename="([^"]+)"/.exec(res.headers.get('Content-Disposition') || '') || [])[1] || 'openvibe-data.zip';
-- const a = document.createElement('a');
-- a.href = URL.createObjectURL(blob);
-- a.download = name;
-- document.body.appendChild(a); a.click(); a.remove();
-- setTimeout(() => URL.revokeObjectURL(a.href), 60000);
-- } catch { toast('The download failed. Try again.', 'error'); } finally { btn.disabled = false; }
--}
--async function renderDeletion() {
-- const body = document.getElementById('delete-body');
-- if (!body || !currentUser) return;
-- body.textContent = '';
-- if (currentUser.is_anon) { body.appendChild(ovEl('p', 'Guests have no account to delete.')); return; }
-- const r = await accountApi('/api/v1/account/deletion');
-- const d = r.body && r.body.deletion;
-- const row = ovEl('div', '', 'btn-row');
-- if (d) {
-- body.appendChild(ovEl('p', `Your account will be deleted on ${whenText(d.delete_after)}. Download your data before then if you want a copy.`));
-- const keep = ovEl('button', 'Keep my account', 'btn btn-primary');
-- keep.type = 'button';
-- keep.onclick = async () => {
-- keep.disabled = true;
-- const c = await accountApi('/api/v1/account/deletion', { method: 'DELETE' });
-- if (c.ok) toast('Deletion cancelled. Your account stays.', 'success');
-- renderDeletion();
-- };
-- row.appendChild(keep); body.appendChild(row);
-- return;
-- }
-- const field = ovEl('div', '', 'form-field');
-- const label = ovEl('label', `Type your username, ${currentUser.username}, to confirm`);
-- const input = ovEl('input');
-- input.type = 'text'; input.autocomplete = 'off'; input.spellcheck = false; input.id = 'delete-confirm';
-- label.htmlFor = 'delete-confirm';
-- field.append(label, input);
-- const go = ovEl('button', 'Delete my account in 30 days', 'btn btn-danger');
-- go.type = 'button';
-- go.onclick = async () => {
-- go.disabled = true;
-- const s = await accountApi('/api/v1/account/deletion', { method: 'POST', body: JSON.stringify({ confirm_username: input.value }) });
-- go.disabled = false;
-- if (s.status === 401 && s.body.error === 'deletion.sign_in_again') {
-- toast('To make sure it is you, sign in again, then come back here and confirm.', 'error');
-- const again = ovEl('button', 'Sign in again', 'btn btn-outline');
-- again.type = 'button'; again.onclick = addAccount;
-- if (!row.querySelector('.btn-outline')) row.appendChild(again);
-- return;
-- }
-- if (!s.ok) { toast(s.body.detail || 'That did not work', 'error'); return; }
-- toast(`Scheduled: your account will be deleted on ${whenText(s.body.delete_after)}. You can cancel until then.`, 'success');
-- renderDeletion();
-- };
-- row.appendChild(go);
-- body.append(field, row);
--}
--
--function switchAccount(id) { OpenVibeAccountSwitcher.switchTo(id); }
--function removeAccount(id) { OpenVibeAccountSwitcher.removeAccount(id); loadAccounts(); }
--function addAccount() { window.location.href = '/login?add_account=1&return=' + encodeURIComponent(window.location.pathname); }
--function signOutAll() { OpenVibeAccountSwitcher.logoutAll(); }
--
--// Small helpers (my.html has no global toast/esc of its own).
--if (typeof window.esc !== 'function') window.esc = (v) => String(v ?? '').replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;').replace(/'/g, '&#39;');
--if (typeof window.toast !== 'function') window.toast = (msg, type = 'info') => {
-- let c = document.getElementById('my-toasts');
-- if (!c) { c = document.createElement('div'); c.id = 'my-toasts'; c.style.cssText = 'position:fixed;top:78px;right:16px;z-index:100001;display:flex;flex-direction:column;gap:8px;max-width:360px'; document.body.appendChild(c); }
-- const t = document.createElement('div');
-- t.style.cssText = `padding:10px 14px;border-radius:10px;font-size:13px;color:#fff;box-shadow:0 8px 24px rgba(0,0,0,.35);background:${type === 'error' ? '#dc2626' : type === 'success' ? '#16a34a' : '#4c4c5e'}`;
-- t.textContent = msg; c.appendChild(t);
-- setTimeout(() => { t.style.transition = 'opacity .3s'; t.style.opacity = '0'; setTimeout(() => t.remove(), 300); }, 4500);
--};
--
--// ── Email verification status (gates opt-in email like go-live alerts) ──
--async function loadEmailStatus() {
-- if (isAnonSession()) return;
-- const card = document.getElementById('notif-verify-card'), body = document.getElementById('notif-verify-body');
-- if (!card || !body) return;
-- try {
-- const st = await apiFetch('/api/auth/email/status');
-- card.style.display = '';
-- if (!st.email) {
-- body.innerHTML = `<p>No email address on your account, so you can't receive go-live emails. Add one in <a href="#" onclick="showSection('profile');return false">Profile</a> — we'll send a confirmation link.</p>`;
-- } else if (st.bounced) {
-- body.innerHTML = `<p><span style="color:var(--danger,#ef4444)"><i class="fa-solid fa-triangle-exclamation"></i> Mail to <strong>${esc(st.email)}</strong> is bouncing</span>${st.bounce_reason ? ` (${esc(st.bounce_reason)})` : ''}. Emails are paused until you update the address in <a href="#" onclick="showSection('profile');return false">Profile</a> and confirm it.</p>`;
-- } else if (st.verified) {
-- body.innerHTML = `<p><span style="color:var(--success,#22c55e)"><i class="fa-solid fa-circle-check"></i> <strong>${esc(st.email)}</strong> is verified.</span> You'll get an email when a streamer you follow goes live (turn this off under Streams → Email below).</p>`;
-- } else {
-- body.innerHTML = `<p><i class="fa-solid fa-envelope"></i> <strong>${esc(st.email)}</strong> isn't confirmed yet — go-live and other opt-in emails stay off until it is.</p>
-- <button class="btn btn-primary" id="notif-verify-btn" onclick="resendVerification(this)" ${st.can_resend && st.email_enabled ? '' : 'disabled'}><i class="fa-solid fa-paper-plane"></i> ${st.email_enabled ? 'Send verification email' : 'Email delivery not configured'}</button>
-- ${!st.can_resend && st.email_enabled ? '<div class="muted" style="font-size:12px;margin-top:6px">A link was sent moments ago — check your inbox and spam folder.</div>' : ''}`;
-- }
-- } catch (e) { card.style.display = 'none'; }
--}
--async function resendVerification(btn) {
-- if (btn) { btn.disabled = true; btn.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> Sending…'; }
-- try { const r = await apiFetch('/api/auth/email/send-verification', { method: 'POST' }); toast(r.message || 'Sent', 'success'); }
-- catch (e) { toast(e.message, 'error'); }
-- loadEmailStatus();
--}
--async function handleVerifyEmailToken() {
-- const token = new URLSearchParams(window.location.search).get('token');
-- if (!token) return;
-- history.replaceState(null, '', '/notifications');
-- try {
-- const r = await fetchWithTimeout(API + '/api/auth/email/verify', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token }) });
-- const data = await r.json().catch(() => ({}));
-- if (r.ok) toast(`${data.email || 'Your email'} is verified — go-live alerts will reach your inbox.`, 'success');
-- else toast(data.error || 'Verification failed', 'error');
-- } catch { toast('Verification failed — try again from your account page.', 'error'); }
-- loadEmailStatus();
--}
--let _inboxRendered = false;
--function renderNotifInbox() {
-- if (_inboxRendered || isAnonSession() || !window.OpenVibeNotifications) return;
-- const el = document.getElementById('notif-inbox'); if (!el) return;
-- _inboxRendered = true;
-- OpenVibeNotifications.renderInbox(el, { tab: 'all' });
--}
--
--// Notification preferences
--const CATEGORIES = ['social','chat','game','stream','economy','achievement','moderation','system','service','admin'];
--
--async function loadNotifPrefs() {
-- if (isAnonSession()) return;
-- try {
-- const data = await apiFetch('/api/notifications/preferences');
-- const prefsMap = {};
-- for (const p of data.preferences) prefsMap[p.category] = p;
-- const grid = document.getElementById('notif-prefs');
-- // Clear existing rows
-- grid.querySelectorAll('.pref-row').forEach(r => r.remove());
-- for (const cat of CATEGORIES) {
-- const p = prefsMap[cat] || { enabled: 1, sound: 1, toasts: 1, email: null };
-- // email NULL = no explicit choice: go-live (stream) alerts email by default.
-- const emailOn = (p.email === null || p.email === undefined) ? cat === 'stream' : !!p.email;
-- p.email = emailOn ? 1 : 0;
-- const label = { social: 'Social', chat: 'Chat', game: 'Game', stream: 'Streams (go-live)', economy: 'Economy', achievement: 'Achievements', moderation: 'Moderation', system: 'System', service: 'Services', admin: 'Announcements' }[cat] || cat;
-- const html = `
-- <div class="cat pref-row" title="${cat}">${label}</div>
-- <button class="toggle pref-row ${p.enabled ? 'on' : 'off'}" data-cat="${cat}" data-field="enabled" onclick="togglePref(this)"></button>
-- <button class="toggle pref-row ${p.sound ? 'on' : 'off'}" data-cat="${cat}" data-field="sound" onclick="togglePref(this)"></button>
-- <button class="toggle pref-row col-toast ${p.toasts ? 'on' : 'off'}" data-cat="${cat}" data-field="toasts" onclick="togglePref(this)"></button>
-- <button class="toggle pref-row col-email ${p.email ? 'on' : 'off'}" data-cat="${cat}" data-field="email" onclick="togglePref(this)"></button>
-- `;
-- grid.insertAdjacentHTML('beforeend', html);
-- }
-- } catch (err) { console.error('Failed to load notif prefs:', err); }
--}
--
--async function togglePref(btn) {
-- const cat = btn.dataset.cat;
-- const field = btn.dataset.field;
-- const isOn = btn.classList.contains('on');
-- btn.classList.toggle('on');
-- btn.classList.toggle('off');
-- try {
-- await apiFetch('/api/notifications/preferences', { method: 'PUT', body: JSON.stringify({ category: cat, [field]: !isOn }) });
-- } catch (err) { btn.classList.toggle('on'); btn.classList.toggle('off'); }
--}
--
--// Linked services
--async function loadLinked() {
-- if (isAnonSession()) return;
-- const list = document.getElementById('linked-list');
-- const SITES = {
-- live: { name: 'OpenVibe.Live', icon: 'fa-tower-broadcast', url: 'https://openvibe.live', color: '#ef4444' },
-- tools: { name: 'OpenVibe.Tools', icon: 'fa-screwdriver-wrench', url: 'https://openvibe.tools', color: '#8b5cf6' },
-- games: { name: 'OpenVibe.Games', icon: 'fa-gamepad', url: 'https://openvibe.games', color: '#22c55e' },
-- media: { name: 'OpenVibe.Media', icon: 'fa-photo-film', url: 'https://openvibe.media', color: '#22d3ee' },
-- community: { name: 'OpenVibe.Community', icon: 'fa-people-group', url: 'https://openvibe.community', color: '#f59e0b' },
-- discord: { name: 'Discord', icon: 'fa-brands fa-discord', url: 'https://discord.gg/M6MuRUaeJj', color: '#5865f2' },
-- network: { name: 'OpenVibe.Network', icon: 'fa-circle-nodes', url: '/', color: '#3b82f6' },
-- };
-- const ago = (iso) => { if (!iso) return ''; const d = (Date.now() - new Date(String(iso).replace(' ', 'T') + (String(iso).endsWith('Z') ? '' : 'Z')).getTime()) / 1000; if (d < 90) return 'just now'; if (d < 3600) return Math.round(d / 60) + 'm ago'; if (d < 86400) return Math.round(d / 3600) + 'h ago'; return Math.round(d / 86400) + 'd ago'; };
-- try {
-- const card = await apiFetch('/api/auth/users/' + currentUser.id + '/card');
-- const linked = card.user?.linked_services || [];
-- const known = new Set(linked.map(l => l.service));
-- const rows = linked.map(l => {
-- const site = SITES[l.service] || { name: l.service, icon: 'fa-link', url: null };
-- const who = l.service_username && !String(l.service_user_id || '').startsWith('network:') ? '@' + l.service_username : (l.service_username ? '@' + l.service_username : 'this account');
-- const inner = `<span class="svc-icon" style="color:${site.color || 'var(--accent-light)'}"><i class="fa-solid ${site.icon}"></i></span>
-- <div><div class="svc-name">${esc(site.name)}</div><div class="svc-user">${esc(who)}</div></div>
-- <div class="svc-meta">${l.last_used_at ? 'used ' + ago(l.last_used_at) : (l.linked_at ? 'linked ' + ago(l.linked_at) : '')}<br><span class="svc-status" style="color:var(--success)"><i class="fa-solid fa-check"></i> Linked</span></div>`;
-- return site.url ? `<a class="linked-item" href="${site.url}" target="_blank" rel="noopener">${inner}</a>` : `<div class="linked-item">${inner}</div>`;
-- }).join('');
-- const rest = Object.entries(SITES).filter(([id]) => !known.has(id) && id !== 'network' && id !== 'discord');
-- list.innerHTML = (rows || '<p style="color:var(--text-muted);font-size:13px">Nothing linked yet — the first sign-in on any OpenVibe site links it here automatically.</p>') +
-- (rest.length ? `<div class="linked-grid">${rest.map(([id, site]) => `<a href="${site.url}" target="_blank" rel="noopener" title="Open ${site.name}"><i class="fa-solid ${site.icon}" style="color:${site.color}"></i> ${esc(site.name.replace('OpenVibe.', ''))}<span style="margin-left:auto;font-size:10px;opacity:.7">not yet</span></a>`).join('')}</div>` : '');
-- } catch (err) { console.error('Failed to load linked:', err); }
--}
--
--// ── History across the network ──
--let _histOffset = 0, _histService = '', _histTimer = null, _histLoaded = false;
--function histDebounce() { clearTimeout(_histTimer); _histTimer = setTimeout(() => loadHistory(false), 250); }
--function histFilter(svc, btn) { _histService = svc; document.querySelectorAll('#hist-chips button').forEach(b => b.classList.toggle('active', b === btn)); loadHistory(false); }
--async function loadHistory(more) {
-- if (isAnonSession()) { document.getElementById('hist-list').innerHTML = '<div class="hist-empty">History is only kept for signed-in accounts.</div>'; return; }
-- if (!more) _histOffset = 0;
-- const listEl = document.getElementById('hist-list');
-- if (!more) listEl.innerHTML = '<div class="loading-spinner">Loading history…</div>';
-- try {
-- const q = new URLSearchParams({ limit: '40', offset: String(_histOffset) });
-- if (_histService) q.set('service', _histService);
-- const term = document.getElementById('hist-q').value.trim(); if (term) q.set('q', term);
-- const data = await apiFetch('/api/history?' + q);
-- document.getElementById('hist-pause').checked = !!data.paused;
-- if (!_histLoaded || !more) {
-- const chips = document.getElementById('hist-chips');
-- chips.innerHTML = `<button class="${_histService ? '' : 'active'}" onclick="histFilter('', this)">All</button>` + (data.services || []).map(s => `<button class="${_histService === s.service ? 'active' : ''}" onclick="histFilter('${esc(s.service)}', this)">${esc(s.label || s.service)} <span style="opacity:.6">${s.count}</span></button>`).join('');
-- _histLoaded = true;
-- }
-- const items = data.items || [];
-- if (!more && !items.length) { listEl.innerHTML = `<div class="hist-empty"><i class="fa-solid fa-clock-rotate-left" style="font-size:22px;display:block;margin-bottom:8px;opacity:.5"></i>${term || _histService ? 'Nothing matches.' : 'Nothing yet — open a tool, watch a stream or read a paste and it shows up here.'}</div>`; document.getElementById('hist-more-row').style.display = 'none'; return; }
-- const dayOf = (iso) => { const d = new Date(String(iso).replace(' ', 'T') + 'Z'); const today = new Date(); const y = new Date(); y.setDate(today.getDate() - 1); if (d.toDateString() === today.toDateString()) return 'Today'; if (d.toDateString() === y.toDateString()) return 'Yesterday'; return d.toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' }); };
-- const timeOf = (iso) => new Date(String(iso).replace(' ', 'T') + 'Z').toLocaleTimeString(undefined, { hour: '2-digit', minute: '2-digit' });
-- let html = '', lastDay = more ? (listEl.dataset.lastDay || '') : '';
-- for (const h of items) {
-- const day = dayOf(h.updated_at);
-- if (day !== lastDay) { html += `<div class="hist-day">${esc(day)}</div>`; lastDay = day; }
-- let host = ''; try { host = new URL(h.url).hostname; } catch {}
-- html += `<a class="hist-item" href="${esc(h.url)}" data-id="${h.id}"><span class="hi"><i class="fa-solid ${esc(h.icon || 'fa-file-lines')}"></i></span><div style="min-width:0"><div class="ht">${esc(h.title)}</div><div class="hs">${esc(h.service_label || host)} · ${esc(host)}${h.hits > 1 ? ` · ${h.hits}×` : ''}</div></div><div class="hx"><span>${esc(timeOf(h.updated_at))}</span><button title="Remove" onclick="removeHistoryItem(event, ${h.id})"><i class="fa-solid fa-xmark"></i></button></div></a>`;
-- }
-- listEl.dataset.lastDay = lastDay;
-- if (more) listEl.insertAdjacentHTML('beforeend', html); else listEl.innerHTML = html;
-- _histOffset += items.length;
-- document.getElementById('hist-more-row').style.display = _histOffset < (data.total || 0) ? '' : 'none';
-- } catch (err) { listEl.innerHTML = `<div class="hist-empty">Could not load history: ${esc(err.message)}</div>`; }
--}
--async function toggleHistoryPause(cb) {
-- try { await apiFetch('/api/history/settings', { method: 'PUT', body: JSON.stringify({ paused: cb.checked }) }); toast(cb.checked ? 'History paused — nothing new is recorded.' : 'History recording resumed.'); }
-- catch (e) { cb.checked = !cb.checked; toast(e.message, 'error'); }
--}
--async function clearHistory() {
-- if (!confirm('Delete your whole OpenVibe history? This cannot be undone.')) return;
-- try { await apiFetch('/api/history', { method: 'DELETE' }); _histLoaded = false; loadHistory(false); toast('History cleared.', 'success'); } catch (e) { toast(e.message, 'error'); }
--}
--async function removeHistoryItem(ev, id) {
-- ev.preventDefault(); ev.stopPropagation();
-- const el = document.querySelector(`.hist-item[data-id="${id}"]`);
-- try { await apiFetch('/api/history/' + id, { method: 'DELETE' }); if (el) { el.style.transition = 'opacity .2s'; el.style.opacity = '0'; setTimeout(() => el.remove(), 200); } } catch (e) { toast(e.message, 'error'); }
--}
--
--// Sessions
--async function loadSessions() {
-- if (isAnonSession()) {
-- document.getElementById('sessions-list').innerHTML = '<p style="color:var(--text-muted);font-size:13px">Anonymous mode does not create device sessions.</p>';
-- return;
-- }
-- try {
-- const data = await apiFetch('/api/auth/sessions');
-- const list = document.getElementById('sessions-list');
-- if (data.sessions.length === 0) {
-- list.innerHTML = '<p style="color:var(--text-muted);font-size:13px">No active sessions.</p>';
-- } else {
-- list.innerHTML = data.sessions.map(s => `
-- <div class="linked-item">
-- <span class="svc-icon"><i class="fa-solid fa-mobile-screen"></i></span>
-- <div>
-- <div class="svc-name">${s.device_name || 'Unknown Device'}</div>
-- <div class="svc-user">IP: ${s.ip || '?'} · Last used: ${new Date(s.last_used).toLocaleDateString()}</div>
-- </div>
-- <button class="btn btn-danger" onclick="revokeSession(${s.id})">Revoke</button>
-- </div>
-- `).join('');
-- }
-- } catch (err) { document.getElementById('sessions-list').innerHTML = '<p style="color:var(--text-muted)">Unable to load sessions.</p>'; }
--}
--
--// Blocked people (platform blocks: /api/v1/me/blocks), built from DOM nodes.
--async function loadBlocks() {
-- const box = document.getElementById('blocks-list');
-- if (!box) return;
-- if (isAnonSession()) { box.textContent = 'Anonymous identities cannot block people.'; return; }
-- try {
-- const data = await apiFetch('/api/v1/me/blocks');
-- box.replaceChildren();
-- if (!(data.blocks || []).length) {
-- const p = document.createElement('p'); p.style.cssText = 'color:var(--text-muted);font-size:13px'; p.textContent = 'You have not blocked anyone.';
-- box.appendChild(p); return;
-- }
-- for (const b of data.blocks) {
-- const row = document.createElement('div'); row.className = 'module-row';
-- const main = document.createElement('div'); main.className = 'module-row-main';
-- const title = document.createElement('strong'); title.textContent = b.display_name || b.username || 'Deleted account';
-- const info = document.createElement('small');
-- info.textContent = `${b.username ? '@' + b.username + ' · ' : ''}blocked ${b.blocked_at ? new Date(b.blocked_at).toLocaleDateString() : 'recently'}`;
-- main.append(title, info);
-- const btn = document.createElement('button'); btn.type = 'button'; btn.className = 'btn btn-small';
-- btn.textContent = 'Unblock'; btn.setAttribute('aria-label', 'Unblock ' + (b.username || b.subject));
-- btn.addEventListener('click', () => unblockPerson(b, btn));
-- row.append(main, btn);
-- box.appendChild(row);
-- }
-- } catch { box.textContent = 'Could not load the people you blocked. Try again later.'; }
--}
--async function blockPerson(e) {
-- e.preventDefault();
-- const input = document.getElementById('block-username');
-- const status = document.getElementById('blocks-status');
-- const name = input.value.trim().replace(/^@/, '');
-- if (!name) return;
-- try {
-- await apiFetch('/api/v1/me/blocks/' + encodeURIComponent(name), { method: 'PUT' });
-- if (status) status.textContent = `Blocked @${name}.`;
-- input.value = '';
-- } catch (err) {
-- if (status) status.textContent = err.status === 404 ? 'Nobody has that username.' : err.status === 400 ? 'You cannot block that account.' : 'Could not block. Try again.';
-- }
-- loadBlocks();
--}
--async function unblockPerson(b, btn) {
-- const status = document.getElementById('blocks-status');
-- btn.disabled = true;
-- try {
-- await apiFetch('/api/v1/me/blocks/' + encodeURIComponent(b.subject), { method: 'DELETE' });
-- if (status) status.textContent = `Unblocked ${b.username ? '@' + b.username : 'that account'}.`;
-- } catch (e) {
-- if (status) status.textContent = e.status === 404 ? 'Already unblocked.' : 'Could not unblock. Try again.';
-- }
-- loadBlocks();
--}
--
--async function revokeSession(id) {
-- try { await apiFetch('/api/auth/sessions/' + id, { method: 'DELETE' }); loadSessions(); }
-- catch (err) { alert('Error: ' + err.message); }
--}
--
--async function revokeAllSessions() {
-- if (!confirm('Sign out every other device? They will need to sign in again. You stay signed in here.')) return;
-- try {
-- const data = await apiFetch('/api/auth/sessions', { method: 'DELETE' });
-- if (data.token) {
-- localStorage.setItem('ov_token', data.token);
-- document.cookie = `ov_token=${data.token};path=/;max-age=${60*60*24*30};SameSite=Lax${location.protocol === 'https:' ? ';Secure' : ''}`;
-- }
-- loadSessions();
-- alert('Signed out everywhere else.');
-- } catch (err) { alert('Error: ' + err.message); }
--}
--
--async function signOutEverywhere() {
-- if (!confirm('Sign out on every device and every OpenVibe site, this one included?')) return;
-- try { await apiFetch('/api/auth/sign-out-everywhere', { method: 'POST' }); }
-- catch (err) { alert('Error: ' + err.message); return; }
-- try { localStorage.removeItem('ov_token'); } catch { /* storage may be blocked */ }
-- location.href = '/sso/fanout?action=logout&next=%2Flogin';
--}
--
--// Anon info
--async function loadAnonInfo() {
-- const anonToken = localStorage.getItem('openvibe_anon_token');
-- const container = document.getElementById('anon-info');
-- if (!anonToken && !currentUser?.anon_number) {
-- container.innerHTML = `
-- <p style="color:var(--text-muted);font-size:14px"><i class="fa-solid fa-user-secret"></i></p>
-- <p style="color:var(--text-muted);font-size:13px;margin-top:8px">You don't have an anonymous identity yet.</p>
-- <button class="btn btn-outline" onclick="createAnon()" style="margin-top:12px">Create Anonymous Identity</button>
-- `;
-- return;
-- }
--
-- if (currentUser?.is_anon) {
-- container.innerHTML = `
-- <p style="font-size:11px;color:var(--text-muted);text-transform:uppercase;letter-spacing:1px">Current Anonymous Session</p>
-- <div class="number">#${currentUser.anon_number}</div>
-- <p style="font-size:12px;color:var(--text-muted)">You're currently browsing as an anonymous identity.</p>
-- `;
-- return;
-- }
--
-- if (currentUser?.anon_number) {
-- container.innerHTML = `
-- <p style="font-size:11px;color:var(--text-muted);text-transform:uppercase;letter-spacing:1px">Your Anonymous Number</p>
-- <div class="number">#${currentUser.anon_number}</div>
-- <p style="font-size:12px;color:var(--text-muted)">This number is permanently linked to your account.</p>
-- `;
-- return;
-- }
--
-- try {
-- const data = await fetch(API + '/api/auth/anon/' + anonToken).then(r => r.json());
-- if (data.user) {
-- container.innerHTML = `
-- <p style="font-size:11px;color:var(--text-muted);text-transform:uppercase;letter-spacing:1px">Your Anonymous Number</p>
-- <div class="number">#${data.user.anon_number}</div>
-- <div class="label">First seen ${new Date(data.user.first_seen).toLocaleDateString()}</div>
-- <div class="stats">
-- <div class="stat"><div class="val">${data.user.total_messages}</div><div class="key">Messages</div></div>
-- <div class="stat"><div class="val">${data.user.total_commands}</div><div class="key">Commands</div></div>
-- </div>
-- `;
-- }
-- } catch (err) {
-- container.innerHTML = '<p style="color:var(--text-muted)">Unable to load anonymous info.</p>';
-- }
--}
--
--async function createAnon() {
-- try {
-- const res = await fetch(API + '/api/auth/anon-session', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' });
-- const data = await res.json();
-- localStorage.setItem('openvibe_anon_token', data.token);
-- const accounts = getStoredAccounts().filter(a => !a.is_anon);
-- accounts.push({ id: 'anon', username: data.user.username, display_name: data.user.display_name, is_anon: true, anon_number: data.user.anon_number, token: data.token, avatar_url: null, email: null });
-- localStorage.setItem('openvibe_accounts', JSON.stringify(accounts));
-- loadAnonInfo();
-- } catch (err) { alert('Error: ' + err.message); }
--}
--
--async function linkAnon() {
-- if (currentUser?.is_anon) { alert('Switch to a real account before linking this anonymous identity.'); return; }
-- const anonToken = localStorage.getItem('openvibe_anon_token');
-- if (!anonToken) { alert('No anonymous session to link.'); return; }
-- try {
-- await apiFetch('/api/auth/anon/' + anonToken + '/link', { method: 'POST' });
-- alert('Anonymous identity linked!');
-- location.reload();
-- } catch (err) { alert('Error: ' + err.message); }
--}
--
--// Password form
--document.getElementById('password-form').addEventListener('submit', async (e) => {
-- e.preventDefault();
-- if (isAnonSession()) { alert('Anonymous identities do not have passwords.'); return; }
-- const newPw = document.getElementById('sec-new-pw').value;
-- if (newPw !== document.getElementById('sec-confirm-pw').value) { alert('Passwords do not match'); return; }
-- try {
-- const data = await apiFetch('/api/auth/change-password', { method: 'POST', body: JSON.stringify({
-- current_password: document.getElementById('sec-current-pw').value,
-- new_password: newPw,
-- })});
-- localStorage.setItem('ov_token', data.token);
-- document.cookie = `ov_token=${data.token};path=/;max-age=${60*60*24*30};SameSite=Lax${location.protocol === 'https:' ? ';Secure' : ''}`;
-- alert(data.message || 'Password changed!');
-- document.getElementById('password-form').reset();
-- } catch (err) { alert('Error: ' + err.message); }
--});
--
--// Init
--OpenVibeAccountSwitcher.init({ apiBase: API });
--loadUser();
--buildDashboardShowcase();
--openInitialSection();
--</script>
++<script src="/js/my/core.js" defer></script>
++<script src="/js/my/tools.js" defer></script>
++<script src="/js/my/themes.js" defer></script>
++<script src="/js/my/profile.js" defer></script>
++<script src="/js/my/security.js" defer></script>
++<script src="/js/my/boot.js" defer></script>
<script src="https://openvibe.live/live-notify.js" async></script>
</body>
</html>
ddiff --git a/test/admin-modules.test.js b/test/admin-modules.test.js
nnew file mode 100644
iindex 00000000..e1d377de
---- /dev/null
++++ b/test/admin-modules.test.js
@@@ -0,0 +1,38 @@
++'use strict';
++// T2 step 1: the admin panel (/admin) runs from classic deferred scripts in public/js/admin/, not one inline
++// monolith. admin.html loads exactly the module files, boot.js last; no function is declared in two modules.
++// node test/admin-modules.test.js
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++
++const pub = path.join(__dirname, '..', 'public');
++const html = fs.readFileSync(path.join(pub, 'admin.html'), 'utf8');
++for (const [, body] of html.matchAll(/<script\b[^>]*>([\s\S]*?)<\/script>/g)) assert.ok(body.length <= 5000, 'admin.html keeps no inline script over 5000 characters');
++
++const files = fs.readdirSync(path.join(pub, 'js', 'admin')).filter((f) => f.endsWith('.js')).sort();
++// The module map, in load order (public/js/admin/…).
++const modules = ['core.js', 'dashboard.js', 'users.js', 'moderation.js', 'settings.js', 'payments.js', 'ai.js', 'content.js', 'analytics.js', 'deploy.js', 'boot.js'];
++assert.deepStrictEqual(files, [...modules].sort(), 'each module file exists and there is no other');
++const tags = [...html.matchAll(/<script\b([^>]*)\bsrc="\/js\/admin\/([^"]+)"([^>]*)><\/script>/g)];
++const loaded = tags.map((m) => m[2]);
++assert.deepStrictEqual(loaded, modules, 'the loader list is the module files, in order, each once');
++assert.strictEqual(loaded.at(-1), 'boot.js', 'boot.js is the last /js/admin/ script');
++for (const m of tags) assert.ok(/\bdefer\b/.test(m[1] + m[3]) && !/\btype=/.test(m[1] + m[3]), `${m[2]} is a classic deferred script`);
++
++const owner = new Map();
++const src = {};
++for (const f of files) {
++ src[f] = fs.readFileSync(path.join(pub, 'js', 'admin', f), 'utf8');
++ for (const [, name] of src[f].matchAll(/^(?:async\s+)?function\s+([\w$]+)\s*\(/gm)) {
++ assert.ok(!owner.has(name), `${name} is declared in both ${owner.get(name)} and ${f}`);
++ owner.set(name, f);
++ }
++}
++assert.ok(owner.size > 100, 'the modules declare the panel functions');
++for (const name of ['esc', 'toast', 'api', 'fmtBytes', 'showTab', 'routeFromUrl']) assert.strictEqual(owner.get(name), 'core.js', `${name} lives in core.js`);
++// tabLoaders names every loader, so it is evaluated after all of them; init() starts the panel last.
++assert.ok(src['boot.js'].includes('const tabLoaders = {'), 'tabLoaders lives in boot.js');
++assert.match(src['boot.js'], /\ninit\(\);\s*$/, 'boot.js ends by calling init()');
++
++console.log('admin-modules: all checks passed');
ddiff --git a/test/blocks.test.js b/test/blocks.test.js
iindex 95988eb5..44e90a83 100644
---- a/test/blocks.test.js
++++ b/test/blocks.test.js
@@@ -207,8 +207,10 @@ const valid = (e) => {
// The account page lists blocks as text (DOM nodes), with unblock buttons.
const page = fs.readFileSync(path.join(__dirname, '..', 'public', 'my.html'), 'utf8');
-- assert.ok(page.includes('id="blocks-list"') && page.includes('async function loadBlocks()') && page.includes("'/api/v1/me/blocks/' + encodeURIComponent(b.subject), { method: 'DELETE' }"));
-- const fn = page.slice(page.indexOf('async function loadBlocks()'), page.indexOf('async function revokeSession('));
++ assert.ok(page.includes('id="blocks-list"'));
++ const js = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'my', 'security.js'), 'utf8');
++ assert.ok(js.includes('async function loadBlocks()') && js.includes("'/api/v1/me/blocks/' + encodeURIComponent(b.subject), { method: 'DELETE' }"));
++ const fn = js.slice(js.indexOf('async function loadBlocks()'), js.indexOf('async function revokeSession('));
assert.ok(!fn.includes('innerHTML'), 'no innerHTML in the blocks list');
server.close(); db.close();
ddiff --git a/test/my-modules.test.js b/test/my-modules.test.js
nnew file mode 100644
iindex 00000000..6c5f96aa
---- /dev/null
++++ b/test/my-modules.test.js
@@@ -0,0 +1,35 @@
++'use strict';
++// T2 step 2: the account hub (/my and its sections) runs from classic deferred scripts in public/js/my/, not one
++// inline monolith. my.html loads exactly the module files, boot.js last; no function is declared in two modules.
++// node test/my-modules.test.js
++const assert = require('assert');
++const fs = require('fs');
++const path = require('path');
++
++const pub = path.join(__dirname, '..', 'public');
++const html = fs.readFileSync(path.join(pub, 'my.html'), 'utf8');
++for (const [, body] of html.matchAll(/<script\b[^>]*>([\s\S]*?)<\/script>/g)) assert.ok(body.length <= 5000, 'my.html keeps no inline script over 5000 characters');
++
++const files = fs.readdirSync(path.join(pub, 'js', 'my')).filter((f) => f.endsWith('.js')).sort();
++// The module map, in load order (public/js/my/…).
++const modules = ['core.js', 'tools.js', 'themes.js', 'profile.js', 'security.js', 'boot.js'];
++assert.deepStrictEqual(files, [...modules].sort(), 'each module file exists and there is no other');
++const tags = [...html.matchAll(/<script\b([^>]*)\bsrc="\/js\/my\/([^"]+)"([^>]*)><\/script>/g)];
++const loaded = tags.map((m) => m[2]);
++assert.deepStrictEqual(loaded, modules, 'the loader list is the module files, in order, each once');
++assert.strictEqual(loaded.at(-1), 'boot.js', 'boot.js is the last /js/my/ script');
++for (const m of tags) assert.ok(/\bdefer\b/.test(m[1] + m[3]) && !/\btype=/.test(m[1] + m[3]), `${m[2]} is a classic deferred script`);
++
++const owner = new Map();
++for (const f of files) {
++ const src = fs.readFileSync(path.join(pub, 'js', 'my', f), 'utf8');
++ for (const [, name] of src.matchAll(/^(?:async\s+)?function\s+([\w$]+)\s*\(/gm)) {
++ assert.ok(!owner.has(name), `${name} is declared in both ${owner.get(name)} and ${f}`);
++ owner.set(name, f);
++ }
++}
++assert.ok(owner.size > 70, 'the modules declare the hub functions');
++// Every section calls these, so they load first.
++for (const name of ['_bootstrapFromCookie', 'getAuthToken', 'apiFetch', 'showSection']) assert.strictEqual(owner.get(name), 'core.js', `${name} lives in core.js`);
++
++console.log('my-modules: all checks passed');
ddiff --git a/test/operator-checklist.test.js b/test/operator-checklist.test.js
iindex e72280dd..4c28b31c 100644
---- a/test/operator-checklist.test.js
++++ b/test/operator-checklist.test.js
@@@ -11,7 +11,8 @@ const { AREAS, checklist } = require('../server/admin/operator-checklist');
const html = fs.readFileSync(path.join(__dirname, '..', 'public', 'admin.html'), 'utf8');
const buttons = new Set([...html.matchAll(/showTab\('([a-z-]+)'/g)].map((m) => m[1]));
--const block = html.slice(html.indexOf('const tabLoaders = {'), html.indexOf('};', html.indexOf('const tabLoaders = {')));
++const boot = fs.readFileSync(path.join(__dirname, '..', 'public', 'js', 'admin', 'boot.js'), 'utf8');
++const block = boot.slice(boot.indexOf('const tabLoaders = {'), boot.indexOf('};', boot.indexOf('const tabLoaders = {')));
const loaders = new Set([...block.matchAll(/^\s{4}'?([a-z-]+)'?:/gm)].map((m) => m[1]));
const ids = new Set(services.manifests.map((m) => m.id));
const plan = ['users', 'search', 'roles', 'bans', 'settings', 'email', 'announcements', 'health', 'storage', 'media', 'queues', 'moderation', 'billing', 'loyalty', 'migration', 'compatibility', 'deploy', 'certificates'];