openvibers/openvibe.live Commits
Sign-in/sign-out everywhere hooks, network history, Tools link, community-paste redirect scaffold, open/community-run wording
Changes
@@@ -103,3 +103,7 @@ UNSAFE_WORDS_PATH=./server/chat/unsafe-words.txt# from OpenVibe.Media (drain stalled / disk critical / recovered). Can also be set from the# admin settings as `ops_alert_webhook_url`.OPS_ALERT_WEBHOOK_URL=++++# Pastes are served by openvibe.community when this is 1 (Live 301s /p/:slug and /pastes there)++PASTES_ON_COMMUNITY=0++OV_COMMUNITY_URL=https://openvibe.community@@@ -1,5 +1,5 @@[Unit]--Description=OpenVibe.Live — Free & Open Live Streaming++Description=OpenVibe.Live — Open Live Streaming, Community Run# Media (openvibe-media) and Network (openvibe-network) should be up first, but# Live degrades gracefully if they aren't (proxies 502, wallet reads fall back).After=network.target@@@ -4,7 +4,7 @@**Brand colors:** Violet accent `#8b5cf6`, light violet `#a78bfa`, deep violet `#6d28d9`, cyan secondary `#22d3ee`, dark background `#131318`, live red `#e74c3c`**Logo motif:** Play triangle inside a broadcast ring + signal waves (see `public/assets/logo.svg`)--**Tagline:** "Free & Open Live Streaming"++**Tagline:** "Open Live Streaming, Community Run"**URL:** `https://openvibe.live`**OpenVibe.Network (SSO / accounts):** `https://openvibe.network`**OpenVibe.Tools:** `https://openvibe.tools`@@@ -4,17 +4,17 @@<meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><meta http-equiv="X-UA-Compatible" content="IE=edge">-- <title>OpenVibe.Live - Free Open Source Live Streaming Platform</title>-- <meta name="description" content="Free, open source live streaming platform. Restream to Twitch, YouTube, Kick, and more. VODs, clips, chat moderation, voice channels, emotes, analytics, and 200+ community tools.">-- <meta name="keywords" content="live streaming, restream, streaming platform, twitch, youtube, open source, free streaming, openvibelive">++ <title>OpenVibe.Live - Open Source Live Streaming, Community Run</title>++ <meta name="description" content="Open source live streaming platform, run by its community. Restream to Twitch, YouTube, Kick, and more. VODs, clips, chat moderation, voice channels, emotes, analytics, and 200+ community tools.">++ <meta name="keywords" content="live streaming, restream, streaming platform, twitch, youtube, open source, openvibelive"><meta name="author" content="OpenVibe"><meta name="theme-color" content="#8b5cf6"><!-- Open Graph / Facebook --><meta property="og:type" content="website"><meta property="og:url" content="https://openvibe.live/">-- <meta property="og:title" content="OpenVibe.Live - Free Open Source Live Streaming">-- <meta property="og:description" content="Go live for free. Restream to Twitch, YouTube, Kick & more. Clips, VODs, chat moderation, emotes, voice, analytics, and 200+ connected community tools.">++ <meta property="og:title" content="OpenVibe.Live - Open Source Live Streaming, Community Run">++ <meta property="og:description" content="Go live in minutes. Restream to Twitch, YouTube, Kick & more. Clips, VODs, chat moderation, emotes, voice, analytics, and 200+ connected community tools."><meta property="og:image" content="https://openvibe.live/og-image.png"><meta property="og:image:width" content="1200"><meta property="og:image:height" content="630">@@@ -25,8 +25,8 @@<!-- Twitter --><meta name="twitter:card" content="summary_large_image"><meta name="twitter:url" content="https://openvibe.live/">-- <meta name="twitter:title" content="OpenVibe.Live - Free Live Streaming">-- <meta name="twitter:description" content="Free, open source live streaming. Restream to Twitch, YouTube, Kick. VODs, clips, community tools.">++ <meta name="twitter:title" content="OpenVibe.Live - Open Live Streaming, Community Run">++ <meta name="twitter:description" content="Open source live streaming, community run. Restream to Twitch, YouTube, Kick. VODs, clips, community tools."><meta name="twitter:image" content="https://openvibe.live/og-image.png"><meta name="twitter:image:alt" content="OpenVibe.Live Platform">@@@ -133,7 +133,7 @@"@context": "https://schema.org","@type": "SoftwareApplication","name": "OpenVibe.Live",-- "description": "Free, open source live streaming platform with restreaming, VODs, clips, and 200+ community tools",++ "description": "Open source live streaming platform with restreaming, VODs, clips, and 200+ community tools","url": "https://openvibe.live","applicationCategory": "Multimedia","operatingSystem": "Web",@@@ -150,7 +150,7 @@"@type": "WebSite","name": "OpenVibe.Live","url": "https://openvibe.live",-- "description": "Free open source live streaming platform",++ "description": "Open source live streaming platform, community run","potentialAction": {"@type": "SearchAction","target": {@@@ -272,6 +272,7 @@<a href="#" onclick="event.preventDefault();showModal('buy-funds')"><i class="fa-solid fa-coins"></i> Support Us</a><a href="#" onclick="event.preventDefault();if(typeof openCosmeticsModal==='function')openCosmeticsModal()"><i class="fa-solid fa-shirt"></i> Cosmetics</a><a href="https://openvibe.network/themes" target="_blank" rel="noopener"><i class="fa-solid fa-palette"></i> Themes</a>++ <a href="https://openvibe.network/my#history" target="_blank" rel="noopener"><i class="fa-solid fa-clock-rotate-left"></i> History</a></div><div class="ud-group"><span class="ud-label">Tools</span>@@@ -327,7 +328,7 @@<div class="hero-slogan-timer" id="hero-slogan-timer" hidden></div><div class="hero-cta" data-fx-viewport><button style="--i:0" class="hb hb-stream" onclick="currentUser ? navigate('/broadcast') : showModal('register')"><span class="hb-ico"><i class="fa-solid fa-broadcast-tower"></i></span><span class="hb-label">Stream</span><i class="fa-solid fa-chevron-right hb-go" aria-hidden="true"></i></button>-- <a href="https://openvibe.network" style="--i:1" class="hb hb-tools"><span class="hb-ico"><i class="fa-solid fa-screwdriver-wrench"></i></span><span class="hb-label">Tools</span><i class="fa-solid fa-chevron-right hb-go" aria-hidden="true"></i></a>++ <a href="https://openvibe.tools" style="--i:1" class="hb hb-tools"><span class="hb-ico"><i class="fa-solid fa-screwdriver-wrench"></i></span><span class="hb-label">Tools</span><i class="fa-solid fa-chevron-right hb-go" aria-hidden="true"></i></a><a href="https://openvibe.games/game" style="--i:2" class="hb hb-game"><span class="hb-ico"><i class="fa-solid fa-gamepad"></i></span><span class="hb-label">Game</span><i class="fa-solid fa-chevron-right hb-go" aria-hidden="true"></i></a><a href="https://github.com/OpenVibers/OpenVibe.Live" target="_blank" style="--i:3" class="hb hb-github" rel="noopener"><span class="hb-ico"><i class="fa-brands fa-github"></i></span><span class="hb-label">GitHub</span><i class="fa-solid fa-chevron-right hb-go" aria-hidden="true"></i></a><a href="https://discord.gg/M6MuRUaeJj" target="_blank" style="--i:4" class="hb hb-discord" rel="noopener noreferrer"><span class="hb-ico"><i class="fa-brands fa-discord"></i></span><span class="hb-label">Discord</span><i class="fa-solid fa-chevron-right hb-go" aria-hidden="true"></i></a>@@@ -530,7 +531,7 @@<div class="home-cta-feature"><i class="fa-solid fa-wand-magic-sparkles"></i><strong>AI clips, VODs & transcripts</strong>-- <span>Every broadcast auto-records, clips on the fly, and gets free AI overviews + searchable audio transcripts.</span>++ <span>Every broadcast auto-records, clips on the fly, and gets AI overviews + searchable audio transcripts.</span></div><div class="home-cta-feature"><i class="fa-solid fa-paste"></i>@@@ -539,8 +540,8 @@</div><div class="home-cta-feature"><i class="fa-solid fa-screwdriver-wrench"></i>-- <strong>200+ free tools</strong>-- <span>Image & audio editing, PDF tools, video downloads, network diagnostics, dev tools and more — all free at openvibe.tools.</span>++ <strong>200+ online tools</strong>++ <span>Image & audio editing, PDF tools, video downloads, network diagnostics, dev tools and more — all at openvibe.tools.</span></div><div class="home-cta-feature feat-star"><i class="fa-solid fa-code"></i>@@@ -565,7 +566,7 @@<div class="home-cta-feature"><i class="fa-solid fa-plug-circle-bolt"></i><strong>Keep it running</strong>-- <span>Hosting, bandwidth, storage and development come first. Free streaming is not free to host, and being honest about that is how it stays free to use.</span>++ <span>Hosting, bandwidth, storage and development come first. Streaming is not free to host, and being honest about that is how the network stays open to everyone.</span></div><div class="home-cta-feature"><i class="fa-solid fa-heart"></i>@@@ -575,7 +575,35 @@ function doRegister() { window.location.href = '/api/auth/sso/login'; }// Session hint: 'account' after a successful sign-in (silent SSO may re-sign you in when the// site token lapses), 'guest' after an explicit logout / "browse as guest" (never re-sign in).function _setSsoHint(v) { try { localStorage.setItem('ov_sso_hint', v); } catch { /* */ } }--function _ssoHint() { try { return localStorage.getItem('ov_sso_hint') || ''; } catch { return ''; } }++function _ssoHint() {++ // The cookie is set by the server on sign-in/sign-out (and by the network's sign-in-everywhere++ // chain, which never runs this page's JS); localStorage is the page-side copy.++ const m = document.cookie.match(/(?:^|;\s*)ov_sso_hint=([^;]*)/);++ if (m && m[1]) return m[1];++ try { return localStorage.getItem('ov_sso_hint') || ''; } catch { return ''; }++}++++// ── Cross-site history (openvibe.network) ────────────────────++// What the signed-in account watches/opens here shows up in its network-wide History and the++// "Recently used" rows of the shared navbar on every other site. Recorded after the route++// rendered and its title settled; never for guests, and only for the pages worth remembering.++const _HISTORY_PAGES = { channel: 'stream', 'vod-player': 'vod', 'clip-player': 'clip', 'paste-viewer': 'paste', pastes: null, game: 'game', arena: 'page' };++let _historyTimer = null;++function _recordHistory(pageId) {++ const type = _HISTORY_PAGES[pageId];++ if (!type) return;++ clearTimeout(_historyTimer);++ _historyTimer = setTimeout(() => {++ const tok = (document.cookie.match(/(?:^|;\s*)ov_token=([^;]*)/) || [])[1] || localStorage.getItem('ov_token');++ if (!tok || !currentUser) return;++ const entry = { type, title: document.title.replace(/\s*[-–|]\s*OpenVibe\.Live\s*$/i, ''), url: location.href };++ const send = () => { try { window.OpenVibeHistory.record(entry, { token: decodeURIComponent(tok) }); } catch { /* */ } };++ if (window.OpenVibeHistory) return send();++ if (document.getElementById('ov-history-loader')) return;++ const sc = document.createElement('script'); sc.id = 'ov-history-loader'; sc.async = true; sc.src = '/shared/history.js'; sc.onload = send;++ document.head.appendChild(sc);++ }, 1800);++}/** Try to sign back in through openvibe.network without a chooser — once per tab session. */function _trySilentSso() {if (_ssoHint() !== 'account') return false;@@@ -999,7 +1027,7 @@ function whenRouteReady(pageId, render) {const path = location.pathname;const run = () => {if (window.ov && !ov.isCurrent(gen)) return;-- try { render(); }++ try { render(); _recordHistory(pageId); }catch (err) {console.error(`[route] ${path} failed to render:`, err);if (window.ov) ov.showRouteError(`page-${pageId}`, err, () => whenRouteReady(pageId, render));@@@ -1116,6 +1144,10 @@ function routeFromURL() {} else if (segments[0] === 'privacy') {window.location.replace('/privacy');return;++ } else if (segments[0] === 'p' && segments[1] && document.querySelector('meta[name="ov-pastes-base"]')) {++ // Pastes live on openvibe.community now — hand the browser over (same slug, same URL shape).++ location.replace(`${document.querySelector('meta[name="ov-pastes-base"]').content}/p/${encodeURIComponent(segments[1])}${location.search}`);++ return;} else if (segments[0] === 'p' && segments[1]) {showPage('paste-viewer');whenRouteReady('paste-viewer', () => { if (typeof loadPasteViewer === 'function') loadPasteViewer(segments[1]); });@@@ -288,12 +288,35 @@ function getNetworkRedirectUri() {return `${config.baseUrl.toLowerCase()}/api/auth/callback`;}++/**++ * Where a sign-in/sign-out may send the browser afterwards: a path on this site, or an++ * https URL on the OpenVibe network — openvibe.network's "sign in everywhere" chain hops++ * through /api/auth/sso/login?silent=1&next=https://openvibe.network/sso/fanout?… and must++ * be handed back. Anything else falls back to the home page.++ */++function safeNext(raw) {++ const s = String(raw || '');++ if (!s) return '/';++ if (s.startsWith('/') && !s.startsWith('//')) return s;++ try {++ const u = new URL(s);++ if (u.protocol !== 'https:' && !(u.protocol === 'http:' && /^(localhost|127\.0\.0\.1)$/.test(u.hostname))) return '/';++ if (/(^|\.)openvibe\.[a-z]+$/.test(u.hostname) || /(^|\.)openre\.stream$/.test(u.hostname) || /^(localhost|127\.0\.0\.1)$/.test(u.hostname)) return u.toString();++ } catch { /* not a URL */ }++ return '/';++}++const HINT_COOKIE = { httpOnly: false, path: '/', maxAge: 365 * 24 * 60 * 60 * 1000, sameSite: 'Lax' };++// ── Initiate OAuth Login (redirect to openvibe.network) ───────────router.get('/sso/login', (req, res) => {const state = require('crypto').randomBytes(16).toString('hex');// Store state in a short-lived cookie for CSRF protectionconst isSecure = config.baseUrl.startsWith('https');res.cookie('oauth_state', state, { httpOnly: true, maxAge: 5 * 60 * 1000, sameSite: 'Lax', secure: isSecure });++ // Where to go afterwards (the fanout chain, or the page that asked to sign in).++ const next = safeNext(req.query.next);++ if (next !== '/') res.cookie('oauth_next', next, { httpOnly: true, maxAge: 5 * 60 * 1000, sameSite: 'Lax', secure: isSecure });++ else res.clearCookie('oauth_next');const params = new URLSearchParams({client_id: OV_CLIENT_ID,@@@ -312,10 +335,13 @@ router.get('/sso/login', (req, res) => {router.get('/callback', async (req, res) => {try {const { code, state } = req.query;++ const next = safeNext(req.cookies?.oauth_next);++ res.clearCookie('oauth_next');if (!code && req.query.error) {-- // Silent sign-in found no network session — go home quietly as a guest.++ // Silent sign-in found no network session — go on quietly as a guest.res.clearCookie('oauth_state');-- return res.redirect('/?sso=none');++ const sep = next.includes('?') ? '&' : '?';++ return res.redirect(`${next}${sep}sso=none`);}if (!code) return res.status(400).send('Missing authorization code');@@@ -450,6 +476,9 @@ router.get('/callback', async (req, res) => {// Also set ov_token (used by shared navbar/notification libs)res.cookie('ov_token', openvibeToolsToken, { httpOnly: false, path: '/', maxAge: 7 * 24 * 60 * 60 * 1000, sameSite: 'Lax', secure: isSecure });++ // Long-lived hint (outlives the token): this browser has signed in here, so a later visit++ // with no session may try one silent sign-in through the network.++ res.cookie('ov_sso_hint', 'account', { ...HINT_COOKIE, secure: isSecure });// Store refresh token in httpOnly cookie (not readable by JS — server handles refresh)if (openvibeRefreshToken) {@@@ -470,10 +499,11 @@ router.get('/callback', async (req, res) => {var acct = { id: u.id, username: u.username, display_name: u.display_name, avatar_url: u.avatar_url, is_anon: false, token: ${JSON.stringify(openvibeToolsToken)}, added_at: Date.now() };localStorage.setItem('openvibe_accounts', JSON.stringify([acct]));localStorage.setItem('openvibe_active_account', String(u.id));++ localStorage.setItem('ov_sso_hint', 'account');} catch(e) {}-- window.location.href = '/';++ window.location.replace(${JSON.stringify(next)});</script>--<noscript><a href="/">Click here to continue</a></noscript>++<noscript><a href="${next.replace(/"/g, '"')}">Click here to continue</a></noscript></body></html>`);} catch (err) {console.error('[Auth/SSO] Callback error:', err);@@@ -536,14 +566,31 @@ router.post('/refresh', async (req, res) => {});// ── Logout (clear all auth cookies) ──────────────────────────--router.post('/logout', (req, res) => {++function clearSessionCookies(res) {const isSecure = (process.env.BASE_URL || '').startsWith('https');res.clearCookie('token', { sameSite: 'Lax', secure: isSecure });res.clearCookie('ov_token', { sameSite: 'Lax', secure: isSecure });res.clearCookie('ov_refresh', { path: '/api/auth', sameSite: 'Lax', secure: isSecure });++ res.cookie('ov_sso_hint', 'guest', { ...HINT_COOKIE, secure: isSecure });++}++++router.post('/logout', (req, res) => {++ clearSessionCookies(res);res.json({ ok: true });});++// Sign-out-everywhere chain from openvibe.network: clear this site's cookies and hand the++// browser back. The page-side localStorage copy is cleared by the tiny page below.++router.get('/logout', (req, res) => {++ clearSessionCookies(res);++ const next = safeNext(req.query.next);++ res.set('Cache-Control', 'no-store');++ res.type('html').send(`<!DOCTYPE html><html><head><meta charset="utf-8"><title>Signing out…</title></head><body><script>++try { ['token','ov_token','openvibe_accounts','openvibe_active_account','openvibe_anon_token'].forEach(function(k){ localStorage.removeItem(k); }); localStorage.setItem('ov_sso_hint','guest'); } catch (e) {}++location.replace(${JSON.stringify(next)});++</script><noscript><a href="${next.replace(/"/g, '"')}">Continue</a></noscript></body></html>`);++});++// ── SSO Status (for client-side detection) ───────────────────router.get('/sso/status', (req, res) => {res.json({@@@ -1,7 +1,7 @@/*** ╔═══════════════════════════════════════════════════════════╗* ║ OpenVibe.Live — Main Server ║-- * ║ Free & Open Live Streaming · openvibe.live ║++ * ║ Open Live Streaming, Community Run · openvibe.live ║* ║ Part of the OpenVibe network — Open Source & Community ║* ╚═══════════════════════════════════════════════════════════╝*@@@ -596,6 +596,14 @@ app.get('/data/pastes/screenshots/:filename', (req, res) => {res.redirect(302, mediaClient.screenshotUrl(path.basename(req.params.filename)));});++// Pastes are moving to openvibe.community (PASTES_ON_COMMUNITY=1): the old Live URLs keep++// working as permanent redirects, so links in chat, search results and clipboards survive.++const COMMUNITY_URL = (process.env.OV_COMMUNITY_URL || 'https://openvibe.community').replace(/\/$/, '');++if (process.env.PASTES_ON_COMMUNITY === '1') {++ app.get('/p/:slug', (req, res) => res.redirect(301, `${COMMUNITY_URL}/p/${encodeURIComponent(req.params.slug)}`));++ app.get('/pastes', (req, res) => res.redirect(301, `${COMMUNITY_URL}/pastes`));++}++// The SPA renders Media's relative paste URLs (/p/<slug>/screenshot, /raw)// against THIS origin — bounce them to the Media public host, where the// canonical paste page lives.@@@ -1029,7 +1037,7 @@ async function start() {console.log('');console.log(' ╔══════════════════════════════════════════╗');console.log(' ║ OpenVibe.Live v1.0.0 ║');-- console.log(' ║ Free & Open Live Streaming ▶((( • ))) ║');++ console.log(' ║ Open Live Streaming, Community Run ▶((( • ))) ║');console.log(' ╚══════════════════════════════════════════╝');console.log('');@@@ -419,10 +419,15 @@ function _base() {}return doc.html;}++// Pastes are moving to openvibe.community; when PASTES_ON_COMMUNITY=1 the SPA router sends++// /p/<slug> there (server/index.js answers the same URL with a 301 for bots and direct hits).++const PASTES_BASE = process.env.PASTES_ON_COMMUNITY === '1' ? (process.env.OV_COMMUNITY_URL || 'https://openvibe.community').replace(/\/$/, '') : '';++function _headBlock(meta) {const canonical = abs(meta.canonicalPath || '/');const img = abs(meta.image || DEFAULT_OG_IMAGE);const parts = [++ PASTES_BASE ? `<meta name="ov-pastes-base" content="${esc(PASTES_BASE)}">` : '',`<title>${esc(meta.title)}</title>`,`<meta name="description" content="${esc(meta.description)}">`,`<meta name="robots" content="${esc(meta.robots || 'index,follow')}">`,@@@ -19,7 +19,7 @@ const BRAND_DEFAULTS = Object.freeze({gamesServiceName: 'OpenVibe.Games',mediaServiceName: 'OpenVibe.Media',tagline: 'One Account. All of OpenVibe.',-- campTagline: 'Free & Open Live Streaming',++ campTagline: 'Open Live Streaming, Community Run',toolsSubdomainBase: 'openvibe.tools',discord: 'https://discord.gg/M6MuRUaeJj',github: 'https://github.com/OpenVibe.Live',@@@ -99,8 +99,8 @@ function buildBrandFromRegistry(registry = {}, env = process.env) {// Build service list using registry URLs where availableconst services = [-- { id: 'live', name: streamerServiceName, url: liveUrl, description: 'Free & Open Live Streaming' },-- { id: 'tools', name: toolsServiceName, url: toolsUrl, description: 'Free Online Tools' },++ { id: 'live', name: streamerServiceName, url: liveUrl, description: 'Open live streaming, community run' },++ { id: 'tools', name: toolsServiceName, url: toolsUrl, description: 'Online tools for everyone' },{ id: 'games', name: gamesServiceName, url: gamesUrl, description: 'Games & Community Canvas' },{ id: 'media', name: mediaServiceName, url: mediaUrl, description: 'VODs, Clips, Pastes & Files' },{ id: 'network', name: BRAND_DEFAULTS.networkServiceName, url: networkUrl, description: 'Accounts, SSO & OpenCoins' },@@@ -155,8 +155,8 @@ const BRAND = Object.freeze({// Primary services in the network (for dashboard display, health checks, etc.)// Static list with OpenVibe defaults — use buildBrandFromRegistry() for live values.services: Object.freeze([-- { id: 'live', name: 'OpenVibe.Live', url: 'https://openvibe.live', description: 'Free & Open Live Streaming' },-- { id: 'tools', name: 'OpenVibe.Tools', url: 'https://openvibe.tools', description: 'Free Online Tools' },++ { id: 'live', name: 'OpenVibe.Live', url: 'https://openvibe.live', description: 'Open live streaming, community run' },++ { id: 'tools', name: 'OpenVibe.Tools', url: 'https://openvibe.tools', description: 'Online tools for everyone' },{ id: 'games', name: 'OpenVibe.Games', url: 'https://openvibe.games', description: 'Games & Community Canvas' },{ id: 'media', name: 'OpenVibe.Media', url: 'https://openvibe.media', description: 'VODs, Clips, Pastes & Files' },{ id: 'network', name: 'OpenVibe.Network', url: 'https://openvibe.network', description: 'Accounts, SSO & OpenCoins' },@@@ -34,7 +34,7 @@// the link, with no edit anywhere else.const NETWORK = [{ id: 'live', name: 'Live streaming', url: 'https://openvibe.live', desc: 'Go live, restream and clip' },-- { id: 'tools', name: 'Free online tools', url: 'https://openvibe.tools', desc: 'The whole toolbox' },++ { id: 'tools', name: 'Online tools', url: 'https://openvibe.tools', desc: 'The whole toolbox' },{ id: 'dev', name: 'Developer tools', url: 'https://dev.openvibe.tools', desc: 'Formatters, encoders, validators' },{ id: 'net', name: 'Network diagnostics', url: 'https://net.openvibe.tools', desc: 'Ping, DNS, Whois, SSL' },{ id: 'img', name: 'Image converter', url: 'https://img.openvibe.tools', desc: 'Convert, resize, compress' },@@@ -42,7 +42,11 @@{ id: 'docs', name: 'PDF & document tools', url: 'https://docs.openvibe.tools', desc: 'Merge, split, compress' },{ id: 'text', name: 'Text tools', url: 'https://text.openvibe.tools', desc: 'Fancy text, case, ASCII' },{ id: 'yt', name: 'YouTube downloader', url: 'https://yt.openvibe.tools', desc: 'Video and audio' },++ { id: 'community', name: 'Community & pastes', url: 'https://openvibe.community', desc: 'Pastes, posts and the people of OpenVibe' },++ { id: 'games', name: 'Games', url: 'https://openvibe.games', desc: 'Scraplandia and browser games' },++ { id: 'media', name: 'Media', url: 'https://openvibe.media', desc: 'VODs, clips and files for every site' },{ id: 'network', name: 'Account & themes', url: NETWORK_URL, desc: 'One account, every site' },++ { id: 'more', name: 'The whole network →', url: NETWORK_URL + '/#network', desc: 'Every OpenVibe site, live and upcoming' },];const LEGAL = [@@@ -59,7 +63,7 @@const DEFAULTS = {service: null, // 'live' | 'tools' | 'dev' | … (auto-detected when omitted)brandName: null,-- tagline: 'Free, open source and community run. Built in the open by the people using it.',++ tagline: 'Open source and community run. Built in the open by the people using it.',variant: 'full',mount: '#ov-footer',links: [], // [{ heading, items: [{ name, url, onclick? }] }]@@@ -84,9 +88,17 @@return sub === h ? 'tools' : (sub || 'tools');}++ 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' };++ /** The full site name from the hostname — the same spelling the navbar uses (Pastes.OpenVibe.Tools). */function brandFor(service) {++ const h = typeof location !== 'undefined' ? location.hostname.toLowerCase() : '';++ const cap = (w) => w ? w.charAt(0).toUpperCase() + w.slice(1) : '';++ let m = h.match(/^(?:(.+)\.)?openvibe\.([a-z]+)$/);++ 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('.'); }++ if ((m = h.match(/^(?:(.+)\.)?openre\.stream$/))) return 'OpenRe.Stream';const found = NETWORK.find(n => n.id === service);-- if (found) return found.name === 'Account & themes' ? 'OpenVibe' : found.name;++ if (service && TLD_LABELS[service]) return `OpenVibe.${TLD_LABELS[service]}`;++ if (found && found.name !== 'Account & themes' && found.name !== 'The whole network →') return `${cap(service)}.OpenVibe.Tools`;return 'OpenVibe';}nnew file mode 100644@@@ -0,0 +1,111 @@++/**++ * openvibe-shared/history.js — cross-site history for a signed-in account.++ *++ * OpenVibeHistory.record({ type, title, url, icon, service, meta }, { token, apiBase })++ * OpenVibeHistory.recent({ limit, service, type, token, apiBase }) → [{...}]++ * OpenVibeHistory.clear({ token }) → wipes it++ *++ * Every property records what the account touched — a tool used, a stream watched, a paste++ * opened, a game played — so "Recently used" in the shared navbar and the History tab on the++ * Network show one timeline across the whole network instead of one per site. Writes are++ * deduplicated server-side (same URL within ten minutes bumps the entry instead of adding one)++ * and the browser side throttles too, so a page cannot flood it. Nothing is recorded for++ * anonymous sessions, and the user can pause or clear the timeline from their account page.++ *++ * Loaded lazily by navbar.js from https://openvibe.network/shared/history.js; safe to include++ * directly. Token resolution matches the navbar: ov_token cookie → localStorage.ov_token.++ */++(function (root) {++ 'use strict';++ if (root.OpenVibeHistory) return;++++ const DEFAULT_API = 'https://openvibe.network';++ const SENT_KEY = 'ov_history_sent'; // sessionStorage: url → ts, to avoid re-posting on every re-render++ const THROTTLE_MS = 5 * 60 * 1000;++++ function token(opts) {++ if (opts && opts.token) return opts.token;++ const m = (typeof document !== 'undefined' ? document.cookie : '').match(/(?:^|;\s*)ov_token=([^;]*)/);++ if (m && m[1]) { try { return decodeURIComponent(m[1]); } catch { return m[1]; } }++ try { return localStorage.getItem('ov_token'); } catch { return null; }++ }++++ function api(opts) { return (opts && opts.apiBase) || DEFAULT_API; }++++ function sent() { try { return JSON.parse(sessionStorage.getItem(SENT_KEY) || '{}'); } catch { return {}; } }++ function markSent(url) {++ try {++ const m = sent(); m[url] = Date.now();++ const keys = Object.keys(m); if (keys.length > 60) for (const k of keys.slice(0, keys.length - 60)) delete m[k];++ sessionStorage.setItem(SENT_KEY, JSON.stringify(m));++ } catch { /* storage unavailable */ }++ }++++ /** Guess an icon and service label from the URL when the caller gives none. */++ function inferService(url) {++ let host = '';++ try { host = new URL(url, location.href).hostname; } catch { return { service: null }; }++ const m = host.match(/^(?:(.+)\.)?openvibe\.([a-z]+)$/) || host.match(/^(?:(.+)\.)?(openre)\.stream$/);++ if (!m) return { service: null };++ return { service: m[2], sub: m[1] || null };++ }++++ const ICONS = { tool: 'fa-screwdriver-wrench', stream: 'fa-tower-broadcast', vod: 'fa-clapperboard', clip: 'fa-scissors', paste: 'fa-paste', game: 'fa-gamepad', page: 'fa-file-lines', post: 'fa-comments', account: 'fa-user', theme: 'fa-palette', download: 'fa-download', chat: 'fa-comment' };++++ async function record(entry, opts) {++ const t = token(opts);++ if (!t || !entry) return false;++ const url = String(entry.url || (typeof location !== 'undefined' ? location.href : '')).slice(0, 2000);++ if (!url) return false;++ const last = sent()[url] || 0;++ if (!entry.force && Date.now() - last < THROTTLE_MS) return false;++ markSent(url);++ const inf = inferService(url);++ const body = {++ type: String(entry.type || 'page').slice(0, 32),++ title: String(entry.title || (typeof document !== 'undefined' ? document.title : '') || url).slice(0, 200),++ url,++ icon: entry.icon || ICONS[entry.type] || null,++ service: entry.service || inf.service || null,++ sub: entry.sub || inf.sub || null,++ meta: entry.meta && typeof entry.meta === 'object' ? entry.meta : null,++ };++ try {++ const res = await fetch(`${api(opts)}/api/history`, {++ method: 'POST', keepalive: true,++ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${t}` },++ body: JSON.stringify(body),++ });++ return res.ok;++ } catch { return false; }++ }++++ async function recent(opts) {++ const t = token(opts);++ if (!t) return [];++ const q = new URLSearchParams();++ if (opts && opts.limit) q.set('limit', String(opts.limit));++ if (opts && opts.service) q.set('service', opts.service);++ if (opts && opts.type) q.set('type', opts.type);++ try {++ const res = await fetch(`${api(opts)}/api/history?${q}`, { headers: { Authorization: `Bearer ${t}` } });++ if (!res.ok) return [];++ const data = await res.json();++ return (data && data.items) || [];++ } catch { return []; }++ }++++ async function clear(opts) {++ const t = token(opts);++ if (!t) return false;++ try { const res = await fetch(`${api(opts)}/api/history`, { method: 'DELETE', headers: { Authorization: `Bearer ${t}` } }); return res.ok; } catch { return false; }++ }++++ async function remove(id, opts) {++ const t = token(opts);++ if (!t) return false;++ try { const res = await fetch(`${api(opts)}/api/history/${encodeURIComponent(id)}`, { method: 'DELETE', headers: { Authorization: `Bearer ${t}` } }); return res.ok; } catch { return false; }++ }++++ root.OpenVibeHistory = { record, recent, clear, remove, ICONS };++})(typeof window !== 'undefined' ? window : globalThis);@@@ -8,12 +8,39 @@// onLogin/onLogout callbacks. When no `user` is passed the navbar resolves// it itself: ov_token cookie → localStorage → token opt → sessionUrl, then// GET {apiBase}/api/auth/me with `Authorization: Bearer <token>`.++//++// Brand: derived from the hostname — `pastes.openvibe.tools` renders as++// Pastes · OpenVibe · Tools (three segments, the subdomain first so the++// context reads left-to-right), `openvibe.live` as OpenVibe · Live. Pass++// brand: { sub, tld, name, icon, variant } to override any part, or the++// legacy brandName/brandIcon. compact: 'auto' (default — the brand shortens++// to the subdomain on narrow viewports), 'always', 'never'.++// Menus are modular: every site keeps the shared account menu and adds its own++// pieces — links: [{label, href, icon?, active?}] replaces the service's top++// links; menu: { before: [item], after: [item] } adds dropdown rows++// ({label, href, icon, onClick, danger, external}); OpenVibeNavbar.addMenuItem()++// / setLinks() do the same at runtime. Signed-in users also get a++// "Recently used" row fed by the shared history module when it is loaded.// ═══════════════════════════════════════════════════════════════(function (root) {'use strict';-- let _config = { service: 'network', token: null, user: null, apiBase: 'https://openvibe.network', onLogin: null, onLogout: null, loginUrl: null, sessionUrl: null };++ let _config = {++ service: 'network', token: null, user: null, apiBase: 'https://openvibe.network',++ onLogin: null, onLogout: null, loginUrl: null, sessionUrl: null,++ brand: null, brandName: null, brandIcon: null, compact: 'auto',++ links: null, menu: null, recent: true,++ // history: { type: 'tool'|'stream'|'paste'|'page'|…, title, url, icon } — recorded for the++ // signed-in user once auth resolves (cross-site "Recently used" / History on the Network).++ history: null,++ // silentLogin: 'https://site/auth/login?silent=1&next={url}' — when nobody is signed in here++ // but this browser has signed in to the network before (ov_sso_hint), try one silent++ // prompt=none round trip per tab so a session on one site becomes a session on all.++ silentLogin: null,++ };++ const _runtimeMenu = { before: [], after: [] };++ let _runtimeLinks = null;let _navEl = null;function injectStyles() {@@@ -29,10 +56,44 @@font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;color: var(--text-primary, #e0e0e0);}-- .openvibe-navbar-brand { display: flex; align-items: center; gap: 8px; text-decoration: none; color: inherit; margin-right: 8px; }-- .openvibe-navbar-brand .flame { font-size: 18px; color: var(--accent, #8b5cf6); }-- .openvibe-navbar-brand .name { font-size: 15px; font-weight: 700; letter-spacing: -.3px; }-- .openvibe-navbar-brand .service-name { font-size: 11px; color: var(--accent-light, #a78bfa); font-weight: 500; letter-spacing: .5px; text-transform: uppercase; }++ .openvibe-navbar-brand { display: flex; align-items: center; gap: 9px; text-decoration: none; color: inherit; margin-right: 8px; min-width: 0; }++ .openvibe-navbar-brand .flame { font-size: 18px; color: var(--accent, #8b5cf6); display: inline-grid; place-items: center; width: 28px; height: 28px; flex: none; }++ .openvibe-navbar-brand .name { display: inline-flex; align-items: baseline; font-size: 15px; font-weight: 700; letter-spacing: -.3px; white-space: nowrap; line-height: 1; }++ /* Segments: subdomain · OpenVibe · TLD. The part that names *this* site is the loud one. */++ .openvibe-navbar-brand .b-sub { color: var(--text-primary, #e0e0e0); }++ .openvibe-navbar-brand .b-core { color: var(--text-primary, #e0e0e0); }++ .openvibe-navbar-brand .b-tld { color: var(--accent-light, var(--accent, #a78bfa)); }++ .openvibe-navbar-brand.has-sub .b-core { color: var(--text-secondary, #b0b0b8); font-weight: 600; }++ .openvibe-navbar-brand.has-sub .b-tld { color: var(--text-secondary, #b0b0b8); font-weight: 600; }++ .openvibe-navbar-brand .b-dot { color: var(--accent, #8b5cf6); opacity: .75; margin: 0 1px; font-weight: 800; }++ .openvibe-navbar-brand .b-sub, .openvibe-navbar-brand .b-core, .openvibe-navbar-brand .b-tld { transition: color .2s, opacity .2s; }++ .openvibe-navbar-brand:hover .b-tld, .openvibe-navbar-brand:hover .b-sub { color: var(--accent-light, #a78bfa); }++ .openvibe-navbar-brand:hover .b-dot { opacity: 1; }++ .openvibe-navbar-brand .b-tag { font-size: 9px; font-weight: 700; letter-spacing: .6px; text-transform: uppercase; color: var(--accent-light, #a78bfa); background: var(--accent-glow, rgba(139,92,246,.14)); border-radius: 4px; padding: 2px 5px; margin-left: 6px; align-self: center; }++ /* Compact: drop the trailing segments on narrow viewports, keep what identifies the page. */++ .openvibe-navbar[data-compact="always"] .openvibe-navbar-brand.has-sub .b-core,++ .openvibe-navbar[data-compact="always"] .openvibe-navbar-brand.has-sub .b-tld,++ .openvibe-navbar[data-compact="always"] .openvibe-navbar-brand.has-sub .b-dot { display: none; }++ @media (max-width: 860px) {++ .openvibe-navbar[data-compact="auto"] .openvibe-navbar-brand.has-sub .b-core,++ .openvibe-navbar[data-compact="auto"] .openvibe-navbar-brand.has-sub .b-tld,++ .openvibe-navbar[data-compact="auto"] .openvibe-navbar-brand.has-sub .b-dot { display: none; }++ }++ @media (max-width: 420px) {++ .openvibe-navbar[data-compact="auto"] .openvibe-navbar-brand:not(.has-sub) .b-core,++ .openvibe-navbar[data-compact="auto"] .openvibe-navbar-brand:not(.has-sub) .b-dot { display: none; }++ .openvibe-navbar[data-compact="auto"] .openvibe-navbar-brand .b-tag { display: none; }++ }++ .openvibe-navbar-links a .icon { margin-right: 5px; opacity: .8; }++ .openvibe-navbar-dropdown-recent { padding: 6px 8px 2px; border-bottom: 1px solid var(--border, #333340); }++ .openvibe-navbar-dropdown-recent .label { font-size: 10px; font-weight: 700; letter-spacing: .6px; text-transform: uppercase; color: var(--text-muted, #707080); padding: 2px 8px 4px; display: flex; justify-content: space-between; align-items: center; }++ .openvibe-navbar-dropdown-recent .label a { color: var(--accent-light, #a78bfa); text-decoration: none; font-weight: 600; letter-spacing: 0; text-transform: none; }++ .openvibe-navbar-dropdown-recent .item { display: flex; align-items: center; gap: 8px; padding: 6px 8px; border-radius: 6px; font-size: 12px; color: var(--text-secondary, #b0b0b8); text-decoration: none; transition: background .12s; min-width: 0; }++ .openvibe-navbar-dropdown-recent .item:hover { background: var(--bg-hover, #2f2f3d); color: var(--text-primary, #e0e0e0); }++ .openvibe-navbar-dropdown-recent .item .icon { width: 18px; text-align: center; color: var(--accent-light, #a78bfa); flex: none; }++ .openvibe-navbar-dropdown-recent .item .t { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }++ .openvibe-navbar-dropdown-recent .item .s { margin-left: auto; font-size: 10px; color: var(--text-muted, #707080); flex: none; }++ .openvibe-navbar-dropdown-menu .sep { height: 1px; background: var(--border, #333340); margin: 4px -8px; }.openvibe-navbar-links { display: flex; align-items: center; gap: 4px; margin-left: 8px; }.openvibe-navbar-links a {@@@ -350,6 +411,113 @@'colors.openvibe.tools': { name: 'OpenVibeColor', icon: 'fa-palette' },};++ // ─── Brand from hostname ───────────────────────────────────++ // Every property is <sub?>.openvibe.<tld> (plus openre.stream). The navbar spells the++ // whole name — Pastes.OpenVibe.Tools, not "Paste.OpenVibe" — because the subdomain and++ // the TLD are what tell a visitor where they are in the network.++ 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',++ };++ const SUB_LABELS = {++ json: 'JSON', yaml: 'YAML', xml: 'XML', csv: 'CSV', sql: 'SQL', html: 'HTML', jwt: 'JWT',++ uuid: 'UUID', guid: 'GUID', url: 'URL', b64: 'B64', sha256: 'SHA256', og: 'OG', md: 'MD',++ yt: 'YT', ip: 'IP', myip: 'MyIP', ipv4: 'IPv4', ipv6: 'IPv6', geoip: 'GeoIP', asn: 'ASN',++ rdns: 'rDNS', dns: 'DNS', mx: 'MX', txt: 'TXT', ns: 'NS', spf: 'SPF', dkim: 'DKIM',++ dmarc: 'DMARC', mtr: 'MTR', ssl: 'SSL', tls: 'TLS', ptr: 'PTR', smtp: 'SMTP', http: 'HTTP',++ httpstatus: 'HTTPStatus', rdap: 'RDAP', isp: 'ISP', pdf: 'PDF', mergepdf: 'MergePDF',++ splitpdf: 'SplitPDF', compresspdf: 'CompressPDF', rotatepdf: 'RotatePDF',++ reorderpdf: 'ReorderPDF', watermarkpdf: 'WatermarkPDF', protectpdf: 'ProtectPDF',++ unlockpdf: 'UnlockPDF', image2pdf: 'Image2PDF', jpg2pdf: 'JPG2PDF', png2pdf: 'PNG2PDF',++ pdf2jpg: 'PDF2JPG', pdf2png: 'PDF2PNG', png: 'PNG', jpg: 'JPG', jpeg: 'JPEG', webp: 'WebP',++ avif: 'AVIF', heic: 'HEIC', heif: 'HEIF', svg: 'SVG', gif: 'GIF', ico: 'ICO', tiff: 'TIFF',++ bmp: 'BMP', mp3: 'MP3', wav: 'WAV', flac: 'FLAC', ogg: 'OGG', m4a: 'M4A', aac: 'AAC',++ opus: 'Opus', wma: 'WMA', aiff: 'AIFF', ac3: 'AC3', eq: 'EQ', equalizer: 'EQ', mxn: 'MXN',++ ascii: 'ASCII', smallcaps: 'SmallCaps', titlecase: 'TitleCase', textlogo: 'TextLogo',++ textart: 'TextArt', channelart: 'ChannelArt', lowerthird: 'LowerThird', copypaste: 'CopyPaste',++ dnspropagation: 'DNSPropagation', opengraph: 'OpenGraph', whip: 'WHIP', ingest: 'Ingest',++ play: 'Play', my: 'My', auth: 'Auth', api: 'API', admin: 'Admin', docs: 'Docs', dev: 'Dev',++ net: 'Net', img: 'Img', pastes: 'Pastes', paste: 'Pastes', maps: 'Maps', food: 'Food',++ text: 'Text', logo: 'Logo', audio: 'Audio', ai: 'AI', cdn: 'CDN', status: 'Status',++ };++ const SERVICE_TLD = { live: 'live', tools: 'tools', games: 'games', media: 'media', network: 'network', community: 'community' };++ const SERVICE_SUB = { net: 'net', dev: 'dev', paste: 'pastes', maps: 'maps', food: 'food', img: 'img', yt: 'yt', audio: 'audio', text: 'text', logo: 'logo', docs: 'docs' };++++ function titleCase(w) { return w ? w.charAt(0).toUpperCase() + w.slice(1) : ''; }++ function subLabel(sub) { return SUB_LABELS[sub] || titleCase(sub); }++++ /**++ * { sub, core, tld, name, short, icon, variant } for the current page.++ * sub 'Pastes' | null tld 'Tools' core 'OpenVibe'++ * name 'Pastes.OpenVibe.Tools' short 'Pastes' (what compact mode keeps)++ * variant the ov-mark flavour: the TLD id ('live', 'tools', …) — every site gets its own twist++ */++ function resolveBrand() {++ const b = Object.assign({}, _config.brand || {});++ const host = currentHost().toLowerCase();++ let sub = null, tld = null, core = 'OpenVibe';++ let m = host.match(/^(?:(.+)\.)?openvibe\.([a-z]+)$/);++ if (m) { sub = m[1] && m[1] !== 'www' ? m[1] : null; tld = m[2]; }++ else if ((m = host.match(/^(?:(.+)\.)?openre\.stream$/))) { sub = m[1] && m[1] !== 'www' ? m[1] : null; core = 'OpenRe'; tld = 'stream'; }++ // Off-network hosts (localhost, previews): fall back to the service id.++ if (!tld) { tld = SERVICE_TLD[_config.service] || (SERVICE_SUB[_config.service] ? 'tools' : 'network'); sub = SERVICE_SUB[_config.service] || null; }++ // Legacy brandName ("Paste.OpenVibe", "OpenVibe.Live") still steers the segments.++ if (_config.brandName && !b.sub && !b.tld) {++ const parts = String(_config.brandName).split('.');++ if (parts.length >= 2 && /^openvibe$/i.test(parts[0])) tld = parts[1].toLowerCase();++ else if (parts.length >= 2 && /^openvibe$/i.test(parts[1])) sub = parts[0].toLowerCase();++ else if (parts.length === 1) sub = parts[0].toLowerCase();++ }++ if (b.sub !== undefined) sub = b.sub ? String(b.sub).toLowerCase() : null;++ if (b.tld) tld = String(b.tld).toLowerCase();++ const subText = b.subLabel || (sub ? subLabel(sub) : null);++ const tldText = b.tldLabel || TLD_LABELS[tld] || titleCase(tld);++ const name = b.name || [subText, core, tldText].filter(Boolean).join('.');++ const icon = b.icon || _config.brandIcon || null;++ const variant = b.variant || (core === 'OpenRe' ? 'stream' : tld);++ return { sub, subText, core, tld, tldText, name, short: subText || `${core}.${tldText}`, icon, variant, tag: b.tag || null, href: b.href || '/' };++ }++++ function brandHTML(brand) {++ const seg = (cls, text) => `<span class="${cls}">${escapeAttr(text)}</span>`;++ const dot = '<span class="b-dot">.</span>';++ const text = brand.subText++ ? seg('b-sub', brand.subText) + dot + seg('b-core', brand.core) + dot + seg('b-tld', brand.tldText)++ : seg('b-core', brand.core) + dot + seg('b-tld', brand.tldText);++ const mark = brand.icon++ ? `<i class="fa-solid ${escapeAttr(brand.icon)}"></i>`++ : `<span class="ov-mark" data-size="28" data-variant="${escapeAttr(brand.variant)}"></span>`;++ return `<a class="openvibe-navbar-brand${brand.subText ? ' has-sub' : ''}" href="${escapeAttr(brand.href)}" title="${escapeAttr(brand.name)}" aria-label="${escapeAttr(brand.name)}">++ <span class="flame">${mark}</span>++ <span class="name">${text}${brand.tag ? `<span class="b-tag">${escapeAttr(brand.tag)}</span>` : ''}</span>++ </a>`;++ }++++ function menuItemHTML(item) {++ if (!item) return '';++ if (item.sep) return '<div class="sep"></div>';++ const icon = item.icon ? `<span class="icon"><i class="fa-solid ${escapeAttr(item.icon)}"></i></span>` : '<span class="icon"></span>';++ const cls = item.danger ? ' class="danger"' : '';++ const id = item.id ? ` data-menu-id="${escapeAttr(item.id)}"` : '';++ if (item.href) return `<a href="${escapeAttr(item.href)}"${cls}${id}${item.external ? ' target="_blank" rel="noopener"' : ''}>${icon} ${escapeAttr(item.label)}</a>`;++ return `<button type="button"${cls}${id}>${icon} ${escapeAttr(item.label)}</button>`;++ }++++ function bindMenuItems(container, items) {++ for (const item of items) {++ if (!item || !item.id || typeof item.onClick !== 'function') continue;++ container.querySelector(`[data-menu-id="${item.id}"]`)?.addEventListener('click', (e) => { if (!item.href) e.preventDefault(); item.onClick(e); });++ }++ }++++ function currentLinks() {++ const list = _runtimeLinks || _config.links || SERVICE_LINKS[_config.service] || [];++ const path = (typeof location !== 'undefined' && location.pathname) || '/';++ return list.map((l) => Object.assign({}, l, { active: l.active !== undefined ? l.active : (l.href === path && path !== '/') }));++ }++const SERVICE_LINKS = {live: [{ label: 'Watch', href: '/' },@@@ -610,6 +778,45 @@let _authInFlight = null;++ function ssoHint() {++ const m = (typeof document !== 'undefined' ? document.cookie : '').match(/(?:^|;\s*)ov_sso_hint=([^;]*)/);++ if (m) return m[1];++ try { return localStorage.getItem('ov_sso_hint'); } catch { return null; }++ }++++ /**++ * One silent sign-in attempt per tab: only when this browser has signed in to the network++ * before (the hint survives token expiry), never after an explicit sign-out ('guest'), and++ * never for bots. The site's login route turns silent=1 into prompt=none and comes straight++ * back on error=login_required, so a signed-out visitor sees one quick redirect at most.++ */++ function maybeSilentLogin() {++ if (!_config.silentLogin || typeof location === 'undefined') return false;++ if (ssoHint() !== 'account') return false;++ if (/bot|crawl|spider|slurp|headless/i.test(navigator.userAgent || '')) return false;++ try { if (sessionStorage.getItem('ov_silent_sso')) return false; sessionStorage.setItem('ov_silent_sso', '1'); } catch { return false; }++ if (/[?&]sso=none\b/.test(location.search)) return false;++ const url = String(_config.silentLogin).replace('{url}', encodeURIComponent(location.href));++ location.replace(url);++ return true;++ }++++ function recordHistory() {++ const h = _config.history;++ if (!h || !_config.user || _config.user.is_anon) return;++ const rec = Object.assign({ url: location.href, title: document.title, service: resolveBrand().tld }, h);++ const H = root.OpenVibeHistory;++ if (H && typeof H.record === 'function') { H.record(rec, { token: _config.token, apiBase: _config.apiBase }); return; }++ if (!document.getElementById('ov-history-loader')) {++ const sc = document.createElement('script'); sc.id = 'ov-history-loader'; sc.async = true;++ sc.src = `${_config.apiBase}/shared/history.js`;++ sc.onload = () => { try { root.OpenVibeHistory.record(rec, { token: _config.token, apiBase: _config.apiBase }); } catch { /* */ } };++ document.head.appendChild(sc);++ } else {++ document.getElementById('ov-history-loader').addEventListener('load', () => { try { root.OpenVibeHistory.record(rec, { token: _config.token, apiBase: _config.apiBase }); } catch { /* */ } });++ }++ }++function refreshAuthState() {if (_authInFlight) return _authInFlight;_authInFlight = resolveSessionUser()@@@ -619,6 +826,9 @@_config.user = session.user;if (session.token) _config.token = session.token;render();++ recordHistory();++ } else {++ maybeSilentLogin();}try {document.dispatchEvent(new CustomEvent('openvibe-navbar-auth', {@@@ -673,28 +883,37 @@});}++ /**++ * "Recently used" rows in the dropdown: the last few things this account touched anywhere++ * on the network, from the shared history module (loaded lazily from the Network).++ */++ function renderRecent(el) {++ if (!el || !_config.user || _config.user.is_anon) return;++ const draw = (items) => {++ if (!items || !items.length) { el.hidden = true; return; }++ el.hidden = false;++ el.innerHTML = `<div class="label"><span>Recently used</span><a href="https://openvibe.network/my#history">All history</a></div>` +++ items.slice(0, 4).map(h => `<a class="item" href="${escapeAttr(h.url)}"><span class="icon"><i class="fa-solid ${escapeAttr(h.icon || 'fa-clock-rotate-left')}"></i></span><span class="t">${escapeAttr(h.title || h.url)}</span><span class="s">${escapeAttr(h.service_label || h.service || '')}</span></a>`).join('');++ };++ const H = root.OpenVibeHistory;++ if (H && typeof H.recent === 'function') { H.recent({ limit: 4, token: _config.token, apiBase: _config.apiBase }).then(draw).catch(() => draw(null)); return; }++ if (document.getElementById('ov-history-loader')) return;++ const sc = document.createElement('script'); sc.id = 'ov-history-loader'; sc.async = true;++ sc.src = `${_config.apiBase}/shared/history.js`;++ sc.onload = () => { try { root.OpenVibeHistory.recent({ limit: 4, token: _config.token, apiBase: _config.apiBase }).then(draw).catch(() => draw(null)); } catch { /* */ } };++ document.head.appendChild(sc);++ }++function render() {if (_navEl) _navEl.remove();const nav = document.createElement('nav');nav.className = 'openvibe-navbar';const svc = _config.service;-- const links = SERVICE_LINKS[svc] || [];---- // Resolve brand name + icon: config override > subdomain lookup > service defaults-- const host = (typeof location !== 'undefined' && location.hostname) || '';-- // Exact match first (e.g., hostname.openvibe.tools), then fallback to service name-- let subBrand = SUBDOMAIN_BRANDS[host];-- if (!subBrand && host && host.includes('.openvibe.tools')) {-- // Try matching against service names-- const parts = host.split('.');-- const potential = parts[0]; // e.g., 'json' from 'json.openvibe.tools'-- subBrand = SUBDOMAIN_BRANDS[`${potential}.openvibe.tools`];-- }-- const svcName = _config.brandName || (subBrand && subBrand.name) || SERVICE_NAMES[svc] || 'OpenVibe';-- const svcIcon = _config.brandIcon || (subBrand && subBrand.icon) || SERVICE_ICONS[svc] || 'fa-circle-nodes';-- const brandHref = '/';++ const brand = resolveBrand();++ if (typeof nav.setAttribute === 'function') { nav.setAttribute('data-compact', _config.compact || 'auto'); nav.setAttribute('data-service', svc); }++ const links = currentLinks();const u = _config.user;const accounts = getAccounts();@@@ -702,20 +921,19 @@const loginHref = resolveLoginHref(currentHost(), window.location.href);const addAccountHref = `${_config.apiBase}/login?add_account=1&return=${encodeURIComponent(window.location.href)}`;++ // The OV brand mark is a self-contained drop-in (mounts every .ov-mark it finds).++ if (!window.__ovMark && !document.getElementById('ov-mark-loader')) {++ try { const sc = document.createElement('script'); sc.id = 'ov-mark-loader'; sc.src = 'https://openvibe.network/shared/ov-mark.js'; sc.async = true; document.head.appendChild(sc); } catch { /* */ }++ }nav.innerHTML = `-- <a class="openvibe-navbar-brand" href="${brandHref}">-- <span class="flame"><i class="fa-solid ${svcIcon}"></i></span>-- <div>-- <div class="name">${svcName}</div>-- </div>-- </a>++ ${brandHTML(brand)}<div class="openvibe-navbar-links">-- ${links.map(l => `<a href="${l.href}">${l.label}</a>`).join('')}++ ${links.map(l => `<a href="${escapeAttr(l.href)}"${l.active ? ' class="active"' : ''}${l.external ? ' target="_blank" rel="noopener"' : ''}>${l.icon ? `<i class="fa-solid ${escapeAttr(l.icon)} icon"></i>` : ''}${escapeAttr(l.label)}</a>`).join('')}${u && u.role === 'admin' ? `<a href="https://openvibe.network/admin"><i class="fa-solid fa-shield-halved"></i> Admin</a>` : ''}</div><div class="openvibe-navbar-spacer"></div><div class="openvibe-navbar-right">-- <a class="openvibe-network-badge" href="https://openvibe.network" title="Connected to OpenVibe"><i class="fa-solid fa-circle-nodes"></i> OpenVibe</a>++ <a class="openvibe-network-badge" href="https://openvibe.network" title="Connected to OpenVibe"><span class="ov-mark" data-size="16" data-static="1"></span> OpenVibe</a><div id="openvibe-bell-mount"></div>${u ? avatarImg(u, 64, 'openvibe-navbar-avatar', 'openvibe-avatar-btn') :`<a class="openvibe-navbar-login" id="openvibe-login-btn" href="${escapeAttr(loginHref)}">Sign In</a>`}@@@ -729,6 +947,8 @@dropdown.id = 'openvibe-user-dropdown';const otherAccounts = accounts.filter(a => isAnon ? !a.is_anon : String(a.id) !== String(u.id));++ const before = ((_config.menu && _config.menu.before) || []).concat(_runtimeMenu.before);++ const after = ((_config.menu && _config.menu.after) || []).concat(_runtimeMenu.after);dropdown.innerHTML = `<div class="openvibe-navbar-dropdown-header">@@@ -755,17 +975,23 @@<span>Add another account</span></a></div>++ <div class="openvibe-navbar-dropdown-recent" id="openvibe-recent" hidden></div><div class="openvibe-navbar-dropdown-menu">++ ${before.map(menuItemHTML).join('')}${before.length ? '<div class="sep"></div>' : ''}<a href="https://openvibe.network/my"><span class="icon"><i class="fa-solid fa-user"></i></span> My Account</a>-- <a href="https://openvibe.network/my#notifications"><span class="icon"><i class="fa-solid fa-bell"></i></span> Notification Settings</a>++ <a href="https://openvibe.network/my#history"><span class="icon"><i class="fa-solid fa-clock-rotate-left"></i></span> History</a>++ <a href="https://openvibe.network/my#notifications"><span class="icon"><i class="fa-solid fa-bell"></i></span> Notifications</a><a href="https://openvibe.network/themes"><span class="icon"><i class="fa-solid fa-palette"></i></span> Themes</a><a href="https://openvibe.network/my#linked"><span class="icon"><i class="fa-solid fa-link"></i></span> Linked Services</a>${u.role === 'admin' ? `<a href="https://openvibe.network/admin"><span class="icon"><i class="fa-solid fa-screwdriver-wrench"></i></span> Admin Panel</a>` : ''}-- <div style="height:1px;background:var(--border,#333340);margin:4px -8px"></div>++ ${after.length ? '<div class="sep"></div>' : ''}${after.map(menuItemHTML).join('')}++ <div class="sep"></div><button id="openvibe-logout-btn" class="danger"><span class="icon"><i class="fa-solid fa-right-from-bracket"></i></span> Sign Out</button></div>`;nav.appendChild(dropdown);++ bindMenuItems(dropdown, before.concat(after));++ if (_config.recent !== false) renderRecent(dropdown.querySelector('#openvibe-recent'));// Avatar click toggles dropdownnav.querySelector('#openvibe-avatar-btn').addEventListener('click', () => {@@@ -834,9 +1060,13 @@// the shared SSO state (ov_token cookie / localStorage / optional// sessionUrl) and the navbar re-renders when it arrives.if (!_config.user) refreshAuthState();++ else recordHistory();return el;},++ /** Record something the signed-in user did here ({type, title, url, icon}) in their network history. */++ record(entry) { const h = _config.history; _config.history = Object.assign({}, h || {}, entry || {}); recordHistory(); _config.history = h; },++/** Re-resolve the signed-in user from the shared SSO state. */refreshAuth() { return refreshAuthState(); },@@@ -846,6 +1076,26 @@render();},++ /** Replace this site's top links at runtime ([{label, href, icon?, active?, external?}]). */++ setLinks(links) { _runtimeLinks = Array.isArray(links) ? links : null; if (_navEl) render(); },++++ /** Add a row to the account dropdown: {label, href|onClick, icon, danger, external, position:'before'|'after'}. */++ addMenuItem(item) {++ if (!item || !item.label) return;++ const it = Object.assign({ id: item.id || `mi-${Math.random().toString(36).slice(2, 8)}` }, item);++ (it.position === 'before' ? _runtimeMenu.before : _runtimeMenu.after).push(it);++ if (_navEl && _config.user) render();++ return it.id;++ },++++ removeMenuItem(id) {++ for (const k of ['before', 'after']) _runtimeMenu[k] = _runtimeMenu[k].filter(i => i.id !== id);++ if (_navEl && _config.user) render();++ },++++ /** The resolved brand for this page ({ sub, core, tld, name, short, variant }). */++ brand() { return resolveBrand(); },++setToken(token) { _config.token = token; },/** Get the bell mount point for OpenVibeNotifications. */nnew file mode 100644@@@ -0,0 +1,83 @@++/**++ * ov-mark.js — the OpenVibe "OV" brand mark as a drop-in.++ * <span class="ov-mark"></span> → 32px animated mark++ * <span class="ov-mark" data-size="56"> → any size; add data-static="1" for no motion++ * <span class="ov-mark" data-variant="live"> → the site's own twist on the motion (see VARIANTS)++ * Self-contained: injects its own CSS once, works on any page (Live, Network, static pages).++ * The O is a ring with a comet arc running around it, the V is drawn inside with a light++ * sweep, and a dot orbits the ring. Transform/opacity/stroke motion only.++ */++(function () {++ 'use strict';++ if (window.__ovMark) return; window.__ovMark = true;++ let n = 0;++ const CSS = `++.ov-mark{display:inline-grid;place-items:center;width:32px;height:32px;color:var(--accent,#8b5cf6);flex:none;vertical-align:middle;line-height:0}++.ov-mark svg{width:100%;height:100%;overflow:visible;transform-origin:50% 50%;animation:ovmFloat 5s ease-in-out infinite}++.ov-mark .g{fill:currentColor;opacity:.1;transform-box:fill-box;transform-origin:center;animation:ovmGlow 3.4s ease-in-out infinite}++.ov-mark .r{fill:none;stroke:currentColor;stroke-width:4;opacity:.28}++.ov-mark .c{fill:none;stroke-width:4;stroke-linecap:round;stroke-dasharray:34 79;transform-box:fill-box;transform-origin:center;animation:ovmComet 3.6s linear infinite}++.ov-mark .v{fill:none;stroke-width:4.6;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:46;animation:ovmDraw 1.1s cubic-bezier(.2,.8,.2,1) both}++.ov-mark .d{fill:var(--ovm-dot,#fff);transform-box:fill-box;transform-origin:center;animation:ovmDot 2.4s ease-in-out infinite}++/* Per-site variants: one family, each site with its own motion so the network reads as one brand++ with many rooms. Colours stay with the theme (currentColor); only the dot and the rhythm change. */++.ov-mark[data-variant=live]{--ovm-dot:#ef4444}++.ov-mark[data-variant=live] .d{animation:ovmOnAir 1.4s ease-in-out infinite}++.ov-mark[data-variant=tools] .c{animation-duration:2.2s;stroke-dasharray:22 91}++.ov-mark[data-variant=tools] .o2{display:none}++.ov-mark[data-variant=media] .c{stroke-dasharray:14 14 14 71;animation-duration:4.4s}++.ov-mark[data-variant=games] svg{animation:ovmBounce 1.6s cubic-bezier(.3,1.4,.4,1) infinite}++.ov-mark[data-variant=games] .d{animation-duration:1.6s}++.ov-mark[data-variant=community] .o2{opacity:.9;r:1.8}++.ov-mark[data-variant=community] .c{animation-direction:reverse}++.ov-mark[data-variant=network] .g{animation-duration:2.4s}++.ov-mark[data-variant=chat] .d{animation:ovmDot 1s ease-in-out infinite}++.ov-mark[data-variant=stream] .c{stroke-dasharray:60 53;animation-duration:2.8s}++.ov-mark[data-variant=codes] .v{animation:ovmDraw 1.1s cubic-bezier(.2,.8,.2,1) infinite alternate}++.ov-mark[data-variant=blog] .c,.ov-mark[data-variant=wiki] .c,.ov-mark[data-variant=news] .c{animation-duration:6s}++.ov-mark[data-variant=vip]{--ovm-dot:#facc15}++.ov-mark[data-variant=tips]{--ovm-dot:#22c55e}++.ov-mark[data-variant=deals],.ov-mark[data-variant=coupons]{--ovm-dot:#f59e0b}++.ov-mark[data-variant=trade] .c{animation-timing-function:cubic-bezier(.4,0,.2,1)}++.ov-mark[data-variant=host] svg{animation:none}++@keyframes ovmOnAir{0%,100%{transform:scale(1);opacity:.7}50%{transform:scale(1.7);opacity:1}}++@keyframes ovmBounce{0%,100%{transform:translateY(0)}50%{transform:translateY(-2.5px)}}++.ov-mark .o{fill:#fff;filter:drop-shadow(0 0 3px currentColor)}++.ov-mark .o2{opacity:.5}++.ov-mark:hover svg,a:hover>.ov-mark svg,button:hover>.ov-mark svg{animation:ovmSpin .9s cubic-bezier(.2,1.5,.3,1) 1}++.ov-mark:hover .c{animation-duration:.9s}++.ov-mark[data-static] svg,.ov-mark[data-static] .g,.ov-mark[data-static] .c,.ov-mark[data-static] .v,.ov-mark[data-static] .d{animation:none!important}++.ov-mark[data-static] .o{display:none}++@keyframes ovmFloat{0%,100%{transform:translateY(0) rotate(0deg)}50%{transform:translateY(-1px) rotate(2deg)}}++@keyframes ovmGlow{0%,100%{opacity:.08;transform:scale(.92)}50%{opacity:.2;transform:scale(1.06)}}++@keyframes ovmComet{to{transform:rotate(360deg)}}++@keyframes ovmDraw{from{stroke-dashoffset:46}to{stroke-dashoffset:0}}++@keyframes ovmDot{0%,100%{transform:scale(1);opacity:.85}50%{transform:scale(1.5);opacity:1}}++@keyframes ovmSpin{from{transform:rotate(0deg) scale(1)}40%{transform:rotate(200deg) scale(1.18)}to{transform:rotate(360deg) scale(1)}}++@media (prefers-reduced-motion:reduce){.ov-mark svg,.ov-mark .g,.ov-mark .c,.ov-mark .v,.ov-mark .d{animation:none!important}.ov-mark .o{display:none}.ov-mark .c{stroke-dasharray:none;opacity:.9}}`;++ function svg(id) {++ return `<svg viewBox="0 0 48 48" aria-hidden="true"><defs>++<linearGradient id="${id}g" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="currentColor"/><stop offset="1" stop-color="currentColor" stop-opacity="0.45"/></linearGradient>++<linearGradient id="${id}v" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#fff"/><stop offset="0.5" stop-color="currentColor"/><stop offset="1" stop-color="#fff" stop-opacity="0.9"/><animateTransform attributeName="gradientTransform" type="translate" from="-1 0" to="1 0" dur="3.2s" repeatCount="indefinite"/></linearGradient>++<path id="${id}p" d="M24,6 A18,18 0 1,1 23.99,6 Z"/></defs>++<circle class="g" cx="24" cy="24" r="21"/><circle class="r" cx="24" cy="24" r="18"/><circle class="c" cx="24" cy="24" r="18" stroke="url(#${id}g)"/>++<path class="v" d="M14.5,17 L24,34 L33.5,17" stroke="url(#${id}v)"/><circle class="d" cx="24" cy="34" r="2.6"/>++<circle class="o" r="2"><animateMotion dur="3.6s" repeatCount="indefinite"><mpath href="#${id}p"/></animateMotion></circle>++<circle class="o o2" r="1.4"><animateMotion dur="3.6s" begin="0.14s" repeatCount="indefinite"><mpath href="#${id}p"/></animateMotion></circle></svg>`;++ }++ function mount(root) {++ (root || document).querySelectorAll('.ov-mark:not([data-ov])').forEach(el => {++ el.setAttribute('data-ov', '1');++ const size = parseInt(el.getAttribute('data-size'), 10);++ if (size) { el.style.width = size + 'px'; el.style.height = size + 'px'; }++ el.innerHTML = svg('ovm' + (++n) + '_');++ });++ }++ function init() {++ if (!document.getElementById('ov-mark-css')) { const st = document.createElement('style'); st.id = 'ov-mark-css'; st.textContent = CSS; document.head.appendChild(st); }++ mount();++ try { new MutationObserver(() => mount()).observe(document.body, { childList: true, subtree: true }); } catch { /* */ }++ }++ window.ovMarkMount = mount;++ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init); else init();++})();nnew file mode 100644@@@ -0,0 +1,51 @@++#!/usr/bin/env node++'use strict';++/**++ * Regenerates the built-in theme map inside theme-loader.js from builtin-themes.js.++ *++ * theme-loader.js runs in the browser before the first paint and cannot require() the catalog, so++ * it carries a copy. That copy used to be maintained by hand and drifted. Now it is generated:++ *++ * node packages/openvibe-shared/scripts/build-theme-loader.js # rewrite++ * node packages/openvibe-shared/scripts/build-theme-loader.js --check # exit 1 if stale++ *++ * Every entry carries the FULL variable set (the theme's own values over the default theme's), so++ * switching themes replaces every token — a theme that does not name --success no longer keeps the++ * previous theme's --success on <html>.++ */++const fs = require('fs');++const path = require('path');++const { BUILTIN_THEMES, DEFAULT_VARS } = require('../builtin-themes');++++const LOADER = path.join(__dirname, '..', 'theme-loader.js');++const BEGIN = ' // THEMES:BEGIN — generated by scripts/build-theme-loader.js from builtin-themes.js; do not edit by hand';++const END = ' // THEMES:END';++++function render() {++ const lines = BUILTIN_THEMES.map((t) => {++ const vars = { ...DEFAULT_VARS, ...t.variables };++ const body = Object.entries(vars).map(([k, v]) => `'${k}':'${String(v).replace(/'/g, "\\'")}'`).join(',');++ return ` '${t.id}': { ${body} },`;++ });++ return `${BEGIN}\n const THEMES = {\n${lines.join('\n')}\n };\n${END}`;++}++++function currentBlock(src) {++ const a = src.indexOf(BEGIN);++ const b = src.indexOf(END);++ if (a >= 0 && b > a) return { a, b: b + END.length };++ // First run: the hand-written block.++ const start = src.indexOf(' const THEMES = {');++ if (start < 0) throw new Error('theme-loader.js: THEMES block not found');++ const close = src.indexOf('\n };', start);++ return { a: start, b: close + '\n };'.length };++}++++const src = fs.readFileSync(LOADER, 'utf8');++const { a, b } = currentBlock(src);++const next = src.slice(0, a) + render() + src.slice(b);++const check = process.argv.includes('--check');++if (next === src) { if (!check) console.log('theme-loader.js is up to date'); process.exit(0); }++if (check) { console.error('theme-loader.js is stale: run node packages/openvibe-shared/scripts/build-theme-loader.js'); process.exit(1); }++fs.writeFileSync(LOADER, next);++console.log(`theme-loader.js regenerated (${BUILTIN_THEMES.length} themes)`);
@@ -103,3 +103,7 @@ UNSAFE_WORDS_PATH=./server/chat/unsafe-words.txt
@@ -103,3 +103,7 @@ UNSAFE_WORDS_PATH=./server/chat/unsafe-words.txt
# from OpenVibe.Media (drain stalled / disk critical / recovered). Can also be set from the
# from OpenVibe.Media (drain stalled / disk critical / recovered). Can also be set from the
# admin settings as `ops_alert_webhook_url`.
# admin settings as `ops_alert_webhook_url`.
OPS_ALERT_WEBHOOK_URL=
OPS_ALERT_WEBHOOK_URL=
+
+# Pastes are served by openvibe.community when this is 1 (Live 301s /p/:slug and /pastes there)
+PASTES_ON_COMMUNITY=0
+OV_COMMUNITY_URL=https://openvibe.community
@@ -1,5 +1,5 @@
@@ -1,5 +1,5 @@
[Unit]
[Unit]
-Description=OpenVibe.Live — Free & Open Live Streaming
+Description=OpenVibe.Live — Open Live Streaming, Community Run
# Media (openvibe-media) and Network (openvibe-network) should be up first, but
# Media (openvibe-media) and Network (openvibe-network) should be up first, but
# Live degrades gracefully if they aren't (proxies 502, wallet reads fall back).
# Live degrades gracefully if they aren't (proxies 502, wallet reads fall back).
After=network.target
After=network.target
@@ -4,7 +4,7 @@
@@ -4,7 +4,7 @@
**Brand colors:** Violet accent `#8b5cf6`, light violet `#a78bfa`, deep violet `#6d28d9`, cyan secondary `#22d3ee`, dark background `#131318`, live red `#e74c3c`
**Brand colors:** Violet accent `#8b5cf6`, light violet `#a78bfa`, deep violet `#6d28d9`, cyan secondary `#22d3ee`, dark background `#131318`, live red `#e74c3c`
**Logo motif:** Play triangle inside a broadcast ring + signal waves (see `public/assets/logo.svg`)
**Logo motif:** Play triangle inside a broadcast ring + signal waves (see `public/assets/logo.svg`)
-**Tagline:** "Free & Open Live Streaming"
+**Tagline:** "Open Live Streaming, Community Run"
**URL:** `https://openvibe.live`
**URL:** `https://openvibe.live`
**OpenVibe.Network (SSO / accounts):** `https://openvibe.network`
**OpenVibe.Network (SSO / accounts):** `https://openvibe.network`
**OpenVibe.Tools:** `https://openvibe.tools`
**OpenVibe.Tools:** `https://openvibe.tools`
@@ -4,17 +4,17 @@
@@ -4,17 +4,17 @@
<meta charset="UTF-8">
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
- <title>OpenVibe.Live - Free Open Source Live Streaming Platform</title>
- <meta name="description" content="Free, open source live streaming platform. Restream to Twitch, YouTube, Kick, and more. VODs, clips, chat moderation, voice channels, emotes, analytics, and 200+ community tools.">
- <meta name="keywords" content="live streaming, restream, streaming platform, twitch, youtube, open source, free streaming, openvibelive">
+ <title>OpenVibe.Live - Open Source Live Streaming, Community Run</title>
+ <meta name="description" content="Open source live streaming platform, run by its community. Restream to Twitch, YouTube, Kick, and more. VODs, clips, chat moderation, voice channels, emotes, analytics, and 200+ community tools.">
+ <meta name="keywords" content="live streaming, restream, streaming platform, twitch, youtube, open source, openvibelive">
<meta name="author" content="OpenVibe">
<meta name="author" content="OpenVibe">
<meta name="theme-color" content="#8b5cf6">
<meta name="theme-color" content="#8b5cf6">
<!-- Open Graph / Facebook -->
<!-- Open Graph / Facebook -->
<meta property="og:type" content="website">
<meta property="og:type" content="website">
<meta property="og:url" content="https://openvibe.live/">
<meta property="og:url" content="https://openvibe.live/">
- <meta property="og:title" content="OpenVibe.Live - Free Open Source Live Streaming">
- <meta property="og:description" content="Go live for free. Restream to Twitch, YouTube, Kick & more. Clips, VODs, chat moderation, emotes, voice, analytics, and 200+ connected community tools.">
+ <meta property="og:title" content="OpenVibe.Live - Open Source Live Streaming, Community Run">
+ <meta property="og:description" content="Go live in minutes. Restream to Twitch, YouTube, Kick & more. Clips, VODs, chat moderation, emotes, voice, analytics, and 200+ connected community tools.">
<meta property="og:image" content="https://openvibe.live/og-image.png">
<meta property="og:image" content="https://openvibe.live/og-image.png">
<meta property="og:image:width" content="1200">
<meta property="og:image:width" content="1200">
<meta property="og:image:height" content="630">
<meta property="og:image:height" content="630">
@@ -25,8 +25,8 @@
@@ -25,8 +25,8 @@
<!-- Twitter -->
<!-- Twitter -->
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:card" content="summary_large_image">
<meta name="twitter:url" content="https://openvibe.live/">
<meta name="twitter:url" content="https://openvibe.live/">
- <meta name="twitter:title" content="OpenVibe.Live - Free Live Streaming">
- <meta name="twitter:description" content="Free, open source live streaming. Restream to Twitch, YouTube, Kick. VODs, clips, community tools.">
+ <meta name="twitter:title" content="OpenVibe.Live - Open Live Streaming, Community Run">
+ <meta name="twitter:description" content="Open source live streaming, community run. Restream to Twitch, YouTube, Kick. VODs, clips, community tools.">
<meta name="twitter:image" content="https://openvibe.live/og-image.png">
<meta name="twitter:image" content="https://openvibe.live/og-image.png">
<meta name="twitter:image:alt" content="OpenVibe.Live Platform">
<meta name="twitter:image:alt" content="OpenVibe.Live Platform">
@@ -133,7 +133,7 @@
@@ -133,7 +133,7 @@
"@context": "https://schema.org",
"@context": "https://schema.org",
"@type": "SoftwareApplication",
"@type": "SoftwareApplication",
"name": "OpenVibe.Live",
"name": "OpenVibe.Live",
- "description": "Free, open source live streaming platform with restreaming, VODs, clips, and 200+ community tools",
+ "description": "Open source live streaming platform with restreaming, VODs, clips, and 200+ community tools",
"url": "https://openvibe.live",
"url": "https://openvibe.live",
"applicationCategory": "Multimedia",
"applicationCategory": "Multimedia",
"operatingSystem": "Web",
"operatingSystem": "Web",
@@ -150,7 +150,7 @@
@@ -150,7 +150,7 @@
"@type": "WebSite",
"@type": "WebSite",
"name": "OpenVibe.Live",
"name": "OpenVibe.Live",
"url": "https://openvibe.live",
"url": "https://openvibe.live",
- "description": "Free open source live streaming platform",
+ "description": "Open source live streaming platform, community run",
"potentialAction": {
"potentialAction": {
"@type": "SearchAction",
"@type": "SearchAction",
"target": {
"target": {
@@ -272,6 +272,7 @@
@@ -272,6 +272,7 @@
<a href="#" onclick="event.preventDefault();showModal('buy-funds')"><i class="fa-solid fa-coins"></i> Support Us</a>
<a href="#" onclick="event.preventDefault();showModal('buy-funds')"><i class="fa-solid fa-coins"></i> Support Us</a>
<a href="#" onclick="event.preventDefault();if(typeof openCosmeticsModal==='function')openCosmeticsModal()"><i class="fa-solid fa-shirt"></i> Cosmetics</a>
<a href="#" onclick="event.preventDefault();if(typeof openCosmeticsModal==='function')openCosmeticsModal()"><i class="fa-solid fa-shirt"></i> Cosmetics</a>
<a href="https://openvibe.network/themes" target="_blank" rel="noopener"><i class="fa-solid fa-palette"></i> Themes</a>
<a href="https://openvibe.network/themes" target="_blank" rel="noopener"><i class="fa-solid fa-palette"></i> Themes</a>
+ <a href="https://openvibe.network/my#history" target="_blank" rel="noopener"><i class="fa-solid fa-clock-rotate-left"></i> History</a>
</div>
</div>
<div class="ud-group">
<div class="ud-group">
<span class="ud-label">Tools</span>
<span class="ud-label">Tools</span>
@@ -327,7 +328,7 @@
@@ -327,7 +328,7 @@
<div class="hero-slogan-timer" id="hero-slogan-timer" hidden></div>
<div class="hero-slogan-timer" id="hero-slogan-timer" hidden></div>
<div class="hero-cta" data-fx-viewport>
<div class="hero-cta" data-fx-viewport>
<button style="--i:0" class="hb hb-stream" onclick="currentUser ? navigate('/broadcast') : showModal('register')"><span class="hb-ico"><i class="fa-solid fa-broadcast-tower"></i></span><span class="hb-label">Stream</span><i class="fa-solid fa-chevron-right hb-go" aria-hidden="true"></i></button>
<button style="--i:0" class="hb hb-stream" onclick="currentUser ? navigate('/broadcast') : showModal('register')"><span class="hb-ico"><i class="fa-solid fa-broadcast-tower"></i></span><span class="hb-label">Stream</span><i class="fa-solid fa-chevron-right hb-go" aria-hidden="true"></i></button>
- <a href="https://openvibe.network" style="--i:1" class="hb hb-tools"><span class="hb-ico"><i class="fa-solid fa-screwdriver-wrench"></i></span><span class="hb-label">Tools</span><i class="fa-solid fa-chevron-right hb-go" aria-hidden="true"></i></a>
+ <a href="https://openvibe.tools" style="--i:1" class="hb hb-tools"><span class="hb-ico"><i class="fa-solid fa-screwdriver-wrench"></i></span><span class="hb-label">Tools</span><i class="fa-solid fa-chevron-right hb-go" aria-hidden="true"></i></a>
<a href="https://openvibe.games/game" style="--i:2" class="hb hb-game"><span class="hb-ico"><i class="fa-solid fa-gamepad"></i></span><span class="hb-label">Game</span><i class="fa-solid fa-chevron-right hb-go" aria-hidden="true"></i></a>
<a href="https://openvibe.games/game" style="--i:2" class="hb hb-game"><span class="hb-ico"><i class="fa-solid fa-gamepad"></i></span><span class="hb-label">Game</span><i class="fa-solid fa-chevron-right hb-go" aria-hidden="true"></i></a>
<a href="https://github.com/OpenVibers/OpenVibe.Live" target="_blank" style="--i:3" class="hb hb-github" rel="noopener"><span class="hb-ico"><i class="fa-brands fa-github"></i></span><span class="hb-label">GitHub</span><i class="fa-solid fa-chevron-right hb-go" aria-hidden="true"></i></a>
<a href="https://github.com/OpenVibers/OpenVibe.Live" target="_blank" style="--i:3" class="hb hb-github" rel="noopener"><span class="hb-ico"><i class="fa-brands fa-github"></i></span><span class="hb-label">GitHub</span><i class="fa-solid fa-chevron-right hb-go" aria-hidden="true"></i></a>
<a href="https://discord.gg/M6MuRUaeJj" target="_blank" style="--i:4" class="hb hb-discord" rel="noopener noreferrer"><span class="hb-ico"><i class="fa-brands fa-discord"></i></span><span class="hb-label">Discord</span><i class="fa-solid fa-chevron-right hb-go" aria-hidden="true"></i></a>
<a href="https://discord.gg/M6MuRUaeJj" target="_blank" style="--i:4" class="hb hb-discord" rel="noopener noreferrer"><span class="hb-ico"><i class="fa-brands fa-discord"></i></span><span class="hb-label">Discord</span><i class="fa-solid fa-chevron-right hb-go" aria-hidden="true"></i></a>
@@ -530,7 +531,7 @@
@@ -530,7 +531,7 @@
<div class="home-cta-feature">
<div class="home-cta-feature">
<i class="fa-solid fa-wand-magic-sparkles"></i>
<i class="fa-solid fa-wand-magic-sparkles"></i>
<strong>AI clips, VODs & transcripts</strong>
<strong>AI clips, VODs & transcripts</strong>
- <span>Every broadcast auto-records, clips on the fly, and gets free AI overviews + searchable audio transcripts.</span>
+ <span>Every broadcast auto-records, clips on the fly, and gets AI overviews + searchable audio transcripts.</span>
</div>
</div>
<div class="home-cta-feature">
<div class="home-cta-feature">
<i class="fa-solid fa-paste"></i>
<i class="fa-solid fa-paste"></i>
@@ -539,8 +540,8 @@
@@ -539,8 +540,8 @@
</div>
</div>
<div class="home-cta-feature">
<div class="home-cta-feature">
<i class="fa-solid fa-screwdriver-wrench"></i>
<i class="fa-solid fa-screwdriver-wrench"></i>
- <strong>200+ free tools</strong>
- <span>Image & audio editing, PDF tools, video downloads, network diagnostics, dev tools and more — all free at openvibe.tools.</span>
+ <strong>200+ online tools</strong>
+ <span>Image & audio editing, PDF tools, video downloads, network diagnostics, dev tools and more — all at openvibe.tools.</span>
</div>
</div>
<div class="home-cta-feature feat-star">
<div class="home-cta-feature feat-star">
<i class="fa-solid fa-code"></i>
<i class="fa-solid fa-code"></i>
@@ -565,7 +566,7 @@
@@ -565,7 +566,7 @@
<div class="home-cta-feature">
<div class="home-cta-feature">
<i class="fa-solid fa-plug-circle-bolt"></i>
<i class="fa-solid fa-plug-circle-bolt"></i>
<strong>Keep it running</strong>
<strong>Keep it running</strong>
- <span>Hosting, bandwidth, storage and development come first. Free streaming is not free to host, and being honest about that is how it stays free to use.</span>
+ <span>Hosting, bandwidth, storage and development come first. Streaming is not free to host, and being honest about that is how the network stays open to everyone.</span>
</div>
</div>
<div class="home-cta-feature">
<div class="home-cta-feature">
<i class="fa-solid fa-heart"></i>
<i class="fa-solid fa-heart"></i>
@@ -575,7 +575,35 @@ function doRegister() { window.location.href = '/api/auth/sso/login'; }
@@ -575,7 +575,35 @@ function doRegister() { window.location.href = '/api/auth/sso/login'; }
// Session hint: 'account' after a successful sign-in (silent SSO may re-sign you in when the
// Session hint: 'account' after a successful sign-in (silent SSO may re-sign you in when the
// site token lapses), 'guest' after an explicit logout / "browse as guest" (never re-sign in).
// site token lapses), 'guest' after an explicit logout / "browse as guest" (never re-sign in).
function _setSsoHint(v) { try { localStorage.setItem('ov_sso_hint', v); } catch { /* */ } }
function _setSsoHint(v) { try { localStorage.setItem('ov_sso_hint', v); } catch { /* */ } }
-function _ssoHint() { try { return localStorage.getItem('ov_sso_hint') || ''; } catch { return ''; } }
+function _ssoHint() {
+ // The cookie is set by the server on sign-in/sign-out (and by the network's sign-in-everywhere
+ // chain, which never runs this page's JS); localStorage is the page-side copy.
+ const m = document.cookie.match(/(?:^|;\s*)ov_sso_hint=([^;]*)/);
+ if (m && m[1]) return m[1];
+ try { return localStorage.getItem('ov_sso_hint') || ''; } catch { return ''; }
+}
+
+// ── Cross-site history (openvibe.network) ────────────────────
+// What the signed-in account watches/opens here shows up in its network-wide History and the
+// "Recently used" rows of the shared navbar on every other site. Recorded after the route
+// rendered and its title settled; never for guests, and only for the pages worth remembering.
+const _HISTORY_PAGES = { channel: 'stream', 'vod-player': 'vod', 'clip-player': 'clip', 'paste-viewer': 'paste', pastes: null, game: 'game', arena: 'page' };
+let _historyTimer = null;
+function _recordHistory(pageId) {
+ const type = _HISTORY_PAGES[pageId];
+ if (!type) return;
+ clearTimeout(_historyTimer);
+ _historyTimer = setTimeout(() => {
+ const tok = (document.cookie.match(/(?:^|;\s*)ov_token=([^;]*)/) || [])[1] || localStorage.getItem('ov_token');
+ if (!tok || !currentUser) return;
+ const entry = { type, title: document.title.replace(/\s*[-–|]\s*OpenVibe\.Live\s*$/i, ''), url: location.href };
+ const send = () => { try { window.OpenVibeHistory.record(entry, { token: decodeURIComponent(tok) }); } catch { /* */ } };
+ if (window.OpenVibeHistory) return send();
+ if (document.getElementById('ov-history-loader')) return;
+ const sc = document.createElement('script'); sc.id = 'ov-history-loader'; sc.async = true; sc.src = '/shared/history.js'; sc.onload = send;
+ document.head.appendChild(sc);
+ }, 1800);
+}
/** Try to sign back in through openvibe.network without a chooser — once per tab session. */
/** Try to sign back in through openvibe.network without a chooser — once per tab session. */
function _trySilentSso() {
function _trySilentSso() {
if (_ssoHint() !== 'account') return false;
if (_ssoHint() !== 'account') return false;
@@ -999,7 +1027,7 @@ function whenRouteReady(pageId, render) {
@@ -999,7 +1027,7 @@ function whenRouteReady(pageId, render) {
const path = location.pathname;
const path = location.pathname;
const run = () => {
const run = () => {
if (window.ov && !ov.isCurrent(gen)) return;
if (window.ov && !ov.isCurrent(gen)) return;
- try { render(); }
+ try { render(); _recordHistory(pageId); }
catch (err) {
catch (err) {
console.error(`[route] ${path} failed to render:`, err);
console.error(`[route] ${path} failed to render:`, err);
if (window.ov) ov.showRouteError(`page-${pageId}`, err, () => whenRouteReady(pageId, render));
if (window.ov) ov.showRouteError(`page-${pageId}`, err, () => whenRouteReady(pageId, render));
@@ -1116,6 +1144,10 @@ function routeFromURL() {
@@ -1116,6 +1144,10 @@ function routeFromURL() {
} else if (segments[0] === 'privacy') {
} else if (segments[0] === 'privacy') {
window.location.replace('/privacy');
window.location.replace('/privacy');
return;
return;
+ } else if (segments[0] === 'p' && segments[1] && document.querySelector('meta[name="ov-pastes-base"]')) {
+ // Pastes live on openvibe.community now — hand the browser over (same slug, same URL shape).
+ location.replace(`${document.querySelector('meta[name="ov-pastes-base"]').content}/p/${encodeURIComponent(segments[1])}${location.search}`);
+ return;
} else if (segments[0] === 'p' && segments[1]) {
} else if (segments[0] === 'p' && segments[1]) {
showPage('paste-viewer');
showPage('paste-viewer');
whenRouteReady('paste-viewer', () => { if (typeof loadPasteViewer === 'function') loadPasteViewer(segments[1]); });
whenRouteReady('paste-viewer', () => { if (typeof loadPasteViewer === 'function') loadPasteViewer(segments[1]); });
@@ -288,12 +288,35 @@ function getNetworkRedirectUri() {
@@ -288,12 +288,35 @@ function getNetworkRedirectUri() {
return `${config.baseUrl.toLowerCase()}/api/auth/callback`;
return `${config.baseUrl.toLowerCase()}/api/auth/callback`;
}
}
+/**
+ * Where a sign-in/sign-out may send the browser afterwards: a path on this site, or an
+ * https URL on the OpenVibe network — openvibe.network's "sign in everywhere" chain hops
+ * through /api/auth/sso/login?silent=1&next=https://openvibe.network/sso/fanout?… and must
+ * be handed back. Anything else falls back to the home page.
+ */
+function safeNext(raw) {
+ const s = String(raw || '');
+ if (!s) return '/';
+ if (s.startsWith('/') && !s.startsWith('//')) return s;
+ try {
+ const u = new URL(s);
+ if (u.protocol !== 'https:' && !(u.protocol === 'http:' && /^(localhost|127\.0\.0\.1)$/.test(u.hostname))) return '/';
+ if (/(^|\.)openvibe\.[a-z]+$/.test(u.hostname) || /(^|\.)openre\.stream$/.test(u.hostname) || /^(localhost|127\.0\.0\.1)$/.test(u.hostname)) return u.toString();
+ } catch { /* not a URL */ }
+ return '/';
+}
+const HINT_COOKIE = { httpOnly: false, path: '/', maxAge: 365 * 24 * 60 * 60 * 1000, sameSite: 'Lax' };
+
// ── Initiate OAuth Login (redirect to openvibe.network) ───────────
// ── Initiate OAuth Login (redirect to openvibe.network) ───────────
router.get('/sso/login', (req, res) => {
router.get('/sso/login', (req, res) => {
const state = require('crypto').randomBytes(16).toString('hex');
const state = require('crypto').randomBytes(16).toString('hex');
// Store state in a short-lived cookie for CSRF protection
// Store state in a short-lived cookie for CSRF protection
const isSecure = config.baseUrl.startsWith('https');
const isSecure = config.baseUrl.startsWith('https');
res.cookie('oauth_state', state, { httpOnly: true, maxAge: 5 * 60 * 1000, sameSite: 'Lax', secure: isSecure });
res.cookie('oauth_state', state, { httpOnly: true, maxAge: 5 * 60 * 1000, sameSite: 'Lax', secure: isSecure });
+ // Where to go afterwards (the fanout chain, or the page that asked to sign in).
+ const next = safeNext(req.query.next);
+ if (next !== '/') res.cookie('oauth_next', next, { httpOnly: true, maxAge: 5 * 60 * 1000, sameSite: 'Lax', secure: isSecure });
+ else res.clearCookie('oauth_next');
const params = new URLSearchParams({
const params = new URLSearchParams({
client_id: OV_CLIENT_ID,
client_id: OV_CLIENT_ID,
@@ -312,10 +335,13 @@ router.get('/sso/login', (req, res) => {
@@ -312,10 +335,13 @@ router.get('/sso/login', (req, res) => {
router.get('/callback', async (req, res) => {
router.get('/callback', async (req, res) => {
try {
try {
const { code, state } = req.query;
const { code, state } = req.query;
+ const next = safeNext(req.cookies?.oauth_next);
+ res.clearCookie('oauth_next');
if (!code && req.query.error) {
if (!code && req.query.error) {
- // Silent sign-in found no network session — go home quietly as a guest.
+ // Silent sign-in found no network session — go on quietly as a guest.
res.clearCookie('oauth_state');
res.clearCookie('oauth_state');
- return res.redirect('/?sso=none');
+ const sep = next.includes('?') ? '&' : '?';
+ return res.redirect(`${next}${sep}sso=none`);
}
}
if (!code) return res.status(400).send('Missing authorization code');
if (!code) return res.status(400).send('Missing authorization code');
@@ -450,6 +476,9 @@ router.get('/callback', async (req, res) => {
@@ -450,6 +476,9 @@ router.get('/callback', async (req, res) => {
// Also set ov_token (used by shared navbar/notification libs)
// Also set ov_token (used by shared navbar/notification libs)
res.cookie('ov_token', openvibeToolsToken, { httpOnly: false, path: '/', maxAge: 7 * 24 * 60 * 60 * 1000, sameSite: 'Lax', secure: isSecure });
res.cookie('ov_token', openvibeToolsToken, { httpOnly: false, path: '/', maxAge: 7 * 24 * 60 * 60 * 1000, sameSite: 'Lax', secure: isSecure });
+ // Long-lived hint (outlives the token): this browser has signed in here, so a later visit
+ // with no session may try one silent sign-in through the network.
+ res.cookie('ov_sso_hint', 'account', { ...HINT_COOKIE, secure: isSecure });
// Store refresh token in httpOnly cookie (not readable by JS — server handles refresh)
// Store refresh token in httpOnly cookie (not readable by JS — server handles refresh)
if (openvibeRefreshToken) {
if (openvibeRefreshToken) {
@@ -470,10 +499,11 @@ router.get('/callback', async (req, res) => {
@@ -470,10 +499,11 @@ router.get('/callback', async (req, res) => {
var acct = { id: u.id, username: u.username, display_name: u.display_name, avatar_url: u.avatar_url, is_anon: false, token: ${JSON.stringify(openvibeToolsToken)}, added_at: Date.now() };
var acct = { id: u.id, username: u.username, display_name: u.display_name, avatar_url: u.avatar_url, is_anon: false, token: ${JSON.stringify(openvibeToolsToken)}, added_at: Date.now() };
localStorage.setItem('openvibe_accounts', JSON.stringify([acct]));
localStorage.setItem('openvibe_accounts', JSON.stringify([acct]));
localStorage.setItem('openvibe_active_account', String(u.id));
localStorage.setItem('openvibe_active_account', String(u.id));
+ localStorage.setItem('ov_sso_hint', 'account');
} catch(e) {}
} catch(e) {}
- window.location.href = '/';
+ window.location.replace(${JSON.stringify(next)});
</script>
</script>
-<noscript><a href="/">Click here to continue</a></noscript>
+<noscript><a href="${next.replace(/"/g, '"')}">Click here to continue</a></noscript>
</body></html>`);
</body></html>`);
} catch (err) {
} catch (err) {
console.error('[Auth/SSO] Callback error:', err);
console.error('[Auth/SSO] Callback error:', err);
@@ -536,14 +566,31 @@ router.post('/refresh', async (req, res) => {
@@ -536,14 +566,31 @@ router.post('/refresh', async (req, res) => {
});
});
// ── Logout (clear all auth cookies) ──────────────────────────
// ── Logout (clear all auth cookies) ──────────────────────────
-router.post('/logout', (req, res) => {
+function clearSessionCookies(res) {
const isSecure = (process.env.BASE_URL || '').startsWith('https');
const isSecure = (process.env.BASE_URL || '').startsWith('https');
res.clearCookie('token', { sameSite: 'Lax', secure: isSecure });
res.clearCookie('token', { sameSite: 'Lax', secure: isSecure });
res.clearCookie('ov_token', { sameSite: 'Lax', secure: isSecure });
res.clearCookie('ov_token', { sameSite: 'Lax', secure: isSecure });
res.clearCookie('ov_refresh', { path: '/api/auth', sameSite: 'Lax', secure: isSecure });
res.clearCookie('ov_refresh', { path: '/api/auth', sameSite: 'Lax', secure: isSecure });
+ res.cookie('ov_sso_hint', 'guest', { ...HINT_COOKIE, secure: isSecure });
+}
+
+router.post('/logout', (req, res) => {
+ clearSessionCookies(res);
res.json({ ok: true });
res.json({ ok: true });
});
});
+// Sign-out-everywhere chain from openvibe.network: clear this site's cookies and hand the
+// browser back. The page-side localStorage copy is cleared by the tiny page below.
+router.get('/logout', (req, res) => {
+ clearSessionCookies(res);
+ const next = safeNext(req.query.next);
+ res.set('Cache-Control', 'no-store');
+ res.type('html').send(`<!DOCTYPE html><html><head><meta charset="utf-8"><title>Signing out…</title></head><body><script>
+try { ['token','ov_token','openvibe_accounts','openvibe_active_account','openvibe_anon_token'].forEach(function(k){ localStorage.removeItem(k); }); localStorage.setItem('ov_sso_hint','guest'); } catch (e) {}
+location.replace(${JSON.stringify(next)});
+</script><noscript><a href="${next.replace(/"/g, '"')}">Continue</a></noscript></body></html>`);
+});
+
// ── SSO Status (for client-side detection) ───────────────────
// ── SSO Status (for client-side detection) ───────────────────
router.get('/sso/status', (req, res) => {
router.get('/sso/status', (req, res) => {
res.json({
res.json({
@@ -1,7 +1,7 @@
@@ -1,7 +1,7 @@
/**
/**
* ╔═══════════════════════════════════════════════════════════╗
* ╔═══════════════════════════════════════════════════════════╗
* ║ OpenVibe.Live — Main Server ║
* ║ OpenVibe.Live — Main Server ║
- * ║ Free & Open Live Streaming · openvibe.live ║
+ * ║ Open Live Streaming, Community Run · openvibe.live ║
* ║ Part of the OpenVibe network — Open Source & Community ║
* ║ Part of the OpenVibe network — Open Source & Community ║
* ╚═══════════════════════════════════════════════════════════╝
* ╚═══════════════════════════════════════════════════════════╝
*
*
@@ -596,6 +596,14 @@ app.get('/data/pastes/screenshots/:filename', (req, res) => {
@@ -596,6 +596,14 @@ app.get('/data/pastes/screenshots/:filename', (req, res) => {
res.redirect(302, mediaClient.screenshotUrl(path.basename(req.params.filename)));
res.redirect(302, mediaClient.screenshotUrl(path.basename(req.params.filename)));
});
});
+// Pastes are moving to openvibe.community (PASTES_ON_COMMUNITY=1): the old Live URLs keep
+// working as permanent redirects, so links in chat, search results and clipboards survive.
+const COMMUNITY_URL = (process.env.OV_COMMUNITY_URL || 'https://openvibe.community').replace(/\/$/, '');
+if (process.env.PASTES_ON_COMMUNITY === '1') {
+ app.get('/p/:slug', (req, res) => res.redirect(301, `${COMMUNITY_URL}/p/${encodeURIComponent(req.params.slug)}`));
+ app.get('/pastes', (req, res) => res.redirect(301, `${COMMUNITY_URL}/pastes`));
+}
+
// The SPA renders Media's relative paste URLs (/p/<slug>/screenshot, /raw)
// The SPA renders Media's relative paste URLs (/p/<slug>/screenshot, /raw)
// against THIS origin — bounce them to the Media public host, where the
// against THIS origin — bounce them to the Media public host, where the
// canonical paste page lives.
// canonical paste page lives.
@@ -1029,7 +1037,7 @@ async function start() {
@@ -1029,7 +1037,7 @@ async function start() {
console.log('');
console.log('');
console.log(' ╔══════════════════════════════════════════╗');
console.log(' ╔══════════════════════════════════════════╗');
console.log(' ║ OpenVibe.Live v1.0.0 ║');
console.log(' ║ OpenVibe.Live v1.0.0 ║');
- console.log(' ║ Free & Open Live Streaming ▶((( • ))) ║');
+ console.log(' ║ Open Live Streaming, Community Run ▶((( • ))) ║');
console.log(' ╚══════════════════════════════════════════╝');
console.log(' ╚══════════════════════════════════════════╝');
console.log('');
console.log('');
@@ -419,10 +419,15 @@ function _base() {
@@ -419,10 +419,15 @@ function _base() {
}
}
return doc.html;
return doc.html;
}
}
+// Pastes are moving to openvibe.community; when PASTES_ON_COMMUNITY=1 the SPA router sends
+// /p/<slug> there (server/index.js answers the same URL with a 301 for bots and direct hits).
+const PASTES_BASE = process.env.PASTES_ON_COMMUNITY === '1' ? (process.env.OV_COMMUNITY_URL || 'https://openvibe.community').replace(/\/$/, '') : '';
+
function _headBlock(meta) {
function _headBlock(meta) {
const canonical = abs(meta.canonicalPath || '/');
const canonical = abs(meta.canonicalPath || '/');
const img = abs(meta.image || DEFAULT_OG_IMAGE);
const img = abs(meta.image || DEFAULT_OG_IMAGE);
const parts = [
const parts = [
+ PASTES_BASE ? `<meta name="ov-pastes-base" content="${esc(PASTES_BASE)}">` : '',
`<title>${esc(meta.title)}</title>`,
`<title>${esc(meta.title)}</title>`,
`<meta name="description" content="${esc(meta.description)}">`,
`<meta name="description" content="${esc(meta.description)}">`,
`<meta name="robots" content="${esc(meta.robots || 'index,follow')}">`,
`<meta name="robots" content="${esc(meta.robots || 'index,follow')}">`,
@@ -19,7 +19,7 @@ const BRAND_DEFAULTS = Object.freeze({
@@ -19,7 +19,7 @@ const BRAND_DEFAULTS = Object.freeze({
gamesServiceName: 'OpenVibe.Games',
gamesServiceName: 'OpenVibe.Games',
mediaServiceName: 'OpenVibe.Media',
mediaServiceName: 'OpenVibe.Media',
tagline: 'One Account. All of OpenVibe.',
tagline: 'One Account. All of OpenVibe.',
- campTagline: 'Free & Open Live Streaming',
+ campTagline: 'Open Live Streaming, Community Run',
toolsSubdomainBase: 'openvibe.tools',
toolsSubdomainBase: 'openvibe.tools',
discord: 'https://discord.gg/M6MuRUaeJj',
discord: 'https://discord.gg/M6MuRUaeJj',
github: 'https://github.com/OpenVibe.Live',
github: 'https://github.com/OpenVibe.Live',
@@ -99,8 +99,8 @@ function buildBrandFromRegistry(registry = {}, env = process.env) {
@@ -99,8 +99,8 @@ function buildBrandFromRegistry(registry = {}, env = process.env) {
// Build service list using registry URLs where available
// Build service list using registry URLs where available
const services = [
const services = [
- { id: 'live', name: streamerServiceName, url: liveUrl, description: 'Free & Open Live Streaming' },
- { id: 'tools', name: toolsServiceName, url: toolsUrl, description: 'Free Online Tools' },
+ { id: 'live', name: streamerServiceName, url: liveUrl, description: 'Open live streaming, community run' },
+ { id: 'tools', name: toolsServiceName, url: toolsUrl, description: 'Online tools for everyone' },
{ id: 'games', name: gamesServiceName, url: gamesUrl, description: 'Games & Community Canvas' },
{ id: 'games', name: gamesServiceName, url: gamesUrl, description: 'Games & Community Canvas' },
{ id: 'media', name: mediaServiceName, url: mediaUrl, description: 'VODs, Clips, Pastes & Files' },
{ id: 'media', name: mediaServiceName, url: mediaUrl, description: 'VODs, Clips, Pastes & Files' },
{ id: 'network', name: BRAND_DEFAULTS.networkServiceName, url: networkUrl, description: 'Accounts, SSO & OpenCoins' },
{ id: 'network', name: BRAND_DEFAULTS.networkServiceName, url: networkUrl, description: 'Accounts, SSO & OpenCoins' },
@@ -155,8 +155,8 @@ const BRAND = Object.freeze({
@@ -155,8 +155,8 @@ const BRAND = Object.freeze({
// Primary services in the network (for dashboard display, health checks, etc.)
// Primary services in the network (for dashboard display, health checks, etc.)
// Static list with OpenVibe defaults — use buildBrandFromRegistry() for live values.
// Static list with OpenVibe defaults — use buildBrandFromRegistry() for live values.
services: Object.freeze([
services: Object.freeze([
- { id: 'live', name: 'OpenVibe.Live', url: 'https://openvibe.live', description: 'Free & Open Live Streaming' },
- { id: 'tools', name: 'OpenVibe.Tools', url: 'https://openvibe.tools', description: 'Free Online Tools' },
+ { id: 'live', name: 'OpenVibe.Live', url: 'https://openvibe.live', description: 'Open live streaming, community run' },
+ { id: 'tools', name: 'OpenVibe.Tools', url: 'https://openvibe.tools', description: 'Online tools for everyone' },
{ id: 'games', name: 'OpenVibe.Games', url: 'https://openvibe.games', description: 'Games & Community Canvas' },
{ id: 'games', name: 'OpenVibe.Games', url: 'https://openvibe.games', description: 'Games & Community Canvas' },
{ id: 'media', name: 'OpenVibe.Media', url: 'https://openvibe.media', description: 'VODs, Clips, Pastes & Files' },
{ id: 'media', name: 'OpenVibe.Media', url: 'https://openvibe.media', description: 'VODs, Clips, Pastes & Files' },
{ id: 'network', name: 'OpenVibe.Network', url: 'https://openvibe.network', description: 'Accounts, SSO & OpenCoins' },
{ id: 'network', name: 'OpenVibe.Network', url: 'https://openvibe.network', description: 'Accounts, SSO & OpenCoins' },
@@ -34,7 +34,7 @@
@@ -34,7 +34,7 @@
// the link, with no edit anywhere else.
// the link, with no edit anywhere else.
const NETWORK = [
const NETWORK = [
{ id: 'live', name: 'Live streaming', url: 'https://openvibe.live', desc: 'Go live, restream and clip' },
{ id: 'live', name: 'Live streaming', url: 'https://openvibe.live', desc: 'Go live, restream and clip' },
- { id: 'tools', name: 'Free online tools', url: 'https://openvibe.tools', desc: 'The whole toolbox' },
+ { id: 'tools', name: 'Online tools', url: 'https://openvibe.tools', desc: 'The whole toolbox' },
{ id: 'dev', name: 'Developer tools', url: 'https://dev.openvibe.tools', desc: 'Formatters, encoders, validators' },
{ id: 'dev', name: 'Developer tools', url: 'https://dev.openvibe.tools', desc: 'Formatters, encoders, validators' },
{ id: 'net', name: 'Network diagnostics', url: 'https://net.openvibe.tools', desc: 'Ping, DNS, Whois, SSL' },
{ id: 'net', name: 'Network diagnostics', url: 'https://net.openvibe.tools', desc: 'Ping, DNS, Whois, SSL' },
{ id: 'img', name: 'Image converter', url: 'https://img.openvibe.tools', desc: 'Convert, resize, compress' },
{ id: 'img', name: 'Image converter', url: 'https://img.openvibe.tools', desc: 'Convert, resize, compress' },
@@ -42,7 +42,11 @@
@@ -42,7 +42,11 @@
{ id: 'docs', name: 'PDF & document tools', url: 'https://docs.openvibe.tools', desc: 'Merge, split, compress' },
{ id: 'docs', name: 'PDF & document tools', url: 'https://docs.openvibe.tools', desc: 'Merge, split, compress' },
{ id: 'text', name: 'Text tools', url: 'https://text.openvibe.tools', desc: 'Fancy text, case, ASCII' },
{ id: 'text', name: 'Text tools', url: 'https://text.openvibe.tools', desc: 'Fancy text, case, ASCII' },
{ id: 'yt', name: 'YouTube downloader', url: 'https://yt.openvibe.tools', desc: 'Video and audio' },
{ id: 'yt', name: 'YouTube downloader', url: 'https://yt.openvibe.tools', desc: 'Video and audio' },
+ { id: 'community', name: 'Community & pastes', url: 'https://openvibe.community', desc: 'Pastes, posts and the people of OpenVibe' },
+ { id: 'games', name: 'Games', url: 'https://openvibe.games', desc: 'Scraplandia and browser games' },
+ { id: 'media', name: 'Media', url: 'https://openvibe.media', desc: 'VODs, clips and files for every site' },
{ id: 'network', name: 'Account & themes', url: NETWORK_URL, desc: 'One account, every site' },
{ id: 'network', name: 'Account & themes', url: NETWORK_URL, desc: 'One account, every site' },
+ { id: 'more', name: 'The whole network →', url: NETWORK_URL + '/#network', desc: 'Every OpenVibe site, live and upcoming' },
];
];
const LEGAL = [
const LEGAL = [
@@ -59,7 +63,7 @@
@@ -59,7 +63,7 @@
const DEFAULTS = {
const DEFAULTS = {
service: null, // 'live' | 'tools' | 'dev' | … (auto-detected when omitted)
service: null, // 'live' | 'tools' | 'dev' | … (auto-detected when omitted)
brandName: null,
brandName: null,
- tagline: 'Free, open source and community run. Built in the open by the people using it.',
+ tagline: 'Open source and community run. Built in the open by the people using it.',
variant: 'full',
variant: 'full',
mount: '#ov-footer',
mount: '#ov-footer',
links: [], // [{ heading, items: [{ name, url, onclick? }] }]
links: [], // [{ heading, items: [{ name, url, onclick? }] }]
@@ -84,9 +88,17 @@
@@ -84,9 +88,17 @@
return sub === h ? 'tools' : (sub || 'tools');
return sub === h ? 'tools' : (sub || 'tools');
}
}
+ 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' };
+ /** The full site name from the hostname — the same spelling the navbar uses (Pastes.OpenVibe.Tools). */
function brandFor(service) {
function brandFor(service) {
+ const h = typeof location !== 'undefined' ? location.hostname.toLowerCase() : '';
+ const cap = (w) => w ? w.charAt(0).toUpperCase() + w.slice(1) : '';
+ let m = h.match(/^(?:(.+)\.)?openvibe\.([a-z]+)$/);
+ 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('.'); }
+ if ((m = h.match(/^(?:(.+)\.)?openre\.stream$/))) return 'OpenRe.Stream';
const found = NETWORK.find(n => n.id === service);
const found = NETWORK.find(n => n.id === service);
- if (found) return found.name === 'Account & themes' ? 'OpenVibe' : found.name;
+ if (service && TLD_LABELS[service]) return `OpenVibe.${TLD_LABELS[service]}`;
+ if (found && found.name !== 'Account & themes' && found.name !== 'The whole network →') return `${cap(service)}.OpenVibe.Tools`;
return 'OpenVibe';
return 'OpenVibe';
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,111 @@
@@ -0,0 +1,111 @@
+/**
+ * openvibe-shared/history.js — cross-site history for a signed-in account.
+ *
+ * OpenVibeHistory.record({ type, title, url, icon, service, meta }, { token, apiBase })
+ * OpenVibeHistory.recent({ limit, service, type, token, apiBase }) → [{...}]
+ * OpenVibeHistory.clear({ token }) → wipes it
+ *
+ * Every property records what the account touched — a tool used, a stream watched, a paste
+ * opened, a game played — so "Recently used" in the shared navbar and the History tab on the
+ * Network show one timeline across the whole network instead of one per site. Writes are
+ * deduplicated server-side (same URL within ten minutes bumps the entry instead of adding one)
+ * and the browser side throttles too, so a page cannot flood it. Nothing is recorded for
+ * anonymous sessions, and the user can pause or clear the timeline from their account page.
+ *
+ * Loaded lazily by navbar.js from https://openvibe.network/shared/history.js; safe to include
+ * directly. Token resolution matches the navbar: ov_token cookie → localStorage.ov_token.
+ */
+(function (root) {
+ 'use strict';
+ if (root.OpenVibeHistory) return;
+
+ const DEFAULT_API = 'https://openvibe.network';
+ const SENT_KEY = 'ov_history_sent'; // sessionStorage: url → ts, to avoid re-posting on every re-render
+ const THROTTLE_MS = 5 * 60 * 1000;
+
+ function token(opts) {
+ if (opts && opts.token) return opts.token;
+ const m = (typeof document !== 'undefined' ? document.cookie : '').match(/(?:^|;\s*)ov_token=([^;]*)/);
+ if (m && m[1]) { try { return decodeURIComponent(m[1]); } catch { return m[1]; } }
+ try { return localStorage.getItem('ov_token'); } catch { return null; }
+ }
+
+ function api(opts) { return (opts && opts.apiBase) || DEFAULT_API; }
+
+ function sent() { try { return JSON.parse(sessionStorage.getItem(SENT_KEY) || '{}'); } catch { return {}; } }
+ function markSent(url) {
+ try {
+ const m = sent(); m[url] = Date.now();
+ const keys = Object.keys(m); if (keys.length > 60) for (const k of keys.slice(0, keys.length - 60)) delete m[k];
+ sessionStorage.setItem(SENT_KEY, JSON.stringify(m));
+ } catch { /* storage unavailable */ }
+ }
+
+ /** Guess an icon and service label from the URL when the caller gives none. */
+ function inferService(url) {
+ let host = '';
+ try { host = new URL(url, location.href).hostname; } catch { return { service: null }; }
+ const m = host.match(/^(?:(.+)\.)?openvibe\.([a-z]+)$/) || host.match(/^(?:(.+)\.)?(openre)\.stream$/);
+ if (!m) return { service: null };
+ return { service: m[2], sub: m[1] || null };
+ }
+
+ const ICONS = { tool: 'fa-screwdriver-wrench', stream: 'fa-tower-broadcast', vod: 'fa-clapperboard', clip: 'fa-scissors', paste: 'fa-paste', game: 'fa-gamepad', page: 'fa-file-lines', post: 'fa-comments', account: 'fa-user', theme: 'fa-palette', download: 'fa-download', chat: 'fa-comment' };
+
+ async function record(entry, opts) {
+ const t = token(opts);
+ if (!t || !entry) return false;
+ const url = String(entry.url || (typeof location !== 'undefined' ? location.href : '')).slice(0, 2000);
+ if (!url) return false;
+ const last = sent()[url] || 0;
+ if (!entry.force && Date.now() - last < THROTTLE_MS) return false;
+ markSent(url);
+ const inf = inferService(url);
+ const body = {
+ type: String(entry.type || 'page').slice(0, 32),
+ title: String(entry.title || (typeof document !== 'undefined' ? document.title : '') || url).slice(0, 200),
+ url,
+ icon: entry.icon || ICONS[entry.type] || null,
+ service: entry.service || inf.service || null,
+ sub: entry.sub || inf.sub || null,
+ meta: entry.meta && typeof entry.meta === 'object' ? entry.meta : null,
+ };
+ try {
+ const res = await fetch(`${api(opts)}/api/history`, {
+ method: 'POST', keepalive: true,
+ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${t}` },
+ body: JSON.stringify(body),
+ });
+ return res.ok;
+ } catch { return false; }
+ }
+
+ async function recent(opts) {
+ const t = token(opts);
+ if (!t) return [];
+ const q = new URLSearchParams();
+ if (opts && opts.limit) q.set('limit', String(opts.limit));
+ if (opts && opts.service) q.set('service', opts.service);
+ if (opts && opts.type) q.set('type', opts.type);
+ try {
+ const res = await fetch(`${api(opts)}/api/history?${q}`, { headers: { Authorization: `Bearer ${t}` } });
+ if (!res.ok) return [];
+ const data = await res.json();
+ return (data && data.items) || [];
+ } catch { return []; }
+ }
+
+ async function clear(opts) {
+ const t = token(opts);
+ if (!t) return false;
+ try { const res = await fetch(`${api(opts)}/api/history`, { method: 'DELETE', headers: { Authorization: `Bearer ${t}` } }); return res.ok; } catch { return false; }
+ }
+
+ async function remove(id, opts) {
+ const t = token(opts);
+ if (!t) return false;
+ try { const res = await fetch(`${api(opts)}/api/history/${encodeURIComponent(id)}`, { method: 'DELETE', headers: { Authorization: `Bearer ${t}` } }); return res.ok; } catch { return false; }
+ }
+
+ root.OpenVibeHistory = { record, recent, clear, remove, ICONS };
+})(typeof window !== 'undefined' ? window : globalThis);
@@ -8,12 +8,39 @@
@@ -8,12 +8,39 @@
// onLogin/onLogout callbacks. When no `user` is passed the navbar resolves
// onLogin/onLogout callbacks. When no `user` is passed the navbar resolves
// it itself: ov_token cookie → localStorage → token opt → sessionUrl, then
// it itself: ov_token cookie → localStorage → token opt → sessionUrl, then
// GET {apiBase}/api/auth/me with `Authorization: Bearer <token>`.
// GET {apiBase}/api/auth/me with `Authorization: Bearer <token>`.
+//
+// Brand: derived from the hostname — `pastes.openvibe.tools` renders as
+// Pastes · OpenVibe · Tools (three segments, the subdomain first so the
+// context reads left-to-right), `openvibe.live` as OpenVibe · Live. Pass
+// brand: { sub, tld, name, icon, variant } to override any part, or the
+// legacy brandName/brandIcon. compact: 'auto' (default — the brand shortens
+// to the subdomain on narrow viewports), 'always', 'never'.
+// Menus are modular: every site keeps the shared account menu and adds its own
+// pieces — links: [{label, href, icon?, active?}] replaces the service's top
+// links; menu: { before: [item], after: [item] } adds dropdown rows
+// ({label, href, icon, onClick, danger, external}); OpenVibeNavbar.addMenuItem()
+// / setLinks() do the same at runtime. Signed-in users also get a
+// "Recently used" row fed by the shared history module when it is loaded.
// ═══════════════════════════════════════════════════════════════
// ═══════════════════════════════════════════════════════════════
(function (root) {
(function (root) {
'use strict';
'use strict';
- let _config = { service: 'network', token: null, user: null, apiBase: 'https://openvibe.network', onLogin: null, onLogout: null, loginUrl: null, sessionUrl: null };
+ let _config = {
+ service: 'network', token: null, user: null, apiBase: 'https://openvibe.network',
+ onLogin: null, onLogout: null, loginUrl: null, sessionUrl: null,
+ brand: null, brandName: null, brandIcon: null, compact: 'auto',
+ links: null, menu: null, recent: true,
+ // history: { type: 'tool'|'stream'|'paste'|'page'|…, title, url, icon } — recorded for the
+ // signed-in user once auth resolves (cross-site "Recently used" / History on the Network).
+ history: null,
+ // silentLogin: 'https://site/auth/login?silent=1&next={url}' — when nobody is signed in here
+ // but this browser has signed in to the network before (ov_sso_hint), try one silent
+ // prompt=none round trip per tab so a session on one site becomes a session on all.
+ silentLogin: null,
+ };
+ const _runtimeMenu = { before: [], after: [] };
+ let _runtimeLinks = null;
let _navEl = null;
let _navEl = null;
function injectStyles() {
function injectStyles() {
@@ -29,10 +56,44 @@
@@ -29,10 +56,44 @@
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
color: var(--text-primary, #e0e0e0);
color: var(--text-primary, #e0e0e0);
}
}
- .openvibe-navbar-brand { display: flex; align-items: center; gap: 8px; text-decoration: none; color: inherit; margin-right: 8px; }
- .openvibe-navbar-brand .flame { font-size: 18px; color: var(--accent, #8b5cf6); }
- .openvibe-navbar-brand .name { font-size: 15px; font-weight: 700; letter-spacing: -.3px; }
- .openvibe-navbar-brand .service-name { font-size: 11px; color: var(--accent-light, #a78bfa); font-weight: 500; letter-spacing: .5px; text-transform: uppercase; }
+ .openvibe-navbar-brand { display: flex; align-items: center; gap: 9px; text-decoration: none; color: inherit; margin-right: 8px; min-width: 0; }
+ .openvibe-navbar-brand .flame { font-size: 18px; color: var(--accent, #8b5cf6); display: inline-grid; place-items: center; width: 28px; height: 28px; flex: none; }
+ .openvibe-navbar-brand .name { display: inline-flex; align-items: baseline; font-size: 15px; font-weight: 700; letter-spacing: -.3px; white-space: nowrap; line-height: 1; }
+ /* Segments: subdomain · OpenVibe · TLD. The part that names *this* site is the loud one. */
+ .openvibe-navbar-brand .b-sub { color: var(--text-primary, #e0e0e0); }
+ .openvibe-navbar-brand .b-core { color: var(--text-primary, #e0e0e0); }
+ .openvibe-navbar-brand .b-tld { color: var(--accent-light, var(--accent, #a78bfa)); }
+ .openvibe-navbar-brand.has-sub .b-core { color: var(--text-secondary, #b0b0b8); font-weight: 600; }
+ .openvibe-navbar-brand.has-sub .b-tld { color: var(--text-secondary, #b0b0b8); font-weight: 600; }
+ .openvibe-navbar-brand .b-dot { color: var(--accent, #8b5cf6); opacity: .75; margin: 0 1px; font-weight: 800; }
+ .openvibe-navbar-brand .b-sub, .openvibe-navbar-brand .b-core, .openvibe-navbar-brand .b-tld { transition: color .2s, opacity .2s; }
+ .openvibe-navbar-brand:hover .b-tld, .openvibe-navbar-brand:hover .b-sub { color: var(--accent-light, #a78bfa); }
+ .openvibe-navbar-brand:hover .b-dot { opacity: 1; }
+ .openvibe-navbar-brand .b-tag { font-size: 9px; font-weight: 700; letter-spacing: .6px; text-transform: uppercase; color: var(--accent-light, #a78bfa); background: var(--accent-glow, rgba(139,92,246,.14)); border-radius: 4px; padding: 2px 5px; margin-left: 6px; align-self: center; }
+ /* Compact: drop the trailing segments on narrow viewports, keep what identifies the page. */
+ .openvibe-navbar[data-compact="always"] .openvibe-navbar-brand.has-sub .b-core,
+ .openvibe-navbar[data-compact="always"] .openvibe-navbar-brand.has-sub .b-tld,
+ .openvibe-navbar[data-compact="always"] .openvibe-navbar-brand.has-sub .b-dot { display: none; }
+ @media (max-width: 860px) {
+ .openvibe-navbar[data-compact="auto"] .openvibe-navbar-brand.has-sub .b-core,
+ .openvibe-navbar[data-compact="auto"] .openvibe-navbar-brand.has-sub .b-tld,
+ .openvibe-navbar[data-compact="auto"] .openvibe-navbar-brand.has-sub .b-dot { display: none; }
+ }
+ @media (max-width: 420px) {
+ .openvibe-navbar[data-compact="auto"] .openvibe-navbar-brand:not(.has-sub) .b-core,
+ .openvibe-navbar[data-compact="auto"] .openvibe-navbar-brand:not(.has-sub) .b-dot { display: none; }
+ .openvibe-navbar[data-compact="auto"] .openvibe-navbar-brand .b-tag { display: none; }
+ }
+ .openvibe-navbar-links a .icon { margin-right: 5px; opacity: .8; }
+ .openvibe-navbar-dropdown-recent { padding: 6px 8px 2px; border-bottom: 1px solid var(--border, #333340); }
+ .openvibe-navbar-dropdown-recent .label { font-size: 10px; font-weight: 700; letter-spacing: .6px; text-transform: uppercase; color: var(--text-muted, #707080); padding: 2px 8px 4px; display: flex; justify-content: space-between; align-items: center; }
+ .openvibe-navbar-dropdown-recent .label a { color: var(--accent-light, #a78bfa); text-decoration: none; font-weight: 600; letter-spacing: 0; text-transform: none; }
+ .openvibe-navbar-dropdown-recent .item { display: flex; align-items: center; gap: 8px; padding: 6px 8px; border-radius: 6px; font-size: 12px; color: var(--text-secondary, #b0b0b8); text-decoration: none; transition: background .12s; min-width: 0; }
+ .openvibe-navbar-dropdown-recent .item:hover { background: var(--bg-hover, #2f2f3d); color: var(--text-primary, #e0e0e0); }
+ .openvibe-navbar-dropdown-recent .item .icon { width: 18px; text-align: center; color: var(--accent-light, #a78bfa); flex: none; }
+ .openvibe-navbar-dropdown-recent .item .t { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+ .openvibe-navbar-dropdown-recent .item .s { margin-left: auto; font-size: 10px; color: var(--text-muted, #707080); flex: none; }
+ .openvibe-navbar-dropdown-menu .sep { height: 1px; background: var(--border, #333340); margin: 4px -8px; }
.openvibe-navbar-links { display: flex; align-items: center; gap: 4px; margin-left: 8px; }
.openvibe-navbar-links { display: flex; align-items: center; gap: 4px; margin-left: 8px; }
.openvibe-navbar-links a {
.openvibe-navbar-links a {
@@ -350,6 +411,113 @@
@@ -350,6 +411,113 @@
'colors.openvibe.tools': { name: 'OpenVibeColor', icon: 'fa-palette' },
'colors.openvibe.tools': { name: 'OpenVibeColor', icon: 'fa-palette' },
};
};
+ // ─── Brand from hostname ───────────────────────────────────
+ // Every property is <sub?>.openvibe.<tld> (plus openre.stream). The navbar spells the
+ // whole name — Pastes.OpenVibe.Tools, not "Paste.OpenVibe" — because the subdomain and
+ // the TLD are what tell a visitor where they are in the network.
+ 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',
+ };
+ const SUB_LABELS = {
+ json: 'JSON', yaml: 'YAML', xml: 'XML', csv: 'CSV', sql: 'SQL', html: 'HTML', jwt: 'JWT',
+ uuid: 'UUID', guid: 'GUID', url: 'URL', b64: 'B64', sha256: 'SHA256', og: 'OG', md: 'MD',
+ yt: 'YT', ip: 'IP', myip: 'MyIP', ipv4: 'IPv4', ipv6: 'IPv6', geoip: 'GeoIP', asn: 'ASN',
+ rdns: 'rDNS', dns: 'DNS', mx: 'MX', txt: 'TXT', ns: 'NS', spf: 'SPF', dkim: 'DKIM',
+ dmarc: 'DMARC', mtr: 'MTR', ssl: 'SSL', tls: 'TLS', ptr: 'PTR', smtp: 'SMTP', http: 'HTTP',
+ httpstatus: 'HTTPStatus', rdap: 'RDAP', isp: 'ISP', pdf: 'PDF', mergepdf: 'MergePDF',
+ splitpdf: 'SplitPDF', compresspdf: 'CompressPDF', rotatepdf: 'RotatePDF',
+ reorderpdf: 'ReorderPDF', watermarkpdf: 'WatermarkPDF', protectpdf: 'ProtectPDF',
+ unlockpdf: 'UnlockPDF', image2pdf: 'Image2PDF', jpg2pdf: 'JPG2PDF', png2pdf: 'PNG2PDF',
+ pdf2jpg: 'PDF2JPG', pdf2png: 'PDF2PNG', png: 'PNG', jpg: 'JPG', jpeg: 'JPEG', webp: 'WebP',
+ avif: 'AVIF', heic: 'HEIC', heif: 'HEIF', svg: 'SVG', gif: 'GIF', ico: 'ICO', tiff: 'TIFF',
+ bmp: 'BMP', mp3: 'MP3', wav: 'WAV', flac: 'FLAC', ogg: 'OGG', m4a: 'M4A', aac: 'AAC',
+ opus: 'Opus', wma: 'WMA', aiff: 'AIFF', ac3: 'AC3', eq: 'EQ', equalizer: 'EQ', mxn: 'MXN',
+ ascii: 'ASCII', smallcaps: 'SmallCaps', titlecase: 'TitleCase', textlogo: 'TextLogo',
+ textart: 'TextArt', channelart: 'ChannelArt', lowerthird: 'LowerThird', copypaste: 'CopyPaste',
+ dnspropagation: 'DNSPropagation', opengraph: 'OpenGraph', whip: 'WHIP', ingest: 'Ingest',
+ play: 'Play', my: 'My', auth: 'Auth', api: 'API', admin: 'Admin', docs: 'Docs', dev: 'Dev',
+ net: 'Net', img: 'Img', pastes: 'Pastes', paste: 'Pastes', maps: 'Maps', food: 'Food',
+ text: 'Text', logo: 'Logo', audio: 'Audio', ai: 'AI', cdn: 'CDN', status: 'Status',
+ };
+ const SERVICE_TLD = { live: 'live', tools: 'tools', games: 'games', media: 'media', network: 'network', community: 'community' };
+ const SERVICE_SUB = { net: 'net', dev: 'dev', paste: 'pastes', maps: 'maps', food: 'food', img: 'img', yt: 'yt', audio: 'audio', text: 'text', logo: 'logo', docs: 'docs' };
+
+ function titleCase(w) { return w ? w.charAt(0).toUpperCase() + w.slice(1) : ''; }
+ function subLabel(sub) { return SUB_LABELS[sub] || titleCase(sub); }
+
+ /**
+ * { sub, core, tld, name, short, icon, variant } for the current page.
+ * sub 'Pastes' | null tld 'Tools' core 'OpenVibe'
+ * name 'Pastes.OpenVibe.Tools' short 'Pastes' (what compact mode keeps)
+ * variant the ov-mark flavour: the TLD id ('live', 'tools', …) — every site gets its own twist
+ */
+ function resolveBrand() {
+ const b = Object.assign({}, _config.brand || {});
+ const host = currentHost().toLowerCase();
+ let sub = null, tld = null, core = 'OpenVibe';
+ let m = host.match(/^(?:(.+)\.)?openvibe\.([a-z]+)$/);
+ if (m) { sub = m[1] && m[1] !== 'www' ? m[1] : null; tld = m[2]; }
+ else if ((m = host.match(/^(?:(.+)\.)?openre\.stream$/))) { sub = m[1] && m[1] !== 'www' ? m[1] : null; core = 'OpenRe'; tld = 'stream'; }
+ // Off-network hosts (localhost, previews): fall back to the service id.
+ if (!tld) { tld = SERVICE_TLD[_config.service] || (SERVICE_SUB[_config.service] ? 'tools' : 'network'); sub = SERVICE_SUB[_config.service] || null; }
+ // Legacy brandName ("Paste.OpenVibe", "OpenVibe.Live") still steers the segments.
+ if (_config.brandName && !b.sub && !b.tld) {
+ const parts = String(_config.brandName).split('.');
+ if (parts.length >= 2 && /^openvibe$/i.test(parts[0])) tld = parts[1].toLowerCase();
+ else if (parts.length >= 2 && /^openvibe$/i.test(parts[1])) sub = parts[0].toLowerCase();
+ else if (parts.length === 1) sub = parts[0].toLowerCase();
+ }
+ if (b.sub !== undefined) sub = b.sub ? String(b.sub).toLowerCase() : null;
+ if (b.tld) tld = String(b.tld).toLowerCase();
+ const subText = b.subLabel || (sub ? subLabel(sub) : null);
+ const tldText = b.tldLabel || TLD_LABELS[tld] || titleCase(tld);
+ const name = b.name || [subText, core, tldText].filter(Boolean).join('.');
+ const icon = b.icon || _config.brandIcon || null;
+ const variant = b.variant || (core === 'OpenRe' ? 'stream' : tld);
+ return { sub, subText, core, tld, tldText, name, short: subText || `${core}.${tldText}`, icon, variant, tag: b.tag || null, href: b.href || '/' };
+ }
+
+ function brandHTML(brand) {
+ const seg = (cls, text) => `<span class="${cls}">${escapeAttr(text)}</span>`;
+ const dot = '<span class="b-dot">.</span>';
+ const text = brand.subText
+ ? seg('b-sub', brand.subText) + dot + seg('b-core', brand.core) + dot + seg('b-tld', brand.tldText)
+ : seg('b-core', brand.core) + dot + seg('b-tld', brand.tldText);
+ const mark = brand.icon
+ ? `<i class="fa-solid ${escapeAttr(brand.icon)}"></i>`
+ : `<span class="ov-mark" data-size="28" data-variant="${escapeAttr(brand.variant)}"></span>`;
+ return `<a class="openvibe-navbar-brand${brand.subText ? ' has-sub' : ''}" href="${escapeAttr(brand.href)}" title="${escapeAttr(brand.name)}" aria-label="${escapeAttr(brand.name)}">
+ <span class="flame">${mark}</span>
+ <span class="name">${text}${brand.tag ? `<span class="b-tag">${escapeAttr(brand.tag)}</span>` : ''}</span>
+ </a>`;
+ }
+
+ function menuItemHTML(item) {
+ if (!item) return '';
+ if (item.sep) return '<div class="sep"></div>';
+ const icon = item.icon ? `<span class="icon"><i class="fa-solid ${escapeAttr(item.icon)}"></i></span>` : '<span class="icon"></span>';
+ const cls = item.danger ? ' class="danger"' : '';
+ const id = item.id ? ` data-menu-id="${escapeAttr(item.id)}"` : '';
+ if (item.href) return `<a href="${escapeAttr(item.href)}"${cls}${id}${item.external ? ' target="_blank" rel="noopener"' : ''}>${icon} ${escapeAttr(item.label)}</a>`;
+ return `<button type="button"${cls}${id}>${icon} ${escapeAttr(item.label)}</button>`;
+ }
+
+ function bindMenuItems(container, items) {
+ for (const item of items) {
+ if (!item || !item.id || typeof item.onClick !== 'function') continue;
+ container.querySelector(`[data-menu-id="${item.id}"]`)?.addEventListener('click', (e) => { if (!item.href) e.preventDefault(); item.onClick(e); });
+ }
+ }
+
+ function currentLinks() {
+ const list = _runtimeLinks || _config.links || SERVICE_LINKS[_config.service] || [];
+ const path = (typeof location !== 'undefined' && location.pathname) || '/';
+ return list.map((l) => Object.assign({}, l, { active: l.active !== undefined ? l.active : (l.href === path && path !== '/') }));
+ }
+
const SERVICE_LINKS = {
const SERVICE_LINKS = {
live: [
live: [
{ label: 'Watch', href: '/' },
{ label: 'Watch', href: '/' },
@@ -610,6 +778,45 @@
@@ -610,6 +778,45 @@
let _authInFlight = null;
let _authInFlight = null;
+ function ssoHint() {
+ const m = (typeof document !== 'undefined' ? document.cookie : '').match(/(?:^|;\s*)ov_sso_hint=([^;]*)/);
+ if (m) return m[1];
+ try { return localStorage.getItem('ov_sso_hint'); } catch { return null; }
+ }
+
+ /**
+ * One silent sign-in attempt per tab: only when this browser has signed in to the network
+ * before (the hint survives token expiry), never after an explicit sign-out ('guest'), and
+ * never for bots. The site's login route turns silent=1 into prompt=none and comes straight
+ * back on error=login_required, so a signed-out visitor sees one quick redirect at most.
+ */
+ function maybeSilentLogin() {
+ if (!_config.silentLogin || typeof location === 'undefined') return false;
+ if (ssoHint() !== 'account') return false;
+ if (/bot|crawl|spider|slurp|headless/i.test(navigator.userAgent || '')) return false;
+ try { if (sessionStorage.getItem('ov_silent_sso')) return false; sessionStorage.setItem('ov_silent_sso', '1'); } catch { return false; }
+ if (/[?&]sso=none\b/.test(location.search)) return false;
+ const url = String(_config.silentLogin).replace('{url}', encodeURIComponent(location.href));
+ location.replace(url);
+ return true;
+ }
+
+ function recordHistory() {
+ const h = _config.history;
+ if (!h || !_config.user || _config.user.is_anon) return;
+ const rec = Object.assign({ url: location.href, title: document.title, service: resolveBrand().tld }, h);
+ const H = root.OpenVibeHistory;
+ if (H && typeof H.record === 'function') { H.record(rec, { token: _config.token, apiBase: _config.apiBase }); return; }
+ if (!document.getElementById('ov-history-loader')) {
+ const sc = document.createElement('script'); sc.id = 'ov-history-loader'; sc.async = true;
+ sc.src = `${_config.apiBase}/shared/history.js`;
+ sc.onload = () => { try { root.OpenVibeHistory.record(rec, { token: _config.token, apiBase: _config.apiBase }); } catch { /* */ } };
+ document.head.appendChild(sc);
+ } else {
+ document.getElementById('ov-history-loader').addEventListener('load', () => { try { root.OpenVibeHistory.record(rec, { token: _config.token, apiBase: _config.apiBase }); } catch { /* */ } });
+ }
+ }
+
function refreshAuthState() {
function refreshAuthState() {
if (_authInFlight) return _authInFlight;
if (_authInFlight) return _authInFlight;
_authInFlight = resolveSessionUser()
_authInFlight = resolveSessionUser()
@@ -619,6 +826,9 @@
@@ -619,6 +826,9 @@
_config.user = session.user;
_config.user = session.user;
if (session.token) _config.token = session.token;
if (session.token) _config.token = session.token;
render();
render();
+ recordHistory();
+ } else {
+ maybeSilentLogin();
}
}
try {
try {
document.dispatchEvent(new CustomEvent('openvibe-navbar-auth', {
document.dispatchEvent(new CustomEvent('openvibe-navbar-auth', {
@@ -673,28 +883,37 @@
@@ -673,28 +883,37 @@
});
});
}
}
+ /**
+ * "Recently used" rows in the dropdown: the last few things this account touched anywhere
+ * on the network, from the shared history module (loaded lazily from the Network).
+ */
+ function renderRecent(el) {
+ if (!el || !_config.user || _config.user.is_anon) return;
+ const draw = (items) => {
+ if (!items || !items.length) { el.hidden = true; return; }
+ el.hidden = false;
+ el.innerHTML = `<div class="label"><span>Recently used</span><a href="https://openvibe.network/my#history">All history</a></div>` +
+ items.slice(0, 4).map(h => `<a class="item" href="${escapeAttr(h.url)}"><span class="icon"><i class="fa-solid ${escapeAttr(h.icon || 'fa-clock-rotate-left')}"></i></span><span class="t">${escapeAttr(h.title || h.url)}</span><span class="s">${escapeAttr(h.service_label || h.service || '')}</span></a>`).join('');
+ };
+ const H = root.OpenVibeHistory;
+ if (H && typeof H.recent === 'function') { H.recent({ limit: 4, token: _config.token, apiBase: _config.apiBase }).then(draw).catch(() => draw(null)); return; }
+ if (document.getElementById('ov-history-loader')) return;
+ const sc = document.createElement('script'); sc.id = 'ov-history-loader'; sc.async = true;
+ sc.src = `${_config.apiBase}/shared/history.js`;
+ sc.onload = () => { try { root.OpenVibeHistory.recent({ limit: 4, token: _config.token, apiBase: _config.apiBase }).then(draw).catch(() => draw(null)); } catch { /* */ } };
+ document.head.appendChild(sc);
+ }
+
function render() {
function render() {
if (_navEl) _navEl.remove();
if (_navEl) _navEl.remove();
const nav = document.createElement('nav');
const nav = document.createElement('nav');
nav.className = 'openvibe-navbar';
nav.className = 'openvibe-navbar';
const svc = _config.service;
const svc = _config.service;
- const links = SERVICE_LINKS[svc] || [];
-
- // Resolve brand name + icon: config override > subdomain lookup > service defaults
- const host = (typeof location !== 'undefined' && location.hostname) || '';
- // Exact match first (e.g., hostname.openvibe.tools), then fallback to service name
- let subBrand = SUBDOMAIN_BRANDS[host];
- if (!subBrand && host && host.includes('.openvibe.tools')) {
- // Try matching against service names
- const parts = host.split('.');
- const potential = parts[0]; // e.g., 'json' from 'json.openvibe.tools'
- subBrand = SUBDOMAIN_BRANDS[`${potential}.openvibe.tools`];
- }
- const svcName = _config.brandName || (subBrand && subBrand.name) || SERVICE_NAMES[svc] || 'OpenVibe';
- const svcIcon = _config.brandIcon || (subBrand && subBrand.icon) || SERVICE_ICONS[svc] || 'fa-circle-nodes';
- const brandHref = '/';
+ const brand = resolveBrand();
+ if (typeof nav.setAttribute === 'function') { nav.setAttribute('data-compact', _config.compact || 'auto'); nav.setAttribute('data-service', svc); }
+ const links = currentLinks();
const u = _config.user;
const u = _config.user;
const accounts = getAccounts();
const accounts = getAccounts();
@@ -702,20 +921,19 @@
@@ -702,20 +921,19 @@
const loginHref = resolveLoginHref(currentHost(), window.location.href);
const loginHref = resolveLoginHref(currentHost(), window.location.href);
const addAccountHref = `${_config.apiBase}/login?add_account=1&return=${encodeURIComponent(window.location.href)}`;
const addAccountHref = `${_config.apiBase}/login?add_account=1&return=${encodeURIComponent(window.location.href)}`;
+ // The OV brand mark is a self-contained drop-in (mounts every .ov-mark it finds).
+ if (!window.__ovMark && !document.getElementById('ov-mark-loader')) {
+ try { const sc = document.createElement('script'); sc.id = 'ov-mark-loader'; sc.src = 'https://openvibe.network/shared/ov-mark.js'; sc.async = true; document.head.appendChild(sc); } catch { /* */ }
+ }
nav.innerHTML = `
nav.innerHTML = `
- <a class="openvibe-navbar-brand" href="${brandHref}">
- <span class="flame"><i class="fa-solid ${svcIcon}"></i></span>
- <div>
- <div class="name">${svcName}</div>
- </div>
- </a>
+ ${brandHTML(brand)}
<div class="openvibe-navbar-links">
<div class="openvibe-navbar-links">
- ${links.map(l => `<a href="${l.href}">${l.label}</a>`).join('')}
+ ${links.map(l => `<a href="${escapeAttr(l.href)}"${l.active ? ' class="active"' : ''}${l.external ? ' target="_blank" rel="noopener"' : ''}>${l.icon ? `<i class="fa-solid ${escapeAttr(l.icon)} icon"></i>` : ''}${escapeAttr(l.label)}</a>`).join('')}
${u && u.role === 'admin' ? `<a href="https://openvibe.network/admin"><i class="fa-solid fa-shield-halved"></i> Admin</a>` : ''}
${u && u.role === 'admin' ? `<a href="https://openvibe.network/admin"><i class="fa-solid fa-shield-halved"></i> Admin</a>` : ''}
</div>
</div>
<div class="openvibe-navbar-spacer"></div>
<div class="openvibe-navbar-spacer"></div>
<div class="openvibe-navbar-right">
<div class="openvibe-navbar-right">
- <a class="openvibe-network-badge" href="https://openvibe.network" title="Connected to OpenVibe"><i class="fa-solid fa-circle-nodes"></i> OpenVibe</a>
+ <a class="openvibe-network-badge" href="https://openvibe.network" title="Connected to OpenVibe"><span class="ov-mark" data-size="16" data-static="1"></span> OpenVibe</a>
<div id="openvibe-bell-mount"></div>
<div id="openvibe-bell-mount"></div>
${u ? avatarImg(u, 64, 'openvibe-navbar-avatar', 'openvibe-avatar-btn') :
${u ? avatarImg(u, 64, 'openvibe-navbar-avatar', 'openvibe-avatar-btn') :
`<a class="openvibe-navbar-login" id="openvibe-login-btn" href="${escapeAttr(loginHref)}">Sign In</a>`}
`<a class="openvibe-navbar-login" id="openvibe-login-btn" href="${escapeAttr(loginHref)}">Sign In</a>`}
@@ -729,6 +947,8 @@
@@ -729,6 +947,8 @@
dropdown.id = 'openvibe-user-dropdown';
dropdown.id = 'openvibe-user-dropdown';
const otherAccounts = accounts.filter(a => isAnon ? !a.is_anon : String(a.id) !== String(u.id));
const otherAccounts = accounts.filter(a => isAnon ? !a.is_anon : String(a.id) !== String(u.id));
+ const before = ((_config.menu && _config.menu.before) || []).concat(_runtimeMenu.before);
+ const after = ((_config.menu && _config.menu.after) || []).concat(_runtimeMenu.after);
dropdown.innerHTML = `
dropdown.innerHTML = `
<div class="openvibe-navbar-dropdown-header">
<div class="openvibe-navbar-dropdown-header">
@@ -755,17 +975,23 @@
@@ -755,17 +975,23 @@
<span>Add another account</span>
<span>Add another account</span>
</a>
</a>
</div>
</div>
+ <div class="openvibe-navbar-dropdown-recent" id="openvibe-recent" hidden></div>
<div class="openvibe-navbar-dropdown-menu">
<div class="openvibe-navbar-dropdown-menu">
+ ${before.map(menuItemHTML).join('')}${before.length ? '<div class="sep"></div>' : ''}
<a href="https://openvibe.network/my"><span class="icon"><i class="fa-solid fa-user"></i></span> My Account</a>
<a href="https://openvibe.network/my"><span class="icon"><i class="fa-solid fa-user"></i></span> My Account</a>
- <a href="https://openvibe.network/my#notifications"><span class="icon"><i class="fa-solid fa-bell"></i></span> Notification Settings</a>
+ <a href="https://openvibe.network/my#history"><span class="icon"><i class="fa-solid fa-clock-rotate-left"></i></span> History</a>
+ <a href="https://openvibe.network/my#notifications"><span class="icon"><i class="fa-solid fa-bell"></i></span> Notifications</a>
<a href="https://openvibe.network/themes"><span class="icon"><i class="fa-solid fa-palette"></i></span> Themes</a>
<a href="https://openvibe.network/themes"><span class="icon"><i class="fa-solid fa-palette"></i></span> Themes</a>
<a href="https://openvibe.network/my#linked"><span class="icon"><i class="fa-solid fa-link"></i></span> Linked Services</a>
<a href="https://openvibe.network/my#linked"><span class="icon"><i class="fa-solid fa-link"></i></span> Linked Services</a>
${u.role === 'admin' ? `<a href="https://openvibe.network/admin"><span class="icon"><i class="fa-solid fa-screwdriver-wrench"></i></span> Admin Panel</a>` : ''}
${u.role === 'admin' ? `<a href="https://openvibe.network/admin"><span class="icon"><i class="fa-solid fa-screwdriver-wrench"></i></span> Admin Panel</a>` : ''}
- <div style="height:1px;background:var(--border,#333340);margin:4px -8px"></div>
+ ${after.length ? '<div class="sep"></div>' : ''}${after.map(menuItemHTML).join('')}
+ <div class="sep"></div>
<button id="openvibe-logout-btn" class="danger"><span class="icon"><i class="fa-solid fa-right-from-bracket"></i></span> Sign Out</button>
<button id="openvibe-logout-btn" class="danger"><span class="icon"><i class="fa-solid fa-right-from-bracket"></i></span> Sign Out</button>
</div>
</div>
`;
`;
nav.appendChild(dropdown);
nav.appendChild(dropdown);
+ bindMenuItems(dropdown, before.concat(after));
+ if (_config.recent !== false) renderRecent(dropdown.querySelector('#openvibe-recent'));
// Avatar click toggles dropdown
// Avatar click toggles dropdown
nav.querySelector('#openvibe-avatar-btn').addEventListener('click', () => {
nav.querySelector('#openvibe-avatar-btn').addEventListener('click', () => {
@@ -834,9 +1060,13 @@
@@ -834,9 +1060,13 @@
// the shared SSO state (ov_token cookie / localStorage / optional
// the shared SSO state (ov_token cookie / localStorage / optional
// sessionUrl) and the navbar re-renders when it arrives.
// sessionUrl) and the navbar re-renders when it arrives.
if (!_config.user) refreshAuthState();
if (!_config.user) refreshAuthState();
+ else recordHistory();
return el;
return el;
},
},
+ /** Record something the signed-in user did here ({type, title, url, icon}) in their network history. */
+ record(entry) { const h = _config.history; _config.history = Object.assign({}, h || {}, entry || {}); recordHistory(); _config.history = h; },
+
/** Re-resolve the signed-in user from the shared SSO state. */
/** Re-resolve the signed-in user from the shared SSO state. */
refreshAuth() { return refreshAuthState(); },
refreshAuth() { return refreshAuthState(); },
@@ -846,6 +1076,26 @@
@@ -846,6 +1076,26 @@
render();
render();
},
},
+ /** Replace this site's top links at runtime ([{label, href, icon?, active?, external?}]). */
+ setLinks(links) { _runtimeLinks = Array.isArray(links) ? links : null; if (_navEl) render(); },
+
+ /** Add a row to the account dropdown: {label, href|onClick, icon, danger, external, position:'before'|'after'}. */
+ addMenuItem(item) {
+ if (!item || !item.label) return;
+ const it = Object.assign({ id: item.id || `mi-${Math.random().toString(36).slice(2, 8)}` }, item);
+ (it.position === 'before' ? _runtimeMenu.before : _runtimeMenu.after).push(it);
+ if (_navEl && _config.user) render();
+ return it.id;
+ },
+
+ removeMenuItem(id) {
+ for (const k of ['before', 'after']) _runtimeMenu[k] = _runtimeMenu[k].filter(i => i.id !== id);
+ if (_navEl && _config.user) render();
+ },
+
+ /** The resolved brand for this page ({ sub, core, tld, name, short, variant }). */
+ brand() { return resolveBrand(); },
+
setToken(token) { _config.token = token; },
setToken(token) { _config.token = token; },
/** Get the bell mount point for OpenVibeNotifications. */
/** Get the bell mount point for OpenVibeNotifications. */
new file mode 100644
new file mode 100644
@@ -0,0 +1,83 @@
@@ -0,0 +1,83 @@
+/**
+ * ov-mark.js — the OpenVibe "OV" brand mark as a drop-in.
+ * <span class="ov-mark"></span> → 32px animated mark
+ * <span class="ov-mark" data-size="56"> → any size; add data-static="1" for no motion
+ * <span class="ov-mark" data-variant="live"> → the site's own twist on the motion (see VARIANTS)
+ * Self-contained: injects its own CSS once, works on any page (Live, Network, static pages).
+ * The O is a ring with a comet arc running around it, the V is drawn inside with a light
+ * sweep, and a dot orbits the ring. Transform/opacity/stroke motion only.
+ */
+(function () {
+ 'use strict';
+ if (window.__ovMark) return; window.__ovMark = true;
+ let n = 0;
+ const CSS = `
+.ov-mark{display:inline-grid;place-items:center;width:32px;height:32px;color:var(--accent,#8b5cf6);flex:none;vertical-align:middle;line-height:0}
+.ov-mark svg{width:100%;height:100%;overflow:visible;transform-origin:50% 50%;animation:ovmFloat 5s ease-in-out infinite}
+.ov-mark .g{fill:currentColor;opacity:.1;transform-box:fill-box;transform-origin:center;animation:ovmGlow 3.4s ease-in-out infinite}
+.ov-mark .r{fill:none;stroke:currentColor;stroke-width:4;opacity:.28}
+.ov-mark .c{fill:none;stroke-width:4;stroke-linecap:round;stroke-dasharray:34 79;transform-box:fill-box;transform-origin:center;animation:ovmComet 3.6s linear infinite}
+.ov-mark .v{fill:none;stroke-width:4.6;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:46;animation:ovmDraw 1.1s cubic-bezier(.2,.8,.2,1) both}
+.ov-mark .d{fill:var(--ovm-dot,#fff);transform-box:fill-box;transform-origin:center;animation:ovmDot 2.4s ease-in-out infinite}
+/* Per-site variants: one family, each site with its own motion so the network reads as one brand
+ with many rooms. Colours stay with the theme (currentColor); only the dot and the rhythm change. */
+.ov-mark[data-variant=live]{--ovm-dot:#ef4444}
+.ov-mark[data-variant=live] .d{animation:ovmOnAir 1.4s ease-in-out infinite}
+.ov-mark[data-variant=tools] .c{animation-duration:2.2s;stroke-dasharray:22 91}
+.ov-mark[data-variant=tools] .o2{display:none}
+.ov-mark[data-variant=media] .c{stroke-dasharray:14 14 14 71;animation-duration:4.4s}
+.ov-mark[data-variant=games] svg{animation:ovmBounce 1.6s cubic-bezier(.3,1.4,.4,1) infinite}
+.ov-mark[data-variant=games] .d{animation-duration:1.6s}
+.ov-mark[data-variant=community] .o2{opacity:.9;r:1.8}
+.ov-mark[data-variant=community] .c{animation-direction:reverse}
+.ov-mark[data-variant=network] .g{animation-duration:2.4s}
+.ov-mark[data-variant=chat] .d{animation:ovmDot 1s ease-in-out infinite}
+.ov-mark[data-variant=stream] .c{stroke-dasharray:60 53;animation-duration:2.8s}
+.ov-mark[data-variant=codes] .v{animation:ovmDraw 1.1s cubic-bezier(.2,.8,.2,1) infinite alternate}
+.ov-mark[data-variant=blog] .c,.ov-mark[data-variant=wiki] .c,.ov-mark[data-variant=news] .c{animation-duration:6s}
+.ov-mark[data-variant=vip]{--ovm-dot:#facc15}
+.ov-mark[data-variant=tips]{--ovm-dot:#22c55e}
+.ov-mark[data-variant=deals],.ov-mark[data-variant=coupons]{--ovm-dot:#f59e0b}
+.ov-mark[data-variant=trade] .c{animation-timing-function:cubic-bezier(.4,0,.2,1)}
+.ov-mark[data-variant=host] svg{animation:none}
+@keyframes ovmOnAir{0%,100%{transform:scale(1);opacity:.7}50%{transform:scale(1.7);opacity:1}}
+@keyframes ovmBounce{0%,100%{transform:translateY(0)}50%{transform:translateY(-2.5px)}}
+.ov-mark .o{fill:#fff;filter:drop-shadow(0 0 3px currentColor)}
+.ov-mark .o2{opacity:.5}
+.ov-mark:hover svg,a:hover>.ov-mark svg,button:hover>.ov-mark svg{animation:ovmSpin .9s cubic-bezier(.2,1.5,.3,1) 1}
+.ov-mark:hover .c{animation-duration:.9s}
+.ov-mark[data-static] svg,.ov-mark[data-static] .g,.ov-mark[data-static] .c,.ov-mark[data-static] .v,.ov-mark[data-static] .d{animation:none!important}
+.ov-mark[data-static] .o{display:none}
+@keyframes ovmFloat{0%,100%{transform:translateY(0) rotate(0deg)}50%{transform:translateY(-1px) rotate(2deg)}}
+@keyframes ovmGlow{0%,100%{opacity:.08;transform:scale(.92)}50%{opacity:.2;transform:scale(1.06)}}
+@keyframes ovmComet{to{transform:rotate(360deg)}}
+@keyframes ovmDraw{from{stroke-dashoffset:46}to{stroke-dashoffset:0}}
+@keyframes ovmDot{0%,100%{transform:scale(1);opacity:.85}50%{transform:scale(1.5);opacity:1}}
+@keyframes ovmSpin{from{transform:rotate(0deg) scale(1)}40%{transform:rotate(200deg) scale(1.18)}to{transform:rotate(360deg) scale(1)}}
+@media (prefers-reduced-motion:reduce){.ov-mark svg,.ov-mark .g,.ov-mark .c,.ov-mark .v,.ov-mark .d{animation:none!important}.ov-mark .o{display:none}.ov-mark .c{stroke-dasharray:none;opacity:.9}}`;
+ function svg(id) {
+ return `<svg viewBox="0 0 48 48" aria-hidden="true"><defs>
+<linearGradient id="${id}g" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="currentColor"/><stop offset="1" stop-color="currentColor" stop-opacity="0.45"/></linearGradient>
+<linearGradient id="${id}v" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#fff"/><stop offset="0.5" stop-color="currentColor"/><stop offset="1" stop-color="#fff" stop-opacity="0.9"/><animateTransform attributeName="gradientTransform" type="translate" from="-1 0" to="1 0" dur="3.2s" repeatCount="indefinite"/></linearGradient>
+<path id="${id}p" d="M24,6 A18,18 0 1,1 23.99,6 Z"/></defs>
+<circle class="g" cx="24" cy="24" r="21"/><circle class="r" cx="24" cy="24" r="18"/><circle class="c" cx="24" cy="24" r="18" stroke="url(#${id}g)"/>
+<path class="v" d="M14.5,17 L24,34 L33.5,17" stroke="url(#${id}v)"/><circle class="d" cx="24" cy="34" r="2.6"/>
+<circle class="o" r="2"><animateMotion dur="3.6s" repeatCount="indefinite"><mpath href="#${id}p"/></animateMotion></circle>
+<circle class="o o2" r="1.4"><animateMotion dur="3.6s" begin="0.14s" repeatCount="indefinite"><mpath href="#${id}p"/></animateMotion></circle></svg>`;
+ }
+ function mount(root) {
+ (root || document).querySelectorAll('.ov-mark:not([data-ov])').forEach(el => {
+ el.setAttribute('data-ov', '1');
+ const size = parseInt(el.getAttribute('data-size'), 10);
+ if (size) { el.style.width = size + 'px'; el.style.height = size + 'px'; }
+ el.innerHTML = svg('ovm' + (++n) + '_');
+ });
+ }
+ function init() {
+ if (!document.getElementById('ov-mark-css')) { const st = document.createElement('style'); st.id = 'ov-mark-css'; st.textContent = CSS; document.head.appendChild(st); }
+ mount();
+ try { new MutationObserver(() => mount()).observe(document.body, { childList: true, subtree: true }); } catch { /* */ }
+ }
+ window.ovMarkMount = mount;
+ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init); else init();
+})();
new file mode 100644
new file mode 100644
@@ -0,0 +1,51 @@
@@ -0,0 +1,51 @@
+#!/usr/bin/env node
+'use strict';
+/**
+ * Regenerates the built-in theme map inside theme-loader.js from builtin-themes.js.
+ *
+ * theme-loader.js runs in the browser before the first paint and cannot require() the catalog, so
+ * it carries a copy. That copy used to be maintained by hand and drifted. Now it is generated:
+ *
+ * node packages/openvibe-shared/scripts/build-theme-loader.js # rewrite
+ * node packages/openvibe-shared/scripts/build-theme-loader.js --check # exit 1 if stale
+ *
+ * Every entry carries the FULL variable set (the theme's own values over the default theme's), so
+ * switching themes replaces every token — a theme that does not name --success no longer keeps the
+ * previous theme's --success on <html>.
+ */
+const fs = require('fs');
+const path = require('path');
+const { BUILTIN_THEMES, DEFAULT_VARS } = require('../builtin-themes');
+
+const LOADER = path.join(__dirname, '..', 'theme-loader.js');
+const BEGIN = ' // THEMES:BEGIN — generated by scripts/build-theme-loader.js from builtin-themes.js; do not edit by hand';
+const END = ' // THEMES:END';
+
+function render() {
+ const lines = BUILTIN_THEMES.map((t) => {
+ const vars = { ...DEFAULT_VARS, ...t.variables };
+ const body = Object.entries(vars).map(([k, v]) => `'${k}':'${String(v).replace(/'/g, "\\'")}'`).join(',');
+ return ` '${t.id}': { ${body} },`;
+ });
+ return `${BEGIN}\n const THEMES = {\n${lines.join('\n')}\n };\n${END}`;
+}
+
+function currentBlock(src) {
+ const a = src.indexOf(BEGIN);
+ const b = src.indexOf(END);
+ if (a >= 0 && b > a) return { a, b: b + END.length };
+ // First run: the hand-written block.
+ const start = src.indexOf(' const THEMES = {');
+ if (start < 0) throw new Error('theme-loader.js: THEMES block not found');
+ const close = src.indexOf('\n };', start);
+ return { a: start, b: close + '\n };'.length };
+}
+
+const src = fs.readFileSync(LOADER, 'utf8');
+const { a, b } = currentBlock(src);
+const next = src.slice(0, a) + render() + src.slice(b);
+const check = process.argv.includes('--check');
+if (next === src) { if (!check) console.log('theme-loader.js is up to date'); process.exit(0); }
+if (check) { console.error('theme-loader.js is stale: run node packages/openvibe-shared/scripts/build-theme-loader.js'); process.exit(1); }
+fs.writeFileSync(LOADER, next);
+console.log(`theme-loader.js regenerated (${BUILTIN_THEMES.length} themes)`);