openvibers/openvibe.network Commits
Status words and badges use the readable status twins (var(--<status>-text, var(--<status>))) (#306)
Changes
@@@ -335,7 +335,7 @@<div id="analytics-overview"><div class="stats-grid" id="analytics-stats"><div class="loading">Loading analytics...</div></div><div class="card" id="analytics-realtime-card" style="display:none">-- <h3><i class="fa-solid fa-signal" style="color:var(--success)"></i> Real-time (last 5 min)</h3>++ <h3><i class="fa-solid fa-signal" style="color:var(--success-text, var(--success))"></i> Real-time (last 5 min)</h3><div id="analytics-realtime"></div></div><div style="display:grid;grid-template-columns:1fr 1fr;gap:16px">@@@ -69,9 +69,9 @@ body{min-height:100%;color:var(--text);font-family:'Inter',-apple-system,BlinkMa.btn-primary:hover{background:var(--accent-dark)}.btn-outline{background:transparent;border:1px solid var(--border);color:var(--text)}.btn-outline:hover{border-color:var(--accent-dark);color:var(--accent-light)}--.btn-danger{background:rgba(231,76,60,0.1);color:var(--live-red);border:1px solid rgba(231,76,60,0.2)}++.btn-danger{background:rgba(231,76,60,0.1);color:var(--live-red-text, var(--live-red));border:1px solid rgba(231,76,60,0.2)}.btn-danger:hover{background:rgba(231,76,60,0.2)}--.btn-success{background:rgba(46,204,113,0.1);color:var(--success);border:1px solid rgba(46,204,113,0.2)}++.btn-success{background:rgba(46,204,113,0.1);color:var(--success-text, var(--success));border:1px solid rgba(46,204,113,0.2)}.btn-success:hover{background:rgba(46,204,113,0.2)}.btn-sm{padding:5px 10px;font-size:11px}a.btn{text-decoration:none}@@@ -156,5 +156,5 @@ a.btn{text-decoration:none}}.ssh-prompt{background:var(--bg-elev,#111);border:1px solid var(--border,#2a2a2a);border-radius:8px;padding:14px;white-space:pre-wrap;word-break:break-word;font-family:ui-monospace,Menlo,Consolas,monospace;font-size:0.8rem;line-height:1.55;color:var(--text,#ddd);max-height:520px;overflow:auto;margin:0}#sec-ssh code,#sec-payments code,#sec-cashouts code{background:var(--bg-elev,#111);border:1px solid var(--border,#2a2a2a);border-radius:5px;padding:2px 7px;font-family:ui-monospace,Menlo,Consolas,monospace;font-size:0.8rem;display:inline-block;margin-top:3px;word-break:break-all}--#sec-ssh .staff-pill{font-size:0.62rem;font-weight:700;text-transform:uppercase;letter-spacing:.04em;padding:3px 8px;border-radius:999px;vertical-align:middle;margin-left:8px;color:var(--warning,#f0b74a);background:color-mix(in srgb,var(--warning,#f0b74a) 18%,transparent);border:1px solid color-mix(in srgb,var(--warning,#f0b74a) 40%,transparent)}++#sec-ssh .staff-pill{font-size:0.62rem;font-weight:700;text-transform:uppercase;letter-spacing:.04em;padding:3px 8px;border-radius:999px;vertical-align:middle;margin-left:8px;color:var(--warning-text, var(--warning,#f0b74a));background:color-mix(in srgb,var(--warning,#f0b74a) 18%,transparent);border:1px solid color-mix(in srgb,var(--warning,#f0b74a) 40%,transparent)}#sec-ssh .staff-pill.user-pill{color:var(--accent,#6ea8ff);background:color-mix(in srgb,var(--accent,#6ea8ff) 18%,transparent);border-color:color-mix(in srgb,var(--accent,#6ea8ff) 40%,transparent)}@@@ -90,7 +90,7 @@ body{min-height:100%;color:var(--text);font-family:'Inter',-apple-system,BlinkMa.btn-primary:hover{background:var(--accent-dark)}.btn-outline{background:transparent;border:1px solid var(--border);color:var(--text)}.btn-outline:hover{border-color:var(--accent-dark);color:var(--accent-light)}--.btn-danger{background:rgba(231,76,60,0.1);color:var(--live-red);border:1px solid rgba(231,76,60,0.2)}++.btn-danger{background:rgba(231,76,60,0.1);color:var(--live-red-text, var(--live-red));border:1px solid rgba(231,76,60,0.2)}.btn-danger:hover{background:rgba(231,76,60,0.2)}.btn-row{display:flex;gap:8px;margin-top:12px}.data-check{display:flex;gap:8px;align-items:flex-start;font-size:13px;color:var(--text-secondary);line-height:1.5}@@@ -147,12 +147,12 @@ a.linked-item:hover{background:var(--bg-hover,var(--bg-card));transform:translat.hist-item .hx{margin-left:auto;display:flex;align-items:center;gap:10px;flex:none;font-size:11px;color:var(--text-muted)}.hist-item .hx button{background:none;border:none;color:var(--text-muted);cursor:pointer;padding:4px;opacity:0;transition:opacity .15s}.hist-item:hover .hx button{opacity:1}--.hist-item .hx button:hover{color:var(--danger,#ef4444)}++.hist-item .hx button:hover{color:var(--danger-text, var(--danger,#ef4444))}.hist-empty{padding:26px;text-align:center;color:var(--text-muted);font-size:13px}.linked-item .svc-icon{font-size:20px;width:32px;text-align:center}.linked-item .svc-name{font-size:13px;font-weight:600}.linked-item .svc-user{font-size:11px;color:var(--text-muted)}--.linked-item .svc-status{margin-left:auto;font-size:11px;font-weight:600;color:var(--success)}++.linked-item .svc-status{margin-left:auto;font-size:11px;font-weight:600;color:var(--success-text, var(--success))}/* Themes */.theme-hero{display:flex;justify-content:space-between;gap:16px;align-items:flex-start;flex-wrap:wrap;margin-bottom:18px}@@@ -232,12 +232,12 @@ async function loadStreamerMediaTools() {<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>'++ ? '<span style="color:var(--success-text, var(--success,#22c55e))"><i class="fa-solid fa-check-circle"></i> Yes</span>'++ : '<span style="color:var(--danger-text, 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 style="color:var(--success-text, 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>' : ''}@@@ -317,8 +317,8 @@ function renderCookieVerdict(data) {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> ';++ ? '<span style="color:var(--success-text, var(--success,#22c55e))"><i class="fa-solid fa-circle-check"></i> '++ : '<span style="color:var(--danger-text, 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>`);@@@ -338,7 +338,7 @@ function renderCookieVerdict(data) {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.errors || []).forEach(e => rows.push(`<div style="color:var(--danger-text, 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>`;@@@ -351,7 +351,7 @@ async function checkStreamerMediaCookies() {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>`;++ if (el) el.innerHTML = `<div style="color:var(--danger-text, var(--danger,#ef4444));margin-top:10px">${esc(e.message)}</div>`;}}@@@ -369,7 +369,7 @@ async function saveStreamerMediaCookies() {// 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>`;++ else if (el) el.innerHTML = `<div style="color:var(--danger-text, var(--danger,#ef4444));margin-top:10px">${esc(e.message)}</div>`;}}@@@ -391,15 +391,15 @@ async function testStreamerMediaExtraction() {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>';++ ? '<i class="fa-solid fa-check-circle" style="color:var(--success-text, var(--success,#22c55e))"></i>'++ : '<i class="fa-solid fa-xmark-circle" style="color:var(--danger-text, var(--danger,#ef4444))"></i>';const detail = step.error-- ? `<span style="color:var(--danger,#ef4444)"> — ${esc(step.error)}</span>`++ ? `<span style="color:var(--danger-text, 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>`;++ results.innerHTML = `<p style="color:var(--danger-text, var(--danger,#ef4444))">${esc(e.message)}</p>`;}}@@@ -125,8 +125,8 @@ async function loadAnalyticsOverview() {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>++ <span><strong style="color:var(--success-text, var(--success))">${fmtNum(rt.visitors)}</strong> visitors</span>++ <span><strong style="color:var(--danger-text, var(--danger))">${fmtNum(rt.bots)}</strong> bots</span></div>`;} else {rtCard.style.display = 'none';@@@ -309,10 +309,10 @@ async function loadAnalyticsOverview() {<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><strong style="color:var(--info-text, var(--info))">${fmtNum(bw.page_requests)}</strong><br><span class="muted">Page Loads</span></div>++ <div><strong style="color:var(--info-text, var(--info))">${fmtNum(bw.api_requests)}</strong><br><span class="muted">API Requests</span></div>++ <div><strong style="color:var(--success-text, var(--success))">${fmtNum(ov.sessionCount || 0)}</strong><br><span class="muted">Unique Sessions</span></div>++ <div><strong style="color:var(--success-text, var(--success))">${fmtNum(t.total_unique_users || 0)}</strong><br><span class="muted">Logged-in Users</span></div></div>`;} catch (e) {@@@ -478,11 +478,11 @@ async function loadServiceAnalytics(serviceName) {<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>++ <div><strong style="color:var(--info-text, var(--info))">${fmtNum(bw.page_requests)}</strong><br><span class="muted">Page Loads</span></div>++ <div><strong style="color:var(--info-text, var(--info))">${fmtNum(bw.api_requests)}</strong><br><span class="muted">API Requests</span></div>++ <div><strong style="color:var(--success-text, 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 style="color:var(--success-text, 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>`;@@@ -546,7 +546,7 @@ async function loadServiceAnalytics(serviceName) {<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 style="color:var(--danger-text, var(--danger))">${e.max_ms || 0}ms</td><td>${fmtNum(e.hits)}</td></tr>`).join('')}</tbody>@@@ -624,7 +624,7 @@ async function loadBotAnalysis() {<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="color:var(--danger-text, 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>`;}@@@ -61,7 +61,7 @@ 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>';++ document.querySelector('.page').innerHTML = '<div class="card" style="text-align:center;padding:60px"><h3 style="color:var(--live-red-text, 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.@@@ -154,8 +154,8 @@ function buildStreamerTierHtml(tierData) {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>';++ ? '<span style="color:var(--success-text, 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-text, 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>' },@@@ -212,7 +212,7 @@ async function loadStreamerCloudStorage(force) {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>';++ 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-text, 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">'@@@ -226,7 +226,7 @@ async function loadStreamerCloudStorage(force) {+ '<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>';++ el.innerHTML = header + '<div style="color:var(--danger-text, var(--danger,#ef4444))">Failed to scan buckets: ' + esc(e.message) + '</div>';}}@@@ -49,7 +49,7 @@ async function loadDashboard() {<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><strong>Email:</strong> ${th.ses_enabled ? '<span style="color:var(--success-text, var(--success))">Enabled</span>' : '<span style="color:var(--text-muted)">Disabled</span>'}</div></div>`;} else {@@@ -92,7 +92,7 @@ function sshCopy(id, btn) {// ── 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>`;++ `<div class="card" style="border-color:var(--warning)"><p style="color:var(--warning-text, 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>@@@ -116,7 +116,7 @@ function renderSSHOwner(el) {<option value="web">Full web + nginx — build & 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">++ <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-text, 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>@@@ -330,12 +330,12 @@ async function loadDeployOverview() {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>`;++ document.getElementById('deploy-stats').innerHTML = `<div class="muted" style="color:var(--danger-text, 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 icon = ok ? '<i class="fa-solid fa-circle-check" style="color:var(--success-text, var(--success))"></i>' : '<i class="fa-solid fa-circle-xmark" style="color:var(--danger-text, 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>`;}@@@ -368,7 +368,7 @@ async function loadDeployCerts() {</div>`;}).join('');} catch (e) {-- el.innerHTML = `<div class="muted" style="color:var(--danger)">${esc(e.message)}</div>`;++ el.innerHTML = `<div class="muted" style="color:var(--danger-text, var(--danger))">${esc(e.message)}</div>`;}}@@@ -384,15 +384,15 @@ async function issueCertCloudflare() {});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>++ out.innerHTML = `<div style="color:var(--success-text, 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>++ out.innerHTML = `<div style="color:var(--danger-text, 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>`;++ out.innerHTML = `<div style="color:var(--danger-text, var(--danger))">${esc(e.message)}</div>`;}}@@@ -419,7 +419,7 @@ async function showManualDnsInfo() {</div></div>`;} catch (e) {-- out.innerHTML = `<div style="color:var(--danger)">${esc(e.message)}</div>`;++ out.innerHTML = `<div style="color:var(--danger-text, var(--danger))">${esc(e.message)}</div>`;}}@@@ -433,15 +433,15 @@ async function issueManualCert(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>++ out.innerHTML = `<div style="color:var(--success-text, 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>++ out.innerHTML = `<div style="color:var(--danger-text, 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>`;++ out.innerHTML = `<div style="color:var(--danger-text, var(--danger))">${esc(e.message)}</div>`;}}@@@ -459,7 +459,7 @@ async function renewAllCerts() {<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>`;++ out.innerHTML = `<div style="color:var(--danger-text, var(--danger))">${esc(e.message)}</div>`;}}@@@ -480,7 +480,7 @@ async function previewNginxConfigs() {</div>`).join('');} catch (e) {-- out.innerHTML = `<div style="color:var(--danger)">${esc(e.message)}</div>`;++ out.innerHTML = `<div style="color:var(--danger-text, var(--danger))">${esc(e.message)}</div>`;}}@@@ -500,7 +500,7 @@ async function validateNginx() {<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>`;++ out.innerHTML = `<div style="color:var(--danger-text, var(--danger))">${esc(e.message)}</div>`;}}@@@ -521,10 +521,10 @@ async function applyNginxConfigs() {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>`);++ if (d.errors?.length) lines.push(`<div style="color:var(--danger-text, 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>`;++ out.innerHTML = `<div style="color:var(--danger-text, var(--danger))">${esc(e.message)}</div>`;}}@@@ -555,7 +555,7 @@ async function loadDeployDomains() {</div>`).join('');} catch (e) {-- el.innerHTML = `<div class="muted" style="color:var(--danger)">${esc(e.message)}</div>`;++ el.innerHTML = `<div class="muted" style="color:var(--danger-text, var(--danger))">${esc(e.message)}</div>`;}}@@@ -601,7 +601,7 @@ async function loadDeployConfig() {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>`;++ document.getElementById('deploy-config-status').innerHTML = `<span style="color:var(--danger-text, var(--danger))">${esc(e.message)}</span>`;}}@@@ -621,9 +621,9 @@ async function saveDeployConfig() {});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>';++ status.innerHTML = '<span style="color:var(--success-text, 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>`;++ status.innerHTML = `<span style="color:var(--danger-text, var(--danger))">${esc(e.message)}</span>`;}}@@@ -228,14 +228,14 @@ function _streamerSettingField(s) {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>`;++ <div class="setting-err muted" data-err-for="${esc(s.key)}" style="display:none;color:var(--danger-text, 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>`;++ <div class="setting-err muted" data-err-for="${esc(s.key)}" style="display:none;color:var(--danger-text, 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.@@@ -223,7 +223,7 @@ function loadAccounts() {<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-outline" onclick="switchAccount('${a.id}')">Switch</button>` : '<span style="font-size:11px;color:var(--success-text, 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>@@@ -15,9 +15,9 @@ async function loadEmailStatus() {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>`;++ body.innerHTML = `<p><span style="color:var(--danger-text, 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>`;++ body.innerHTML = `<p><span style="color:var(--success-text, 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>@@@ -116,7 +116,7 @@ async function loadLinked() {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>`;++ <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-text, 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');@@@ -32,8 +32,8 @@ body{background:var(--bg-primary);color:var(--text-primary);font-family:'Inter',.ring.spin{border-top-color:var(--accent);animation:spin 1s linear infinite}@keyframes spin{to{transform:rotate(360deg)}}.glyph .ico{position:absolute;inset:0;display:grid;place-items:center;font-size:38px;color:var(--accent-light);transition:transform .35s cubic-bezier(.2,1.4,.3,1),color .3s}--.card[data-state="ok"] .ico{color:var(--success);transform:scale(1.12)}--.card[data-state="bad"] .ico{color:var(--danger)}++.card[data-state="ok"] .ico{color:var(--success-text, var(--success));transform:scale(1.12)}++.card[data-state="bad"] .ico{color:var(--danger-text, var(--danger))}.card[data-state="ok"] .ring{border-color:rgba(34,197,94,.35);animation:pop .6s cubic-bezier(.2,1.6,.3,1) both}.card[data-state="bad"] .ring{border-color:rgba(239,68,68,.35)}@keyframes pop{0%{transform:scale(.6);opacity:0}60%{transform:scale(1.08)}100%{transform:scale(1);opacity:1}}@@@ -141,7 +141,7 @@ const CSS = `.st th{font-size:12px;text-transform:uppercase;letter-spacing:.5px;color:var(--text-muted,#7386a3)}.st td small{display:block;color:var(--text-muted,#7386a3);font-size:12.5px;margin-top:2px}.st code{font-size:12.5px;overflow-wrap:anywhere}.st-b{display:inline-block;padding:2px 9px;border-radius:999px;font-weight:700;font-size:12.5px;border:1px solid currentColor}--.st-up{color:var(--success,#22c55e)}.st-degraded{color:#f59e0b}.st-down{color:var(--live-red,#ef4444)}.st-not-running,.st-unknown{color:var(--text-muted,#7386a3)}++.st-up{color:var(--success-text, var(--success,#22c55e))}.st-degraded{color:#f59e0b}.st-down{color:var(--live-red-text, var(--live-red,#ef4444))}.st-not-running,.st-unknown{color:var(--text-muted,#7386a3)}.st ul.chk{margin:4px 0 0;padding:0;list-style:none;font-size:12.5px}.st ul.chk li{margin:1px 0}.st section{margin-top:40px}.st dl{display:grid;grid-template-columns:minmax(160px,240px) 1fr;gap:6px 16px;font-size:14px}.st dt{font-weight:600}.st dd{margin:0;color:var(--text-secondary,#96a7c2)}@media (max-width:720px){.st table,.st tbody,.st tr,.st td{display:block}.st thead{display:none}.st tr{border-bottom:1px solid var(--border,#1f2d47);padding:8px 0}.st td{border:0;padding:4px 0}.st dl{grid-template-columns:1fr}}`;
@@ -335,7 +335,7 @@
@@ -335,7 +335,7 @@
<div id="analytics-overview">
<div id="analytics-overview">
<div class="stats-grid" id="analytics-stats"><div class="loading">Loading analytics...</div></div>
<div class="stats-grid" id="analytics-stats"><div class="loading">Loading analytics...</div></div>
<div class="card" id="analytics-realtime-card" style="display:none">
<div class="card" id="analytics-realtime-card" style="display:none">
- <h3><i class="fa-solid fa-signal" style="color:var(--success)"></i> Real-time (last 5 min)</h3>
+ <h3><i class="fa-solid fa-signal" style="color:var(--success-text, var(--success))"></i> Real-time (last 5 min)</h3>
<div id="analytics-realtime"></div>
<div id="analytics-realtime"></div>
</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:16px">
@@ -69,9 +69,9 @@ body{min-height:100%;color:var(--text);font-family:'Inter',-apple-system,BlinkMa
@@ -69,9 +69,9 @@ body{min-height:100%;color:var(--text);font-family:'Inter',-apple-system,BlinkMa
.btn-primary:hover{background:var(--accent-dark)}
.btn-primary:hover{background:var(--accent-dark)}
.btn-outline{background:transparent;border:1px solid var(--border);color:var(--text)}
.btn-outline{background:transparent;border:1px solid var(--border);color:var(--text)}
.btn-outline:hover{border-color:var(--accent-dark);color:var(--accent-light)}
.btn-outline:hover{border-color:var(--accent-dark);color:var(--accent-light)}
-.btn-danger{background:rgba(231,76,60,0.1);color:var(--live-red);border:1px solid rgba(231,76,60,0.2)}
+.btn-danger{background:rgba(231,76,60,0.1);color:var(--live-red-text, var(--live-red));border:1px solid rgba(231,76,60,0.2)}
.btn-danger:hover{background:rgba(231,76,60,0.2)}
.btn-danger:hover{background:rgba(231,76,60,0.2)}
-.btn-success{background:rgba(46,204,113,0.1);color:var(--success);border:1px solid rgba(46,204,113,0.2)}
+.btn-success{background:rgba(46,204,113,0.1);color:var(--success-text, var(--success));border:1px solid rgba(46,204,113,0.2)}
.btn-success:hover{background:rgba(46,204,113,0.2)}
.btn-success:hover{background:rgba(46,204,113,0.2)}
.btn-sm{padding:5px 10px;font-size:11px}
.btn-sm{padding:5px 10px;font-size:11px}
a.btn{text-decoration:none}
a.btn{text-decoration:none}
@@ -156,5 +156,5 @@ a.btn{text-decoration:none}
@@ -156,5 +156,5 @@ a.btn{text-decoration:none}
}
}
.ssh-prompt{background:var(--bg-elev,#111);border:1px solid var(--border,#2a2a2a);border-radius:8px;padding:14px;white-space:pre-wrap;word-break:break-word;font-family:ui-monospace,Menlo,Consolas,monospace;font-size:0.8rem;line-height:1.55;color:var(--text,#ddd);max-height:520px;overflow:auto;margin:0}
.ssh-prompt{background:var(--bg-elev,#111);border:1px solid var(--border,#2a2a2a);border-radius:8px;padding:14px;white-space:pre-wrap;word-break:break-word;font-family:ui-monospace,Menlo,Consolas,monospace;font-size:0.8rem;line-height:1.55;color:var(--text,#ddd);max-height:520px;overflow:auto;margin:0}
#sec-ssh code,#sec-payments code,#sec-cashouts code{background:var(--bg-elev,#111);border:1px solid var(--border,#2a2a2a);border-radius:5px;padding:2px 7px;font-family:ui-monospace,Menlo,Consolas,monospace;font-size:0.8rem;display:inline-block;margin-top:3px;word-break:break-all}
#sec-ssh code,#sec-payments code,#sec-cashouts code{background:var(--bg-elev,#111);border:1px solid var(--border,#2a2a2a);border-radius:5px;padding:2px 7px;font-family:ui-monospace,Menlo,Consolas,monospace;font-size:0.8rem;display:inline-block;margin-top:3px;word-break:break-all}
-#sec-ssh .staff-pill{font-size:0.62rem;font-weight:700;text-transform:uppercase;letter-spacing:.04em;padding:3px 8px;border-radius:999px;vertical-align:middle;margin-left:8px;color:var(--warning,#f0b74a);background:color-mix(in srgb,var(--warning,#f0b74a) 18%,transparent);border:1px solid color-mix(in srgb,var(--warning,#f0b74a) 40%,transparent)}
+#sec-ssh .staff-pill{font-size:0.62rem;font-weight:700;text-transform:uppercase;letter-spacing:.04em;padding:3px 8px;border-radius:999px;vertical-align:middle;margin-left:8px;color:var(--warning-text, var(--warning,#f0b74a));background:color-mix(in srgb,var(--warning,#f0b74a) 18%,transparent);border:1px solid color-mix(in srgb,var(--warning,#f0b74a) 40%,transparent)}
#sec-ssh .staff-pill.user-pill{color:var(--accent,#6ea8ff);background:color-mix(in srgb,var(--accent,#6ea8ff) 18%,transparent);border-color:color-mix(in srgb,var(--accent,#6ea8ff) 40%,transparent)}
#sec-ssh .staff-pill.user-pill{color:var(--accent,#6ea8ff);background:color-mix(in srgb,var(--accent,#6ea8ff) 18%,transparent);border-color:color-mix(in srgb,var(--accent,#6ea8ff) 40%,transparent)}
@@ -90,7 +90,7 @@ body{min-height:100%;color:var(--text);font-family:'Inter',-apple-system,BlinkMa
@@ -90,7 +90,7 @@ body{min-height:100%;color:var(--text);font-family:'Inter',-apple-system,BlinkMa
.btn-primary:hover{background:var(--accent-dark)}
.btn-primary:hover{background:var(--accent-dark)}
.btn-outline{background:transparent;border:1px solid var(--border);color:var(--text)}
.btn-outline{background:transparent;border:1px solid var(--border);color:var(--text)}
.btn-outline:hover{border-color:var(--accent-dark);color:var(--accent-light)}
.btn-outline:hover{border-color:var(--accent-dark);color:var(--accent-light)}
-.btn-danger{background:rgba(231,76,60,0.1);color:var(--live-red);border:1px solid rgba(231,76,60,0.2)}
+.btn-danger{background:rgba(231,76,60,0.1);color:var(--live-red-text, var(--live-red));border:1px solid rgba(231,76,60,0.2)}
.btn-danger:hover{background:rgba(231,76,60,0.2)}
.btn-danger:hover{background:rgba(231,76,60,0.2)}
.btn-row{display:flex;gap:8px;margin-top:12px}
.btn-row{display:flex;gap:8px;margin-top:12px}
.data-check{display:flex;gap:8px;align-items:flex-start;font-size:13px;color:var(--text-secondary);line-height:1.5}
.data-check{display:flex;gap:8px;align-items:flex-start;font-size:13px;color:var(--text-secondary);line-height:1.5}
@@ -147,12 +147,12 @@ a.linked-item:hover{background:var(--bg-hover,var(--bg-card));transform:translat
@@ -147,12 +147,12 @@ a.linked-item:hover{background:var(--bg-hover,var(--bg-card));transform:translat
.hist-item .hx{margin-left:auto;display:flex;align-items:center;gap:10px;flex:none;font-size:11px;color:var(--text-muted)}
.hist-item .hx{margin-left:auto;display:flex;align-items:center;gap:10px;flex:none;font-size:11px;color:var(--text-muted)}
.hist-item .hx button{background:none;border:none;color:var(--text-muted);cursor:pointer;padding:4px;opacity:0;transition:opacity .15s}
.hist-item .hx button{background:none;border:none;color:var(--text-muted);cursor:pointer;padding:4px;opacity:0;transition:opacity .15s}
.hist-item:hover .hx button{opacity:1}
.hist-item:hover .hx button{opacity:1}
-.hist-item .hx button:hover{color:var(--danger,#ef4444)}
+.hist-item .hx button:hover{color:var(--danger-text, var(--danger,#ef4444))}
.hist-empty{padding:26px;text-align:center;color:var(--text-muted);font-size:13px}
.hist-empty{padding:26px;text-align:center;color:var(--text-muted);font-size:13px}
.linked-item .svc-icon{font-size:20px;width:32px;text-align:center}
.linked-item .svc-icon{font-size:20px;width:32px;text-align:center}
.linked-item .svc-name{font-size:13px;font-weight:600}
.linked-item .svc-name{font-size:13px;font-weight:600}
.linked-item .svc-user{font-size:11px;color:var(--text-muted)}
.linked-item .svc-user{font-size:11px;color:var(--text-muted)}
-.linked-item .svc-status{margin-left:auto;font-size:11px;font-weight:600;color:var(--success)}
+.linked-item .svc-status{margin-left:auto;font-size:11px;font-weight:600;color:var(--success-text, var(--success))}
/* Themes */
/* Themes */
.theme-hero{display:flex;justify-content:space-between;gap:16px;align-items:flex-start;flex-wrap:wrap;margin-bottom:18px}
.theme-hero{display:flex;justify-content:space-between;gap:16px;align-items:flex-start;flex-wrap:wrap;margin-bottom:18px}
@@ -232,12 +232,12 @@ async function loadStreamerMediaTools() {
@@ -232,12 +232,12 @@ async function loadStreamerMediaTools() {
<h3><i class="fa-solid fa-wrench"></i> OpenVibe.Live yt-dlp Status</h3>
<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 style="display:grid;gap:8px;font-size:0.95rem">
<div><strong>yt-dlp available:</strong> ${status.ytdlp_available
<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>'
+ ? '<span style="color:var(--success-text, var(--success,#22c55e))"><i class="fa-solid fa-check-circle"></i> Yes</span>'
+ : '<span style="color:var(--danger-text, var(--danger,#ef4444))"><i class="fa-solid fa-xmark-circle"></i> No</span>'
}</div>
}</div>
<div><strong>Path:</strong> <span style="font-family:monospace;font-size:0.88rem">${esc(status.ytdlp_path || '')}</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
<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 style="color:var(--success-text, 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>'
: '<span class="muted"><i class="fa-solid fa-cookie-bite"></i> Not configured</span>'
}</div>
}</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>' : ''}
${!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>' : ''}
@@ -317,8 +317,8 @@ function renderCookieVerdict(data) {
@@ -317,8 +317,8 @@ function renderCookieVerdict(data) {
const c = data.check || {};
const c = data.check || {};
const t = data.test || {};
const t = data.test || {};
const ok = (v) => v
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> ';
+ ? '<span style="color:var(--success-text, var(--success,#22c55e))"><i class="fa-solid fa-circle-check"></i> '
+ : '<span style="color:var(--danger-text, var(--danger,#ef4444))"><i class="fa-solid fa-circle-xmark"></i> ';
const rows = [];
const rows = [];
rows.push(`<div>${ok(c.ok)}Signed-in cookies${c.ok ? '' : ' — missing'}</span></div>`);
rows.push(`<div>${ok(c.ok)}Signed-in cookies${c.ok ? '' : ' — missing'}</span></div>`);
@@ -338,7 +338,7 @@ function renderCookieVerdict(data) {
@@ -338,7 +338,7 @@ function renderCookieVerdict(data) {
rows.push(`<div style="margin-top:6px">${ok(t.ok)}Live extraction${t.ok ? `: ${esc(t.title || 'ok')}` : ' failed'}</span></div>`);
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>`);
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.errors || []).forEach(e => rows.push(`<div style="color:var(--danger-text, 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>`));
(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>`;
el.innerHTML = `<div style="margin-top:12px;padding:12px;border:1px solid var(--border);border-radius:8px;background:var(--bg-input)">${rows.join('')}</div>`;
@@ -351,7 +351,7 @@ async function checkStreamerMediaCookies() {
@@ -351,7 +351,7 @@ async function checkStreamerMediaCookies() {
const data = await api('/api/admin/streamer/media-tools/cookies/check', { method: 'POST', body: {} });
const data = await api('/api/admin/streamer/media-tools/cookies/check', { method: 'POST', body: {} });
renderCookieVerdict(data);
renderCookieVerdict(data);
} catch (e) {
} catch (e) {
- if (el) el.innerHTML = `<div style="color:var(--danger,#ef4444);margin-top:10px">${esc(e.message)}</div>`;
+ if (el) el.innerHTML = `<div style="color:var(--danger-text, var(--danger,#ef4444));margin-top:10px">${esc(e.message)}</div>`;
}
}
}
}
@@ -369,7 +369,7 @@ async function saveStreamerMediaCookies() {
@@ -369,7 +369,7 @@ async function saveStreamerMediaCookies() {
// The server refuses a jar it can already tell is unusable and returns the reason.
// The server refuses a jar it can already tell is unusable and returns the reason.
toast(e.message, 'error');
toast(e.message, 'error');
if (e.body) renderCookieVerdict(e.body);
if (e.body) renderCookieVerdict(e.body);
- else if (el) el.innerHTML = `<div style="color:var(--danger,#ef4444);margin-top:10px">${esc(e.message)}</div>`;
+ else if (el) el.innerHTML = `<div style="color:var(--danger-text, var(--danger,#ef4444));margin-top:10px">${esc(e.message)}</div>`;
}
}
}
}
@@ -391,15 +391,15 @@ async function testStreamerMediaExtraction() {
@@ -391,15 +391,15 @@ async function testStreamerMediaExtraction() {
const data = await api('/api/admin/streamer/media-tools/test', { method: 'POST', body: { url } });
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 => {
results.innerHTML = `<div style="display:grid;gap:8px;font-size:0.9rem">${(data.steps || []).map(step => {
const icon = step.ok
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>';
+ ? '<i class="fa-solid fa-check-circle" style="color:var(--success-text, var(--success,#22c55e))"></i>'
+ : '<i class="fa-solid fa-xmark-circle" style="color:var(--danger-text, var(--danger,#ef4444))"></i>';
const detail = step.error
const detail = step.error
- ? `<span style="color:var(--danger,#ef4444)"> — ${esc(step.error)}</span>`
+ ? `<span style="color:var(--danger-text, var(--danger,#ef4444))"> — ${esc(step.error)}</span>`
: step.data ? ` — ${esc(JSON.stringify(step.data))}` : '';
: step.data ? ` — ${esc(JSON.stringify(step.data))}` : '';
return `<div>${icon} <strong>${esc(step.name)}</strong>${detail}</div>`;
return `<div>${icon} <strong>${esc(step.name)}</strong>${detail}</div>`;
}).join('')}</div>`;
}).join('')}</div>`;
} catch (e) {
} catch (e) {
- results.innerHTML = `<p style="color:var(--danger,#ef4444)">${esc(e.message)}</p>`;
+ results.innerHTML = `<p style="color:var(--danger-text, var(--danger,#ef4444))">${esc(e.message)}</p>`;
}
}
}
}
@@ -125,8 +125,8 @@ async function loadAnalyticsOverview() {
@@ -125,8 +125,8 @@ async function loadAnalyticsOverview() {
document.getElementById('analytics-realtime').innerHTML = `
document.getElementById('analytics-realtime').innerHTML = `
<div style="display:flex;gap:24px;font-size:14px">
<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(--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>
+ <span><strong style="color:var(--success-text, var(--success))">${fmtNum(rt.visitors)}</strong> visitors</span>
+ <span><strong style="color:var(--danger-text, var(--danger))">${fmtNum(rt.bots)}</strong> bots</span>
</div>`;
</div>`;
} else {
} else {
rtCard.style.display = 'none';
rtCard.style.display = 'none';
@@ -309,10 +309,10 @@ async function loadAnalyticsOverview() {
@@ -309,10 +309,10 @@ async function loadAnalyticsOverview() {
<div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;font-size:13px">
<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)">${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(--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><strong style="color:var(--info-text, var(--info))">${fmtNum(bw.page_requests)}</strong><br><span class="muted">Page Loads</span></div>
+ <div><strong style="color:var(--info-text, var(--info))">${fmtNum(bw.api_requests)}</strong><br><span class="muted">API Requests</span></div>
+ <div><strong style="color:var(--success-text, var(--success))">${fmtNum(ov.sessionCount || 0)}</strong><br><span class="muted">Unique Sessions</span></div>
+ <div><strong style="color:var(--success-text, var(--success))">${fmtNum(t.total_unique_users || 0)}</strong><br><span class="muted">Logged-in Users</span></div>
</div>`;
</div>`;
} catch (e) {
} catch (e) {
@@ -478,11 +478,11 @@ async function loadServiceAnalytics(serviceName) {
@@ -478,11 +478,11 @@ async function loadServiceAnalytics(serviceName) {
<div style="display:grid;grid-template-columns:1fr 1fr;gap:10px;font-size:13px">
<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)">${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(--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>
+ <div><strong style="color:var(--info-text, var(--info))">${fmtNum(bw.page_requests)}</strong><br><span class="muted">Page Loads</span></div>
+ <div><strong style="color:var(--info-text, var(--info))">${fmtNum(bw.api_requests)}</strong><br><span class="muted">API Requests</span></div>
+ <div><strong style="color:var(--success-text, var(--success))">${fmtNum(a.sessionCount || 0)}</strong><br><span class="muted">Sessions</span></div>
${vis.new_visitors != null
${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 style="color:var(--success-text, 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><strong class="muted">Not measured</strong><br><span class="muted" title="ADR-021: no visitor identity outlives a day">New / returning</span></div>'}
</div>`;
</div>`;
@@ -546,7 +546,7 @@ async function loadServiceAnalytics(serviceName) {
@@ -546,7 +546,7 @@ async function loadServiceAnalytics(serviceName) {
<td style="max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap" title="${esc(e.path)}">${esc(e.path)}</td>
<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><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:${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 style="color:var(--danger-text, var(--danger))">${e.max_ms || 0}ms</td>
<td>${fmtNum(e.hits)}</td>
<td>${fmtNum(e.hits)}</td>
</tr>
</tr>
`).join('')}</tbody>
`).join('')}</tbody>
@@ -624,7 +624,7 @@ async function loadBotAnalysis() {
@@ -624,7 +624,7 @@ async function loadBotAnalysis() {
<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>
<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 => `
<tbody>${suspicious.map(s => `
<tr><td style="font-family:monospace;font-size:11px">${esc(s.session_id || '-')}</td><td>${fmtNum(s.total_hits)}</td>
<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="color:var(--danger-text, 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>
<td style="font-size:11px">${s.first_seen ? timeAgo(s.first_seen) : '-'} — ${s.last_seen ? timeAgo(s.last_seen) : '-'}</td></tr>
`).join('')}</tbody></table>`;
`).join('')}</tbody></table>`;
}
}
@@ -61,7 +61,7 @@ async function _bootAdmin() {
@@ -61,7 +61,7 @@ async function _bootAdmin() {
const data = await api('/api/auth/me');
const data = await api('/api/auth/me');
currentUser = data.user || data;
currentUser = data.user || data;
if (currentUser.role !== 'admin') {
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>';
+ document.querySelector('.page').innerHTML = '<div class="card" style="text-align:center;padding:60px"><h3 style="color:var(--live-red-text, 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;
return;
}
}
// Owner-only UI (Deploy tab, etc.) — hide for plain admins.
// Owner-only UI (Deploy tab, etc.) — hide for plain admins.
@@ -154,8 +154,8 @@ function buildStreamerTierHtml(tierData) {
@@ -154,8 +154,8 @@ function buildStreamerTierHtml(tierData) {
if (!p) return '<span style="color:var(--text-muted)">—</span>';
if (!p) return '<span style="color:var(--text-muted)">—</span>';
if (!p.configured) return '<span style="color:var(--text-muted)">not configured</span>';
if (!p.configured) return '<span style="color:var(--text-muted)">not configured</span>';
return p.healthy
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>';
+ ? '<span style="color:var(--success-text, 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-text, var(--danger,#ef4444))"><i class="fa-solid fa-circle-xmark"></i> error</span>';
};
};
const rows = [
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: '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>' },
@@ -212,7 +212,7 @@ async function loadStreamerCloudStorage(force) {
@@ -212,7 +212,7 @@ async function loadStreamerCloudStorage(force) {
const u = usage[key];
const u = usage[key];
const cc = costs[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 || 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>';
+ 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-text, 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 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('');
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">'
return '<div class="card" style="flex:1;min-width:260px">'
@@ -226,7 +226,7 @@ async function loadStreamerCloudStorage(force) {
@@ -226,7 +226,7 @@ async function loadStreamerCloudStorage(force) {
+ '<div style="display:flex;gap:16px;flex-wrap:wrap;margin-bottom:12px">' + card('b2', 'Backblaze B2', '#e21e2b') + card('r2', 'Cloudflare R2', '#f6821f') + '</div>'
+ '<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>';
+ '<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) {
} catch (e) {
- el.innerHTML = header + '<div style="color:var(--danger,#ef4444)">Failed to scan buckets: ' + esc(e.message) + '</div>';
+ el.innerHTML = header + '<div style="color:var(--danger-text, var(--danger,#ef4444))">Failed to scan buckets: ' + esc(e.message) + '</div>';
}
}
}
}
@@ -49,7 +49,7 @@ async function loadDashboard() {
@@ -49,7 +49,7 @@ async function loadDashboard() {
<div><strong>Heap Used:</strong> ${fmtBytes(mem?.heapUsed)}</div>
<div><strong>Heap Used:</strong> ${fmtBytes(mem?.heapUsed)}</div>
<div><strong>Heap Total:</strong> ${fmtBytes(mem?.heapTotal)}</div>
<div><strong>Heap Total:</strong> ${fmtBytes(mem?.heapTotal)}</div>
<div><strong>RSS:</strong> ${fmtBytes(mem?.rss)}</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><strong>Email:</strong> ${th.ses_enabled ? '<span style="color:var(--success-text, var(--success))">Enabled</span>' : '<span style="color:var(--text-muted)">Disabled</span>'}</div>
</div>
</div>
`;
`;
} else {
} else {
@@ -92,7 +92,7 @@ function sshCopy(id, btn) {
@@ -92,7 +92,7 @@ function sshCopy(id, btn) {
// ── Owner view ──────────────────────────────────────────────
// ── Owner view ──────────────────────────────────────────────
function renderSSHOwner(el) {
function renderSSHOwner(el) {
const hostWarn = sshInfo.host ? '' :
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>`;
+ `<div class="card" style="border-color:var(--warning)"><p style="color:var(--warning-text, 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 = `
el.innerHTML = `
<div class="card">
<div class="card">
<h3><i class="fa-solid fa-user-lock"></i> Provision an SSH dev account <span class="staff-pill">Owner</span></h3>
<h3><i class="fa-solid fa-user-lock"></i> Provision an SSH dev account <span class="staff-pill">Owner</span></h3>
@@ -116,7 +116,7 @@ function renderSSHOwner(el) {
@@ -116,7 +116,7 @@ function renderSSHOwner(el) {
<option value="web">Full web + nginx — build & run their own sites (high trust)</option>
<option value="web">Full web + nginx — build & run their own sites (high trust)</option>
</select>
</select>
</div>
</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">
+ <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-text, 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.
<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>
</div>
@@ -330,12 +330,12 @@ async function loadDeployOverview() {
@@ -330,12 +330,12 @@ async function loadDeployOverview() {
items.push(prereqLine('ACME email configured', !!c.acmeEmail, c.acmeEmail || 'Not set'));
items.push(prereqLine('ACME email configured', !!c.acmeEmail, c.acmeEmail || 'Not set'));
prereqsEl.innerHTML = items.join('');
prereqsEl.innerHTML = items.join('');
} catch (e) {
} catch (e) {
- document.getElementById('deploy-stats').innerHTML = `<div class="muted" style="color:var(--danger)">${esc(e.message)}</div>`;
+ document.getElementById('deploy-stats').innerHTML = `<div class="muted" style="color:var(--danger-text, var(--danger))">${esc(e.message)}</div>`;
}
}
}
}
function prereqLine(label, ok, detail) {
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 icon = ok ? '<i class="fa-solid fa-circle-check" style="color:var(--success-text, var(--success))"></i>' : '<i class="fa-solid fa-circle-xmark" style="color:var(--danger-text, var(--danger))"></i>';
const detailHtml = detail ? ` <span class="muted" style="font-size:11px">— ${esc(detail)}</span>` : '';
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>`;
return `<div style="padding:6px 0;display:flex;align-items:center;gap:8px">${icon} ${esc(label)}${detailHtml}</div>`;
}
}
@@ -368,7 +368,7 @@ async function loadDeployCerts() {
@@ -368,7 +368,7 @@ async function loadDeployCerts() {
</div>`;
</div>`;
}).join('');
}).join('');
} catch (e) {
} catch (e) {
- el.innerHTML = `<div class="muted" style="color:var(--danger)">${esc(e.message)}</div>`;
+ el.innerHTML = `<div class="muted" style="color:var(--danger-text, var(--danger))">${esc(e.message)}</div>`;
}
}
}
}
@@ -384,15 +384,15 @@ async function issueCertCloudflare() {
@@ -384,15 +384,15 @@ async function issueCertCloudflare() {
});
});
const d = await r.json();
const d = await r.json();
if (d.ok) {
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>
+ out.innerHTML = `<div style="color:var(--success-text, 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>`;
<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();
loadDeployCerts();
} else {
} else {
- out.innerHTML = `<div style="color:var(--danger);padding:8px"><i class="fa-solid fa-xmark"></i> ${esc(d.error || 'Failed')}</div>
+ out.innerHTML = `<div style="color:var(--danger-text, 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>`;
<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) {
} catch (e) {
- out.innerHTML = `<div style="color:var(--danger)">${esc(e.message)}</div>`;
+ out.innerHTML = `<div style="color:var(--danger-text, var(--danger))">${esc(e.message)}</div>`;
}
}
}
}
@@ -419,7 +419,7 @@ async function showManualDnsInfo() {
@@ -419,7 +419,7 @@ async function showManualDnsInfo() {
</div>
</div>
</div>`;
</div>`;
} catch (e) {
} catch (e) {
- out.innerHTML = `<div style="color:var(--danger)">${esc(e.message)}</div>`;
+ out.innerHTML = `<div style="color:var(--danger-text, var(--danger))">${esc(e.message)}</div>`;
}
}
}
}
@@ -433,15 +433,15 @@ async function issueManualCert(domain) {
@@ -433,15 +433,15 @@ async function issueManualCert(domain) {
});
});
const d = await r.json();
const d = await r.json();
if (d.ok) {
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>
+ out.innerHTML = `<div style="color:var(--success-text, 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>`;
<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();
loadDeployCerts();
} else {
} else {
- out.innerHTML = `<div style="color:var(--danger);padding:8px"><i class="fa-solid fa-xmark"></i> ${esc(d.error || 'Failed')}</div>
+ out.innerHTML = `<div style="color:var(--danger-text, 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>`;
<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) {
} catch (e) {
- out.innerHTML = `<div style="color:var(--danger)">${esc(e.message)}</div>`;
+ out.innerHTML = `<div style="color:var(--danger-text, var(--danger))">${esc(e.message)}</div>`;
}
}
}
}
@@ -459,7 +459,7 @@ async function renewAllCerts() {
@@ -459,7 +459,7 @@ async function renewAllCerts() {
<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>`;
<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();
if (d.ok) loadDeployCerts();
} catch (e) {
} catch (e) {
- out.innerHTML = `<div style="color:var(--danger)">${esc(e.message)}</div>`;
+ out.innerHTML = `<div style="color:var(--danger-text, var(--danger))">${esc(e.message)}</div>`;
}
}
}
}
@@ -480,7 +480,7 @@ async function previewNginxConfigs() {
@@ -480,7 +480,7 @@ async function previewNginxConfigs() {
</div>
</div>
`).join('');
`).join('');
} catch (e) {
} catch (e) {
- out.innerHTML = `<div style="color:var(--danger)">${esc(e.message)}</div>`;
+ out.innerHTML = `<div style="color:var(--danger-text, var(--danger))">${esc(e.message)}</div>`;
}
}
}
}
@@ -500,7 +500,7 @@ async function validateNginx() {
@@ -500,7 +500,7 @@ async function validateNginx() {
<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>
<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>`;
</div>`;
} catch (e) {
} catch (e) {
- out.innerHTML = `<div style="color:var(--danger)">${esc(e.message)}</div>`;
+ out.innerHTML = `<div style="color:var(--danger-text, var(--danger))">${esc(e.message)}</div>`;
}
}
}
}
@@ -521,10 +521,10 @@ async function applyNginxConfigs() {
@@ -521,10 +521,10 @@ async function applyNginxConfigs() {
if (d.backed_up?.length) lines.push(`<div class="muted">Backed up: ${d.backed_up.length} files</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.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.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>`);
+ if (d.errors?.length) lines.push(`<div style="color:var(--danger-text, var(--danger))">Errors: ${d.errors.map(e => esc(e)).join('; ')}</div>`);
out.innerHTML = `<div class="card" style="padding:16px">${lines.join('')}</div>`;
out.innerHTML = `<div class="card" style="padding:16px">${lines.join('')}</div>`;
} catch (e) {
} catch (e) {
- out.innerHTML = `<div style="color:var(--danger)">${esc(e.message)}</div>`;
+ out.innerHTML = `<div style="color:var(--danger-text, var(--danger))">${esc(e.message)}</div>`;
}
}
}
}
@@ -555,7 +555,7 @@ async function loadDeployDomains() {
@@ -555,7 +555,7 @@ async function loadDeployDomains() {
</div>
</div>
`).join('');
`).join('');
} catch (e) {
} catch (e) {
- el.innerHTML = `<div class="muted" style="color:var(--danger)">${esc(e.message)}</div>`;
+ el.innerHTML = `<div class="muted" style="color:var(--danger-text, var(--danger))">${esc(e.message)}</div>`;
}
}
}
}
@@ -601,7 +601,7 @@ async function loadDeployConfig() {
@@ -601,7 +601,7 @@ async function loadDeployConfig() {
document.getElementById('deploy-nginx-mode').value = d.config.nginxMode || 'preview';
document.getElementById('deploy-nginx-mode').value = d.config.nginxMode || 'preview';
document.getElementById('deploy-nginx-path').value = d.config.nginxSitesPath || '/etc/nginx/sites-enabled';
document.getElementById('deploy-nginx-path').value = d.config.nginxSitesPath || '/etc/nginx/sites-enabled';
} catch (e) {
} catch (e) {
- document.getElementById('deploy-config-status').innerHTML = `<span style="color:var(--danger)">${esc(e.message)}</span>`;
+ document.getElementById('deploy-config-status').innerHTML = `<span style="color:var(--danger-text, var(--danger))">${esc(e.message)}</span>`;
}
}
}
}
@@ -621,9 +621,9 @@ async function saveDeployConfig() {
@@ -621,9 +621,9 @@ async function saveDeployConfig() {
});
});
const d = await r.json();
const d = await r.json();
if (!d.ok) throw new Error(d.error);
if (!d.ok) throw new Error(d.error);
- status.innerHTML = '<span style="color:var(--success)"><i class="fa-solid fa-check"></i> Saved</span>';
+ status.innerHTML = '<span style="color:var(--success-text, var(--success))"><i class="fa-solid fa-check"></i> Saved</span>';
setTimeout(() => status.innerHTML = '', 3000);
setTimeout(() => status.innerHTML = '', 3000);
} catch (e) {
} catch (e) {
- status.innerHTML = `<span style="color:var(--danger)">${esc(e.message)}</span>`;
+ status.innerHTML = `<span style="color:var(--danger-text, var(--danger))">${esc(e.message)}</span>`;
}
}
}
}
@@ -228,14 +228,14 @@ function _streamerSettingField(s) {
@@ -228,14 +228,14 @@ function _streamerSettingField(s) {
return `<div class="setting-row" style="flex-direction:column;align-items:stretch">${label}
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"
<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>
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>`;
+ <div class="setting-err muted" data-err-for="${esc(s.key)}" style="display:none;color:var(--danger-text, var(--danger,#f87171));font-size:12px;margin-top:4px"></div></div>`;
}
}
const type = s.type === 'number' ? 'number' : (isSensitive ? 'password' : 'text');
const type = s.type === 'number' ? 'number' : (isSensitive ? 'password' : 'text');
const width = s.type === 'number' ? 'width:200px;' : '';
const width = s.type === 'number' ? 'width:200px;' : '';
return `<div class="setting-row" style="flex-direction:column;align-items:stretch">${label}
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"' : ''}
<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}">
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>`;
+ <div class="setting-err muted" data-err-for="${esc(s.key)}" style="display:none;color:var(--danger-text, 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.
// Snapshot of what the server sent (keyed by setting key) so Save only submits real changes.
@@ -223,7 +223,7 @@ function loadAccounts() {
@@ -223,7 +223,7 @@ function loadAccounts() {
<div class="meta">${a.is_anon ? 'Anonymous #' + (a.anon_number || '?') : '@' + a.username}</div>
<div class="meta">${a.is_anon ? 'Anonymous #' + (a.anon_number || '?') : '@' + a.username}</div>
</div>
</div>
<div class="actions">
<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-outline" onclick="switchAccount('${a.id}')">Switch</button>` : '<span style="font-size:11px;color:var(--success-text, 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>` : ''}
${String(a.id) !== String(activeId) ? `<button class="btn btn-danger" onclick="removeAccount('${a.id}')"><i class="fa-solid fa-xmark"></i></button>` : ''}
</div>
</div>
</div>
</div>
@@ -15,9 +15,9 @@ async function loadEmailStatus() {
@@ -15,9 +15,9 @@ async function loadEmailStatus() {
if (!st.email) {
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>`;
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) {
} 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>`;
+ body.innerHTML = `<p><span style="color:var(--danger-text, 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) {
} 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>`;
+ body.innerHTML = `<p><span style="color:var(--success-text, 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 {
} 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>
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>
<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>
@@ -116,7 +116,7 @@ async function loadLinked() {
@@ -116,7 +116,7 @@ async function loadLinked() {
const who = l.service_username && !String(l.service_user_id || '').startsWith('network:') ? '@' + l.service_username : (l.service_username ? '@' + l.service_username : 'this account');
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>
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><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>`;
+ <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-text, 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>`;
return site.url ? `<a class="linked-item" href="${site.url}" target="_blank" rel="noopener">${inner}</a>` : `<div class="linked-item">${inner}</div>`;
}).join('');
}).join('');
const rest = Object.entries(SITES).filter(([id]) => !known.has(id) && id !== 'network' && id !== 'discord');
const rest = Object.entries(SITES).filter(([id]) => !known.has(id) && id !== 'network' && id !== 'discord');
@@ -32,8 +32,8 @@ body{background:var(--bg-primary);color:var(--text-primary);font-family:'Inter',
@@ -32,8 +32,8 @@ body{background:var(--bg-primary);color:var(--text-primary);font-family:'Inter',
.ring.spin{border-top-color:var(--accent);animation:spin 1s linear infinite}
.ring.spin{border-top-color:var(--accent);animation:spin 1s linear infinite}
@keyframes spin{to{transform:rotate(360deg)}}
@keyframes spin{to{transform:rotate(360deg)}}
.glyph .ico{position:absolute;inset:0;display:grid;place-items:center;font-size:38px;color:var(--accent-light);transition:transform .35s cubic-bezier(.2,1.4,.3,1),color .3s}
.glyph .ico{position:absolute;inset:0;display:grid;place-items:center;font-size:38px;color:var(--accent-light);transition:transform .35s cubic-bezier(.2,1.4,.3,1),color .3s}
-.card[data-state="ok"] .ico{color:var(--success);transform:scale(1.12)}
-.card[data-state="bad"] .ico{color:var(--danger)}
+.card[data-state="ok"] .ico{color:var(--success-text, var(--success));transform:scale(1.12)}
+.card[data-state="bad"] .ico{color:var(--danger-text, var(--danger))}
.card[data-state="ok"] .ring{border-color:rgba(34,197,94,.35);animation:pop .6s cubic-bezier(.2,1.6,.3,1) both}
.card[data-state="ok"] .ring{border-color:rgba(34,197,94,.35);animation:pop .6s cubic-bezier(.2,1.6,.3,1) both}
.card[data-state="bad"] .ring{border-color:rgba(239,68,68,.35)}
.card[data-state="bad"] .ring{border-color:rgba(239,68,68,.35)}
@keyframes pop{0%{transform:scale(.6);opacity:0}60%{transform:scale(1.08)}100%{transform:scale(1);opacity:1}}
@keyframes pop{0%{transform:scale(.6);opacity:0}60%{transform:scale(1.08)}100%{transform:scale(1);opacity:1}}
@@ -141,7 +141,7 @@ const CSS = `
@@ -141,7 +141,7 @@ const CSS = `
.st th{font-size:12px;text-transform:uppercase;letter-spacing:.5px;color:var(--text-muted,#7386a3)}
.st th{font-size:12px;text-transform:uppercase;letter-spacing:.5px;color:var(--text-muted,#7386a3)}
.st td small{display:block;color:var(--text-muted,#7386a3);font-size:12.5px;margin-top:2px}
.st td small{display:block;color:var(--text-muted,#7386a3);font-size:12.5px;margin-top:2px}
.st code{font-size:12.5px;overflow-wrap:anywhere}.st-b{display:inline-block;padding:2px 9px;border-radius:999px;font-weight:700;font-size:12.5px;border:1px solid currentColor}
.st code{font-size:12.5px;overflow-wrap:anywhere}.st-b{display:inline-block;padding:2px 9px;border-radius:999px;font-weight:700;font-size:12.5px;border:1px solid currentColor}
-.st-up{color:var(--success,#22c55e)}.st-degraded{color:#f59e0b}.st-down{color:var(--live-red,#ef4444)}.st-not-running,.st-unknown{color:var(--text-muted,#7386a3)}
+.st-up{color:var(--success-text, var(--success,#22c55e))}.st-degraded{color:#f59e0b}.st-down{color:var(--live-red-text, var(--live-red,#ef4444))}.st-not-running,.st-unknown{color:var(--text-muted,#7386a3)}
.st ul.chk{margin:4px 0 0;padding:0;list-style:none;font-size:12.5px}.st ul.chk li{margin:1px 0}
.st ul.chk{margin:4px 0 0;padding:0;list-style:none;font-size:12.5px}.st ul.chk li{margin:1px 0}
.st section{margin-top:40px}.st dl{display:grid;grid-template-columns:minmax(160px,240px) 1fr;gap:6px 16px;font-size:14px}.st dt{font-weight:600}.st dd{margin:0;color:var(--text-secondary,#96a7c2)}
.st section{margin-top:40px}.st dl{display:grid;grid-template-columns:minmax(160px,240px) 1fr;gap:6px 16px;font-size:14px}.st dt{font-weight:600}.st dd{margin:0;color:var(--text-secondary,#96a7c2)}
@media (max-width:720px){.st table,.st tbody,.st tr,.st td{display:block}.st thead{display:none}.st tr{border-bottom:1px solid var(--border,#1f2d47);padding:8px 0}.st td{border:0;padding:4px 0}.st dl{grid-template-columns:1fr}}`;
@media (max-width:720px){.st table,.st tbody,.st tr,.st td{display:block}.st thead{display:none}.st tr{border-bottom:1px solid var(--border,#1f2d47);padding:8px 0}.st td{border:0;padding:4px 0}.st dl{grid-template-columns:1fr}}`;