openvibers/openvibe.shared footer.js
footer.js
| 1 | /** |
| 2 | * openvibe-shared/footer.js — the cross-site footer, the counterpart to navbar.js. |
| 3 | * |
| 4 | * OpenVibeFooter.init({ service, variant, links, apiBase, mount, pulse }) |
| 5 | * OpenVibeFooter.setVariant('full' | 'compact' | 'auto') the page's own variant (SPA route changes) |
| 6 | * OpenVibeFooter.claim('compact' | 'full', key) → release(): a part of the page asks for a variant while it needs |
| 7 | * it (Live's channel while its stream is live: compact; offline: |
| 8 | * the page's own full). Compact wins over full, any claim over the |
| 9 | * page's own; releasing a claim brings back what is left. |
| 10 | * OpenVibeFooter.buildHTML(cfg) markup only — usable from Node for SSR |
| 11 | * |
| 12 | * Why this exists: every property was hand-rolling its own footer, so the network links, the |
| 13 | * legal links and the styling drifted apart, and a site that gained a new sibling had to be |
| 14 | * edited by hand. Here the site passes only the links that are *its own*; the Network column, |
| 15 | * the Legal column, the signed-in row and all the styling come from this module, so adding a |
| 16 | * property or changing a policy link updates every site at once. |
| 17 | * |
| 18 | * Variants: |
| 19 | * auto full on the site's front page (and the paths in fullOn), compact everywhere else: a site looks like an app |
| 20 | * once someone is in it (the owner, 2026-10-28: "the full-size footer on the chat pages might be a little |
| 21 | * much … if we want to have that app-like design … make it a little more sleeker across sites"). On the |
| 22 | * server, ssr({ variant: 'auto', path }) picks with the request's path (no path: compact). |
| 23 | * full the multi-column footer, for home and browse pages |
| 24 | * compact one quiet line, for pages where the footer is not the point — a streamer's |
| 25 | * channel, a player, a tool someone is mid-task in. "More from OpenVibe" opens the |
| 26 | * whole footer in place (a <details>: no script needed, and every link stays in the |
| 27 | * page for crawlers, so a player page links the network as a home page does) |
| 28 | * |
| 29 | * The pulse line (full, and an opened compact one): what is happening on the network right now — |
| 30 | * streams live, people watching, open sites, last week's views — from openvibe.network/api/frame/pulse, |
| 31 | * read once the footer scrolls into view and every minute while it stays there; numbers only. |
| 32 | * |
| 33 | * Signed-in visitors get a cross-site row: their account, their channel, themes and the tools |
| 34 | * hub, so the network behaves like one product instead of a ring of separate sites. It reads |
| 35 | * the shared ov_token the same way navbar.js does and never blocks rendering on it. |
| 36 | * |
| 37 | * The page's end (plan 280, the owner: "That huge wasted space … in between that and the footer … It's just waste"): |
| 38 | * content ends, then 32 px on a computer (24 on a phone), then the footer — the footer's own margin-top is that |
| 39 | * number and nothing else may add to it. A site's bottom padding above the footer is its own dead space and is |
| 40 | * removed in that site (scripts/audit-page-end.js measures it on every home). |
| 41 | */ |
| 42 | (function (root, factory) { |
| 43 | const api = factory(); |
| 44 | if (typeof module === 'object' && module.exports) module.exports = api; // Node (SSR) |
| 45 | if (typeof window !== 'undefined') window.OpenVibeFooter = api; // browser |
| 46 | })(typeof globalThis !== 'undefined' ? globalThis : this, function () { |
| 47 | ; |
| 48 | |
| 49 | const root = typeof globalThis !== 'undefined' ? globalThis : (typeof window !== 'undefined' ? window : {}); |
| 50 | // Where this file was served from: shipped.js is loaded from the same place (the site's own |
| 51 | // /shared/ copy, or openvibe.network), so the site's CSP already allows it. |
| 52 | const OWN_SRC = (function () { try { return document.currentScript && document.currentScript.src ? document.currentScript.src : null; } catch { return null; } })(); |
| 53 | // ── Frame data (https://openvibe.network/api/frame) ───────────────────────────── |
| 54 | // Sites ordered by real use, footer copy and per-site legal links. Cached per host for 30 |
| 55 | // minutes in localStorage and refreshed in the background, so pages paint from cache and the |
| 56 | // order never jumps while someone is looking at it. Defined once, shared by navbar + footer. |
| 57 | const OVFrame = root.OpenVibeFrame || root.OpenVibeChrome || (root.OpenVibeFrame = root.OpenVibeChrome = (function () { |
| 58 | const KEY = 'ov_frame_v2', TTL = 30 * 60000; // v2: every site, with its launcher group |
| 59 | let inflight = null; |
| 60 | const host = () => (typeof location !== 'undefined' ? location.hostname : ''); |
| 61 | const https = (u) => { try { return new URL(u).protocol === 'https:'; } catch { return false; } }; |
| 62 | function clean(d) { |
| 63 | if (!d || !Array.isArray(d.nav)) return null; |
| 64 | const link = (l) => (l && typeof l.name === 'string' && https(l.url) ? { id: String(l.id || ''), name: l.name.slice(0, 60), url: l.url, icon: String(l.icon || ''), tagline: String(l.tagline || '').slice(0, 80), group: String(l.group || '').slice(0, 20) } : null); |
| 65 | const f = d.footer || {}, lg = f.legal || {}; |
| 66 | return { nav: d.nav.map(link).filter(Boolean).slice(0, 60), groups: (Array.isArray(d.groups) ? d.groups : []).filter(g => g && typeof g.id === 'string' && typeof g.name === 'string').slice(0, 10).map(g => ({ id: g.id.slice(0, 20), name: g.name.slice(0, 40) })), soon: (d.soon || []).map(link).filter(Boolean).slice(0, 24), |
| 67 | footer: { blurb: String(f.blurb || '').slice(0, 200), discover: (f.discover || []).map(link).filter(Boolean).slice(0, 6), popular: (f.popular || []).map(link).filter(Boolean).slice(0, 10), |
| 68 | legal: https(lg.terms) && https(lg.privacy) && https(lg.dmca) ? { terms: lg.terms, privacy: lg.privacy, dmca: lg.dmca } : null } }; |
| 69 | } |
| 70 | function cached() { try { const c = JSON.parse(localStorage.getItem(KEY) || 'null'); return c && c.host === host() && c.data ? c : null; } catch { return null; } } |
| 71 | function refresh() { |
| 72 | if (typeof window === 'undefined' || typeof document === 'undefined') return Promise.resolve(null); // server-side render: never call out |
| 73 | if (inflight || typeof fetch === 'undefined') return inflight || Promise.resolve(null); |
| 74 | inflight = fetch('https://openvibe.network/api/frame?host=' + encodeURIComponent(host()), { credentials: 'omit' }) |
| 75 | .then(r => (r.ok ? r.json() : null)).then(clean) |
| 76 | .then(d => { if (d) { try { localStorage.setItem(KEY, JSON.stringify({ at: Date.now(), host: host(), data: d })); } catch { /* */ } } return d; }) |
| 77 | .catch(() => null); |
| 78 | return inflight; |
| 79 | } |
| 80 | /** Cached data now (or null); `onFirst` fires once when a first-ever fetch lands. */ |
| 81 | function get(onFirst) { |
| 82 | const c = cached(); |
| 83 | if (!c || Date.now() - c.at > TTL) { const p = refresh(); if (!c && onFirst) p.then(d => { if (d) onFirst(d); }); } |
| 84 | return c ? c.data : null; |
| 85 | } |
| 86 | return { get, refresh }; |
| 87 | })()); |
| 88 | |
| 89 | const NETWORK_URL = 'https://openvibe.network'; |
| 90 | const REPORT_URL = 'https://openvibe.help/tickets/new'; |
| 91 | const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c])); |
| 92 | |
| 93 | // The eight groups, in the menu's order (OpenVibe.Network frame/sites.js GROUPS, the owner's regrouping of |
| 94 | // 2026-11-10). The Network column links each group's section on /sites; the Frame's `groups` replaces these names |
| 95 | // and order when it answers, so the footer, the launcher and the home pages read the same list. |
| 96 | const GROUP_LINKS = [ |
| 97 | { id: 'watch', name: 'Watch & stream' }, |
| 98 | { id: 'talk', name: 'Talk & belong' }, |
| 99 | { id: 'play', name: 'Play' }, |
| 100 | { id: 'read', name: 'Read & ask' }, |
| 101 | { id: 'shop', name: 'Shop & money' }, |
| 102 | { id: 'tools', name: 'Free tools' }, |
| 103 | { id: 'make', name: 'Make with AI' }, |
| 104 | { id: 'build', name: 'For developers' }, |
| 105 | ]; |
| 106 | const groupHref = (id) => `${NETWORK_URL}/sites#group-${encodeURIComponent(id)}`; |
| 107 | const NETWORK = GROUP_LINKS.map(({ id, name }) => ({ id, name, url: groupHref(id), desc: '' })); |
| 108 | // Tool subdomains have no site of their own; brandFor names them "<Sub>.OpenVibe.Tools". |
| 109 | const TOOL_FAMILIES = new Set(['dev', 'net', 'img', 'audio', 'docs', 'text', 'yt']); |
| 110 | |
| 111 | const LEGAL = [ |
| 112 | { key: 'terms', name: 'Terms of Service', path: '/tos', fallback: 'https://openvibe.live/tos' }, |
| 113 | { key: 'privacy', name: 'Privacy Policy', path: '/privacy', fallback: 'https://openvibe.live/privacy' }, |
| 114 | { key: 'dmca', name: 'DMCA', path: '/dmca', fallback: 'https://openvibe.live/dmca' }, |
| 115 | ]; |
| 116 | |
| 117 | // "Report a problem" on every site: a ticket on openvibe.help with this site and page filled in (OpenVibe.Help#81). |
| 118 | // The page is the origin and path only, since a query or fragment can carry a sign-in code or a token. Rendered on |
| 119 | // the server the page is unknown, so a click fills it in at that moment (a single-page app's route counts). |
| 120 | function reportHref() { |
| 121 | try { |
| 122 | if (typeof location === 'undefined' || !/^https?:$/.test(location.protocol)) return REPORT_URL; |
| 123 | return `${REPORT_URL}?${new URLSearchParams({ site: location.hostname.replace(/^www\./, ''), page: (location.origin + location.pathname).slice(0, 500) })}`; |
| 124 | } catch { return REPORT_URL; } |
| 125 | } |
| 126 | const reportLink = () => `<a href="${esc(reportHref())}" data-ov-report>Report a problem</a>`; |
| 127 | |
| 128 | const SOCIAL = [ |
| 129 | { name: 'GitHub', icon: 'github', url: 'https://github.com/OpenVibers' }, |
| 130 | { name: 'Discord', icon: 'discord', url: 'https://discord.gg/M6MuRUaeJj' }, |
| 131 | ]; |
| 132 | |
| 133 | const DEFAULTS = { |
| 134 | service: null, // 'live' | 'tools' | 'dev' | … (auto-detected when omitted) |
| 135 | brandName: null, |
| 136 | tagline: 'Open source and community run. Built in the open by the people using it.', |
| 137 | variant: 'full', // 'full' | 'compact' | 'auto' |
| 138 | fullOn: ['/'], // with variant 'auto': the paths that get the full footer |
| 139 | mount: '#ov-footer', |
| 140 | links: [], // [{ heading, items: [{ name, url, onclick? }] }] |
| 141 | legalBase: null, // origin that hosts /tos, /privacy, /dmca for this site |
| 142 | apiBase: NETWORK_URL, |
| 143 | user: null, |
| 144 | showNetwork: true, |
| 145 | showAccount: true, |
| 146 | sitemap: '/sitemap.xml', |
| 147 | shipped: true, // the "shipped X ago" line (openvibe-shared/shipped.js) |
| 148 | pulse: true, // the network-right-now line (openvibe.network/api/frame/pulse) |
| 149 | updates: null, // this site's update log; default: openvibe.network/updates?site=<host> |
| 150 | }; |
| 151 | |
| 152 | let _cfg = { ...DEFAULTS }; |
| 153 | let _mounted = null; |
| 154 | const _claims = new Map(); // key → 'compact' | 'full' (claim()) |
| 155 | /** 'auto' for a path: full on the front page and the paths in fullOn, compact elsewhere (no path: compact). */ |
| 156 | function autoFor(path, fullOn) { |
| 157 | if (typeof path !== 'string' || !path) return 'compact'; |
| 158 | const p = path.replace(/[?#].*$/, '').replace(/(.)\/+$/, '$1') || '/'; |
| 159 | return (Array.isArray(fullOn) && fullOn.length ? fullOn : ['/']).includes(p) ? 'full' : 'compact'; |
| 160 | } |
| 161 | const herePath = () => (typeof location !== 'undefined' && location.pathname) || '/'; |
| 162 | /** The variant shown: a compact claim, else a full one, else the page's own ('auto' read from where the page is). */ |
| 163 | function shownVariant(base) { |
| 164 | const asked = [..._claims.values()]; |
| 165 | if (asked.includes('compact')) return 'compact'; |
| 166 | if (asked.includes('full')) return 'full'; |
| 167 | if (base === 'auto') return autoFor(herePath(), _cfg.fullOn); |
| 168 | return base === 'compact' ? 'compact' : 'full'; |
| 169 | } |
| 170 | |
| 171 | function detectService() { |
| 172 | if (typeof location === 'undefined') return 'network'; |
| 173 | const h = location.hostname; |
| 174 | if (h.endsWith('openvibe.live')) return 'live'; |
| 175 | if (h.endsWith('openvibe.network')) return 'network'; |
| 176 | if (h.endsWith('openvibe.games')) return 'games'; |
| 177 | if (/(^|\.)openre\.stream$/.test(h)) return 'openre'; |
| 178 | const tld = /(?:^|\.)openvibe\.([a-z]+)$/.exec(h); |
| 179 | if (tld && tld[1] !== 'tools' && TLD_LABELS[tld[1]]) return tld[1]; |
| 180 | const sub = h.replace(/\.openvibe\.tools$/, ''); |
| 181 | return sub === h ? 'tools' : (sub || 'tools'); |
| 182 | } |
| 183 | |
| 184 | const TLD_LABELS = { live: 'Live', tools: 'Tools', network: 'Network', media: 'Media', games: 'Games', community: 'Community', chat: 'Chat', codes: 'Codes', blog: 'Blog', wiki: 'Wiki', news: 'News', reviews: 'Reviews', tips: 'Tips', vip: 'VIP', trade: 'Trade', host: 'Host', deals: 'Deals', coupons: 'Coupons' }; |
| 185 | /** The full site name from the hostname — the same spelling the navbar uses (Pastes.OpenVibe.Tools). */ |
| 186 | function brandFor(service) { |
| 187 | // The navbar already resolved the spelling (MergePDF, JSON, Pastes…): reuse it so the two |
| 188 | // never disagree on the same page. |
| 189 | try { const nb = typeof window !== 'undefined' && window.OpenVibeNavbar && window.OpenVibeNavbar.brand && window.OpenVibeNavbar.brand(); if (nb && nb.name) return nb.name; } catch { /* */ } |
| 190 | const h = typeof location !== 'undefined' ? location.hostname.toLowerCase() : ''; |
| 191 | const cap = (w) => w ? w.charAt(0).toUpperCase() + w.slice(1) : ''; |
| 192 | let m = h.match(/^(?:(.+)\.)?openvibe\.([a-z]+)$/); |
| 193 | if (m) { const sub = m[1] && m[1] !== 'www' ? m[1] : null; return [sub ? (sub.length <= 4 ? sub.toUpperCase() : cap(sub)) : null, 'OpenVibe', TLD_LABELS[m[2]] || cap(m[2])].filter(Boolean).join('.'); } |
| 194 | if ((m = h.match(/^(?:(.+)\.)?openre\.stream$/))) return 'OpenRestream'; |
| 195 | if ((m = h.match(/^(?:(.+)\.)?open\.([a-z]+)$/))) return `Open.${TLD_LABELS[m[2]] || cap(m[2])}`; |
| 196 | if (service && TLD_LABELS[service]) return `OpenVibe.${TLD_LABELS[service]}`; |
| 197 | if (TOOL_FAMILIES.has(service)) return `${cap(service)}.OpenVibe.Tools`; |
| 198 | return 'OpenVibe'; |
| 199 | } |
| 200 | |
| 201 | function legalHref(item, cfg) { |
| 202 | // Each site answers for itself: the Frame service names this domain's own documents. |
| 203 | const frame = cfg._frame; |
| 204 | if (frame && frame.footer.legal && !cfg.legalBase) return frame.footer.legal[item.key]; |
| 205 | // Policies live on whichever site owns them; a tool subdomain links out rather than 404ing. |
| 206 | if (cfg.legalBase) return cfg.legalBase.replace(/\/$/, '') + item.path; |
| 207 | if (typeof location !== 'undefined' && location.hostname.endsWith('openvibe.live')) return item.path; |
| 208 | return item.fallback; |
| 209 | } |
| 210 | |
| 211 | // ── Icons ────────────────────────────────────────────────── |
| 212 | // The footer ships its own icons. It used to borrow the page's Font Awesome, so on pages that do not |
| 213 | // load that font (the server-rendered Tools and legal pages) every icon was an empty box. |
| 214 | const ICONS = { |
| 215 | github: '<path fill="currentColor" stroke="none" d="M12 2.2a9.8 9.8 0 0 0-3.1 19.1c.5.1.7-.2.7-.5v-1.7c-2.7.6-3.3-1.2-3.3-1.2-.4-1.1-1.1-1.4-1.1-1.4-.9-.6.1-.6.1-.6 1 .1 1.5 1 1.5 1 .9 1.5 2.3 1.1 2.9.8.1-.6.3-1.1.6-1.3-2.2-.2-4.5-1.1-4.5-4.8 0-1.1.4-1.9 1-2.6-.1-.2-.4-1.2.1-2.6 0 0 .8-.3 2.7 1a9.3 9.3 0 0 1 4.9 0c1.9-1.3 2.7-1 2.7-1 .5 1.4.2 2.4.1 2.6.6.7 1 1.5 1 2.6 0 3.7-2.3 4.6-4.5 4.8.4.3.7.9.7 1.800v2.700c0 .3.2.6.7.5A9.8 9.8 0 0 0 12 2.200z"/>', |
| 216 | discord: '<path fill="currentColor" stroke="none" d="M19.3 5.300a16.5 16.5 0 0 0-4.1-1.300l-.5 1a15.3 15.3 0 0 0-5.4 0l-.5-1a16.5 16.5 0 0 0-4.1 1.300C2.1 9.2 1.4 13 1.7 16.700a16.6 16.6 0 0 0 5.1 2.600l1.1-1.800c-.6-.2-1.2-.5-1.7-.8l.4-.3a11.8 11.8 0 0 0 10.8 0l.4.3c-.5.3-1.1.6-1.7.8l1.1 1.800a16.6 16.6 0 0 0 5.1-2.600c.4-4.3-.7-8.1-3-11.400zM8.7 14.400c-1 0-1.8-.9-1.8-2.100s.8-2.1 1.8-2.1 1.9 1 1.8 2.100c0 1.2-.8 2.1-1.8 2.100zm6.6 0c-1 0-1.8-.9-1.8-2.100s.8-2.1 1.8-2.1 1.9 1 1.8 2.100c0 1.2-.8 2.1-1.8 2.100z"/>', |
| 217 | sitemap: '<rect x="9" y="3.5" width="6" height="4.5" rx="1"/><rect x="3" y="16" width="6" height="4.5" rx="1"/><rect x="15" y="16" width="6" height="4.5" rx="1"/><path d="M12 8v4M6 16v-2.500h12V16"/>', |
| 218 | arrow: '<path d="M5 12h14M13 6l6 6-6 6"/>', |
| 219 | channel: '<circle cx="12" cy="12" r="2"/><path d="M7.8 7.800a6 6 0 0 0 0 8.400M16.2 7.800a6 6 0 0 1 0 8.400M5 5a10 10 0 0 0 0 14M19 5a10 10 0 0 1 0 14"/>', |
| 220 | dashboard: '<path d="M4.5 17a8 8 0 1 1 15 0M12 13l3.5-4"/><circle cx="12" cy="13.5" r="1.3"/>', |
| 221 | account: '<circle cx="12" cy="9" r="3.2"/><path d="M5.5 19a6.5 6.5 0 0 1 13 0"/>', |
| 222 | themes: '<path d="M12 4.500a7.5 7.5 0 1 0 0 15c1 0 1.6-.8 1.2-1.7-.5-1.1.3-2.3 1.5-2.300h1.600a3.2 3.2 0 0 0 3.2-3.200C19.5 8 16.2 4.5 12 4.500z"/><circle cx="8.5" cy="11" r=".7"/><circle cx="11.5" cy="8" r=".7"/><circle cx="15" cy="9" r=".7"/>', |
| 223 | tools: '<path d="M14.5 5.500a4 4 0 0 0-5.2 5.200L4.5 15.500a1.8 1.8 0 0 0 2.5 2.500l4.8-4.800a4 4 0 0 0 5.2-5.200l-2.6 2.6-2.1-.5-.5-2.100z"/>', |
| 224 | // 4.188.0: the strip's icons, drawn by openvibe-shared/glyphs.js (the same hand) for the counts and the |
| 225 | // items a site declares. A name the set does not carry falls back to the arrow above. |
| 226 | members: '<circle cx="9" cy="8" r="3.2"/><path d="M3 20c0-3.3 2.7-5.5 6-5.5s6 2.2 6 5.5M16 4.8a3.2 3.2 0 0 1 0 6.4M18 14.8c1.9.6 3 2.4 3 5.2"/>', |
| 227 | video: '<rect x="3" y="6" width="13" height="12" rx="2"/><path d="m16 10 5-3v10l-5-3"/>', |
| 228 | clipboard: '<rect x="5.5" y="5" width="13" height="16" rx="2"/><path d="M9 5V3.5h6V5M9 11h6M9 15h4"/>', |
| 229 | image: '<rect x="3" y="5" width="18" height="14" rx="3"/><circle cx="9" cy="10" r="1.6"/><path d="m21 16-5-5-8 8"/>', |
| 230 | news: '<path d="M4 5h13v14a2 2 0 0 0 2 2H6a2 2 0 0 1-2-2Z"/><path d="M17 9h3v10a2 2 0 0 1-2 2M7.5 9h6M7.5 12.5h6M7.5 16h3.5"/>', |
| 231 | clapper: '<path d="M3.5 10h17v9a1 1 0 0 1-1 1h-15a1 1 0 0 1-1-1Z"/><path d="M3.5 10 2.9 6.6l16.4-3 .7 3.6ZM8 5.7l2.3 3.3M13.3 4.7l2.3 3.3"/>', |
| 232 | film: '<rect x="3" y="4" width="18" height="16" rx="2"/><path d="M7.5 4v16M16.5 4v16M3 9h4.5M3 15h4.5M16.5 9H21M16.5 15H21"/>', |
| 233 | game: '<rect x="2.5" y="7" width="19" height="11" rx="5"/><path d="M7 11v3M5.5 12.5h3M15.5 12h.01M18 13.5h.01"/>', |
| 234 | live: '<circle cx="12" cy="12" r="2.5"/><path d="M7.8 7.8a6 6 0 0 0 0 8.4M16.2 7.8a6 6 0 0 1 0 8.4M5 5a10 10 0 0 0 0 14M19 5a10 10 0 0 1 0 14"/>', |
| 235 | chart: '<path d="M4 4v16h16"/><path d="m7.5 14.5 3.5-4 3 2.5 5-6"/>', |
| 236 | star: '<path d="m12 4 2.4 5 5.4.6-4 3.7 1.1 5.4L12 16l-4.9 2.7 1.1-5.4-4-3.7 5.4-.6Z"/>', |
| 237 | sites: '<rect x="4" y="4" width="6.5" height="6.5" rx="1.7"/><rect x="13.5" y="4" width="6.5" height="6.5" rx="1.7"/><rect x="4" y="13.5" width="6.5" height="6.5" rx="1.7"/><rect x="13.5" y="13.5" width="6.5" height="6.5" rx="1.7"/>', |
| 238 | }; |
| 239 | const icon = (name) => `<svg class="ovf-ic" viewBox="0 0 24 24" width="1em" height="1em" fill="none" stroke="currentColor" stroke-width="1.800" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">${(ICONS[name] || ICONS.arrow)}</svg>`; |
| 240 | |
| 241 | // ── Markup ───────────────────────────────────────────────── |
| 242 | function linkTag(raw) { |
| 243 | // Items are { name, url }; { label, href } (the navbar's shape) is read the same, so a site that |
| 244 | // passes it does not get empty links. |
| 245 | const item = { ...raw, name: raw.name ?? raw.label, url: raw.url ?? raw.href }; |
| 246 | // Our own sites open in this tab (the sign-in hand-off follows the click); the rest of the web in a new one. |
| 247 | const own = /^https:\/\/([a-z0-9-]+\.)*(openvibe\.[a-z]+|openre\.stream)(\/|$)/i.test(item.url); |
| 248 | const rel = /^https?:\/\//.test(item.url) && !own ? ' target="_blank" rel="noopener"' : ''; |
| 249 | const onclick = item.onclick ? ` onclick="${esc(item.onclick)}"` : ''; |
| 250 | const title = item.desc ? ` title="${esc(item.desc)}"` : ''; |
| 251 | return `<a href="${esc(item.url)}"${rel}${onclick}${title}>${esc(item.name)}</a>`; |
| 252 | } |
| 253 | |
| 254 | /** The "🚀 shipped X ago: …" line, filled by shipped.js from the network changelog; hidden until then. */ |
| 255 | function shippedLine(c) { |
| 256 | if (c.shipped === false) return ''; |
| 257 | const host = typeof location !== 'undefined' ? location.hostname : ''; |
| 258 | const updates = c.updates || `${NETWORK_URL}/updates?site=${encodeURIComponent(host || c.service || '')}`; |
| 259 | return `<a class="ovf-shipped" data-ov-shipped="latest" data-service="${esc(c.service || 'auto')}" href="${esc(updates)}" hidden></a>`; |
| 260 | } |
| 261 | function updatesHref(c) { |
| 262 | const host = typeof location !== 'undefined' ? location.hostname : ''; |
| 263 | return c.updates || `${NETWORK_URL}/updates?site=${encodeURIComponent(host || c.service || '')}`; |
| 264 | } |
| 265 | |
| 266 | function buildHTML(cfg) { |
| 267 | const c = { ...DEFAULTS, ...(cfg || {}) }; |
| 268 | const service = c.service || detectService(); |
| 269 | const brand = c.brandName || brandFor(service); |
| 270 | const frame = c._frame = OVFrame.get(() => { try { render(); } catch { /* */ } }); |
| 271 | // The Frame's own groups, in its order, when it answers; the built-in eight otherwise. One column of groups, |
| 272 | // not a head-list of sites, so the footer shows the same neighbourhoods as the launcher and the home pages. |
| 273 | const network = frame && Array.isArray(frame.groups) && frame.groups.length |
| 274 | ? frame.groups.map(g => ({ name: g.name, url: groupHref(g.id), desc: '' })) |
| 275 | : NETWORK; |
| 276 | // The ranked sites are still what the discover picks must not repeat. |
| 277 | const nav = frame && frame.nav.length |
| 278 | ? frame.nav.filter(n => n.id !== service && !(typeof location !== 'undefined' && new URL(n.url).hostname === location.hostname)) |
| 279 | : []; |
| 280 | const tagline = (cfg && cfg.tagline) || (frame && frame.footer.blurb) || c.tagline; |
| 281 | const popular = frame ? frame.footer.popular : []; |
| 282 | const discover = frame ? frame.footer.discover.filter(d => !nav.slice(0, 8).some(n => n.url === d.url)) : []; |
| 283 | const year = new Date().getFullYear(); |
| 284 | |
| 285 | if (c.variant === 'compact') { |
| 286 | return `<div class="ovf-inner ovf-inner--compact"> |
| 287 | <a class="ovf-brand ovf-brand--sm" href="/"><span class="ov-mark" data-size="28" data-wide></span><span>${esc(brand)}</span></a> |
| 288 | <nav class="ovf-compact-links" aria-label="Site links"> |
| 289 | ${(c.links[0] ? c.links[0].items.slice(0, 3) : []).map(linkTag).join('')} |
| 290 | <a href="${NETWORK_URL}" target="_blank" rel="noopener">Network</a> |
| 291 | ${c.shipped === false ? '' : `<a href="${esc(updatesHref(c))}">Updates</a>`} |
| 292 | ${LEGAL.map(l => `<a href="${esc(legalHref(l, c))}">${esc(l.name.replace(' of Service', '').replace(' Policy', ''))}</a>`).join('')} |
| 293 | ${reportLink()} |
| 294 | </nav> |
| 295 | ${shippedLine(c)} |
| 296 | <span class="ovf-compact-meta">Open source · ${year}</span> |
| 297 | </div> |
| 298 | <details class="ovf-expand"${c._open ? ' open' : ''}><summary>More from OpenVibe</summary>${fullBody(c, { network, popular, discover, brand, tagline, compact: true })}</details>`; |
| 299 | } |
| 300 | return fullBody(c, { network, popular, discover, brand, tagline }); |
| 301 | } |
| 302 | |
| 303 | /** The multi-column footer: the brand, the site's own links, the network, popular tools, legal, the account row and the bar. */ |
| 304 | function fullBody(c, { network, popular, discover, brand, tagline, compact = false }) { |
| 305 | // Each link column is a fold: open on a desktop (its heading reads and behaves as a heading), folded on a phone |
| 306 | // (a 48 px row with a chevron), so a phone's footer is about one screen shorter. Every link stays in the page. |
| 307 | // The fold's line carries its count ("9 links"), so a folded column still says how much it holds (#208). |
| 308 | const fold = (label, heading, body, count) => ` |
| 309 | <nav class="ovf-col" aria-label="${esc(label)}"><details class="ovf-fold" open><summary><h3>${esc(heading)}</h3><span class="ovf-fold-n">${esc(String(count))} ${count === 1 ? 'link' : 'links'}</span></summary> |
| 310 | <div class="ovf-fold-body">${body}</div></details></nav>`; |
| 311 | // A column needs a name and at least one link: an untitled "0 links" fold is never drawn (4.202.0's screenshots |
| 312 | // showed two when a site passed empty groups). |
| 313 | const cols = (c.links || []).filter(group => group && String(group.heading || '').trim() && Array.isArray(group.items) && group.items.length) |
| 314 | .map(group => fold(group.heading, group.heading, group.items.map(linkTag).join(''), group.items.length)).join(''); |
| 315 | const networkLinks = network.slice(0, 8); |
| 316 | |
| 317 | return `<div class="ovf-inner${compact ? ' ovf-inner--expanded' : ''}"> |
| 318 | ${c.pulse === false ? '' : '<div class="ovf-pulse" data-ovf-pulse hidden></div>'} |
| 319 | <div class="ovf-cols"> |
| 320 | <div class="ovf-col ovf-col--brand"> |
| 321 | <a class="ovf-brand" href="/"><span class="ov-mark" data-size="38" data-wide></span><span>${esc(brand)}</span></a> |
| 322 | <p class="ovf-tagline">${esc(tagline)}</p> |
| 323 | <div class="ovf-social"> |
| 324 | ${SOCIAL.map(s => `<a href="${s.url}" target="_blank" rel="noopener" aria-label="${s.name}" title="${s.name}">${icon(s.icon)}</a>`).join('')} |
| 325 | <a href="${esc(c.sitemap)}" aria-label="Sitemap" title="Sitemap">${icon('sitemap')}</a> |
| 326 | </div> |
| 327 | </div> |
| 328 | ${cols} |
| 329 | ${c.showNetwork ? fold('The OpenVibe network', 'Network', `${networkLinks.map(linkTag).join('')} |
| 330 | <a class="ovf-more" href="${NETWORK_URL}/sites" target="_blank" rel="noopener">Everything else ${icon('arrow')}</a>`, networkLinks.length + 1) : ''} |
| 331 | ${popular.length ? fold('Popular tools', 'Popular tools', `${popular.slice(0, 6).concat(discover.slice(0, 2)).map(linkTag).join('')} |
| 332 | <a class="ovf-more" href="https://openvibe.tools/all-tools">All tools ${icon('arrow')}</a>`, Math.min(popular.length, 6) + Math.min(discover.length, 2) + 1) : ''} |
| 333 | ${fold('Legal', 'Legal', `${LEGAL.map(l => `<a href="${esc(legalHref(l, c))}">${esc(l.name)}</a>`).join('')} |
| 334 | ${reportLink()}`, LEGAL.length + 1)} |
| 335 | </div> |
| 336 | ${compact ? '' : `<div class="ovf-account" id="ovf-account" hidden></div> |
| 337 | <div class="ovf-bar"> |
| 338 | <span class="ovf-copy">Open source & community driven · built in the open</span> |
| 339 | ${shippedLine(c)} |
| 340 | <span class="ovf-legal-inline"> |
| 341 | ${c.shipped === false ? '' : `<a href="${esc(updatesHref(c))}">Updates</a>`} |
| 342 | ${LEGAL.map(l => `<a href="${esc(legalHref(l, c))}">${esc(l.name.replace(' of Service', '').replace(' Policy', ''))}</a>`).join('')} |
| 343 | </span> |
| 344 | </div>`} |
| 345 | </div>`; |
| 346 | } |
| 347 | |
| 348 | // ── Signed-in row: the same account, wherever you are ────── |
| 349 | function accountHTML(user, service) { |
| 350 | const name = esc(user.display_name || user.username || 'you'); |
| 351 | // An openvibe.media object picture comes in sizes (Media's /o/:id?w=&fmt=): 64 px as WebP for the 24 px avatar. |
| 352 | // Without one in the record, the Network's /avatar/<username>: the picture the account has now (navbar.js, the same rule). |
| 353 | const media = /^https:\/\/openvibe\.media\/o\/med_[0-9A-Za-z]{10,40}$/.test(user.avatar_url || ''); |
| 354 | const sized = media ? `${user.avatar_url}?w=64&fmt=webp` : (user.username && !user.is_anon ? `${NETWORK_URL}/avatar/${encodeURIComponent(user.username)}?s=32` : ''); |
| 355 | const avatar = sized |
| 356 | ? `<img src="${esc(sized)}" alt="" width="${media ? 64 : 32}" height="${media ? 64 : 32}" loading="lazy" decoding="async" data-ov-initial="${esc(String(name).charAt(0).toUpperCase())}">` |
| 357 | : `<span>${esc(String(name).charAt(0).toUpperCase())}</span>`; |
| 358 | const quick = [ |
| 359 | service !== 'live' ? { name: 'My channel', url: `https://openvibe.live/@${esc(user.username || '')}`, icon: 'channel' } : { name: 'My dashboard', url: '/dashboard', icon: 'dashboard' }, |
| 360 | { name: 'Account', url: `${NETWORK_URL}/my`, icon: 'account' }, |
| 361 | { name: 'Themes', url: `${NETWORK_URL}/themes`, icon: 'themes' }, |
| 362 | { name: 'Tools', url: 'https://openvibe.tools', icon: 'tools' }, |
| 363 | ]; |
| 364 | return `<span class="ovf-account-who"><span class="ovf-avatar">${avatar}</span> Signed in as <strong>${name}</strong> across OpenVibe</span> |
| 365 | <span class="ovf-account-links">${quick.map(q => `<a href="${esc(q.url)}"${/^https?:/.test(q.url) ? ' target="_blank" rel="noopener"' : ''}>${icon(q.icon)} ${esc(q.name)}</a>`).join('')}</span>`; |
| 366 | } |
| 367 | |
| 368 | function storedToken() { |
| 369 | try { |
| 370 | const m = document.cookie.match(/(?:^|; )ov_token=([^;]*)/); |
| 371 | return (m && decodeURIComponent(m[1])) || localStorage.getItem('ov_token') || localStorage.getItem('token') || null; |
| 372 | } catch { return null; } |
| 373 | } |
| 374 | |
| 375 | async function fillAccount(cfg) { |
| 376 | const host = document.getElementById('ovf-account'); |
| 377 | if (!host || !cfg.showAccount || cfg.variant === 'compact') return; |
| 378 | let user = cfg.user; |
| 379 | if (!user) { |
| 380 | const token = storedToken(); |
| 381 | if (!token) return; |
| 382 | try { |
| 383 | const res = await fetch(`${cfg.apiBase}/api/auth/me`, { headers: { Authorization: 'Bearer ' + token }, credentials: 'include' }); |
| 384 | if (!res.ok) return; |
| 385 | const data = await res.json(); |
| 386 | user = data.user || data; |
| 387 | } catch { return; } |
| 388 | } |
| 389 | if (!user || !user.username) return; |
| 390 | host.innerHTML = accountHTML(user, cfg.service || detectService()); |
| 391 | host.hidden = false; |
| 392 | } |
| 393 | |
| 394 | // ── Styles (injected once) ───────────────────────────────── |
| 395 | const CSS = ` |
| 396 | .ovf{background:var(--bg-secondary,#12131c);border-top:1px solid var(--border,rgba(255,255,255,.08));margin-top:32px;font-family:var(--ov-font,'Inter',-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,'Helvetica Neue',Arial,sans-serif); |
| 397 | padding:clamp(24px,4vw,40px) clamp(14px,4vw,32px) calc(clamp(20px,3vw,28px) + env(safe-area-inset-bottom,0px));} |
| 398 | .ovf[data-variant="compact"]{margin-top:32px;padding:14px clamp(12px,3vw,24px) calc(14px + env(safe-area-inset-bottom,0px));} |
| 399 | .ovf-inner{max-width:1240px;margin:0 auto} |
| 400 | .ovf-inner--compact{display:flex;flex-wrap:wrap;align-items:center;justify-content:center;gap:8px 18px;text-align:center} |
| 401 | .ovf-cols{display:grid;gap:clamp(20px,3vw,36px);grid-template-columns:minmax(230px,1.6fr) repeat(auto-fit,minmax(120px,1fr));align-items:start} |
| 402 | .ovf-col{display:flex;flex-direction:column;gap:8px;min-width:0} |
| 403 | .ovf-col h3{margin:0 0 2px;font-size:.75rem;font-weight:800;letter-spacing:.1em;text-transform:uppercase;color:var(--text-muted,#8b93ad)} |
| 404 | .ovf-fold>summary{list-style:none;cursor:default;pointer-events:none} |
| 405 | .ovf-fold>summary::-webkit-details-marker{display:none} |
| 406 | .ovf-fold-body{display:flex;flex-direction:column;gap:8px;margin-top:8px} |
| 407 | .ovf-fold-n{display:none} |
| 408 | .ovf-col a{color:var(--text-secondary,#b6bdd2);font-size:.86rem;text-decoration:none;line-height:1.4;width:fit-content;transition:color .15s,transform .15s} |
| 409 | .ovf-col a:hover{color:var(--accent,#60a5fa);transform:translateX(2px)} |
| 410 | @media (pointer:coarse){.ovf-col a{display:inline-flex;align-items:center;min-height:44px}.ovf-col .ovf-social a{width:44px;height:44px}.ovf-legal-inline a,.ovf-compact-links a{position:relative}.ovf-legal-inline a::after,.ovf-compact-links a::after{content:'';position:absolute;left:-5px;right:-5px;top:-15px;bottom:-15px}} |
| 411 | .ovf-more{font-weight:600;opacity:.85}.ovf-ic{flex:none;vertical-align:-.14em}.ovf-more .ovf-ic{font-size:.85em;margin-left:3px;transition:transform .15s}.ovf-more:hover .ovf-ic{transform:translateX(3px)}.ovf-social .ovf-ic{font-size:17px}.ovf-account-links .ovf-ic{font-size:1.05em;opacity:.85} |
| 412 | .ovf-brand{position:relative;display:inline-flex;align-items:center;gap:9px;color:var(--text-primary,#f1f4fb);font-family:'OV Brand',Nunito,Inter,-apple-system,BlinkMacSystemFont,sans-serif;font-size:1.12rem;font-weight:800;letter-spacing:-.1px;text-decoration:none} |
| 413 | .ovf-brand--sm{font-size:.9rem} |
| 414 | .ovf-brand::after{content:'';position:absolute;inset:-6px 0} |
| 415 | .ovf-tagline{margin:0;font-size:.82rem;line-height:1.55;color:var(--text-muted,#8b93ad);max-width:46ch} |
| 416 | .ovf-social{display:flex;gap:8px;margin-top:4px} |
| 417 | .ovf-social a{width:36px;height:36px;border-radius:10px;display:grid;place-items:center;color:var(--text-secondary,#b6bdd2); |
| 418 | background:var(--bg-tertiary,#1a1b28);border:1px solid var(--border,rgba(255,255,255,.08));font-size:.95rem;transition:color .15s,border-color .15s,transform .15s} |
| 419 | .ovf-social a:hover{color:var(--accent,#60a5fa);border-color:var(--accent,#60a5fa);transform:translateY(-2px)} |
| 420 | .ovf-account{display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:10px 18px; |
| 421 | margin-top:clamp(18px,2.5vw,26px);padding:10px 14px;border-radius:14px; |
| 422 | background:color-mix(in srgb, var(--accent,#60a5fa) 8%, var(--bg-tertiary,#1a1b28)); |
| 423 | border:1px solid color-mix(in srgb, var(--accent,#60a5fa) 26%, var(--border,rgba(255,255,255,.08))); |
| 424 | animation:ovfIn .4s ease-out both} |
| 425 | @keyframes ovfIn{from{opacity:0;transform:translateY(6px)}to{opacity:1;transform:none}} |
| 426 | .ovf-account-who{display:inline-flex;align-items:center;gap:8px;font-size:.84rem;color:var(--text-secondary,#b6bdd2)} |
| 427 | .ovf-account-who strong{color:var(--text-primary,#f1f4fb)} |
| 428 | .ovf-avatar{width:24px;height:24px;border-radius:50%;overflow:hidden;display:grid;place-items:center; |
| 429 | background:var(--accent-strong,var(--accent,#3472d8));color:var(--on-accent-strong,var(--on-accent,#fff));font-size:.7rem;font-weight:800;flex:none} |
| 430 | .ovf-avatar img{width:100%;height:100%;object-fit:cover} |
| 431 | .ovf-account-links{display:flex;flex-wrap:wrap;gap:6px} |
| 432 | .ovf-account-links a{display:inline-flex;align-items:center;gap:6px;padding:5px 11px;border-radius:999px;font-size:.78rem;font-weight:600; |
| 433 | color:var(--text-secondary,#b6bdd2);background:var(--bg-secondary,#12131c);border:1px solid var(--border,rgba(255,255,255,.08)); |
| 434 | text-decoration:none;transition:color .15s,border-color .15s,transform .15s} |
| 435 | .ovf-account-links a:hover{color:var(--accent,#60a5fa);border-color:var(--accent,#60a5fa);transform:translateY(-1px)} |
| 436 | .ovf-account-links i{font-size:.72rem;opacity:.8} |
| 437 | .ovf-bar{display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:10px; |
| 438 | margin-top:clamp(20px,3vw,30px);padding-top:16px;border-top:1px solid var(--border,rgba(255,255,255,.08)); |
| 439 | padding-inline:clamp(0px, calc(84px - (100vw - 1240px) / 2), 84px)} |
| 440 | .ovf-copy{color:var(--text-muted,#8b93ad);font-size:.78rem} |
| 441 | .ovf-legal-inline,.ovf-compact-links{display:flex;flex-wrap:wrap;gap:14px} |
| 442 | .ovf-compact-links{justify-content:center} |
| 443 | .ovf [hidden],.ovf-account[hidden]{display:none!important} |
| 444 | .ovf .ovf-shipped.ov-shipped-latest{font-size:.76rem;padding:4px 12px;max-width:min(520px,100%);background:transparent} |
| 445 | @media (pointer:coarse){.ovf .ovf-shipped.ov-shipped-latest{min-height:44px;padding-top:12px;padding-bottom:12px;box-sizing:border-box}} |
| 446 | .ovf-legal-inline a,.ovf-compact-links a{color:var(--text-muted,#8b93ad);font-size:.78rem;text-decoration:none;transition:color .15s} |
| 447 | .ovf-legal-inline a:hover,.ovf-compact-links a:hover{color:var(--accent,#60a5fa)} |
| 448 | .ovf-compact-meta{color:var(--text-muted,#8b93ad);font-size:.75rem} |
| 449 | .ovf-expand{max-width:1240px;margin:8px auto 0} |
| 450 | .ovf[data-variant="compact"]{display:flex;flex-wrap:wrap;align-items:center;justify-content:center;column-gap:18px} |
| 451 | .ovf[data-variant="compact"]>.ovf-inner--compact{flex:0 1 auto;min-width:0;margin:0} |
| 452 | .ovf[data-variant="compact"]>.ovf-expand{margin:0} |
| 453 | .ovf[data-variant="compact"]>.ovf-expand:not([open])>summary{padding:0;border:0;border-radius:0;font-size:.78rem;font-weight:400;line-height:inherit} |
| 454 | .ovf[data-variant="compact"]>.ovf-expand[open]{flex:1 1 100%;margin:10px auto 0} |
| 455 | .ovf-expand>summary{list-style:none;cursor:pointer;width:fit-content;margin:0 auto;padding:4px 12px;border-radius:999px;font-size:.76rem;font-weight:600;color:var(--text-muted,#8b93ad);border:1px solid var(--border,rgba(255,255,255,.08));transition:color .15s,border-color .15s} |
| 456 | .ovf-expand>summary::-webkit-details-marker{display:none} |
| 457 | .ovf-expand>summary::after{content:"";display:inline-block;width:.42em;height:.42em;margin-left:.5em;border:solid currentColor;border-width:0 1.6px 1.6px 0;transform:translateY(-.15em) rotate(45deg);transition:transform .2s} |
| 458 | .ovf-expand[open]>summary::after{transform:translateY(.05em) rotate(-135deg)} |
| 459 | .ovf-expand>summary:hover{color:var(--accent,#60a5fa);border-color:var(--accent,#60a5fa)} |
| 460 | .ovf-expand[open]>summary{margin-bottom:clamp(16px,2.5vw,24px)} |
| 461 | .ovf-inner--expanded{animation:ovfIn .3s ease-out both} |
| 462 | .ovf-pulse{display:flex;flex-wrap:wrap;align-items:center;gap:6px 10px;margin:0 0 clamp(18px,2.5vw,24px);padding:9px 14px;border-radius:12px;font-size:.8rem;color:var(--text-secondary,#b6bdd2); |
| 463 | background:color-mix(in srgb, var(--accent,#60a5fa) 6%, var(--bg-tertiary,#1a1b28));border:1px solid var(--border,rgba(255,255,255,.08));animation:ovfIn .4s ease-out both} |
| 464 | .ovf-pulse a{color:inherit;text-decoration:none;font-weight:600} |
| 465 | .ovf-now{flex:1 1 100%;min-width:0;display:grid;gap:8px} |
| 466 | .ovf-who>summary{list-style:none;display:flex;flex-wrap:wrap;align-items:center;gap:4px 9px;min-height:36px;color:var(--text-primary,#f1f4fb)} |
| 467 | .ovf-who>summary{cursor:pointer;border-radius:10px} |
| 468 | .ovf-who>summary::-webkit-details-marker{display:none} |
| 469 | .ovf-who>summary::after{content:"";width:.45em;height:.45em;margin:0 4px 3px auto;border:solid var(--text-muted,#8b93ad);border-width:0 1.8px 1.8px 0;transform:rotate(45deg);transition:transform .2s} |
| 470 | .ovf-who[open]>summary::after{transform:translateY(3px) rotate(-135deg)} |
| 471 | .ovf-who>summary:focus-visible{outline:2px solid var(--accent,#60a5fa);outline-offset:3px} |
| 472 | .ovf-who-dot{flex:none;width:8px;height:8px;border-radius:50%;background:var(--success-text,#34d399);box-shadow:0 0 0 3px color-mix(in srgb,var(--success-text,#34d399) 22%,transparent);animation:ovf-on 2.4s ease-in-out infinite} |
| 473 | @keyframes ovf-on{50%{box-shadow:0 0 0 calc(3px + 3px*var(--ov-move,1)) color-mix(in srgb,var(--success-text,#34d399) 6%,transparent)}} |
| 474 | .ovf-who-n{font-weight:500}.ovf-who-n b{font-size:1.05em;font-weight:800;font-variant-numeric:tabular-nums} |
| 475 | .ovf-who-when{margin:6px 2px 0;color:var(--text-muted,#8b93ad)} |
| 476 | .ovf-who-faces{display:inline-flex;padding-left:7px} |
| 477 | .ovf-who-faces img,.ovf-who-faces i{width:24px;height:24px;margin-left:-7px;border-radius:50%;box-shadow:0 0 0 2px var(--bg-secondary,#0f1420);object-fit:cover} |
| 478 | .ovf-who i{display:grid;place-items:center;font-style:normal;font-size:.72rem;font-weight:800;color:var(--text-primary,#f1f4fb);background:color-mix(in srgb,var(--accent,#3b82f6) 38%,var(--bg-secondary,#0f1420))} |
| 479 | .ovf-who-list{list-style:none;display:flex;flex-wrap:wrap;gap:6px;margin:8px 0 0;padding:0;animation:ovf-list .3s cubic-bezier(.2,.9,.3,1.1) both} |
| 480 | @keyframes ovf-list{from{opacity:0;transform:translateY(calc(-6px*var(--ov-move,1)))}} |
| 481 | .ovf-who-list a{display:inline-flex;align-items:center;gap:7px;min-height:36px;box-sizing:border-box;padding:3px 12px 3px 4px;border-radius:999px;color:var(--text-primary,#f1f4fb);background:color-mix(in srgb,var(--text-primary,#f1f4fb) 6%,transparent);transition:background-color .15s,transform .12s} |
| 482 | .ovf-who-list a:hover{background:color-mix(in srgb,var(--accent,#3b82f6) 16%,transparent);color:var(--text-primary,#f1f4fb)} |
| 483 | .ovf-who-list a:active{transform:scale(.96)} |
| 484 | .ovf-who-list img,.ovf-who-list i{flex:none;width:26px;height:26px;border-radius:50%;object-fit:cover} |
| 485 | .ovf-who-list small{color:var(--text-muted,#8b93ad);font-weight:500} |
| 486 | .ovf-who-more{margin:8px 2px 0;color:var(--text-muted,#8b93ad)} |
| 487 | .ovf-tiles{display:flex;gap:6px;margin:0 -14px;padding:0 14px 2px;overflow-x:auto;overscroll-behavior-x:contain;scrollbar-width:none} |
| 488 | .ovf-tiles::-webkit-scrollbar{display:none} |
| 489 | .ovf-tile{flex:none;display:inline-flex;align-items:center;gap:6px;min-height:36px;box-sizing:border-box;padding:0 12px 0 10px;border-radius:999px;white-space:nowrap;font-weight:500!important;color:var(--text-secondary,#b6bdd2);background:color-mix(in srgb,var(--text-primary,#f1f4fb) 5%,transparent);transition:background-color .15s,transform .12s} |
| 490 | a.ovf-tile:hover{color:var(--text-primary,#f1f4fb);background:color-mix(in srgb,var(--accent,#3b82f6) 15%,transparent)} |
| 491 | a.ovf-tile:active{transform:scale(.96)} |
| 492 | .ovf-tile b{color:var(--text-primary,#f1f4fb);font-weight:800;font-variant-numeric:tabular-nums} |
| 493 | .ovf-tile small{color:var(--text-muted,#8b93ad)} |
| 494 | .ovf-tile .ovf-ic{font-size:1.15em;color:var(--accent-light,var(--accent,#60a5fa))} |
| 495 | .ovf-tile.is-live{background:color-mix(in srgb,var(--live-red-text,#ef4444) 15%,transparent)} |
| 496 | .ovf-tile.is-channel{padding:0 12px;color:var(--text-primary,#f1f4fb)} |
| 497 | @media (pointer:coarse){.ovf-who>summary,.ovf-tile,.ovf-who-list a{min-height:44px}} |
| 498 | .ovf-pulse a:hover{color:var(--accent,#60a5fa)} |
| 499 | .ovf-pulse .ovf-sep{color:var(--text-muted,#8b93ad)} |
| 500 | .ovf-pulse-live{display:inline-flex;align-items:center;gap:7px;color:var(--text-primary,#f1f4fb)!important} |
| 501 | .ovf-dot{width:8px;height:8px;border-radius:50%;background:#ef4444;box-shadow:0 0 0 0 rgba(239,68,68,.6);animation:ovfLive 1.8s ease-out infinite} |
| 502 | @keyframes ovfLive{0%{box-shadow:0 0 0 0 rgba(239,68,68,.55)}70%{box-shadow:0 0 0 7px rgba(239,68,68,0)}100%{box-shadow:0 0 0 0 rgba(239,68,68,0)}} |
| 503 | .ovf-pulse-quiet{font-weight:500!important;color:var(--text-secondary,#b6bdd2)!important} |
| 504 | @media (max-width:900px){.ovf-cols{grid-template-columns:repeat(2,minmax(0,1fr))}.ovf-col--brand{grid-column:1/-1}} |
| 505 | @media (max-width:760px){.ovf,.ovf[data-variant="compact"]{margin-top:24px}} |
| 506 | @media (max-width:560px){ |
| 507 | .ovf{padding-bottom:calc(20px + var(--ov-footer-clearance,0px) + env(safe-area-inset-bottom,0px))} |
| 508 | .ovf[data-variant="compact"]{padding-bottom:calc(14px + var(--ov-footer-clearance,0px) + env(safe-area-inset-bottom,0px))} |
| 509 | .ovf-cols{gap:0 16px} |
| 510 | .ovf-col--brand{margin-bottom:14px} |
| 511 | .ovf-col:not(.ovf-col--brand){grid-column:1/-1;border-top:1px solid var(--border,rgba(255,255,255,.08))} |
| 512 | .ovf-col:not(.ovf-col--brand):last-child{border-bottom:1px solid var(--border,rgba(255,255,255,.08))} |
| 513 | .ovf-fold>summary{pointer-events:auto;cursor:pointer;display:flex;align-items:center;justify-content:space-between;min-height:48px} |
| 514 | .ovf-fold>summary h3{margin:0} |
| 515 | .ovf-fold-n{display:inline;margin-left:auto;margin-right:10px;color:var(--text-muted,#8b93ad);font-size:.82rem;font-weight:600;font-variant-numeric:tabular-nums} |
| 516 | .ovf-fold>summary::after{content:"";width:.5em;height:.5em;margin-right:4px;border:solid var(--text-muted,#8b93ad);border-width:0 1.8px 1.8px 0;transform:translateY(-.2em) rotate(45deg);transition:transform .2s} |
| 517 | .ovf-fold[open]>summary::after{transform:translateY(.1em) rotate(-135deg)} |
| 518 | .ovf-fold-body{margin-top:0;padding-bottom:14px} |
| 519 | .ovf-bar{justify-content:center;text-align:center;padding-inline:0} |
| 520 | .ovf-legal-inline{justify-content:center} |
| 521 | .ovf-account{flex-direction:column;align-items:flex-start} |
| 522 | } |
| 523 | html[data-ov-motion=off] .ovf-col a:hover,html[data-ov-motion=off] .ovf-social a:hover,html[data-ov-motion=off] .ovf-account-links a:hover{transform:none} |
| 524 | .ovf{-webkit-tap-highlight-color:transparent} |
| 525 | :where(.ovf) :where(a,button):active{scale:.97} |
| 526 | :where(.ovf) :where(a,button):focus-visible{outline:2px solid var(--accent-light,var(--accent,#3b82f6));outline-offset:2px;border-radius:4px} |
| 527 | html[data-ov-motion=off] :where(.ovf) :where(a,button):active{scale:none;opacity:.82}`; |
| 528 | |
| 529 | function injectCSS() { |
| 530 | if (typeof document === 'undefined' || document.getElementById('ovf-css')) return; |
| 531 | const st = document.createElement('style'); |
| 532 | st.id = 'ovf-css'; |
| 533 | st.textContent = CSS; |
| 534 | document.head.appendChild(st); |
| 535 | } |
| 536 | |
| 537 | // ── Phones: the columns fold ─────────────────────────────── |
| 538 | // The server and the first render write every column open (no script: everything shows). On a phone the client |
| 539 | // folds them, except the ones the person opened themselves, which stay open through any later render. |
| 540 | // Wider than a phone (a phone turned sideways too) every column opens again and its heading is only a heading. |
| 541 | const _openFolds = new Set(); |
| 542 | let _phoneQuery = null; |
| 543 | function foldOnPhones(el) { |
| 544 | if (!el || typeof window === 'undefined' || typeof window.matchMedia !== 'function') return; |
| 545 | if (!_phoneQuery) { |
| 546 | _phoneQuery = window.matchMedia('(max-width: 560px)'); |
| 547 | const again = () => foldOnPhones(_mounted); |
| 548 | if (_phoneQuery.addEventListener) _phoneQuery.addEventListener('change', again); else if (_phoneQuery.addListener) _phoneQuery.addListener(again); |
| 549 | } |
| 550 | const phone = _phoneQuery.matches; |
| 551 | if (!el._ovFolds) { |
| 552 | el._ovFolds = true; |
| 553 | // The person's own taps on a heading (toggle events are async, so the intent is read from the click). |
| 554 | el.addEventListener('click', (e) => { |
| 555 | const sum = e.target && e.target.closest && e.target.closest('details.ovf-fold > summary'); |
| 556 | if (!sum) return; |
| 557 | if (!_phoneQuery.matches) { e.preventDefault(); return; } |
| 558 | const d = sum.parentNode, nav = d.closest('nav'), key = nav ? nav.getAttribute('aria-label') : ''; |
| 559 | if (d.open) _openFolds.delete(key); else _openFolds.add(key); |
| 560 | }); |
| 561 | } |
| 562 | el.querySelectorAll('details.ovf-fold').forEach((d) => { |
| 563 | const nav = d.closest('nav'); |
| 564 | d.open = !phone || _openFolds.has(nav && nav.getAttribute('aria-label')); |
| 565 | const sum = d.querySelector('summary'); |
| 566 | if (sum) sum.tabIndex = phone ? 0 : -1; |
| 567 | }); |
| 568 | } |
| 569 | |
| 570 | // ── Mount ────────────────────────────────────────────────── |
| 571 | function render() { |
| 572 | if (!_mounted) return; |
| 573 | const variant = shownVariant(_cfg.variant); |
| 574 | // An opened "More from OpenVibe" stays open through a re-render (a claim, the Frame's first answer). |
| 575 | const open = !!(_mounted.querySelector && _mounted.querySelector('details.ovf-expand[open]')); |
| 576 | _mounted.className = 'ovf ' + (_mounted.dataset.extraClass || ''); |
| 577 | _mounted.setAttribute('data-variant', variant); |
| 578 | _mounted.innerHTML = buildHTML({ ..._cfg, variant, _open: open }); |
| 579 | foldOnPhones(_mounted); |
| 580 | if (!_mounted._ovReport) { |
| 581 | _mounted._ovReport = true; |
| 582 | const fresh = (e) => { const a = e.target && e.target.closest && e.target.closest('a[data-ov-report]'); if (a) a.href = reportHref(); }; |
| 583 | _mounted.addEventListener('click', fresh); |
| 584 | _mounted.addEventListener('auxclick', fresh); |
| 585 | } |
| 586 | try { if (typeof window !== 'undefined' && typeof window.ovMarkMount === 'function') window.ovMarkMount(_mounted); } catch { /* */ } |
| 587 | fillAccount({ ..._cfg, variant }); |
| 588 | if (_cfg.shipped !== false) shippedWhenNear(_mounted); |
| 589 | watchPulse(); |
| 590 | } |
| 591 | |
| 592 | // ── The pulse: the network right now ─────────────────────── |
| 593 | // Read once the footer is in view (nobody who never scrolls there costs a request), then every minute while it is. |
| 594 | // One read fills the strip (owner, 2026-11-10): GET /api/frame/online says who was on the network in the last |
| 595 | // thirty minutes (members by name only when their presence lets it, guests as a count), the live channels, a few |
| 596 | // counts that open what they count, and this site's own items. The old /api/frame/pulse line is the fallback |
| 597 | // until the main session's design lands, and its four facts are kept here from the same read. |
| 598 | const PULSE_URL = `${NETWORK_URL}/api/frame/pulse`; |
| 599 | const ONLINE_URL = `${NETWORK_URL}/api/frame/online`; |
| 600 | // A picture that cannot show (a site's image policy without openvibe.media, a deleted file) becomes its initial |
| 601 | // (ask 345: a broken-image icon in Locker's "5 online"). One capture listener for every <img data-ov-initial> on the |
| 602 | // page, no inline handler, so it works under a strict Content-Security-Policy too. |
| 603 | if (typeof document !== 'undefined' && !document.__ovInitials) { |
| 604 | document.__ovInitials = true; |
| 605 | const toInitial = (img) => { |
| 606 | if (!img || img.tagName !== 'IMG' || !img.hasAttribute('data-ov-initial') || !img.parentNode) return; |
| 607 | const i = document.createElement('i'); |
| 608 | i.setAttribute('aria-hidden', 'true'); |
| 609 | i.className = 'ov-initial'; |
| 610 | i.textContent = img.getAttribute('data-ov-initial') || '?'; |
| 611 | img.replaceWith(i); |
| 612 | }; |
| 613 | document.addEventListener('error', (e) => toInitial(e.target), true); |
| 614 | // Pictures that had already failed before this file ran. |
| 615 | const sweep = () => document.querySelectorAll('img[data-ov-initial]').forEach((img) => { if (img.complete && !img.naturalWidth) toInitial(img); }); |
| 616 | if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', sweep, { once: true }); else sweep(); |
| 617 | } |
| 618 | const PULSE_MS = 60_000; |
| 619 | let _pulse = null; |
| 620 | let _pulseAt = 0; |
| 621 | let _online = null; |
| 622 | let _onlineAt = 0; |
| 623 | let _pulseObserver = null; |
| 624 | let _pulseTimer = null; |
| 625 | const fmtN = (n) => (n >= 10000 ? `${Math.round(n / 1000)}k` : n >= 1000 ? `${(n / 1000).toFixed(1).replace(/\.0$/, '')}k` : String(n)); |
| 626 | function pulseHTML(p) { |
| 627 | const bits = []; |
| 628 | if (p.live_now != null) { |
| 629 | bits.push(p.live_now > 0 |
| 630 | ? `<a class="ovf-pulse-live" href="https://openvibe.live/"><span class="ovf-dot" aria-hidden="true"></span>${fmtN(p.live_now)} live now</a>` |
| 631 | : '<a class="ovf-pulse-quiet" href="https://openvibe.live/">Nobody live right now · go live</a>'); |
| 632 | } |
| 633 | if (p.watching > 0) bits.push(`<span>${fmtN(p.watching)} watching</span>`); |
| 634 | if (p.sites > 0) bits.push(`<a href="${NETWORK_URL}/#network">${fmtN(p.sites)} sites, one account</a>`); |
| 635 | if (p.views_7d > 0) bits.push(`<span>${fmtN(p.views_7d)} page views this week</span>`); |
| 636 | return bits.join('<span class="ovf-sep" aria-hidden="true">·</span>'); |
| 637 | } |
| 638 | /** A number from the read, or null; the read is ours but nothing on a page trusts its input. */ |
| 639 | const onlineN = (v) => (v === null || v === undefined || !Number.isFinite(Number(v)) ? null : Math.max(0, Math.floor(Number(v)))); |
| 640 | const onlineUrl = (u) => (/^https:\/\/[^\s"'<>]+$/i.test(String(u || '')) ? String(u) : ''); |
| 641 | const onlineText = (v, max) => String(v == null ? '' : v).replace(/[<>]/g, '').replace(/\s+/g, ' ').trim().slice(0, max); |
| 642 | /** The read, cleaned: only what the strip draws, every string capped, every link https. */ |
| 643 | function cleanOnline(o) { |
| 644 | if (!o || typeof o !== 'object') return null; |
| 645 | const people = (Array.isArray(o.people) ? o.people : []).slice(0, 60).map((p) => { |
| 646 | const username = onlineText(p && p.username, 40); |
| 647 | const avatar = onlineUrl(p && p.avatar); |
| 648 | return username && /^[A-Za-z0-9_.-]+$/.test(username) |
| 649 | ? { name: onlineText(p.name, 60) || username, username, avatar, site: onlineText(p.site, 40) } |
| 650 | : null; |
| 651 | }).filter(Boolean); |
| 652 | const row = (r, withLine) => { |
| 653 | const label = onlineText(r && r.label, 40), link = onlineUrl(r && r.link); |
| 654 | if (!label || !link) return null; |
| 655 | const value = onlineN(r.value); |
| 656 | const out = { label, link, icon: onlineText(r.icon, 40) }; |
| 657 | if (value != null && r.value !== undefined) out.value = value; |
| 658 | else if (withLine) out.line = onlineText(r.line, 80); |
| 659 | return out; |
| 660 | }; |
| 661 | const live = o.live && typeof o.live === 'object' |
| 662 | ? { channels: onlineN(o.live.channels), watching: onlineN(o.live.watching), first: (Array.isArray(o.live.first) ? o.live.first : []).slice(0, 3).map((s) => ({ name: onlineText(s && s.name, 100), url: onlineUrl(s && s.url) })).filter((s) => s.name && s.url) } |
| 663 | : null; |
| 664 | return { |
| 665 | members: onlineN(o.members), guests: onlineN(o.guests), total: onlineN(o.total), more: onlineN(o.more), |
| 666 | people, live, |
| 667 | counts: (Array.isArray(o.counts) ? o.counts : []).map((c) => row({ ...c, link: c && (c.link || c.url) }, false)).filter(Boolean), |
| 668 | items: (Array.isArray(o.items) ? o.items : []).map((i) => row(i, true)).filter(Boolean), |
| 669 | sites: onlineN(o.sites), views_7d: onlineN(o.views_7d), |
| 670 | }; |
| 671 | } |
| 672 | /** The strip from the one read: the old facts it carries, who is online, the counts and this site's own items. */ |
| 673 | function onlineHTML(o) { |
| 674 | // Who is on the network, first: a count with a few faces, and (where anyone may be named) the list an old forum |
| 675 | // had under its pages: every member by name, then how many more and how many guests. A guest is a number only. |
| 676 | let who = ''; |
| 677 | if (o.total > 0) { |
| 678 | const face = (p, size) => (p.avatar ? `<img src="${esc(p.avatar)}" alt="" width="${size}" height="${size}" loading="lazy" decoding="async" data-ov-initial="${esc(p.name.charAt(0).toUpperCase())}">` : `<i aria-hidden="true">${esc(p.name.charAt(0).toUpperCase())}</i>`); |
| 679 | const parts = [o.members > 0 ? `${fmtN(o.members)} member${o.members === 1 ? '' : 's'}` : '', o.guests > 0 ? `${fmtN(o.guests)} guest${o.guests === 1 ? '' : 's'}` : ''].filter(Boolean).join(' · '); |
| 680 | // Shorthand (the owner, 2026-11-10: "Make it like shorthand '3 online'. If you put your mouse over it, it says |
| 681 | // 'last 30 minutes'"): the count and the faces. When, and how many are members and guests, are the tooltip's |
| 682 | // and the first line of what a press opens (a finger never sees a tooltip). |
| 683 | const head = `<span class="ovf-who-dot" aria-hidden="true"></span><span class="ovf-who-n" data-ov-tip="In the last 30 minutes"${parts ? ` data-ov-tip-sub="${esc(parts)}"` : ''}><b>${fmtN(o.total)}</b> online</span>${o.people.length ? `<span class="ovf-who-faces" aria-hidden="true">${o.people.slice(0, 5).map((p) => face(p, 24)).join('')}</span>` : ''}`; |
| 684 | const when = `<p class="ovf-who-when">In the last 30 minutes${parts ? ` · ${parts}` : ''}</p>`; |
| 685 | const list = o.people.map((p) => `<li><a href="${NETWORK_URL}/@${encodeURIComponent(p.username)}">${face(p, 26)}<span>${esc(p.name)}</span>${p.site ? `<small>${esc(p.site)}</small>` : ''}</a></li>`).join(''); |
| 686 | who = `<details class="ovf-who"><summary>${head}</summary>${when}${list ? `<ul class="ovf-who-list">${list}</ul>` : ''}${o.more > 0 ? `<p class="ovf-who-more">and ${fmtN(o.more)} more</p>` : ''}</details>`; |
| 687 | } |
| 688 | // Then a row of small tiles, each opening what it counts: live (only while someone is), the counts, this |
| 689 | // site's own items, and the two facts the strip always had. |
| 690 | const tile = (href, cls, inner) => (href ? `<a class="ovf-tile${cls}" href="${esc(href)}">${inner}</a>` : `<span class="ovf-tile${cls}">${inner}</span>`); |
| 691 | const row = (r) => tile(r.link, '', `${icon(r.icon)}${r.value != null ? `<b>${esc(fmtN(r.value))}</b>` : ''}<span>${esc(r.label)}</span>${r.line ? `<small>${esc(r.line)}</small>` : ''}`); |
| 692 | const tiles = []; |
| 693 | const live = o.live; |
| 694 | if (live && live.channels > 0) { |
| 695 | tiles.push(tile('https://openvibe.live/', ' is-live', `<span class="ovf-dot" aria-hidden="true"></span><b>${fmtN(live.channels)}</b><span>live now</span>${live.watching > 0 ? `<small>${fmtN(live.watching)} watching</small>` : ''}`)); |
| 696 | live.first.forEach((c) => tiles.push(tile(c.url, ' is-channel', `<span>${esc(c.name)}</span>`))); |
| 697 | } |
| 698 | o.counts.forEach((c) => tiles.push(row(c))); |
| 699 | o.items.forEach((i) => tiles.push(row(i))); |
| 700 | if (o.sites > 0) tiles.push(tile(`${NETWORK_URL}/#network`, '', `${icon('sites')}<b>${fmtN(o.sites)}</b><span>sites, one account</span>`)); |
| 701 | if (o.views_7d > 0) tiles.push(tile('', '', `${icon('chart')}<b>${fmtN(o.views_7d)}</b><span>page views this week</span>`)); |
| 702 | const more = o.counts.length || o.items.length; |
| 703 | return `<div class="ovf-now">${who}${tiles.length ? `<div class="ovf-tiles${more ? ' ovf-online-actions' : ''}">${tiles.join('')}</div>` : ''}</div>`; |
| 704 | } |
| 705 | function paintPulse() { |
| 706 | if (!_mounted) return; |
| 707 | const html = _online ? onlineHTML(_online) : (_pulse ? pulseHTML(_pulse) : ''); |
| 708 | for (const el of _mounted.querySelectorAll('[data-ovf-pulse]')) { el.innerHTML = html; el.hidden = !html; } |
| 709 | } |
| 710 | function readPulse() { |
| 711 | if (typeof fetch === 'undefined' || (typeof document !== 'undefined' && document.hidden)) return; |
| 712 | if (_pulse && Date.now() - _pulseAt < PULSE_MS - 1000) { paintPulse(); return; } |
| 713 | fetch(PULSE_URL, { credentials: 'omit' }).then(r => (r.ok ? r.json() : null)).then((p) => { |
| 714 | if (!p || typeof p !== 'object') return; |
| 715 | const n = (v) => (Number.isFinite(Number(v)) && v !== null ? Math.max(0, Math.floor(Number(v))) : null); |
| 716 | _pulse = { live_now: n(p.live_now), watching: n(p.watching), sites: n(p.sites), views_7d: n(p.views_7d) }; |
| 717 | _pulseAt = Date.now(); |
| 718 | paintPulse(); |
| 719 | }).catch(() => { /* a quiet footer is fine */ }); |
| 720 | } |
| 721 | /** The strip's one read; the old pulse endpoint only when it does not answer, until the design lands. */ |
| 722 | function readNetwork() { |
| 723 | if (typeof fetch === 'undefined' || (typeof document !== 'undefined' && document.hidden)) return; |
| 724 | if (_online && Date.now() - _onlineAt < PULSE_MS - 1000) { paintPulse(); return; } |
| 725 | // This site's own items come back only when it asks as itself (its service id, or its site id). |
| 726 | const site = (_cfg.service || detectService() || ''); |
| 727 | const url = site ? `${ONLINE_URL}?site=${encodeURIComponent(site)}` : ONLINE_URL; |
| 728 | fetch(url, { credentials: 'omit' }).then(r => (r.ok ? r.json() : null)).then((o) => { |
| 729 | const clean = cleanOnline(o); |
| 730 | if (!clean) { readPulse(); return; } |
| 731 | _online = clean; |
| 732 | _onlineAt = Date.now(); |
| 733 | paintPulse(); |
| 734 | }).catch(() => { readPulse(); }); |
| 735 | } |
| 736 | function watchPulse() { |
| 737 | if (!_mounted || _cfg.pulse === false || typeof window === 'undefined') return; |
| 738 | if (_online || _pulse) paintPulse(); |
| 739 | if (_pulseObserver) return; |
| 740 | if (typeof IntersectionObserver !== 'function') { readNetwork(); return; } |
| 741 | _pulseObserver = new IntersectionObserver((entries) => { |
| 742 | const seen = entries.some(e => e.isIntersecting); |
| 743 | clearInterval(_pulseTimer); |
| 744 | _pulseTimer = null; |
| 745 | if (!seen) return; |
| 746 | readNetwork(); |
| 747 | _pulseTimer = setInterval(readNetwork, PULSE_MS); |
| 748 | }, { rootMargin: '200px 0px' }); |
| 749 | _pulseObserver.observe(_mounted); |
| 750 | } |
| 751 | |
| 752 | // shipped.js mounts every [data-ov-shipped] element when it loads; loaded once, lazily, after the footer. |
| 753 | let _shippedLoading = false; |
| 754 | // shipped.js's content hash (scripts/sibling-revs.js writes it), and the footer's "shipped" line is fetched only when the |
| 755 | // footer comes near the screen (4.138.0): most visits never scroll there, and it is 12 KB. |
| 756 | const FOOTER_REV = { 'shipped.js': '6b4ed7c3dc2d' }; |
| 757 | let _shippedSeen = null; |
| 758 | function shippedWhenNear(el) { |
| 759 | if (typeof IntersectionObserver !== 'function' || !el || !el.getBoundingClientRect) { loadShipped(); return; } |
| 760 | if (_shippedSeen) return; |
| 761 | _shippedSeen = new IntersectionObserver((es) => { if (es.some((e) => e.isIntersecting)) { _shippedSeen.disconnect(); _shippedSeen = null; loadShipped(); } }, { rootMargin: '900px 0px' }); |
| 762 | _shippedSeen.observe(el); |
| 763 | } |
| 764 | function loadShipped() { |
| 765 | if (typeof document === 'undefined') return; |
| 766 | if (root.OpenVibeShipped && typeof root.OpenVibeShipped.scan === 'function') { try { root.OpenVibeShipped.scan(document); } catch { /* */ } return; } |
| 767 | if (_shippedLoading || document.querySelector('script[data-ov-shipped-src]')) return; |
| 768 | _shippedLoading = true; |
| 769 | const sc = document.createElement('script'); |
| 770 | let src = `${NETWORK_URL}/shared/shipped.js`; |
| 771 | try { if (OWN_SRC) src = new URL('shipped.js', OWN_SRC).href; } catch { /* */ } |
| 772 | sc.src = src + (FOOTER_REV['shipped.js'] ? `?v=${FOOTER_REV['shipped.js']}` : ''); |
| 773 | sc.defer = true; |
| 774 | sc.setAttribute('data-ov-shipped-src', ''); |
| 775 | (document.head || document.documentElement).appendChild(sc); |
| 776 | } |
| 777 | |
| 778 | function init(cfg = {}) { |
| 779 | _cfg = { ...DEFAULTS, ..._cfg, ...cfg }; |
| 780 | _cfg.service = _cfg.service || detectService(); |
| 781 | injectCSS(); |
| 782 | const el = typeof _cfg.mount === 'string' ? document.querySelector(_cfg.mount) : _cfg.mount; |
| 783 | if (!el) return null; |
| 784 | _mounted = el; |
| 785 | // Started: shell.page's own start (after the site's scripts) leaves a footer the site started alone. |
| 786 | try { el.setAttribute('data-ovf-live', ''); } catch { /* */ } |
| 787 | render(); |
| 788 | // 'auto' on a single-page site follows its routes: back and forward re-read the path (a route the site pushes |
| 789 | // itself calls setVariant or render). |
| 790 | if (_cfg.variant === 'auto' && !_routeWatch && typeof window !== 'undefined' && window.addEventListener) { |
| 791 | _routeWatch = true; |
| 792 | window.addEventListener('popstate', () => { if (_cfg.variant === 'auto' && _mounted) render(); }); |
| 793 | } |
| 794 | return el; |
| 795 | } |
| 796 | let _routeWatch = false; |
| 797 | |
| 798 | /** The page's own variant (an SPA route's); a claim still wins while it holds. */ |
| 799 | function setVariant(variant) { |
| 800 | if (variant !== 'full' && variant !== 'compact' && variant !== 'auto') return; |
| 801 | if (_cfg.variant === variant) return; |
| 802 | const before = shownVariant(_cfg.variant); |
| 803 | _cfg.variant = variant; |
| 804 | if (shownVariant(variant) !== before) render(); |
| 805 | } |
| 806 | |
| 807 | /** |
| 808 | * A part of the page asks for a variant while it needs it: Live's channel claims 'compact' while its stream plays and |
| 809 | * releases it when the stream ends or the route changes, and the page's own variant comes back. Same key twice |
| 810 | * replaces. → release() |
| 811 | */ |
| 812 | function claim(variant, key = 'default') { |
| 813 | if (variant !== 'full' && variant !== 'compact') return () => {}; |
| 814 | const k = String(key); |
| 815 | const before = shownVariant(_cfg.variant); |
| 816 | _claims.set(k, variant); |
| 817 | if (shownVariant(_cfg.variant) !== before) render(); |
| 818 | return function release() { |
| 819 | if (_claims.get(k) !== variant) return; |
| 820 | const was = shownVariant(_cfg.variant); |
| 821 | _claims.delete(k); |
| 822 | if (shownVariant(_cfg.variant) !== was) render(); |
| 823 | }; |
| 824 | } |
| 825 | |
| 826 | /** |
| 827 | * Server-side render: the complete footer as HTML + its CSS, so every page carries the network's links for |
| 828 | * crawlers and for visitors without JavaScript. In the browser, init() renders into the same element. |
| 829 | * ssr({ service, variant, links, mountId }) → '<style>…</style><footer id="ov-footer" …>…</footer>' |
| 830 | */ |
| 831 | function ssr(cfg = {}) { |
| 832 | const c = { ...DEFAULTS, ...cfg }; |
| 833 | if (c.variant === 'auto') c.variant = autoFor(cfg.path, c.fullOn); // the request's path, on the server |
| 834 | const id = String(cfg.mountId || 'ov-footer').replace(/[^\w-]/g, ''); |
| 835 | return `<style id="ovf-css">${CSS}</style><footer id="${id}" class="ovf" data-variant="${c.variant === 'compact' ? 'compact' : 'full'}">${buildHTML(c)}</footer>`; |
| 836 | } |
| 837 | |
| 838 | return { init, ssr, setVariant, claim, buildHTML, render, CSS, NETWORK, LEGAL, detectService, pulseHTML, onlineHTML, cleanOnline, autoFor, get config() { return { ..._cfg }; }, get variant() { return shownVariant(_cfg.variant); } }; |
| 839 | }); |
| 840 |