openvibers/openvibe.network Commits
The mark's wait instead of spinner icons and bare Loading text (#791)
Changes
@@@ -218,7 +218,7 @@<!-- Storage --><div class="section admin-clarity" id="sec-storage"><p class="admin-intro">This shows how much space recordings and other files use. Review it when storage runs low; deleting recordings is permanent and requires a separate backup to recover.</p>-- <div id="storage-content"><div class="loading"><i class="fa-solid fa-spinner fa-spin"></i> Analyzing storage...</div></div>++ <div id="storage-content"><div class="loading"><span class="ov-mark" data-state="busy" data-size="16" data-tone="ink" aria-hidden="true"></span> Analyzing storage...</div></div></div><!-- Notifications (Broadcast) -->@@@ -672,6 +672,7 @@<!-- Shared scripts --><script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/chart.umd.min.js"></script>++<script src="/shared/ov-mark.js" defer></script><script src="/shared/navbar.js" defer></script><script src="/js/account-safety.js" defer></script><script src="/shared/notification-ui.js" defer></script>@@@ -402,7 +402,7 @@ function renderCookieVerdict(data) {async function checkStreamerMediaCookies() {const el = document.getElementById('streamer-media-cookie-verdict');-- if (el) el.innerHTML = '<div class="muted" style="margin-top:10px"><i class="fa-solid fa-spinner fa-spin"></i> Validating and running a live extraction…</div>';++ if (el) el.innerHTML = '<div class="muted" style="margin-top:10px"><span class="ov-mark" data-state="busy" data-size="16" data-tone="ink" aria-hidden="true"></span> Validating and running a live extraction…</div>';try {const data = await api('/api/admin/streamer/media-tools/cookies/check', { method: 'POST', body: {} });renderCookieVerdict(data);@@@ -416,7 +416,7 @@ async function saveStreamerMediaCookies() {const cookies = ta?.value?.trim() || '';if (!cookies) return toast('Paste cookies.txt content first', 'error');const el = document.getElementById('streamer-media-cookie-verdict');-- if (el) el.innerHTML = '<div class="muted" style="margin-top:10px"><i class="fa-solid fa-spinner fa-spin"></i> Validating and running a live extraction…</div>';++ if (el) el.innerHTML = '<div class="muted" style="margin-top:10px"><span class="ov-mark" data-state="busy" data-size="16" data-tone="ink" aria-hidden="true"></span> Validating and running a live extraction…</div>';try {const data = await api('/api/admin/streamer/media-tools/cookies', { method: 'PUT', body: { cookies } });toast(data?.test?.ok ? 'Cookies saved — live extraction passed' : 'Cookies saved', 'success');@@@ -442,7 +442,7 @@ async function testStreamerMediaExtraction() {const url = document.getElementById('streamer-media-test-url')?.value?.trim() || '';const results = document.getElementById('streamer-media-test-results');if (!url) return toast('Enter a URL to test', 'error');-- results.innerHTML = '<p class="muted"><i class="fa-solid fa-spinner fa-spin"></i> Testing live OpenVibe.Live extraction…</p>';++ results.innerHTML = '<p class="muted"><span class="ov-mark" data-state="busy" data-size="16" data-tone="ink" aria-hidden="true"></span> Testing live OpenVibe.Live extraction…</p>';try {const data = await api('/api/admin/streamer/media-tools/test', { method: 'POST', body: { url } });results.innerHTML = `<div style="display:grid;gap:8px;font-size:0.9rem">${(data.steps || []).map(step => {@@@ -64,7 +64,7 @@ async function deleteAllPasteForks() {// ═══════════════════════════════════════════════════════════════async function loadStorage() {const c = document.getElementById('storage-content');-- c.innerHTML = '<div class="loading"><i class="fa-solid fa-spinner fa-spin"></i> Analyzing storage...</div>';++ c.innerHTML = '<div class="loading"><span class="ov-mark" data-state="busy" data-size="16" data-tone="ink" aria-hidden="true"></span> Analyzing storage...</div>';try {const [data, tierData] = await Promise.all([api('/api/admin/streamer/storage'),@@@ -180,7 +180,7 @@ function buildStreamerTierHtml(tierData) {+ '<thead><tr style="text-align:left;color:var(--text-muted);border-bottom:1px solid var(--border)">'+ '<th style="padding:6px 10px">Tier</th><th style="padding:6px 10px;text-align:right">VODs</th><th style="padding:6px 10px;text-align:right">VOD Size</th><th style="padding:6px 10px;text-align:right">Share</th><th style="padding:6px 10px;text-align:right">Clips</th><th style="padding:6px 10px">Provider</th></tr></thead>'+ '<tbody>' + rowHtml-- + '<tr style="border-top:2px solid var(--border);font-weight:700"><td style="padding:8px 10px">Total</td><td style="padding:8px 10px;text-align:right">' + totalVods.toLocaleString() + '</td><td style="padding:8px 10px;text-align:right">' + fmtBytes(totalBytes) + '</td><td style="padding:8px 10px;text-align:right">100%</td><td style="padding:8px 10px;text-align:right">' + totalClips.toLocaleString() + '</td><td style="padding:8px 10px">' + (tierData.sweepRunning ? '<span style="color:#f59e0b"><i class="fa-solid fa-spinner fa-spin"></i> sweeping</span>' : '<span style="color:var(--text-muted)">idle</span>') + '</td></tr>'++ + '<tr style="border-top:2px solid var(--border);font-weight:700"><td style="padding:8px 10px">Total</td><td style="padding:8px 10px;text-align:right">' + totalVods.toLocaleString() + '</td><td style="padding:8px 10px;text-align:right">' + fmtBytes(totalBytes) + '</td><td style="padding:8px 10px;text-align:right">100%</td><td style="padding:8px 10px;text-align:right">' + totalClips.toLocaleString() + '</td><td style="padding:8px 10px">' + (tierData.sweepRunning ? '<span style="color:#f59e0b"><span class="ov-mark" data-state="busy" data-size="16" data-tone="ink" aria-hidden="true"></span> sweeping</span>' : '<span style="color:var(--text-muted)">idle</span>') + '</td></tr>'+ '</tbody></table></div>'+ '<div style="display:flex;flex-wrap:wrap;gap:8px;font-size:12px;color:var(--text-muted);margin-top:12px">'+ '<span><i class="fa-solid ' + (ts.enabled ? 'fa-circle-check' : 'fa-circle-xmark') + '" style="color:' + (ts.enabled ? '#22c55e' : '#ef4444') + '"></i> Auto-offload: ' + (ts.enabled ? 'ON' : 'OFF') + '</span>'@@@ -202,7 +202,7 @@ async function loadStreamerCloudStorage(force) {const header = '<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:12px">'+ '<h3 style="margin:0"><i class="fa-solid fa-cloud"></i> Cloud storage usage and cost</h3>'+ '<p class="admin-intro">This estimates storage in Backblaze B2 and Cloudflare R2, in bytes and US dollars per month. Rescan when checking capacity; higher usage means more stored data and may increase charges.</p><button class="btn btn-sm btn-outline" onclick="loadStreamerCloudStorage(true)"><i class="fa-solid fa-rotate"></i> Rescan now</button></div>';-- el.innerHTML = header + '<div class="loading"><i class="fa-solid fa-spinner fa-spin"></i> Scanning cloud buckets…</div>';++ el.innerHTML = header + '<div class="loading"><span class="ov-mark" data-state="busy" data-size="16" data-tone="ink" aria-hidden="true"></span> Scanning cloud buckets…</div>';try {const data = await api('/api/admin/streamer/storage/buckets' + (force ? '?force=1' : ''));const usage = data.usage || {};@@@ -103,7 +103,7 @@ function consentRow(purpose) {function renderConsent() {const box = document.getElementById('consent-choices');if (!box) return;-- if (!consentDoc) { box.innerHTML = '<div class="loading-spinner">Loading your choices…</div>'; return; }++ if (!consentDoc) { box.innerHTML = '<div class="loading-spinner"><span class="ov-mark" data-state="busy" data-size="32" aria-hidden="true"></span></div>'; return; }// A re-render (a save, or the apps arriving) keeps the folds the person had open.const open = new Set([...box.querySelectorAll('details[open][data-consent-row]')].map((d) => d.getAttribute('data-consent-row')));box.innerHTML = (consentDoc.purposes || []).map(consentRow).join('');@@@ -188,7 +188,7 @@ function renderConsentApps() {const box = document.getElementById('consent-apps');if (!box) return;box.replaceChildren();-- if (!consentAppsDoc) { box.append(consentNode('div', 'loading-spinner', 'Loading your apps…')); return; }++ if (!consentAppsDoc) { const wait = consentNode('div', 'loading-spinner', null); wait.innerHTML = '<span class="ov-mark" data-state="busy" data-size="32" aria-hidden="true"></span>'; box.append(wait); return; }const apps = consentAppsDoc.apps || [];if (!apps.length) {box.append(consentNode('p', 'ovk-muted consent-empty', 'No app has asked you for anything yet. An app asks when you sign in to it, and what you allow shows here.'));@@@ -26,7 +26,7 @@ async function loadEmailStatus() {} catch (e) { card.style.display = 'none'; }}async function resendVerification(btn) {-- if (btn) { btn.disabled = true; btn.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> Sending…'; }++ if (btn) { btn.disabled = true; btn.innerHTML = '<span class="ov-mark" data-state="busy" data-size="16" data-tone="ink" aria-hidden="true"></span> Sending…'; }try { const r = await apiFetch('/api/auth/email/send-verification', { method: 'POST' }); toast(r.message || 'Sent', 'success'); }catch (e) { toast(e.message, 'error'); }loadEmailStatus();@@@ -184,7 +184,7 @@ async function loadHistory(more) {if (isAnonSession()) { document.getElementById('hist-list').innerHTML = '<div class="hist-empty">History is only kept for signed-in accounts.</div>'; return; }if (!more) _histOffset = 0;const listEl = document.getElementById('hist-list');-- if (!more) listEl.innerHTML = '<div class="loading-spinner">Loading history…</div>';++ if (!more) listEl.innerHTML = '<div class="loading-spinner"><span class="ov-mark" data-state="busy" data-size="32" aria-hidden="true"></span></div>';try {const q = new URLSearchParams({ limit: '40', offset: String(_histOffset) });if (_histService) q.set('service', _histService);@@@ -383,7 +383,7 @@<button class="btn btn-small btn-danger" onclick="clearHistory()" title="Delete the whole timeline"><i class="fa-solid fa-trash"></i> Clear</button></div><p id="hist-summary" role="status" hidden></p>-- <div id="hist-list"><div class="loading-spinner">Loading history…</div></div>++ <div id="hist-list"><div class="loading-spinner"><span class="ov-mark" data-state="busy" data-size="32" aria-hidden="true"></span></div></div><div class="btn-row" id="hist-more-row" style="display:none;margin-top:10px"><button class="btn" onclick="loadHistory(true)">Load more</button></div></div></div>@@@ -396,12 +396,12 @@<div class="card" id="consent-card"><h3><span class="icon"><i class="fa-solid fa-circle-check"></i></span> Your data choices</h3><p>AI training, the open dataset, face recognition and apps that use your content are <b>off until you turn them on</b>. Each choice is yours, and you can turn it off again at any time. Nothing here is on by default. We never sell your data.</p>-- <div id="consent-choices"><div class="loading-spinner">Loading your choices…</div></div>++ <div id="consent-choices"><div class="loading-spinner"><span class="ov-mark" data-state="busy" data-size="32" aria-hidden="true"></span></div></div><!-- Apps you allowed (C4, 2026-11-07): what each app asked for when you signed in to it (consent:<purpose>)and was given, one Turn off per purpose, and what you said no to in the last 30 days(GET /api/v1/me/consent/apps). --><h4 class="consent-apps-title">Apps you allowed</h4>-- <div id="consent-apps"><div class="loading-spinner">Loading your apps…</div></div>++ <div id="consent-apps"><div class="loading-spinner"><span class="ov-mark" data-state="busy" data-size="32" aria-hidden="true"></span></div></div><p class="muted consent-status" id="consent-status" role="status"></p></div><!-- What you see (L1, 2026-11-02): one row per content label, three equal choices, saved at once. The web default@@@ -426,7 +426,7 @@<div class="card"><h3><span class="icon"><i class="fa-solid fa-database"></i></span> Your data on OpenVibe</h3><p style="margin-bottom:12px">Settings and summaries OpenVibe sites keep with your account: your chat settings, favourite tools, streaming and community summaries and more. The sites themselves keep the full records. You can download all of this, or delete any of it. A site that writes a summary may write it again the next time you use that site.</p>-- <div id="modules-list"><div class="loading-spinner">Loading…</div></div>++ <div id="modules-list"><div class="loading-spinner"><span class="ov-mark" data-state="busy" data-size="32" aria-hidden="true"></span></div></div><div class="btn-row"><button class="btn" type="button" onclick="exportModules()"><i class="fa-solid fa-download"></i> Download as JSON</button></div></div></div>@@@ -531,7 +531,7 @@</div><div class="card"><h3><span class="icon"><i class="fa-solid fa-mobile-screen"></i></span> Active Sessions</h3>-- <div id="sessions-list"><div class="loading-spinner">Loading sessions...</div></div>++ <div id="sessions-list"><div class="loading-spinner"><span class="ov-mark" data-state="busy" data-size="32" aria-hidden="true"></span></div></div><p style="margin-top:12px;color:var(--text-secondary);font-size:13px">Lost a phone or used a shared computer? Signing out ends every sign-in on every OpenVibe site at once, and open chats and games disconnect.</p><div class="btn-row" style="margin-top:10px"><button class="btn btn-danger" onclick="revokeAllSessions()"><i class="fa-solid fa-mobile-screen-button"></i> Sign out other devices</button>@@@ -541,7 +541,7 @@<div class="card"><h3><span class="icon"><i class="fa-solid fa-plug"></i></span> Signed-in apps</h3><p style="margin-bottom:12px;color:var(--text-secondary);font-size:13px">Extensions and apps that hold their own sign-in. Revoking one ends its refresh token; the next request asks you to sign in again.</p>-- <div id="apps-list"><div class="loading-spinner">Loading apps...</div></div>++ <div id="apps-list"><div class="loading-spinner"><span class="ov-mark" data-state="busy" data-size="32" aria-hidden="true"></span></div></div></div><div class="card" id="blocks-card"><h3><span class="icon"><i class="fa-solid fa-user-slash"></i></span> Blocked people</h3>@@@ -550,7 +550,7 @@<div class="form-field"><label for="block-username">Block someone</label><input id="block-username" name="username" maxlength="64" autocomplete="off" placeholder="username"></div><div class="btn-row"><button class="btn btn-danger" type="submit">Block</button></div></form>-- <div id="blocks-list"><div class="loading-spinner">Loading…</div></div>++ <div id="blocks-list"><div class="loading-spinner"><span class="ov-mark" data-state="busy" data-size="32" aria-hidden="true"></span></div></div><p class="data-status" id="blocks-status" role="status"></p></div></div>@@@ -626,6 +626,7 @@<!-- Shared scripts --><script src="/shared/theme-loader.js" defer></script>++<script src="/shared/ov-mark.js" defer></script><script src="/shared/navbar.js" defer></script><script src="/js/account-safety.js" defer></script><script src="/shared/notification-ui.js" defer></script>
@@ -218,7 +218,7 @@
@@ -218,7 +218,7 @@
<!-- Storage -->
<!-- Storage -->
<div class="section admin-clarity" id="sec-storage">
<div class="section admin-clarity" id="sec-storage">
<p class="admin-intro">This shows how much space recordings and other files use. Review it when storage runs low; deleting recordings is permanent and requires a separate backup to recover.</p>
<p class="admin-intro">This shows how much space recordings and other files use. Review it when storage runs low; deleting recordings is permanent and requires a separate backup to recover.</p>
- <div id="storage-content"><div class="loading"><i class="fa-solid fa-spinner fa-spin"></i> Analyzing storage...</div></div>
+ <div id="storage-content"><div class="loading"><span class="ov-mark" data-state="busy" data-size="16" data-tone="ink" aria-hidden="true"></span> Analyzing storage...</div></div>
</div>
</div>
<!-- Notifications (Broadcast) -->
<!-- Notifications (Broadcast) -->
@@ -672,6 +672,7 @@
@@ -672,6 +672,7 @@
<!-- Shared scripts -->
<!-- Shared scripts -->
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/chart.umd.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/chart.umd.min.js"></script>
+<script src="/shared/ov-mark.js" defer></script>
<script src="/shared/navbar.js" defer></script>
<script src="/shared/navbar.js" defer></script>
<script src="/js/account-safety.js" defer></script>
<script src="/js/account-safety.js" defer></script>
<script src="/shared/notification-ui.js" defer></script>
<script src="/shared/notification-ui.js" defer></script>
@@ -402,7 +402,7 @@ function renderCookieVerdict(data) {
@@ -402,7 +402,7 @@ function renderCookieVerdict(data) {
async function checkStreamerMediaCookies() {
async function checkStreamerMediaCookies() {
const el = document.getElementById('streamer-media-cookie-verdict');
const el = document.getElementById('streamer-media-cookie-verdict');
- if (el) el.innerHTML = '<div class="muted" style="margin-top:10px"><i class="fa-solid fa-spinner fa-spin"></i> Validating and running a live extraction…</div>';
+ if (el) el.innerHTML = '<div class="muted" style="margin-top:10px"><span class="ov-mark" data-state="busy" data-size="16" data-tone="ink" aria-hidden="true"></span> Validating and running a live extraction…</div>';
try {
try {
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);
@@ -416,7 +416,7 @@ async function saveStreamerMediaCookies() {
@@ -416,7 +416,7 @@ async function saveStreamerMediaCookies() {
const cookies = ta?.value?.trim() || '';
const cookies = ta?.value?.trim() || '';
if (!cookies) return toast('Paste cookies.txt content first', 'error');
if (!cookies) return toast('Paste cookies.txt content first', 'error');
const el = document.getElementById('streamer-media-cookie-verdict');
const el = document.getElementById('streamer-media-cookie-verdict');
- if (el) el.innerHTML = '<div class="muted" style="margin-top:10px"><i class="fa-solid fa-spinner fa-spin"></i> Validating and running a live extraction…</div>';
+ if (el) el.innerHTML = '<div class="muted" style="margin-top:10px"><span class="ov-mark" data-state="busy" data-size="16" data-tone="ink" aria-hidden="true"></span> Validating and running a live extraction…</div>';
try {
try {
const data = await api('/api/admin/streamer/media-tools/cookies', { method: 'PUT', body: { cookies } });
const data = await api('/api/admin/streamer/media-tools/cookies', { method: 'PUT', body: { cookies } });
toast(data?.test?.ok ? 'Cookies saved — live extraction passed' : 'Cookies saved', 'success');
toast(data?.test?.ok ? 'Cookies saved — live extraction passed' : 'Cookies saved', 'success');
@@ -442,7 +442,7 @@ async function testStreamerMediaExtraction() {
@@ -442,7 +442,7 @@ async function testStreamerMediaExtraction() {
const url = document.getElementById('streamer-media-test-url')?.value?.trim() || '';
const url = document.getElementById('streamer-media-test-url')?.value?.trim() || '';
const results = document.getElementById('streamer-media-test-results');
const results = document.getElementById('streamer-media-test-results');
if (!url) return toast('Enter a URL to test', 'error');
if (!url) return toast('Enter a URL to test', 'error');
- results.innerHTML = '<p class="muted"><i class="fa-solid fa-spinner fa-spin"></i> Testing live OpenVibe.Live extraction…</p>';
+ results.innerHTML = '<p class="muted"><span class="ov-mark" data-state="busy" data-size="16" data-tone="ink" aria-hidden="true"></span> Testing live OpenVibe.Live extraction…</p>';
try {
try {
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 => {
@@ -64,7 +64,7 @@ async function deleteAllPasteForks() {
@@ -64,7 +64,7 @@ async function deleteAllPasteForks() {
// ═══════════════════════════════════════════════════════════════
// ═══════════════════════════════════════════════════════════════
async function loadStorage() {
async function loadStorage() {
const c = document.getElementById('storage-content');
const c = document.getElementById('storage-content');
- c.innerHTML = '<div class="loading"><i class="fa-solid fa-spinner fa-spin"></i> Analyzing storage...</div>';
+ c.innerHTML = '<div class="loading"><span class="ov-mark" data-state="busy" data-size="16" data-tone="ink" aria-hidden="true"></span> Analyzing storage...</div>';
try {
try {
const [data, tierData] = await Promise.all([
const [data, tierData] = await Promise.all([
api('/api/admin/streamer/storage'),
api('/api/admin/streamer/storage'),
@@ -180,7 +180,7 @@ function buildStreamerTierHtml(tierData) {
@@ -180,7 +180,7 @@ function buildStreamerTierHtml(tierData) {
+ '<thead><tr style="text-align:left;color:var(--text-muted);border-bottom:1px solid var(--border)">'
+ '<thead><tr style="text-align:left;color:var(--text-muted);border-bottom:1px solid var(--border)">'
+ '<th style="padding:6px 10px">Tier</th><th style="padding:6px 10px;text-align:right">VODs</th><th style="padding:6px 10px;text-align:right">VOD Size</th><th style="padding:6px 10px;text-align:right">Share</th><th style="padding:6px 10px;text-align:right">Clips</th><th style="padding:6px 10px">Provider</th></tr></thead>'
+ '<th style="padding:6px 10px">Tier</th><th style="padding:6px 10px;text-align:right">VODs</th><th style="padding:6px 10px;text-align:right">VOD Size</th><th style="padding:6px 10px;text-align:right">Share</th><th style="padding:6px 10px;text-align:right">Clips</th><th style="padding:6px 10px">Provider</th></tr></thead>'
+ '<tbody>' + rowHtml
+ '<tbody>' + rowHtml
- + '<tr style="border-top:2px solid var(--border);font-weight:700"><td style="padding:8px 10px">Total</td><td style="padding:8px 10px;text-align:right">' + totalVods.toLocaleString() + '</td><td style="padding:8px 10px;text-align:right">' + fmtBytes(totalBytes) + '</td><td style="padding:8px 10px;text-align:right">100%</td><td style="padding:8px 10px;text-align:right">' + totalClips.toLocaleString() + '</td><td style="padding:8px 10px">' + (tierData.sweepRunning ? '<span style="color:#f59e0b"><i class="fa-solid fa-spinner fa-spin"></i> sweeping</span>' : '<span style="color:var(--text-muted)">idle</span>') + '</td></tr>'
+ + '<tr style="border-top:2px solid var(--border);font-weight:700"><td style="padding:8px 10px">Total</td><td style="padding:8px 10px;text-align:right">' + totalVods.toLocaleString() + '</td><td style="padding:8px 10px;text-align:right">' + fmtBytes(totalBytes) + '</td><td style="padding:8px 10px;text-align:right">100%</td><td style="padding:8px 10px;text-align:right">' + totalClips.toLocaleString() + '</td><td style="padding:8px 10px">' + (tierData.sweepRunning ? '<span style="color:#f59e0b"><span class="ov-mark" data-state="busy" data-size="16" data-tone="ink" aria-hidden="true"></span> sweeping</span>' : '<span style="color:var(--text-muted)">idle</span>') + '</td></tr>'
+ '</tbody></table></div>'
+ '</tbody></table></div>'
+ '<div style="display:flex;flex-wrap:wrap;gap:8px;font-size:12px;color:var(--text-muted);margin-top:12px">'
+ '<div style="display:flex;flex-wrap:wrap;gap:8px;font-size:12px;color:var(--text-muted);margin-top:12px">'
+ '<span><i class="fa-solid ' + (ts.enabled ? 'fa-circle-check' : 'fa-circle-xmark') + '" style="color:' + (ts.enabled ? '#22c55e' : '#ef4444') + '"></i> Auto-offload: ' + (ts.enabled ? 'ON' : 'OFF') + '</span>'
+ '<span><i class="fa-solid ' + (ts.enabled ? 'fa-circle-check' : 'fa-circle-xmark') + '" style="color:' + (ts.enabled ? '#22c55e' : '#ef4444') + '"></i> Auto-offload: ' + (ts.enabled ? 'ON' : 'OFF') + '</span>'
@@ -202,7 +202,7 @@ async function loadStreamerCloudStorage(force) {
@@ -202,7 +202,7 @@ async function loadStreamerCloudStorage(force) {
const header = '<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:12px">'
const header = '<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:12px">'
+ '<h3 style="margin:0"><i class="fa-solid fa-cloud"></i> Cloud storage usage and cost</h3>'
+ '<h3 style="margin:0"><i class="fa-solid fa-cloud"></i> Cloud storage usage and cost</h3>'
+ '<p class="admin-intro">This estimates storage in Backblaze B2 and Cloudflare R2, in bytes and US dollars per month. Rescan when checking capacity; higher usage means more stored data and may increase charges.</p><button class="btn btn-sm btn-outline" onclick="loadStreamerCloudStorage(true)"><i class="fa-solid fa-rotate"></i> Rescan now</button></div>';
+ '<p class="admin-intro">This estimates storage in Backblaze B2 and Cloudflare R2, in bytes and US dollars per month. Rescan when checking capacity; higher usage means more stored data and may increase charges.</p><button class="btn btn-sm btn-outline" onclick="loadStreamerCloudStorage(true)"><i class="fa-solid fa-rotate"></i> Rescan now</button></div>';
- el.innerHTML = header + '<div class="loading"><i class="fa-solid fa-spinner fa-spin"></i> Scanning cloud buckets…</div>';
+ el.innerHTML = header + '<div class="loading"><span class="ov-mark" data-state="busy" data-size="16" data-tone="ink" aria-hidden="true"></span> Scanning cloud buckets…</div>';
try {
try {
const data = await api('/api/admin/streamer/storage/buckets' + (force ? '?force=1' : ''));
const data = await api('/api/admin/streamer/storage/buckets' + (force ? '?force=1' : ''));
const usage = data.usage || {};
const usage = data.usage || {};
@@ -103,7 +103,7 @@ function consentRow(purpose) {
@@ -103,7 +103,7 @@ function consentRow(purpose) {
function renderConsent() {
function renderConsent() {
const box = document.getElementById('consent-choices');
const box = document.getElementById('consent-choices');
if (!box) return;
if (!box) return;
- if (!consentDoc) { box.innerHTML = '<div class="loading-spinner">Loading your choices…</div>'; return; }
+ if (!consentDoc) { box.innerHTML = '<div class="loading-spinner"><span class="ov-mark" data-state="busy" data-size="32" aria-hidden="true"></span></div>'; return; }
// A re-render (a save, or the apps arriving) keeps the folds the person had open.
// A re-render (a save, or the apps arriving) keeps the folds the person had open.
const open = new Set([...box.querySelectorAll('details[open][data-consent-row]')].map((d) => d.getAttribute('data-consent-row')));
const open = new Set([...box.querySelectorAll('details[open][data-consent-row]')].map((d) => d.getAttribute('data-consent-row')));
box.innerHTML = (consentDoc.purposes || []).map(consentRow).join('');
box.innerHTML = (consentDoc.purposes || []).map(consentRow).join('');
@@ -188,7 +188,7 @@ function renderConsentApps() {
@@ -188,7 +188,7 @@ function renderConsentApps() {
const box = document.getElementById('consent-apps');
const box = document.getElementById('consent-apps');
if (!box) return;
if (!box) return;
box.replaceChildren();
box.replaceChildren();
- if (!consentAppsDoc) { box.append(consentNode('div', 'loading-spinner', 'Loading your apps…')); return; }
+ if (!consentAppsDoc) { const wait = consentNode('div', 'loading-spinner', null); wait.innerHTML = '<span class="ov-mark" data-state="busy" data-size="32" aria-hidden="true"></span>'; box.append(wait); return; }
const apps = consentAppsDoc.apps || [];
const apps = consentAppsDoc.apps || [];
if (!apps.length) {
if (!apps.length) {
box.append(consentNode('p', 'ovk-muted consent-empty', 'No app has asked you for anything yet. An app asks when you sign in to it, and what you allow shows here.'));
box.append(consentNode('p', 'ovk-muted consent-empty', 'No app has asked you for anything yet. An app asks when you sign in to it, and what you allow shows here.'));
@@ -26,7 +26,7 @@ async function loadEmailStatus() {
@@ -26,7 +26,7 @@ async function loadEmailStatus() {
} catch (e) { card.style.display = 'none'; }
} catch (e) { card.style.display = 'none'; }
}
}
async function resendVerification(btn) {
async function resendVerification(btn) {
- if (btn) { btn.disabled = true; btn.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> Sending…'; }
+ if (btn) { btn.disabled = true; btn.innerHTML = '<span class="ov-mark" data-state="busy" data-size="16" data-tone="ink" aria-hidden="true"></span> Sending…'; }
try { const r = await apiFetch('/api/auth/email/send-verification', { method: 'POST' }); toast(r.message || 'Sent', 'success'); }
try { const r = await apiFetch('/api/auth/email/send-verification', { method: 'POST' }); toast(r.message || 'Sent', 'success'); }
catch (e) { toast(e.message, 'error'); }
catch (e) { toast(e.message, 'error'); }
loadEmailStatus();
loadEmailStatus();
@@ -184,7 +184,7 @@ async function loadHistory(more) {
@@ -184,7 +184,7 @@ async function loadHistory(more) {
if (isAnonSession()) { document.getElementById('hist-list').innerHTML = '<div class="hist-empty">History is only kept for signed-in accounts.</div>'; return; }
if (isAnonSession()) { document.getElementById('hist-list').innerHTML = '<div class="hist-empty">History is only kept for signed-in accounts.</div>'; return; }
if (!more) _histOffset = 0;
if (!more) _histOffset = 0;
const listEl = document.getElementById('hist-list');
const listEl = document.getElementById('hist-list');
- if (!more) listEl.innerHTML = '<div class="loading-spinner">Loading history…</div>';
+ if (!more) listEl.innerHTML = '<div class="loading-spinner"><span class="ov-mark" data-state="busy" data-size="32" aria-hidden="true"></span></div>';
try {
try {
const q = new URLSearchParams({ limit: '40', offset: String(_histOffset) });
const q = new URLSearchParams({ limit: '40', offset: String(_histOffset) });
if (_histService) q.set('service', _histService);
if (_histService) q.set('service', _histService);
@@ -383,7 +383,7 @@
@@ -383,7 +383,7 @@
<button class="btn btn-small btn-danger" onclick="clearHistory()" title="Delete the whole timeline"><i class="fa-solid fa-trash"></i> Clear</button>
<button class="btn btn-small btn-danger" onclick="clearHistory()" title="Delete the whole timeline"><i class="fa-solid fa-trash"></i> Clear</button>
</div>
</div>
<p id="hist-summary" role="status" hidden></p>
<p id="hist-summary" role="status" hidden></p>
- <div id="hist-list"><div class="loading-spinner">Loading history…</div></div>
+ <div id="hist-list"><div class="loading-spinner"><span class="ov-mark" data-state="busy" data-size="32" aria-hidden="true"></span></div></div>
<div class="btn-row" id="hist-more-row" style="display:none;margin-top:10px"><button class="btn" onclick="loadHistory(true)">Load more</button></div>
<div class="btn-row" id="hist-more-row" style="display:none;margin-top:10px"><button class="btn" onclick="loadHistory(true)">Load more</button></div>
</div>
</div>
</div>
</div>
@@ -396,12 +396,12 @@
@@ -396,12 +396,12 @@
<div class="card" id="consent-card">
<div class="card" id="consent-card">
<h3><span class="icon"><i class="fa-solid fa-circle-check"></i></span> Your data choices</h3>
<h3><span class="icon"><i class="fa-solid fa-circle-check"></i></span> Your data choices</h3>
<p>AI training, the open dataset, face recognition and apps that use your content are <b>off until you turn them on</b>. Each choice is yours, and you can turn it off again at any time. Nothing here is on by default. We never sell your data.</p>
<p>AI training, the open dataset, face recognition and apps that use your content are <b>off until you turn them on</b>. Each choice is yours, and you can turn it off again at any time. Nothing here is on by default. We never sell your data.</p>
- <div id="consent-choices"><div class="loading-spinner">Loading your choices…</div></div>
+ <div id="consent-choices"><div class="loading-spinner"><span class="ov-mark" data-state="busy" data-size="32" aria-hidden="true"></span></div></div>
<!-- Apps you allowed (C4, 2026-11-07): what each app asked for when you signed in to it (consent:<purpose>)
<!-- Apps you allowed (C4, 2026-11-07): what each app asked for when you signed in to it (consent:<purpose>)
and was given, one Turn off per purpose, and what you said no to in the last 30 days
and was given, one Turn off per purpose, and what you said no to in the last 30 days
(GET /api/v1/me/consent/apps). -->
(GET /api/v1/me/consent/apps). -->
<h4 class="consent-apps-title">Apps you allowed</h4>
<h4 class="consent-apps-title">Apps you allowed</h4>
- <div id="consent-apps"><div class="loading-spinner">Loading your apps…</div></div>
+ <div id="consent-apps"><div class="loading-spinner"><span class="ov-mark" data-state="busy" data-size="32" aria-hidden="true"></span></div></div>
<p class="muted consent-status" id="consent-status" role="status"></p>
<p class="muted consent-status" id="consent-status" role="status"></p>
</div>
</div>
<!-- What you see (L1, 2026-11-02): one row per content label, three equal choices, saved at once. The web default
<!-- What you see (L1, 2026-11-02): one row per content label, three equal choices, saved at once. The web default
@@ -426,7 +426,7 @@
@@ -426,7 +426,7 @@
<div class="card">
<div class="card">
<h3><span class="icon"><i class="fa-solid fa-database"></i></span> Your data on OpenVibe</h3>
<h3><span class="icon"><i class="fa-solid fa-database"></i></span> Your data on OpenVibe</h3>
<p style="margin-bottom:12px">Settings and summaries OpenVibe sites keep with your account: your chat settings, favourite tools, streaming and community summaries and more. The sites themselves keep the full records. You can download all of this, or delete any of it. A site that writes a summary may write it again the next time you use that site.</p>
<p style="margin-bottom:12px">Settings and summaries OpenVibe sites keep with your account: your chat settings, favourite tools, streaming and community summaries and more. The sites themselves keep the full records. You can download all of this, or delete any of it. A site that writes a summary may write it again the next time you use that site.</p>
- <div id="modules-list"><div class="loading-spinner">Loading…</div></div>
+ <div id="modules-list"><div class="loading-spinner"><span class="ov-mark" data-state="busy" data-size="32" aria-hidden="true"></span></div></div>
<div class="btn-row"><button class="btn" type="button" onclick="exportModules()"><i class="fa-solid fa-download"></i> Download as JSON</button></div>
<div class="btn-row"><button class="btn" type="button" onclick="exportModules()"><i class="fa-solid fa-download"></i> Download as JSON</button></div>
</div>
</div>
</div>
</div>
@@ -531,7 +531,7 @@
@@ -531,7 +531,7 @@
</div>
</div>
<div class="card">
<div class="card">
<h3><span class="icon"><i class="fa-solid fa-mobile-screen"></i></span> Active Sessions</h3>
<h3><span class="icon"><i class="fa-solid fa-mobile-screen"></i></span> Active Sessions</h3>
- <div id="sessions-list"><div class="loading-spinner">Loading sessions...</div></div>
+ <div id="sessions-list"><div class="loading-spinner"><span class="ov-mark" data-state="busy" data-size="32" aria-hidden="true"></span></div></div>
<p style="margin-top:12px;color:var(--text-secondary);font-size:13px">Lost a phone or used a shared computer? Signing out ends every sign-in on every OpenVibe site at once, and open chats and games disconnect.</p>
<p style="margin-top:12px;color:var(--text-secondary);font-size:13px">Lost a phone or used a shared computer? Signing out ends every sign-in on every OpenVibe site at once, and open chats and games disconnect.</p>
<div class="btn-row" style="margin-top:10px">
<div class="btn-row" style="margin-top:10px">
<button class="btn btn-danger" onclick="revokeAllSessions()"><i class="fa-solid fa-mobile-screen-button"></i> Sign out other devices</button>
<button class="btn btn-danger" onclick="revokeAllSessions()"><i class="fa-solid fa-mobile-screen-button"></i> Sign out other devices</button>
@@ -541,7 +541,7 @@
@@ -541,7 +541,7 @@
<div class="card">
<div class="card">
<h3><span class="icon"><i class="fa-solid fa-plug"></i></span> Signed-in apps</h3>
<h3><span class="icon"><i class="fa-solid fa-plug"></i></span> Signed-in apps</h3>
<p style="margin-bottom:12px;color:var(--text-secondary);font-size:13px">Extensions and apps that hold their own sign-in. Revoking one ends its refresh token; the next request asks you to sign in again.</p>
<p style="margin-bottom:12px;color:var(--text-secondary);font-size:13px">Extensions and apps that hold their own sign-in. Revoking one ends its refresh token; the next request asks you to sign in again.</p>
- <div id="apps-list"><div class="loading-spinner">Loading apps...</div></div>
+ <div id="apps-list"><div class="loading-spinner"><span class="ov-mark" data-state="busy" data-size="32" aria-hidden="true"></span></div></div>
</div>
</div>
<div class="card" id="blocks-card">
<div class="card" id="blocks-card">
<h3><span class="icon"><i class="fa-solid fa-user-slash"></i></span> Blocked people</h3>
<h3><span class="icon"><i class="fa-solid fa-user-slash"></i></span> Blocked people</h3>
@@ -550,7 +550,7 @@
@@ -550,7 +550,7 @@
<div class="form-field"><label for="block-username">Block someone</label><input id="block-username" name="username" maxlength="64" autocomplete="off" placeholder="username"></div>
<div class="form-field"><label for="block-username">Block someone</label><input id="block-username" name="username" maxlength="64" autocomplete="off" placeholder="username"></div>
<div class="btn-row"><button class="btn btn-danger" type="submit">Block</button></div>
<div class="btn-row"><button class="btn btn-danger" type="submit">Block</button></div>
</form>
</form>
- <div id="blocks-list"><div class="loading-spinner">Loading…</div></div>
+ <div id="blocks-list"><div class="loading-spinner"><span class="ov-mark" data-state="busy" data-size="32" aria-hidden="true"></span></div></div>
<p class="data-status" id="blocks-status" role="status"></p>
<p class="data-status" id="blocks-status" role="status"></p>
</div>
</div>
</div>
</div>
@@ -626,6 +626,7 @@
@@ -626,6 +626,7 @@
<!-- Shared scripts -->
<!-- Shared scripts -->
<script src="/shared/theme-loader.js" defer></script>
<script src="/shared/theme-loader.js" defer></script>
+<script src="/shared/ov-mark.js" defer></script>
<script src="/shared/navbar.js" defer></script>
<script src="/shared/navbar.js" defer></script>
<script src="/js/account-safety.js" defer></script>
<script src="/js/account-safety.js" defer></script>
<script src="/shared/notification-ui.js" defer></script>
<script src="/shared/notification-ui.js" defer></script>