openvibers/openvibe.live Commits
Live runs on the network's shared navbar: its own 10 KB navbar markup is gone, replaced by a config adapter (js/ov-navbar-live.js)
Changes
@@@ -11875,3 +11875,21 @@ a .arena-thread:hover { color: var(--text-primary); background: color-mix(in srg}body.ov-panel-open #floating-chat-fab { opacity: 0; pointer-events: none; transform: scale(.85); transition: opacity .15s, transform .15s; }++++/* ── Live on the shared navbar ────────────────────────────────────────────────++ The bar is the network's shared component (/shared/navbar.js). It carries the class "navbar" as well,++ so Live's fixed positioning, glass background and the transparent hero-top state keep applying; these++ rules only settle the places where the component's own defaults would otherwise win. */++.openvibe-navbar.navbar {++ position: fixed; top: 0; left: 0; right: 0; z-index: 1000;++ height: var(--navbar-h); padding: 0 clamp(10px, 2vw, 16px); gap: clamp(6px, 1.2vw, 14px);++ background: var(--glass); border-bottom: 1px solid rgba(255,255,255,0.06);++ backdrop-filter: blur(18px); -webkit-backdrop-filter: blur(18px);++ --ovnav-h: var(--navbar-h);++}++.openvibe-navbar.navbar.nav-hero-top { background: transparent; border-bottom-color: transparent; box-shadow: none; backdrop-filter: none; -webkit-backdrop-filter: none; }++.openvibe-navbar.navbar .openvibe-navbar-links a.ovnav-link { font-size: 0.86rem; padding: 7px 11px; border-radius: 10px; }++.openvibe-navbar.navbar .openvibe-navbar-links a.ovnav-link.active { color: var(--text-primary); background: color-mix(in srgb, var(--accent) 16%, transparent); }++.openvibe-navbar.navbar .openvibe-navbar-dropdown { position: fixed; top: calc(var(--navbar-h) + 6px); right: 8px; }++.openvibe-navbar.navbar .openvibe-navbar-avatar { width: 36px; height: 36px; border-radius: 50%; cursor: pointer; object-fit: cover; box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 50%, transparent); }++@media (max-width: 560px) { .openvibe-navbar.navbar .ovnav-chip { padding: 0 8px; } .openvibe-navbar.navbar .ovnav-launch { display: none; } }@@@ -166,137 +166,8 @@<!-- ═══════════════════════════════════════════════════════════ --><!-- NAVBAR --><!-- ═══════════════════════════════════════════════════════════ -->--<nav class="navbar">-- <a class="nav-brand" href="/" onclick="return handleLinkClick(event, '/')" data-brand-tooltip="This platform is in early alpha — features may be incomplete, buggy, or change without notice. Data will likely be reset multiple times during the alpha phase. Thanks for testing!">-- <span class="brand-mark" aria-hidden="true"><svg viewBox="0 0 48 48" width="30" height="30">-- <defs>-- <linearGradient id="ovn-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="ovn-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="ovn-ring" d="M24,6 A18,18 0 1,1 23.99,6 Z"/>-- </defs>-- <circle class="ov-glow" cx="24" cy="24" r="21"/>-- <circle class="ov-ring" cx="24" cy="24" r="18"/>-- <circle class="ov-comet" cx="24" cy="24" r="18" stroke="url(#ovn-g)"/>-- <path class="ov-v" d="M14.5,17 L24,34 L33.5,17" stroke="url(#ovn-v)"/>-- <circle class="ov-dot" cx="24" cy="34" r="2.6"/>-- <circle class="ov-orbit" r="2"><animateMotion dur="3.6s" repeatCount="indefinite" rotate="auto"><mpath href="#ovn-ring"/></animateMotion></circle>-- <circle class="ov-orbit ov-orbit--tail" r="1.4"><animateMotion dur="3.6s" begin="0.14s" repeatCount="indefinite"><mpath href="#ovn-ring"/></animateMotion></circle>-- </svg></span>-- <span class="brand-text-wrap">-- <span class="brand-text"><span class="brand-openvibe">OpenVibe</span><strong class="brand-streamer">.Live</strong></span>-- </span>-- </a>-- <div class="nav-links-wrap">-- <button class="nav-scroll-btn nav-scroll-left" id="nav-scroll-left" onclick="scrollNavLinks(-1)" title="Scroll left"><i class="fa-solid fa-chevron-left"></i></button>-- <div class="nav-links">-- <a href="/" onclick="return handleLinkClick(event, '/')" class="nav-link active" data-page="home" data-tooltip="Home">-- <i class="fa-solid fa-house"></i> <span>Home</span>-- </a>-- <a href="/vods" onclick="return handleLinkClick(event, '/vods')" class="nav-link" data-page="vods" data-tooltip="VODs">-- <i class="fa-solid fa-video"></i> <span>VODs</span>-- </a>-- <a href="/clips" onclick="return handleLinkClick(event, '/clips')" class="nav-link" data-page="clips" data-tooltip="Clips">-- <i class="fa-solid fa-scissors"></i> <span>Clips</span>-- </a>-- <a href="/pastes" onclick="return handleLinkClick(event, '/pastes')" class="nav-link" data-page="pastes" data-tooltip="Pastes">-- <i class="fa-solid fa-paste"></i> <span>Pastes</span>-- </a>-- <a href="/chat" onclick="return handleLinkClick(event, '/chat')" class="nav-link" data-page="chat" data-tooltip="Chat">-- <i class="fa-solid fa-comments"></i> <span>Chat</span>-- </a>-- <a href="/arena" onclick="return handleLinkClick(event, '/arena')" class="nav-link" data-page="arena" data-tooltip="Arena">-- <i class="fa-solid fa-hand-fist"></i> <span>Arena</span>-- </a>-- <a href="https://openvibe.games" class="nav-link" data-page="game" data-tooltip="Game" target="_blank" rel="noopener">-- <i class="fa-solid fa-gamepad"></i> <span>Game</span>-- </a>-- <div class="nav-dropdown" id="nav-golive-dropdown">-- <a href="/broadcast" onclick="return goLiveNav(event)" class="nav-link" data-page="broadcast" id="nav-broadcast" data-tooltip="Go Live">-- <i class="fa-solid fa-tower-broadcast"></i> <span>Go Live</span><span class="nav-live-dot" id="nav-live-dot" style="display:none"><i class="fa-solid fa-circle"></i></span> <i class="fa-solid fa-caret-down nav-dd-caret"></i>-- </a>-- <div class="nav-dropdown-menu" id="nav-golive-menu">-- <a href="/dashboard" id="nav-golive-dash" onclick="return dashNav(event)"><i class="fa-solid fa-gauge-high"></i> Dashboard</a>-- </div>-- </div>-- <a href="https://openvibe.network/admin" class="nav-link" data-page="admin" id="nav-admin" style="display:none" data-tooltip="Admin" rel="noopener noreferrer">-- <i class="fa-solid fa-shield-halved"></i> <span>Admin</span>-- </a>-- </div>-- <button class="nav-scroll-btn nav-scroll-right" id="nav-scroll-right" onclick="scrollNavLinks(1)" title="Scroll right"><i class="fa-solid fa-chevron-right"></i></button>-- </div>-- <div class="nav-right">-- <div id="nav-auth-anon">-- <a href="/api/auth/sso/login" class="btn btn-primary" style="display:inline-flex;align-items:center;gap:6px;text-decoration:none">-- <i class="fa-solid fa-network-wired"></i> Sign In-- </a>-- </div>-- <div id="nav-auth-user" style="display:none">-- <span class="nav-coins" id="nav-coins" title="OpenCoins — click to open inventory" onclick="openCosmeticsModal()">-- <i class="fa-solid fa-coins"></i> <span id="nav-coins-amount">0</span>-- </span>-- <span class="nav-balance" id="nav-balance" title="Vibes balance" onclick="showModal('buy-funds')">-- <i class="fa-solid fa-wallet"></i> <span id="nav-balance-amount">0</span>-- </span>-- <span id="openvibe-bell-mount" style="display:inline-flex;align-items:center"></span>-- <div class="nav-avatar-wrap" onclick="toggleUserMenu()">-- <div class="nav-avatar" id="nav-avatar">?</div>-- <span id="nav-username"></span>-- <i class="fa-solid fa-caret-down"></i>-- </div>-- <!-- The account menu. Deliberately not shaped like the nav menu next to it: that one is-- a list of places, this one is about who you are signed in as, so it opens with your-- identity and what you are carrying, and groups the rest under labels. -->-- <div class="user-dropdown" id="user-dropdown">-- <div class="ud-head">-- <div class="ud-avatar" id="ud-avatar" aria-hidden="true">?</div>-- <div class="ud-who">-- <b id="ud-name"> </b>-- <small id="ud-handle"></small>-- </div>-- <div class="ud-wallet">-- <span class="ud-coin" title="OpenCoins"><i class="fa-solid fa-coins"></i><b id="ud-coins">0</b></span>-- <span class="ud-vibe" title="Vibes"><i class="fa-solid fa-wallet"></i><b id="ud-vibes">0</b></span>-- </div>-- </div>-- <div class="ud-group">-- <span class="ud-label">You</span>-- <a href="/dashboard" onclick="return handleLinkClick(event, '/dashboard')"><i class="fa-solid fa-gauge-high"></i> My Dashboard</a>-- <a href="#" onclick="return handleLinkClick(event, channelPath(currentUser?.username))"><i class="fa-solid fa-user"></i> My Channel</a>-- <a href="#" onclick="event.preventDefault();openSetupHub()"><i class="fa-solid fa-list-check"></i> Streamer setup</a>-- <a href="https://openvibe.network/admin" target="_blank" rel="noopener" id="user-dropdown-admin" style="display:none"><i class="fa-solid fa-shield-halved"></i> Admin Panel</a>-- </div>-- <div class="ud-group" id="ud-display">-- <span class="ud-label">Display</span>-- <a href="#" data-ov-display="text"><i class="fa-solid fa-text-height"></i> Text size <b data-v style="margin-left:auto;font-weight:600;opacity:.8"></b></a>-- <a href="#" data-ov-display="motion"><i class="fa-solid fa-wand-magic-sparkles"></i> Animations <b data-v style="margin-left:auto;font-weight:600;opacity:.8"></b></a>-- </div>-- <div class="ud-group">-- <span class="ud-label">Across OpenVibe</span>-- <a href="https://openvibe.network"><i class="fa-solid fa-network-wired"></i> OpenVibe Network</a>-- <a href="https://openvibe.tools"><i class="fa-solid fa-screwdriver-wrench"></i> Tools</a>-- <a href="https://openvibe.community"><i class="fa-solid fa-people-group"></i> Community</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="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>-- <a href="#" onclick="event.preventDefault();captureScreenshot()"><i class="fa-solid fa-camera"></i> Screenshot</a>-- <a href="/documentation" onclick="return handleLinkClick(event, '/documentation')"><i class="fa-solid fa-book"></i> Documentation</a>-- </div>-- <div class="ud-group ud-group--exit">-- <a href="#" onclick="event.preventDefault();switchAccount()"><i class="fa-solid fa-people-arrows"></i> Switch account</a>-- <a href="#" onclick="event.preventDefault();browseAsGuest()"><i class="fa-solid fa-user-secret"></i> Browse as guest</a>-- <a class="ud-logout" href="#" onclick="event.preventDefault();logout()"><i class="fa-solid fa-right-from-bracket"></i> Logout</a>-- </div>-- </div>-- </div>-- </div>-- <button class="nav-hamburger" onclick="toggleMobileNav()">-- <i class="fa-solid fa-bars"></i>-- </button>--</nav>++<!-- The navbar is the network's shared component (/shared/navbar.js), configured for Live in js/ov-navbar-live.js. -->++<div id="navbar-mount"></div><!-- ═══════════════════════════════════════════════════════════ --><!-- MAIN CONTENT AREA (SPA pages rendered here) -->@@@ -1149,7 +1020,9 @@rest when someone navigates. The asset manifest below carries content hashes and the registry. --><!--ov:asset-manifest--><script src="/js/ov-loader.js" defer></script>++<script src="/shared/navbar.js" defer></script><script src="/js/app.js" defer></script>++<script src="/js/ov-navbar-live.js" defer></script><script src="/js/chat-shared.js" defer></script><script src="/js/chat.js" defer></script><!-- (js/admin.js removed — 130KB of unreachable code; /admin redirects off-site.) -->@@@ -1239,7 +1112,8 @@ document.addEventListener('DOMContentLoaded', function initOpenVibeNetwork() {soundBase: '/assets/sounds',swPath: '/openvibe-sw.js',});-- mount.appendChild(OpenVibeNotifications.createBell());++ OpenVibeNotifications.__ovNavInit = true; // the shared navbar mounts (and re-mounts) the bell itself++ if (!mount.childElementCount) mount.appendChild(OpenVibeNotifications.createBell());}});</script>@@@ -745,6 +745,8 @@ try { if (new URLSearchParams(location.search).get('sso') === 'none') { _setSsoHfunction onAuthChange() {if (currentUser) _enableHandoff(); else if (window.OpenVibeSSO) { try { window.OpenVibeSSO.handoffLinks({ signedIn: false }); } catch { /* */ } }++ // The navbar is the network's shared component; tell it who is here (js/ov-navbar-live.js).++ if (window.LiveNav) window.LiveNav.sync(currentUser || null);const anon = document.getElementById('nav-auth-anon');const user = document.getElementById('nav-auth-user');const admin = document.getElementById('nav-admin');@@@ -808,6 +810,7 @@ async function loadBalance() {const bal = Math.round(data.balance || 0);const balEl = document.getElementById('nav-balance-amount');if (balEl) balEl.textContent = bal.toLocaleString();++ if (window.LiveNav) window.LiveNav.setChip('vibes', bal.toLocaleString());const udV = document.getElementById('ud-vibes');if (udV) udV.textContent = bal.toLocaleString();} catch { /* silent */ }@@@ -817,6 +820,7 @@ async function loadBalance() {const coins = coinData.balance || 0;const coinEl = document.getElementById('nav-coins-amount');if (coinEl) coinEl.textContent = coins.toLocaleString();++ if (window.LiveNav) window.LiveNav.setChip('coins', coins.toLocaleString());const udC = document.getElementById('ud-coins');if (udC) udC.textContent = coins.toLocaleString();} catch { /* silent */ }@@@ -1396,6 +1400,7 @@ function showPage(page) {const link = document.querySelector(`.nav-link[data-page="${navPage}"]`);if (link) link.classList.add('active');}++ if (window.LiveNav) window.LiveNav.setActive(navPage || null);updateNavHeroTransparency();}@@@ -2326,6 +2326,7 @@ function cleanupAllStreams() {}function setNavLiveIndicator(isLive) {++ if (window.LiveNav) window.LiveNav.setLive(!!isLive);const dot = document.getElementById('nav-live-dot');if (dot) dot.style.display = isLive ? '' : 'none';const link = document.getElementById('nav-broadcast');@@@ -221,6 +221,7 @@ function handleCoinEarned(msg) {if (msg.currency === 'gold') {const navCoins = document.getElementById('nav-coins-amount');if (navCoins) navCoins.textContent = msg.total.toLocaleString();++ if (window.LiveNav) window.LiveNav.setChip('coins', msg.total.toLocaleString());return;}// Otherwise it's CHANNEL POINTS for a streamer → update the in-chat balance,nnew file mode 100644@@@ -0,0 +1,103 @@++/* OpenVibe.Live on the network's shared navbar (vendor/openvibe-shared/navbar.js, served at /shared/navbar.js).++ *++ * Live used to carry its own navbar: 130 lines of markup, its own user menu, its own mobile drawer. It now++ * mounts the same component every other OpenVibe site uses and describes what is specific to Live as data:++ * its pages, the Go Live dropdown, the two balances, and the Live-only rows of the user menu.++ * Live signs people in itself, so the navbar runs with auth: 'external' and is told who is here (LiveNav.sync).++ *++ * LiveNav.sync(user) after sign-in, sign-out or a profile change++ * LiveNav.setActive(page) the router calls this on every page change++ * LiveNav.setChip(id, text) 'coins' | 'vibes'++ * LiveNav.setLive(isLive) the red dot on "Go Live"++ */++(function () {++ 'use strict';++ const call = (name, ...a) => (typeof window[name] === 'function' ? window[name](...a) : undefined);++ const nav = () => window.OpenVibeNavbar;++ let booted = false;++++ function links(user) {++ const staff = !!(user && ((user.capabilities && user.capabilities.admin_panel) || (typeof hasCapability === 'function' && hasCapability('can_access_staff_console'))));++ return [++ { id: 'home', page: 'home', label: 'Home', href: '/', icon: 'fa-house' },++ { id: 'vods', page: 'vods', label: 'VODs', href: '/vods', icon: 'fa-video' },++ { id: 'clips', page: 'clips', label: 'Clips', href: '/clips', icon: 'fa-scissors' },++ { id: 'pastes', page: 'pastes', label: 'Pastes', href: '/pastes', icon: 'fa-paste' },++ { id: 'chat', page: 'chat', label: 'Chat', href: '/chat', icon: 'fa-comments' },++ { id: 'arena', page: 'arena', label: 'Arena', href: '/arena', icon: 'fa-hand-fist' },++ { id: 'game', page: 'game', label: 'Game', href: 'https://openvibe.games', icon: 'fa-gamepad' },++ { id: 'broadcast', page: 'broadcast', elId: 'nav-broadcast', label: 'Go Live', href: '/broadcast', icon: 'fa-tower-broadcast', dot: false, dotId: 'nav-live-dot',++ onClick: (e) => call('goLiveNav', e),++ children: user ? [{ id: 'dashboard', label: 'Dashboard', href: '/dashboard', icon: 'fa-gauge-high', onClick: (e) => call('dashNav', e) }] : [] },++ { id: 'admin', page: 'admin', label: 'Admin', href: 'https://openvibe.network/admin', icon: 'fa-shield-halved', hidden: !staff },++ ];++ }++++ function menu(user) {++ const isAdmin = !!(user && (user.role === 'admin' || (user.capabilities && user.capabilities.is_owner)));++ const act = (fn, ...a) => (e) => { if (e && e.preventDefault) e.preventDefault(); call(fn, ...a); return false; };++ return {++ label: 'Live', defaults: false,++ headerChips: [++ { id: 'coins', icon: 'fa-coins', tone: 'gold', value: '0', title: 'OpenCoins', onClick: () => call('openCosmeticsModal') },++ { id: 'vibes', icon: 'fa-wallet', tone: 'green', value: '0', title: 'Vibes balance', onClick: () => call('showModal', 'buy-funds') },++ ],++ sections: [++ { id: 'you', label: 'You', items: [++ { id: 'dash', label: 'My Dashboard', icon: 'fa-gauge-high', href: '/dashboard' },++ { id: 'channel', label: 'My Channel', icon: 'fa-user', href: user ? (typeof channelPath === 'function' ? channelPath(user.username) : '/@' + user.username) : '#' },++ { id: 'setup', label: 'Streamer setup', icon: 'fa-list-check', onClick: act('openSetupHub') },++ { id: 'account', label: 'My Account', icon: 'fa-id-card', href: 'https://openvibe.network/my' },++ { id: 'adminpanel', label: 'Admin Panel', icon: 'fa-shield-halved', href: 'https://openvibe.network/admin', hidden: !isAdmin },++ ] },++ { id: 'live', label: 'On Live', items: [++ { id: 'support', label: 'Support Us', icon: 'fa-coins', onClick: act('showModal', 'buy-funds') },++ { id: 'cosmetics', label: 'Cosmetics', icon: 'fa-shirt', onClick: act('openCosmeticsModal') },++ { id: 'shot', label: 'Screenshot', icon: 'fa-camera', onClick: act('captureScreenshot') },++ { id: 'docs', label: 'Documentation', icon: 'fa-book', href: '/documentation' },++ ] },++ ],++ after: [++ { id: 'history', label: 'History', icon: 'fa-clock-rotate-left', href: 'https://openvibe.network/my#history' },++ { id: 'switch', label: 'Switch account', icon: 'fa-people-arrows', onClick: act('switchAccount') },++ { id: 'guest', label: 'Browse as guest', icon: 'fa-user-secret', onClick: act('browseAsGuest') },++ ],++ };++ }++++ function config(user) {++ return {++ service: 'live', mount: '#navbar-mount', className: 'navbar', auth: 'external', apiBase: 'https://openvibe.network',++ user: user || null, token: (function () { try { return localStorage.getItem('token'); } catch (e) { return null; } })(),++ loginUrl: '/api/auth/sso/login', accounts: false, networkLinks: 0, history: false, silentLogin: false, fedcm: false, recent: false,++ links: links(user), menu: menu(user),++ chips: [++ { id: 'coins', icon: 'fa-coins', tone: 'gold', value: '0', valueId: 'nav-coins-amount', title: 'OpenCoins: click to open your inventory', onClick: () => call('openCosmeticsModal') },++ { id: 'vibes', icon: 'fa-wallet', tone: 'green', value: '0', valueId: 'nav-balance-amount', title: 'Vibes balance', onClick: () => call('showModal', 'buy-funds') },++ ],++ // Same-origin links are routes of this single-page app.++ onNavigate: (href, e) => (typeof handleLinkClick === 'function' ? handleLinkClick(e, href) : true),++ onLogout: () => call('logout'),++ };++ }++++ let last = { coins: '0', vibes: '0', live: false, page: null };++ function sync(user) {++ const N = nav(); if (!N) return;++ booted = true; N.init(config(user)); // init() merges the new config and re-renders++ N.setChip('coins', last.coins); N.setChip('vibes', last.vibes);++ if (last.page) N.setActive(last.page);++ if (last.live) N.updateLink('broadcast', { dot: true });++ }++++ window.LiveNav = {++ sync,++ setActive(page) { last.page = page; const N = nav(); if (N && booted) N.setActive(page); },++ setChip(id, text) { last[id] = String(text); const N = nav(); if (N && booted) N.setChip(id, String(text)); },++ setLive(isLive) { last.live = !!isLive; const N = nav(); if (N && booted) N.updateLink('broadcast', { dot: !!isLive, label: isLive ? 'LIVE' : 'Go Live' }); },++ };++++ // First paint: the bar appears signed out immediately; app.js calls sync() again when it knows who is here.++ const start = () => sync(typeof currentUser !== 'undefined' ? currentUser : null);++ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', start); else start();++})();@@@ -84,6 +84,41 @@.openvibe-navbar .ovnav-sep { width: 1px; height: 18px; background: var(--border, rgba(255,255,255,.12)); margin: 0 6px; flex: none; align-self: center; }@media (max-width: 1180px) { .openvibe-navbar .ovnav-net, .openvibe-navbar .ovnav-sep { display: none; } }.openvibe-navbar .ovnav-ic, .openvibe-navbar-dropdown .ovnav-ic { flex: none; font-size: 1.05em; vertical-align: -.15em; }++ .openvibe-navbar-links { min-width: 0; overflow-x: auto; scrollbar-width: none; }++ .openvibe-navbar-links::-webkit-scrollbar { display: none; }++ .openvibe-navbar-links a.ovnav-link { display: inline-flex; align-items: center; gap: 6px; white-space: nowrap; flex: none; }++ .openvibe-navbar .ovnav-link[hidden], .openvibe-navbar .ovnav-dd[hidden], .openvibe-navbar-dropdown [hidden] { display: none !important; }++ .openvibe-navbar .ovnav-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--danger, #ef4444); box-shadow: 0 0 0 0 rgba(239,68,68,.6); animation: ovnavPulse 1.8s infinite; }++ .openvibe-navbar .ovnav-dot[hidden] { display: none; }++ @keyframes ovnavPulse { 70% { box-shadow: 0 0 0 6px rgba(239,68,68,0); } 100% { box-shadow: 0 0 0 0 rgba(239,68,68,0); } }++ .openvibe-navbar .ovnav-caret { font-size: 10px; opacity: .6; }++ .openvibe-navbar .ovnav-dd { position: relative; display: inline-flex; flex: none; }++ .openvibe-navbar .ovnav-dd-menu { display: none; position: absolute; top: 100%; left: 0; min-width: 180px; padding: 5px; border-radius: 12px; z-index: 1002; background: var(--bg-elevated, var(--bg-secondary, #111826)); border: 1px solid var(--border, rgba(255,255,255,.12)); box-shadow: 0 18px 44px rgba(0,0,0,.45); flex-direction: column; gap: 2px; }++ .openvibe-navbar .ovnav-dd:hover .ovnav-dd-menu, .openvibe-navbar .ovnav-dd.open .ovnav-dd-menu, .openvibe-navbar .ovnav-dd:focus-within .ovnav-dd-menu { display: flex; }++ .openvibe-navbar .ovnav-chip { all: unset; box-sizing: border-box; cursor: pointer; display: inline-flex; align-items: center; gap: 6px; height: 32px; padding: 0 11px; border-radius: 999px; font: 700 13px/1 inherit; color: var(--chip-c, var(--text-primary, #e6edf7)); background: color-mix(in srgb, var(--chip-c, var(--accent, #3b82f6)) 12%, transparent); border: 1px solid color-mix(in srgb, var(--chip-c, var(--accent, #3b82f6)) 38%, transparent); font-variant-numeric: tabular-nums; }++ .ovnav-chip[data-tone="gold"] { --chip-c: #fbbf24; } .ovnav-chip[data-tone="green"] { --chip-c: #4ade80; } .ovnav-chip[hidden] { display: none !important; }++ .openvibe-navbar-dropdown .ud-chips { display: flex; gap: 6px; flex-wrap: nowrap; margin-top: 6px; }++ .openvibe-navbar-dropdown-header .info { min-width: 0; flex: 1; }++ .openvibe-navbar-dropdown .ovnav-chip--menu { all: unset; box-sizing: border-box; cursor: pointer; display: inline-flex; align-items: center; gap: 5px; padding: 3px 10px; border-radius: 999px; font: 700 12px/1.2 inherit; color: var(--chip-c, #fbbf24); background: color-mix(in srgb, var(--chip-c, #fbbf24) 12%, transparent); border: 1px solid color-mix(in srgb, var(--chip-c, #fbbf24) 34%, transparent); }++ .openvibe-navbar .ovnav-burger { all: unset; box-sizing: border-box; cursor: pointer; display: none; width: 38px; height: 38px; border-radius: 11px; flex-direction: column; align-items: center; justify-content: center; gap: 4px; flex: none; }++ .openvibe-navbar .ovnav-burger span { display: block; width: 18px; height: 2px; border-radius: 2px; background: currentColor; transition: transform .2s, opacity .2s; }++ .openvibe-navbar .ovnav-burger[aria-expanded="true"] span:nth-child(1) { transform: translateY(6px) rotate(45deg); }++ .openvibe-navbar .ovnav-burger[aria-expanded="true"] span:nth-child(2) { opacity: 0; }++ .openvibe-navbar .ovnav-burger[aria-expanded="true"] span:nth-child(3) { transform: translateY(-6px) rotate(-45deg); }++ .openvibe-navbar .ovnav-burger:hover, .openvibe-navbar .ovnav-burger[aria-expanded="true"] { background: var(--accent-glow, rgba(59,130,246,.14)); }++ .ovnav-drawer { display: none; position: fixed; top: calc(var(--ovnav-h, 56px) + 6px); right: 8px; width: min(300px, calc(100vw - 16px)); padding: 7px; border-radius: 16px; z-index: 1001; flex-direction: column; gap: 2px;++ background: var(--bg-elevated, var(--bg-secondary, #111826)); border: 1px solid var(--border, rgba(255,255,255,.12)); box-shadow: 0 22px 48px rgba(0,0,0,.45);++ max-height: calc(100vh - var(--ovnav-h, 56px) - 16px); max-height: calc(100dvh - var(--ovnav-h, 56px) - 16px - env(safe-area-inset-bottom, 0px)); overflow-y: auto; overscroll-behavior: contain; }++ .ovnav-drawer.open { display: flex; animation: openvibe-slide-down .2s ease; }++ .ovnav-drawer a { display: flex; align-items: center; gap: 10px; padding: 11px 12px; border-radius: 10px; color: var(--text-primary, #e6edf7); text-decoration: none; font-size: 14.5px; font-weight: 600; }++ .ovnav-drawer a.ovnav-sublink { padding-left: 34px; font-weight: 500; color: var(--text-secondary, #a8b3c4); }++ .ovnav-drawer a:hover, .ovnav-drawer a.active { background: var(--accent-glow, rgba(59,130,246,.14)); }++ .ovnav-drawer a[hidden] { display: none; }++ .ovnav-drawer .ud-label { padding: 10px 12px 3px; font-size: 10.5px; font-weight: 800; letter-spacing: .9px; text-transform: uppercase; color: var(--text-muted, #7d8aa0); }++ .ovnav-drawer-net:empty { display: none; } .ovnav-drawer-net { border-top: 1px solid var(--border, rgba(255,255,255,.08)); margin-top: 4px; }++ .ovnav-drawer-net a.ovnav-net { display: flex !important; }++ @media (max-width: 860px) { .openvibe-navbar .ovnav-burger { display: inline-flex; } .openvibe-navbar .openvibe-navbar-links { display: none; } .openvibe-navbar .ovnav-chip .ovnav-ic + .ovnav-chip-v:empty { display: none; } }++ @media (max-width: 420px) { .openvibe-navbar .ovnav-chip { padding: 0 8px; height: 30px; font-size: 12px; } }.ovnav-launcher { position: absolute; top: calc(100% + 6px); left: 12px; width: min(440px, calc(100vw - 24px)); background: var(--bg-elevated, var(--bg-secondary, #111826)); border: 1px solid var(--border, rgba(255,255,255,.12)); border-radius: 16px; box-shadow: 0 24px 60px rgba(0,0,0,.5); padding: 12px; z-index: 1000; opacity: 0; transform: translateY(-6px) scale(.98); transform-origin: top left; pointer-events: none; transition: opacity .16s, transform .2s cubic-bezier(.2,1.2,.3,1); }.ovnav-launcher.open { opacity: 1; transform: none; pointer-events: auto; }.ovnav-launcher .ovl-q { width: 100%; box-sizing: border-box; padding: 9px 12px; border-radius: 10px; border: 1px solid var(--border, rgba(255,255,255,.12)); background: var(--bg-primary, #0a0f18); color: var(--text-primary, #e6edf7); font: 500 14px/1.2 inherit; outline: none; }@@@ -281,6 +316,20 @@'fa-text-height': '<path d="M4 7V5.5h9V7M8.5 5.5v13M6.5 18.5h4M18 6v12M15.5 8.5 18 6l2.5 2.5M15.5 15.5 18 18l2.5-2.5"/>','fa-wand-magic-sparkles': '<path d="M5 19 15 9M13.5 7.5l3 3M17.5 4l.6 1.4 1.4.6-1.4.6-.6 1.4-.6-1.4L15.5 6l1.4-.6zM7 5l.5 1.2 1.2.5-1.2.5L7 8.400l-.5-1.200L5.3 6.700l1.2-.5zM18.5 13l.5 1.2 1.2.5-1.2.5-.5 1.2-.5-1.2-1.2-.5 1.2-.5z"/>','fa-coins': '<ellipse cx="12" cy="7.5" rx="6.5" ry="3"/><path d="M5.5 7.5v4.500c0 1.7 2.9 3 6.5 3s6.5-1.3 6.5-3V7.500M5.5 12v4.500c0 1.7 2.9 3 6.5 3s6.5-1.3 6.5-3V12"/>',++ 'fa-video': '<rect x="3.5" y="6.5" width="12" height="11" rx="2"/><path d="M15.5 10.5 20.5 8v8l-5-2.500z"/>',++ 'fa-scissors': '<circle cx="6.5" cy="7.5" r="2.5"/><circle cx="6.5" cy="16.5" r="2.5"/><path d="M8.7 9 20 17M8.7 15 20 7"/>',++ 'fa-comments': '<path d="M4 6.500h10a1.5 1.5 0 0 1 1.5 1.500v5a1.5 1.5 0 0 1-1.5 1.500H9l-3 2.500v-2.500H4A1.5 1.5 0 0 1 2.5 13V8A1.5 1.5 0 0 1 4 6.500zM18 10.500h2A1.5 1.5 0 0 1 21.5 12v4.500A1.5 1.5 0 0 1 20 18h-1v2.500L16 18h-4"/>',++ 'fa-hand-fist': '<path d="M7 11V8a1.5 1.5 0 0 1 3 0v2M10 9.500V7a1.5 1.5 0 0 1 3 0v3M13 9.500V8a1.5 1.5 0 0 1 3 0v3M16 10.500a1.5 1.5 0 0 1 3 0V14a5.5 5.5 0 0 1-5.5 5.500h-2A5.5 5.5 0 0 1 6 14v-1.500a1.5 1.5 0 0 1 3 0"/>',++ 'fa-gamepad': '<path d="M7 9h10a4 4 0 0 1 3.9 4.900l-.6 2.600a2 2 0 0 1-3.5.8L15.5 16h-7l-1.3 1.300a2 2 0 0 1-3.5-.8l-.6-2.600A4 4 0 0 1 7 9zM8 11.500v3M6.5 13h3"/><circle cx="15.5" cy="12" r=".6"/><circle cx="17.5" cy="14" r=".6"/>',++ 'fa-list-check': '<path d="M4 7l1.5 1.500L8 6M4 12.5 5.5 14 8 11.500M11 7.500h9M11 13h9M4.5 18.500h.01M11 18.500h9"/>',++ 'fa-id-card': '<rect x="3.5" y="5.5" width="17" height="13" rx="2"/><circle cx="9" cy="11" r="2"/><path d="M6 15.500a3 3 0 0 1 6 0M14.5 10h3.500M14.5 13.500h3.5"/>',++ 'fa-shirt': '<path d="M8.5 4.5 4 7l1.5 3.500L7.5 10v9.500h9V10l2 .5L20 7l-4.5-2.500a3.5 3.5 0 0 1-7 0z"/>',++ 'fa-camera': '<path d="M4 8.500h3l1.5-2.500h7L17 8.500h3A1.5 1.5 0 0 1 21.5 10v8A1.5 1.5 0 0 1 20 19.500H4A1.5 1.5 0 0 1 2.5 18v-8A1.5 1.5 0 0 1 4 8.500z"/><circle cx="12" cy="13.5" r="3.2"/>',++ 'fa-book': '<path d="M6 4.500h12v15H6.500A1.5 1.5 0 0 1 5 18V5.500a1 1 0 0 1 1-1zM5 17.500A1.5 1.5 0 0 1 6.5 16H18M9 8.500h6"/>',++ 'fa-wallet': '<path d="M4.5 7.500A1.5 1.5 0 0 1 6 6h11.500v3M4.5 7.500V17A1.5 1.5 0 0 0 6 18.500h12.500A1.5 1.5 0 0 0 20 17V10.500A1.5 1.5 0 0 0 18.5 9H6a1.5 1.5 0 0 1-1.5-1.500z"/><circle cx="16.5" cy="13.7" r=".8"/>',++ 'fa-people-arrows': '<circle cx="6" cy="8" r="2"/><circle cx="18" cy="8" r="2"/><path d="M3 17a3 3 0 0 1 6 0M15 17a3 3 0 0 1 6 0M9.5 12h5M12.5 10l2 2-2 2M11.5 10l-2 2 2 2"/>',++ 'fa-network-wired': '<rect x="9" y="3.5" width="6" height="4.5" rx="1"/><rect x="3" y="16" width="6" height="4.5" rx="1"/><rect x="15" y="16" width="6" height="4.5" rx="1"/><path d="M12 8v4M6 16v-2.500h12V16"/>',++ 'fa-people-group': '<circle cx="9" cy="9" r="2.6"/><circle cx="16.5" cy="10" r="2"/><path d="M3.8 18.500a5.2 5.2 0 0 1 10.4 0M14.6 14.200a4 4 0 0 1 5.6 3.8"/>','fa-plus': '<path d="M12 5.500v13M5.5 12h13"/>','fa-paste': '<rect x="6" y="6" width="12" height="14" rx="1.8"/><path d="M9.5 6V5a1 1 0 0 1 1-1h3a1 1 0 0 1 1 1v1M9 11h6M9 14h6"/>','fa-house': '<path d="M4.5 11.5 12 5l7.5 6.500M6.5 10v8.500h11V10"/>',@@@ -533,6 +582,44 @@} catch { /* */ }}++ /** Status chips beside the bell (balances, counters): [{ id, icon, value, valueId, title, tone, onClick, hidden }]. */++ function chipsHTML(chips, cls) {++ return (Array.isArray(chips) ? chips : []).filter(Boolean).map(c => `<button type="button" class="${cls || 'ovnav-chip'}" data-chip-id="${escapeAttr(c.id)}"${c.tone ? ` data-tone="${escapeAttr(c.tone)}"` : ''}${c.title ? ` title="${escapeAttr(c.title)}"` : ''}${c.hidden ? ' hidden' : ''}>${c.icon ? navIcon(c.icon) : ''}<span class="ovnav-chip-v"${c.valueId ? ` id="${escapeAttr((cls ? 'menu-' : '') + c.valueId)}"` : ''}>${escapeAttr(c.value == null ? '' : c.value)}</span></button>`).join('');++ }++ function bindChips(scope) {++ (_config.chips || []).concat((_config.menu && _config.menu.headerChips) || []).forEach((c) => {++ if (!c || typeof c.onClick !== 'function') return;++ scope.querySelectorAll(`[data-chip-id="${c.id}"]`).forEach(el => { if (!el.__b) { el.__b = true; el.addEventListener('click', (e) => { e.stopPropagation(); c.onClick(e); }); } });++ });++ }++++ /** Link behaviour: per-link onClick, in-app navigation (onNavigate), dropdowns, the mobile drawer. */++ function bindLinks(nav, links) {++ const byId = new Map(flatLinks(links).filter(l => l.id).map(l => [l.id, l]));++ const drawer = nav.querySelector('#openvibe-drawer'), burger = nav.querySelector('#openvibe-burger');++ const closeDrawer = () => { if (drawer) drawer.classList.remove('open'); if (burger) burger.setAttribute('aria-expanded', 'false'); };++ nav.addEventListener('click', (e) => {++ const a = e.target.closest && e.target.closest('a.ovnav-link, .ovnav-net'); if (!a || !nav.contains(a)) return;++ if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;++ const item = byId.get(a.getAttribute('data-link-id'));++ // A parent of a dropdown opens it on touch (there is no hover); a second tap follows the link.++ const dd = a.parentElement && a.parentElement.classList.contains('ovnav-dd') ? a.parentElement : null;++ if (dd && matchMedia('(hover: none)').matches && !dd.classList.contains('open')) { e.preventDefault(); nav.querySelectorAll('.ovnav-dd.open').forEach(x => x.classList.remove('open')); dd.classList.add('open'); return; }++ if (item && typeof item.onClick === 'function') { const r = item.onClick(e); if (r === false) e.preventDefault(); closeDrawer(); if (e.defaultPrevented) return; }++ if (typeof _config.onNavigate === 'function' && a.target !== '_blank') {++ let same = false; try { same = new URL(a.href, location.href).origin === location.origin; } catch { /* */ }++ if (same && _config.onNavigate(a.getAttribute('href'), e) === false) e.preventDefault();++ }++ closeDrawer();++ });++ document.addEventListener('click', (e) => { if (!e.target.closest || !e.target.closest('.ovnav-dd')) nav.querySelectorAll('.ovnav-dd.open').forEach(x => x.classList.remove('open')); });++ if (burger && drawer) {++ burger.addEventListener('click', (e) => { e.stopPropagation(); const open = drawer.classList.toggle('open'); burger.setAttribute('aria-expanded', String(open)); });++ document.addEventListener('click', (e) => { if (drawer.classList.contains('open') && !drawer.contains(e.target) && !burger.contains(e.target)) closeDrawer(); });++ regPanel(drawer, 'nav-drawer', closeDrawer);++ }++ }++/** Dropdown, launcher and notifications share one coordinator (panels.js): one open at a time, Escape, rotation.* Panels are registered where they are created (no page-wide observers); registrations queue until the script loads. */const _panelQueue = [];@@@ -569,7 +656,7 @@/** OpenCoins balance in the menu header (one request when the menu first opens). */let _walletLoaded = false;async function loadWallet(dropdown) {-- if (_walletLoaded || !_config.token) return; _walletLoaded = true;++ if (_walletLoaded || !_config.token || (_config.menu && _config.menu.headerChips)) return; _walletLoaded = true;try {const r = await fetch(`${_config.apiBase}/api/coins/me`, { headers: { Authorization: `Bearer ${_config.token}` }, credentials: 'include' });if (!r.ok) return; const j = await r.json(); const bal = Number(j.balance ?? (j.wallet && j.wallet.balance));@@@ -579,16 +666,16 @@}/** The network's most used sites, after the page's own links (networkLinks: false turns it off). */-- function networkLinksHTML(pageLinks) {++ function networkLinksHTML(pageLinks, inDrawer) {if (_config.networkLinks === false) return '';const chrome = OVChrome.get((d) => { if (d && _navEl && !_navEl.querySelector('.ovnav-net')) { try { render(); } catch { /* */ } } });if (!chrome) return '';const here = currentHost().toLowerCase();const taken = new Set((pageLinks || []).map(l => { try { return new URL(l.href, location.href).hostname; } catch { return ''; } }));-- const max = typeof _config.networkLinks === 'number' ? _config.networkLinks : 4;++ const max = inDrawer ? 6 : (typeof _config.networkLinks === 'number' ? _config.networkLinks : 4);const pick = chrome.nav.filter(n => { try { const h = new URL(n.url).hostname; return h !== here && !here.endsWith('.' + h) && !taken.has(h); } catch { return false; } }).slice(0, max);if (!pick.length) return '';-- return `<span class="ovnav-sep" aria-hidden="true"></span>` + pick.map(n => `<a class="ovnav-net" href="${escapeAttr(n.url)}" title="${escapeAttr(n.tagline)}">${escapeAttr(n.name)}</a>`).join('');++ return (inDrawer ? '<div class="ud-label">Across OpenVibe</div>' : `<span class="ovnav-sep" aria-hidden="true"></span>`) + pick.map(n => `<a class="ovnav-net" href="${escapeAttr(n.url)}" title="${escapeAttr(n.tagline)}">${escapeAttr(n.name)}</a>`).join('');}/** Display settings in the launcher, for guests and members alike (theme-loader.js owns the state). */@@@ -619,29 +706,51 @@const sc = document.createElement('script'); sc.id = 'ov-notify-loader'; sc.src = 'https://openvibe.network/shared/notification-ui.js'; sc.async = true; sc.onload = () => setTimeout(go, 0); document.head.appendChild(sc);}++ /** Custom sections for the user menu: [{ label, items: [{ label, icon, href, id, elId, hidden, danger, value, onClick }] }]. */++ function sectionsHTML(sections) {++ return (Array.isArray(sections) ? sections : []).filter(sec => sec && Array.isArray(sec.items) && sec.items.length).map(sec =>++ `<div class="ud-sec"${sec.id ? ` data-sec-id="${escapeAttr(sec.id)}"` : ''}${sec.hidden ? ' hidden' : ''}>${sec.label ? `<div class="ud-label">${escapeAttr(sec.label)}</div>` : ''}${sec.items.map(menuItemHTML).join('')}</div><div class="sep"></div>`).join('');++ }++ const sectionItems = (sections) => (Array.isArray(sections) ? sections : []).reduce((o, sec) => o.concat((sec && sec.items) || []), []);++function menuItemHTML(item) {if (!item) return '';if (item.sep) return '<div class="sep"></div>';const icon = item.icon ? `<span class="icon">${navIcon(item.icon)}</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>`;++ const id = `${item.id ? ` data-menu-id="${escapeAttr(item.id)}"` : ''}${item.elId ? ` id="${escapeAttr(item.elId)}"` : ''}${item.hidden ? ' hidden' : ''}`;++ const val = item.value !== undefined ? `<b class="ud-val">${escapeAttr(item.value)}</b>` : '';++ if (item.href) return `<a href="${escapeAttr(item.href)}"${cls}${id}${item.external ? ' target="_blank" rel="noopener"' : ''}>${icon} ${escapeAttr(item.label)}${val}</a>`;++ return `<button type="button"${cls}${id}>${icon} ${escapeAttr(item.label)}${val}</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); });++ container.querySelector(`[data-menu-id="${item.id}"]`)?.addEventListener('click', (e) => { if (!item.href) e.preventDefault(); const r = item.onClick(e); if (r === false) e.preventDefault(); if (item.keepOpen !== true) container.closest('.openvibe-navbar-dropdown')?.classList.remove('open'); });}}++ /** One top-level link. Supports { label, href, icon, id, elId, page, hidden, external, dot, dotId, children: [...] }. */++ function linkHTML(l, inDrawer) {++ const attrs = `${l.id ? ` data-link-id="${escapeAttr(l.id)}"` : ''}${l.elId ? ` id="${escapeAttr((inDrawer ? 'drawer-' : '') + l.elId)}"` : ''}${l.page ? ` data-page="${escapeAttr(l.page)}"` : ''}${l.hidden ? ' hidden' : ''}${l.external ? ' target="_blank" rel="noopener"' : ''}`;++ const kids = Array.isArray(l.children) ? l.children.filter(Boolean) : [];++ const inner = `${l.icon ? `<span class="icon">${navIcon(l.icon)}</span>` : ''}<span class="ovnav-l">${escapeAttr(l.label)}</span>${l.dot !== undefined ? `<span class="ovnav-dot"${l.dotId && !inDrawer ? ` id="${escapeAttr(l.dotId)}"` : ''}${l.dot ? '' : ' hidden'}></span>` : ''}`;++ const a = `<a href="${escapeAttr(l.href || '#')}" class="ovnav-link${l.active ? ' active' : ''}"${attrs}>${inner}${kids.length && !inDrawer ? '<span class="ovnav-caret" aria-hidden="true">▾</span>' : ''}</a>`;++ if (!kids.length) return a;++ const menu = kids.map(k => `<a href="${escapeAttr(k.href || '#')}" class="ovnav-link ovnav-sublink"${k.id ? ` data-link-id="${escapeAttr(k.id)}"` : ''}${k.hidden ? ' hidden' : ''}>${k.icon ? `<span class="icon">${navIcon(k.icon)}</span>` : ''}<span class="ovnav-l">${escapeAttr(k.label)}</span></a>`).join('');++ return inDrawer ? a + menu : `<div class="ovnav-dd"${l.hidden ? ' hidden' : ''}>${a}<div class="ovnav-dd-menu">${menu}</div></div>`;++ }++ const flatLinks = (list) => list.reduce((out, l) => out.concat([l], Array.isArray(l.children) ? l.children : []), []);++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 activePage = _activePage;++ return list.map((l) => Object.assign({}, l, { active: l.active !== undefined ? l.active : (activePage ? l.page === activePage : (l.href === path && path !== '/')) }));}++ let _activePage = null; // setActive(): single-page apps tell the navbar where they areconst SERVICE_LINKS = {live: [{ label: 'Watch', href: '/' },@@@ -1122,17 +1231,23 @@nav.innerHTML = `${brandHTML(brand)}<div class="openvibe-navbar-links">-- ${links.map(l => `<a href="${escapeAttr(l.href)}"${l.active ? ' class="active"' : ''}${l.external ? ' target="_blank" rel="noopener"' : ''}>${l.icon ? `<span class="icon">${navIcon(l.icon)}</span>` : ''}${escapeAttr(l.label)}</a>`).join('')}++ ${links.map(l => linkHTML(l, false)).join('')}${networkLinksHTML(links)}-- ${u && u.role === 'admin' ? `<a href="https://openvibe.network/admin">${navIcon('fa-shield-halved')} Admin</a>` : ''}++ ${u && u.role === 'admin' && _config.adminLink !== false && !flatLinks(links).some(l => l.id === 'admin') ? `<a href="https://openvibe.network/admin">${navIcon('fa-shield-halved')} Admin</a>` : ''}</div><div class="openvibe-navbar-spacer"></div><div class="openvibe-navbar-right">++ ${u ? chipsHTML(_config.chips) : ''}<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>`}++ ${links.length ? '<button type="button" class="ovnav-burger" id="openvibe-burger" aria-label="Menu" aria-haspopup="true" aria-expanded="false"><span></span><span></span><span></span></button>' : ''}</div>++ ${links.length ? `<div class="ovnav-drawer" id="openvibe-drawer" role="menu">${links.map(l => linkHTML(l, true)).join('')}<div class="ovnav-drawer-net">${networkLinksHTML(links, true)}</div></div>` : ''}`;++ if (_config.className) String(_config.className).split(/\s+/).filter(Boolean).forEach(c => nav.classList.add(c));++ bindLinks(nav, links);++ bindChips(nav);bindLauncher(nav);countView();// Pages that wire the bell themselves run right after init(); give them the first go.@@@ -1145,6 +1260,7 @@dropdown.id = 'openvibe-user-dropdown';const otherAccounts = accounts.filter(a => isAnon ? !a.is_anon : String(a.id) !== String(u.id));++ const menuCfg = _config.menu || {};const before = ((_config.menu && _config.menu.before) || []).concat(_runtimeMenu.before);const after = ((_config.menu && _config.menu.after) || []).concat(_runtimeMenu.after);@@@ -1154,11 +1270,11 @@<div class="info"><div class="name">${escapeAttr(u.display_name || u.username)}</div><div class="email">${isAnon ? '' : '@' + escapeAttr(u.username || '')}</div>-- <div class="ud-wallet" id="openvibe-wallet" hidden></div>++ ${menuCfg.headerChips ? `<div class="ud-chips">${chipsHTML(menuCfg.headerChips, 'ovnav-chip ovnav-chip--menu')}</div>` : '<div class="ud-wallet" id="openvibe-wallet" hidden></div>'}${isAnon ? `<div class="anon-tag">Anonymous #${u.anon_number || '?'}</div>` : ''}</div></div>-- <div class="openvibe-navbar-dropdown-accounts">++ <div class="openvibe-navbar-dropdown-accounts"${_config.accounts === false ? ' hidden' : ''}>${otherAccounts.map(a => `<div class="account-item" data-account-id="${a.id}">${avatarImg(a, 48, '', '')}@@@ -1177,11 +1293,12 @@<div class="openvibe-navbar-dropdown-recent" id="openvibe-recent" hidden></div><div class="openvibe-navbar-dropdown-menu">${before.length ? `<div class="ud-label">${escapeAttr((_config.menu && _config.menu.label) || brand.short || 'This site')}</div>${before.map(menuItemHTML).join('')}<div class="sep"></div>` : ''}-- <div class="ud-label">You</div>++ ${sectionsHTML(menuCfg.sections)}++ ${menuCfg.defaults === false ? '' : `<div class="ud-label">You</div><a href="https://openvibe.network/my"><span class="icon">${navIcon('fa-user')}</span> My Account</a>${!isAnon && u.username ? `<a href="https://openvibe.live/@${escapeAttr(u.username)}"><span class="icon">${navIcon('fa-tower-broadcast')}</span> My Channel</a>` : ''}<a href="https://openvibe.network/my#notifications"><span class="icon">${navIcon('fa-bell')}</span> Notifications</a>-- <div class="sep"></div>++ <div class="sep"></div>`}<div class="ud-label">Display</div><button type="button" data-ov-display="text"><span class="icon">${navIcon('fa-text-height')}</span> Text size <b class="ud-val"></b></button><button type="button" data-ov-display="motion"><span class="icon">${navIcon('fa-wand-magic-sparkles')}</span> Animations <b class="ud-val"></b></button>@@@ -1189,16 +1306,19 @@<div class="sep"></div><div class="ud-label">Across OpenVibe</div>${acrossHTML()}++ ${menuCfg.defaults === false ? '' : `<a href="https://openvibe.network/my#history"><span class="icon">${navIcon('fa-clock-rotate-left')}</span> History</a><a href="https://openvibe.network/my#linked"><span class="icon">${navIcon('fa-link')}</span> Linked Services</a>-- ${u.role === 'admin' ? `<a href="https://openvibe.network/admin"><span class="icon">${navIcon('fa-shield-halved')}</span> Admin Panel</a>` : ''}++ ${u.role === 'admin' ? `<a href="https://openvibe.network/admin"><span class="icon">${navIcon('fa-shield-halved')}</span> Admin Panel</a>` : ''}`}${after.length ? '<div class="sep"></div>' : ''}${after.map(menuItemHTML).join('')}<div class="sep"></div><button id="openvibe-logout-btn" class="danger"><span class="icon">${navIcon('fa-right-from-bracket')}</span> Sign Out</button></div>`;nav.appendChild(dropdown);-- bindMenuItems(dropdown, before.concat(after));++ bindMenuItems(dropdown, before.concat(after, sectionItems(menuCfg.sections)));++ bindChips(dropdown);++ if (typeof _config.onNavigate === 'function') dropdown.addEventListener('click', (e) => { const a = e.target.closest && e.target.closest('a[href]'); if (!a || e.defaultPrevented || a.target === '_blank' || e.metaKey || e.ctrlKey) return; let same = false; try { same = new URL(a.href, location.href).origin === location.origin; } catch { /* */ } if (same) { if (_config.onNavigate(a.getAttribute('href'), e) === false) e.preventDefault(); dropdown.classList.remove('open'); } });bindDisplayRows(dropdown);regPanel(dropdown, 'user-menu');if (_config.recent !== false) renderRecent(dropdown.querySelector('#openvibe-recent'));@@@ -1272,7 +1392,9 @@// only a token, or nothing at all — gets the user resolved from// the shared SSO state (ov_token cookie / localStorage / optional// sessionUrl) and the navbar re-renders when it arrives.-- if (!_config.user) refreshAuthState();++ // auth: 'external' — the site signs people in itself (Live) and tells us with setUser().++ if (_config.auth === 'external') { if (_config.user) { recordHistory(); enableHandoff(); } }++ else if (!_config.user) refreshAuthState();else { recordHistory(); enableHandoff(); }return el;},@@@ -1312,6 +1434,34 @@/** The resolved brand for this page ({ sub, core, tld, name, short, variant }). */brand() { return resolveBrand(); },++ /** The signed-in user changed (sites with auth: 'external'). Pass null for signed out. */++ setUser(user, token) { _config.user = user || null; if (token !== undefined) _config.token = token; _walletLoaded = false; return render(); },++ /** Single-page apps: mark the link whose `page` matches as active. */++ setActive(page) { _activePage = page || null; if (!_navEl) return; _navEl.querySelectorAll('.ovnav-link[data-page]').forEach(a => a.classList.toggle('active', a.getAttribute('data-page') === _activePage)); },++ /** Change a status chip's text (navbar and menu header): setChip('coins', '1,234'). */++ setChip(id, value, patch) {++ const lists = [_config.chips || [], (_config.menu && _config.menu.headerChips) || []];++ lists.forEach(l => l.forEach(c => { if (c && c.id === id) { c.value = value; if (patch) Object.assign(c, patch); } }));++ document.querySelectorAll(`[data-chip-id="${id}"]`).forEach(el => { const v = el.querySelector('.ovnav-chip-v'); if (v) v.textContent = value == null ? '' : String(value); if (patch && 'hidden' in patch) el.hidden = !!patch.hidden; });++ },++ /** Patch a top-level or dropdown link by id: updateLink('broadcast', { hidden: false, dot: true, label }). */++ updateLink(id, patch) {++ const list = _runtimeLinks || _config.links || [];++ const l = flatLinks(list).find(x => x && x.id === id); if (!l) return; Object.assign(l, patch || {});++ if (!_navEl) return;++ _navEl.querySelectorAll(`[data-link-id="${id}"]`).forEach(a => {++ if ('hidden' in patch) { a.hidden = !!patch.hidden; const dd = a.parentElement; if (dd && dd.classList.contains('ovnav-dd')) dd.hidden = !!patch.hidden; }++ if ('label' in patch) { const t = a.querySelector('.ovnav-l'); if (t) t.textContent = patch.label; }++ if ('href' in patch) a.setAttribute('href', patch.href);++ if ('dot' in patch) { const d = a.querySelector('.ovnav-dot'); if (d) d.hidden = !patch.dot; }++ });++ },++ /** Patch a custom menu item by id: updateMenuItem('admin', { hidden: false, value: '3' }). */++ updateMenuItem(id, patch) {++ const item = sectionItems(_config.menu && _config.menu.sections).find(x => x && x.id === id); if (item) Object.assign(item, patch || {});++ document.querySelectorAll(`.openvibe-navbar-dropdown [data-menu-id="${id}"]`).forEach(el => { if ('hidden' in patch) el.hidden = !!patch.hidden; if ('value' in patch) { const v = el.querySelector('.ud-val'); if (v) v.textContent = patch.value; } });++ },++/** The activity island (island.js), loaded on first use: OpenVibeNavbar.activity.start({...}). */get activity() { return root.OpenVibeIsland || null; },set activity(_v) { /* island.js binds itself; nothing to store */ },
@@ -11875,3 +11875,21 @@ a .arena-thread:hover { color: var(--text-primary); background: color-mix(in srg
@@ -11875,3 +11875,21 @@ a .arena-thread:hover { color: var(--text-primary); background: color-mix(in srg
}
}
body.ov-panel-open #floating-chat-fab { opacity: 0; pointer-events: none; transform: scale(.85); transition: opacity .15s, transform .15s; }
body.ov-panel-open #floating-chat-fab { opacity: 0; pointer-events: none; transform: scale(.85); transition: opacity .15s, transform .15s; }
+
+/* ── Live on the shared navbar ────────────────────────────────────────────────
+ The bar is the network's shared component (/shared/navbar.js). It carries the class "navbar" as well,
+ so Live's fixed positioning, glass background and the transparent hero-top state keep applying; these
+ rules only settle the places where the component's own defaults would otherwise win. */
+.openvibe-navbar.navbar {
+ position: fixed; top: 0; left: 0; right: 0; z-index: 1000;
+ height: var(--navbar-h); padding: 0 clamp(10px, 2vw, 16px); gap: clamp(6px, 1.2vw, 14px);
+ background: var(--glass); border-bottom: 1px solid rgba(255,255,255,0.06);
+ backdrop-filter: blur(18px); -webkit-backdrop-filter: blur(18px);
+ --ovnav-h: var(--navbar-h);
+}
+.openvibe-navbar.navbar.nav-hero-top { background: transparent; border-bottom-color: transparent; box-shadow: none; backdrop-filter: none; -webkit-backdrop-filter: none; }
+.openvibe-navbar.navbar .openvibe-navbar-links a.ovnav-link { font-size: 0.86rem; padding: 7px 11px; border-radius: 10px; }
+.openvibe-navbar.navbar .openvibe-navbar-links a.ovnav-link.active { color: var(--text-primary); background: color-mix(in srgb, var(--accent) 16%, transparent); }
+.openvibe-navbar.navbar .openvibe-navbar-dropdown { position: fixed; top: calc(var(--navbar-h) + 6px); right: 8px; }
+.openvibe-navbar.navbar .openvibe-navbar-avatar { width: 36px; height: 36px; border-radius: 50%; cursor: pointer; object-fit: cover; box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent) 50%, transparent); }
+@media (max-width: 560px) { .openvibe-navbar.navbar .ovnav-chip { padding: 0 8px; } .openvibe-navbar.navbar .ovnav-launch { display: none; } }
@@ -166,137 +166,8 @@
@@ -166,137 +166,8 @@
<!-- ═══════════════════════════════════════════════════════════ -->
<!-- ═══════════════════════════════════════════════════════════ -->
<!-- NAVBAR -->
<!-- NAVBAR -->
<!-- ═══════════════════════════════════════════════════════════ -->
<!-- ═══════════════════════════════════════════════════════════ -->
-<nav class="navbar">
- <a class="nav-brand" href="/" onclick="return handleLinkClick(event, '/')" data-brand-tooltip="This platform is in early alpha — features may be incomplete, buggy, or change without notice. Data will likely be reset multiple times during the alpha phase. Thanks for testing!">
- <span class="brand-mark" aria-hidden="true"><svg viewBox="0 0 48 48" width="30" height="30">
- <defs>
- <linearGradient id="ovn-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="ovn-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="ovn-ring" d="M24,6 A18,18 0 1,1 23.99,6 Z"/>
- </defs>
- <circle class="ov-glow" cx="24" cy="24" r="21"/>
- <circle class="ov-ring" cx="24" cy="24" r="18"/>
- <circle class="ov-comet" cx="24" cy="24" r="18" stroke="url(#ovn-g)"/>
- <path class="ov-v" d="M14.5,17 L24,34 L33.5,17" stroke="url(#ovn-v)"/>
- <circle class="ov-dot" cx="24" cy="34" r="2.6"/>
- <circle class="ov-orbit" r="2"><animateMotion dur="3.6s" repeatCount="indefinite" rotate="auto"><mpath href="#ovn-ring"/></animateMotion></circle>
- <circle class="ov-orbit ov-orbit--tail" r="1.4"><animateMotion dur="3.6s" begin="0.14s" repeatCount="indefinite"><mpath href="#ovn-ring"/></animateMotion></circle>
- </svg></span>
- <span class="brand-text-wrap">
- <span class="brand-text"><span class="brand-openvibe">OpenVibe</span><strong class="brand-streamer">.Live</strong></span>
- </span>
- </a>
- <div class="nav-links-wrap">
- <button class="nav-scroll-btn nav-scroll-left" id="nav-scroll-left" onclick="scrollNavLinks(-1)" title="Scroll left"><i class="fa-solid fa-chevron-left"></i></button>
- <div class="nav-links">
- <a href="/" onclick="return handleLinkClick(event, '/')" class="nav-link active" data-page="home" data-tooltip="Home">
- <i class="fa-solid fa-house"></i> <span>Home</span>
- </a>
- <a href="/vods" onclick="return handleLinkClick(event, '/vods')" class="nav-link" data-page="vods" data-tooltip="VODs">
- <i class="fa-solid fa-video"></i> <span>VODs</span>
- </a>
- <a href="/clips" onclick="return handleLinkClick(event, '/clips')" class="nav-link" data-page="clips" data-tooltip="Clips">
- <i class="fa-solid fa-scissors"></i> <span>Clips</span>
- </a>
- <a href="/pastes" onclick="return handleLinkClick(event, '/pastes')" class="nav-link" data-page="pastes" data-tooltip="Pastes">
- <i class="fa-solid fa-paste"></i> <span>Pastes</span>
- </a>
- <a href="/chat" onclick="return handleLinkClick(event, '/chat')" class="nav-link" data-page="chat" data-tooltip="Chat">
- <i class="fa-solid fa-comments"></i> <span>Chat</span>
- </a>
- <a href="/arena" onclick="return handleLinkClick(event, '/arena')" class="nav-link" data-page="arena" data-tooltip="Arena">
- <i class="fa-solid fa-hand-fist"></i> <span>Arena</span>
- </a>
- <a href="https://openvibe.games" class="nav-link" data-page="game" data-tooltip="Game" target="_blank" rel="noopener">
- <i class="fa-solid fa-gamepad"></i> <span>Game</span>
- </a>
- <div class="nav-dropdown" id="nav-golive-dropdown">
- <a href="/broadcast" onclick="return goLiveNav(event)" class="nav-link" data-page="broadcast" id="nav-broadcast" data-tooltip="Go Live">
- <i class="fa-solid fa-tower-broadcast"></i> <span>Go Live</span><span class="nav-live-dot" id="nav-live-dot" style="display:none"><i class="fa-solid fa-circle"></i></span> <i class="fa-solid fa-caret-down nav-dd-caret"></i>
- </a>
- <div class="nav-dropdown-menu" id="nav-golive-menu">
- <a href="/dashboard" id="nav-golive-dash" onclick="return dashNav(event)"><i class="fa-solid fa-gauge-high"></i> Dashboard</a>
- </div>
- </div>
- <a href="https://openvibe.network/admin" class="nav-link" data-page="admin" id="nav-admin" style="display:none" data-tooltip="Admin" rel="noopener noreferrer">
- <i class="fa-solid fa-shield-halved"></i> <span>Admin</span>
- </a>
- </div>
- <button class="nav-scroll-btn nav-scroll-right" id="nav-scroll-right" onclick="scrollNavLinks(1)" title="Scroll right"><i class="fa-solid fa-chevron-right"></i></button>
- </div>
- <div class="nav-right">
- <div id="nav-auth-anon">
- <a href="/api/auth/sso/login" class="btn btn-primary" style="display:inline-flex;align-items:center;gap:6px;text-decoration:none">
- <i class="fa-solid fa-network-wired"></i> Sign In
- </a>
- </div>
- <div id="nav-auth-user" style="display:none">
- <span class="nav-coins" id="nav-coins" title="OpenCoins — click to open inventory" onclick="openCosmeticsModal()">
- <i class="fa-solid fa-coins"></i> <span id="nav-coins-amount">0</span>
- </span>
- <span class="nav-balance" id="nav-balance" title="Vibes balance" onclick="showModal('buy-funds')">
- <i class="fa-solid fa-wallet"></i> <span id="nav-balance-amount">0</span>
- </span>
- <span id="openvibe-bell-mount" style="display:inline-flex;align-items:center"></span>
- <div class="nav-avatar-wrap" onclick="toggleUserMenu()">
- <div class="nav-avatar" id="nav-avatar">?</div>
- <span id="nav-username"></span>
- <i class="fa-solid fa-caret-down"></i>
- </div>
- <!-- The account menu. Deliberately not shaped like the nav menu next to it: that one is
- a list of places, this one is about who you are signed in as, so it opens with your
- identity and what you are carrying, and groups the rest under labels. -->
- <div class="user-dropdown" id="user-dropdown">
- <div class="ud-head">
- <div class="ud-avatar" id="ud-avatar" aria-hidden="true">?</div>
- <div class="ud-who">
- <b id="ud-name"> </b>
- <small id="ud-handle"></small>
- </div>
- <div class="ud-wallet">
- <span class="ud-coin" title="OpenCoins"><i class="fa-solid fa-coins"></i><b id="ud-coins">0</b></span>
- <span class="ud-vibe" title="Vibes"><i class="fa-solid fa-wallet"></i><b id="ud-vibes">0</b></span>
- </div>
- </div>
- <div class="ud-group">
- <span class="ud-label">You</span>
- <a href="/dashboard" onclick="return handleLinkClick(event, '/dashboard')"><i class="fa-solid fa-gauge-high"></i> My Dashboard</a>
- <a href="#" onclick="return handleLinkClick(event, channelPath(currentUser?.username))"><i class="fa-solid fa-user"></i> My Channel</a>
- <a href="#" onclick="event.preventDefault();openSetupHub()"><i class="fa-solid fa-list-check"></i> Streamer setup</a>
- <a href="https://openvibe.network/admin" target="_blank" rel="noopener" id="user-dropdown-admin" style="display:none"><i class="fa-solid fa-shield-halved"></i> Admin Panel</a>
- </div>
- <div class="ud-group" id="ud-display">
- <span class="ud-label">Display</span>
- <a href="#" data-ov-display="text"><i class="fa-solid fa-text-height"></i> Text size <b data-v style="margin-left:auto;font-weight:600;opacity:.8"></b></a>
- <a href="#" data-ov-display="motion"><i class="fa-solid fa-wand-magic-sparkles"></i> Animations <b data-v style="margin-left:auto;font-weight:600;opacity:.8"></b></a>
- </div>
- <div class="ud-group">
- <span class="ud-label">Across OpenVibe</span>
- <a href="https://openvibe.network"><i class="fa-solid fa-network-wired"></i> OpenVibe Network</a>
- <a href="https://openvibe.tools"><i class="fa-solid fa-screwdriver-wrench"></i> Tools</a>
- <a href="https://openvibe.community"><i class="fa-solid fa-people-group"></i> Community</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="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>
- <a href="#" onclick="event.preventDefault();captureScreenshot()"><i class="fa-solid fa-camera"></i> Screenshot</a>
- <a href="/documentation" onclick="return handleLinkClick(event, '/documentation')"><i class="fa-solid fa-book"></i> Documentation</a>
- </div>
- <div class="ud-group ud-group--exit">
- <a href="#" onclick="event.preventDefault();switchAccount()"><i class="fa-solid fa-people-arrows"></i> Switch account</a>
- <a href="#" onclick="event.preventDefault();browseAsGuest()"><i class="fa-solid fa-user-secret"></i> Browse as guest</a>
- <a class="ud-logout" href="#" onclick="event.preventDefault();logout()"><i class="fa-solid fa-right-from-bracket"></i> Logout</a>
- </div>
- </div>
- </div>
- </div>
- <button class="nav-hamburger" onclick="toggleMobileNav()">
- <i class="fa-solid fa-bars"></i>
- </button>
-</nav>
+<!-- The navbar is the network's shared component (/shared/navbar.js), configured for Live in js/ov-navbar-live.js. -->
+<div id="navbar-mount"></div>
<!-- ═══════════════════════════════════════════════════════════ -->
<!-- ═══════════════════════════════════════════════════════════ -->
<!-- MAIN CONTENT AREA (SPA pages rendered here) -->
<!-- MAIN CONTENT AREA (SPA pages rendered here) -->
@@ -1149,7 +1020,9 @@
@@ -1149,7 +1020,9 @@
rest when someone navigates. The asset manifest below carries content hashes and the registry. -->
rest when someone navigates. The asset manifest below carries content hashes and the registry. -->
<!--ov:asset-manifest-->
<!--ov:asset-manifest-->
<script src="/js/ov-loader.js" defer></script>
<script src="/js/ov-loader.js" defer></script>
+<script src="/shared/navbar.js" defer></script>
<script src="/js/app.js" defer></script>
<script src="/js/app.js" defer></script>
+<script src="/js/ov-navbar-live.js" defer></script>
<script src="/js/chat-shared.js" defer></script>
<script src="/js/chat-shared.js" defer></script>
<script src="/js/chat.js" defer></script>
<script src="/js/chat.js" defer></script>
<!-- (js/admin.js removed — 130KB of unreachable code; /admin redirects off-site.) -->
<!-- (js/admin.js removed — 130KB of unreachable code; /admin redirects off-site.) -->
@@ -1239,7 +1112,8 @@ document.addEventListener('DOMContentLoaded', function initOpenVibeNetwork() {
@@ -1239,7 +1112,8 @@ document.addEventListener('DOMContentLoaded', function initOpenVibeNetwork() {
soundBase: '/assets/sounds',
soundBase: '/assets/sounds',
swPath: '/openvibe-sw.js',
swPath: '/openvibe-sw.js',
});
});
- mount.appendChild(OpenVibeNotifications.createBell());
+ OpenVibeNotifications.__ovNavInit = true; // the shared navbar mounts (and re-mounts) the bell itself
+ if (!mount.childElementCount) mount.appendChild(OpenVibeNotifications.createBell());
}
}
});
});
</script>
</script>
@@ -745,6 +745,8 @@ try { if (new URLSearchParams(location.search).get('sso') === 'none') { _setSsoH
@@ -745,6 +745,8 @@ try { if (new URLSearchParams(location.search).get('sso') === 'none') { _setSsoH
function onAuthChange() {
function onAuthChange() {
if (currentUser) _enableHandoff(); else if (window.OpenVibeSSO) { try { window.OpenVibeSSO.handoffLinks({ signedIn: false }); } catch { /* */ } }
if (currentUser) _enableHandoff(); else if (window.OpenVibeSSO) { try { window.OpenVibeSSO.handoffLinks({ signedIn: false }); } catch { /* */ } }
+ // The navbar is the network's shared component; tell it who is here (js/ov-navbar-live.js).
+ if (window.LiveNav) window.LiveNav.sync(currentUser || null);
const anon = document.getElementById('nav-auth-anon');
const anon = document.getElementById('nav-auth-anon');
const user = document.getElementById('nav-auth-user');
const user = document.getElementById('nav-auth-user');
const admin = document.getElementById('nav-admin');
const admin = document.getElementById('nav-admin');
@@ -808,6 +810,7 @@ async function loadBalance() {
@@ -808,6 +810,7 @@ async function loadBalance() {
const bal = Math.round(data.balance || 0);
const bal = Math.round(data.balance || 0);
const balEl = document.getElementById('nav-balance-amount');
const balEl = document.getElementById('nav-balance-amount');
if (balEl) balEl.textContent = bal.toLocaleString();
if (balEl) balEl.textContent = bal.toLocaleString();
+ if (window.LiveNav) window.LiveNav.setChip('vibes', bal.toLocaleString());
const udV = document.getElementById('ud-vibes');
const udV = document.getElementById('ud-vibes');
if (udV) udV.textContent = bal.toLocaleString();
if (udV) udV.textContent = bal.toLocaleString();
} catch { /* silent */ }
} catch { /* silent */ }
@@ -817,6 +820,7 @@ async function loadBalance() {
@@ -817,6 +820,7 @@ async function loadBalance() {
const coins = coinData.balance || 0;
const coins = coinData.balance || 0;
const coinEl = document.getElementById('nav-coins-amount');
const coinEl = document.getElementById('nav-coins-amount');
if (coinEl) coinEl.textContent = coins.toLocaleString();
if (coinEl) coinEl.textContent = coins.toLocaleString();
+ if (window.LiveNav) window.LiveNav.setChip('coins', coins.toLocaleString());
const udC = document.getElementById('ud-coins');
const udC = document.getElementById('ud-coins');
if (udC) udC.textContent = coins.toLocaleString();
if (udC) udC.textContent = coins.toLocaleString();
} catch { /* silent */ }
} catch { /* silent */ }
@@ -1396,6 +1400,7 @@ function showPage(page) {
@@ -1396,6 +1400,7 @@ function showPage(page) {
const link = document.querySelector(`.nav-link[data-page="${navPage}"]`);
const link = document.querySelector(`.nav-link[data-page="${navPage}"]`);
if (link) link.classList.add('active');
if (link) link.classList.add('active');
}
}
+ if (window.LiveNav) window.LiveNav.setActive(navPage || null);
updateNavHeroTransparency();
updateNavHeroTransparency();
}
}
@@ -2326,6 +2326,7 @@ function cleanupAllStreams() {
@@ -2326,6 +2326,7 @@ function cleanupAllStreams() {
}
}
function setNavLiveIndicator(isLive) {
function setNavLiveIndicator(isLive) {
+ if (window.LiveNav) window.LiveNav.setLive(!!isLive);
const dot = document.getElementById('nav-live-dot');
const dot = document.getElementById('nav-live-dot');
if (dot) dot.style.display = isLive ? '' : 'none';
if (dot) dot.style.display = isLive ? '' : 'none';
const link = document.getElementById('nav-broadcast');
const link = document.getElementById('nav-broadcast');
@@ -221,6 +221,7 @@ function handleCoinEarned(msg) {
@@ -221,6 +221,7 @@ function handleCoinEarned(msg) {
if (msg.currency === 'gold') {
if (msg.currency === 'gold') {
const navCoins = document.getElementById('nav-coins-amount');
const navCoins = document.getElementById('nav-coins-amount');
if (navCoins) navCoins.textContent = msg.total.toLocaleString();
if (navCoins) navCoins.textContent = msg.total.toLocaleString();
+ if (window.LiveNav) window.LiveNav.setChip('coins', msg.total.toLocaleString());
return;
return;
}
}
// Otherwise it's CHANNEL POINTS for a streamer → update the in-chat balance,
// Otherwise it's CHANNEL POINTS for a streamer → update the in-chat balance,
new file mode 100644
new file mode 100644
@@ -0,0 +1,103 @@
@@ -0,0 +1,103 @@
+/* OpenVibe.Live on the network's shared navbar (vendor/openvibe-shared/navbar.js, served at /shared/navbar.js).
+ *
+ * Live used to carry its own navbar: 130 lines of markup, its own user menu, its own mobile drawer. It now
+ * mounts the same component every other OpenVibe site uses and describes what is specific to Live as data:
+ * its pages, the Go Live dropdown, the two balances, and the Live-only rows of the user menu.
+ * Live signs people in itself, so the navbar runs with auth: 'external' and is told who is here (LiveNav.sync).
+ *
+ * LiveNav.sync(user) after sign-in, sign-out or a profile change
+ * LiveNav.setActive(page) the router calls this on every page change
+ * LiveNav.setChip(id, text) 'coins' | 'vibes'
+ * LiveNav.setLive(isLive) the red dot on "Go Live"
+ */
+(function () {
+ 'use strict';
+ const call = (name, ...a) => (typeof window[name] === 'function' ? window[name](...a) : undefined);
+ const nav = () => window.OpenVibeNavbar;
+ let booted = false;
+
+ function links(user) {
+ const staff = !!(user && ((user.capabilities && user.capabilities.admin_panel) || (typeof hasCapability === 'function' && hasCapability('can_access_staff_console'))));
+ return [
+ { id: 'home', page: 'home', label: 'Home', href: '/', icon: 'fa-house' },
+ { id: 'vods', page: 'vods', label: 'VODs', href: '/vods', icon: 'fa-video' },
+ { id: 'clips', page: 'clips', label: 'Clips', href: '/clips', icon: 'fa-scissors' },
+ { id: 'pastes', page: 'pastes', label: 'Pastes', href: '/pastes', icon: 'fa-paste' },
+ { id: 'chat', page: 'chat', label: 'Chat', href: '/chat', icon: 'fa-comments' },
+ { id: 'arena', page: 'arena', label: 'Arena', href: '/arena', icon: 'fa-hand-fist' },
+ { id: 'game', page: 'game', label: 'Game', href: 'https://openvibe.games', icon: 'fa-gamepad' },
+ { id: 'broadcast', page: 'broadcast', elId: 'nav-broadcast', label: 'Go Live', href: '/broadcast', icon: 'fa-tower-broadcast', dot: false, dotId: 'nav-live-dot',
+ onClick: (e) => call('goLiveNav', e),
+ children: user ? [{ id: 'dashboard', label: 'Dashboard', href: '/dashboard', icon: 'fa-gauge-high', onClick: (e) => call('dashNav', e) }] : [] },
+ { id: 'admin', page: 'admin', label: 'Admin', href: 'https://openvibe.network/admin', icon: 'fa-shield-halved', hidden: !staff },
+ ];
+ }
+
+ function menu(user) {
+ const isAdmin = !!(user && (user.role === 'admin' || (user.capabilities && user.capabilities.is_owner)));
+ const act = (fn, ...a) => (e) => { if (e && e.preventDefault) e.preventDefault(); call(fn, ...a); return false; };
+ return {
+ label: 'Live', defaults: false,
+ headerChips: [
+ { id: 'coins', icon: 'fa-coins', tone: 'gold', value: '0', title: 'OpenCoins', onClick: () => call('openCosmeticsModal') },
+ { id: 'vibes', icon: 'fa-wallet', tone: 'green', value: '0', title: 'Vibes balance', onClick: () => call('showModal', 'buy-funds') },
+ ],
+ sections: [
+ { id: 'you', label: 'You', items: [
+ { id: 'dash', label: 'My Dashboard', icon: 'fa-gauge-high', href: '/dashboard' },
+ { id: 'channel', label: 'My Channel', icon: 'fa-user', href: user ? (typeof channelPath === 'function' ? channelPath(user.username) : '/@' + user.username) : '#' },
+ { id: 'setup', label: 'Streamer setup', icon: 'fa-list-check', onClick: act('openSetupHub') },
+ { id: 'account', label: 'My Account', icon: 'fa-id-card', href: 'https://openvibe.network/my' },
+ { id: 'adminpanel', label: 'Admin Panel', icon: 'fa-shield-halved', href: 'https://openvibe.network/admin', hidden: !isAdmin },
+ ] },
+ { id: 'live', label: 'On Live', items: [
+ { id: 'support', label: 'Support Us', icon: 'fa-coins', onClick: act('showModal', 'buy-funds') },
+ { id: 'cosmetics', label: 'Cosmetics', icon: 'fa-shirt', onClick: act('openCosmeticsModal') },
+ { id: 'shot', label: 'Screenshot', icon: 'fa-camera', onClick: act('captureScreenshot') },
+ { id: 'docs', label: 'Documentation', icon: 'fa-book', href: '/documentation' },
+ ] },
+ ],
+ after: [
+ { id: 'history', label: 'History', icon: 'fa-clock-rotate-left', href: 'https://openvibe.network/my#history' },
+ { id: 'switch', label: 'Switch account', icon: 'fa-people-arrows', onClick: act('switchAccount') },
+ { id: 'guest', label: 'Browse as guest', icon: 'fa-user-secret', onClick: act('browseAsGuest') },
+ ],
+ };
+ }
+
+ function config(user) {
+ return {
+ service: 'live', mount: '#navbar-mount', className: 'navbar', auth: 'external', apiBase: 'https://openvibe.network',
+ user: user || null, token: (function () { try { return localStorage.getItem('token'); } catch (e) { return null; } })(),
+ loginUrl: '/api/auth/sso/login', accounts: false, networkLinks: 0, history: false, silentLogin: false, fedcm: false, recent: false,
+ links: links(user), menu: menu(user),
+ chips: [
+ { id: 'coins', icon: 'fa-coins', tone: 'gold', value: '0', valueId: 'nav-coins-amount', title: 'OpenCoins: click to open your inventory', onClick: () => call('openCosmeticsModal') },
+ { id: 'vibes', icon: 'fa-wallet', tone: 'green', value: '0', valueId: 'nav-balance-amount', title: 'Vibes balance', onClick: () => call('showModal', 'buy-funds') },
+ ],
+ // Same-origin links are routes of this single-page app.
+ onNavigate: (href, e) => (typeof handleLinkClick === 'function' ? handleLinkClick(e, href) : true),
+ onLogout: () => call('logout'),
+ };
+ }
+
+ let last = { coins: '0', vibes: '0', live: false, page: null };
+ function sync(user) {
+ const N = nav(); if (!N) return;
+ booted = true; N.init(config(user)); // init() merges the new config and re-renders
+ N.setChip('coins', last.coins); N.setChip('vibes', last.vibes);
+ if (last.page) N.setActive(last.page);
+ if (last.live) N.updateLink('broadcast', { dot: true });
+ }
+
+ window.LiveNav = {
+ sync,
+ setActive(page) { last.page = page; const N = nav(); if (N && booted) N.setActive(page); },
+ setChip(id, text) { last[id] = String(text); const N = nav(); if (N && booted) N.setChip(id, String(text)); },
+ setLive(isLive) { last.live = !!isLive; const N = nav(); if (N && booted) N.updateLink('broadcast', { dot: !!isLive, label: isLive ? 'LIVE' : 'Go Live' }); },
+ };
+
+ // First paint: the bar appears signed out immediately; app.js calls sync() again when it knows who is here.
+ const start = () => sync(typeof currentUser !== 'undefined' ? currentUser : null);
+ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', start); else start();
+})();
@@ -84,6 +84,41 @@
@@ -84,6 +84,41 @@
.openvibe-navbar .ovnav-sep { width: 1px; height: 18px; background: var(--border, rgba(255,255,255,.12)); margin: 0 6px; flex: none; align-self: center; }
.openvibe-navbar .ovnav-sep { width: 1px; height: 18px; background: var(--border, rgba(255,255,255,.12)); margin: 0 6px; flex: none; align-self: center; }
@media (max-width: 1180px) { .openvibe-navbar .ovnav-net, .openvibe-navbar .ovnav-sep { display: none; } }
@media (max-width: 1180px) { .openvibe-navbar .ovnav-net, .openvibe-navbar .ovnav-sep { display: none; } }
.openvibe-navbar .ovnav-ic, .openvibe-navbar-dropdown .ovnav-ic { flex: none; font-size: 1.05em; vertical-align: -.15em; }
.openvibe-navbar .ovnav-ic, .openvibe-navbar-dropdown .ovnav-ic { flex: none; font-size: 1.05em; vertical-align: -.15em; }
+ .openvibe-navbar-links { min-width: 0; overflow-x: auto; scrollbar-width: none; }
+ .openvibe-navbar-links::-webkit-scrollbar { display: none; }
+ .openvibe-navbar-links a.ovnav-link { display: inline-flex; align-items: center; gap: 6px; white-space: nowrap; flex: none; }
+ .openvibe-navbar .ovnav-link[hidden], .openvibe-navbar .ovnav-dd[hidden], .openvibe-navbar-dropdown [hidden] { display: none !important; }
+ .openvibe-navbar .ovnav-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--danger, #ef4444); box-shadow: 0 0 0 0 rgba(239,68,68,.6); animation: ovnavPulse 1.8s infinite; }
+ .openvibe-navbar .ovnav-dot[hidden] { display: none; }
+ @keyframes ovnavPulse { 70% { box-shadow: 0 0 0 6px rgba(239,68,68,0); } 100% { box-shadow: 0 0 0 0 rgba(239,68,68,0); } }
+ .openvibe-navbar .ovnav-caret { font-size: 10px; opacity: .6; }
+ .openvibe-navbar .ovnav-dd { position: relative; display: inline-flex; flex: none; }
+ .openvibe-navbar .ovnav-dd-menu { display: none; position: absolute; top: 100%; left: 0; min-width: 180px; padding: 5px; border-radius: 12px; z-index: 1002; background: var(--bg-elevated, var(--bg-secondary, #111826)); border: 1px solid var(--border, rgba(255,255,255,.12)); box-shadow: 0 18px 44px rgba(0,0,0,.45); flex-direction: column; gap: 2px; }
+ .openvibe-navbar .ovnav-dd:hover .ovnav-dd-menu, .openvibe-navbar .ovnav-dd.open .ovnav-dd-menu, .openvibe-navbar .ovnav-dd:focus-within .ovnav-dd-menu { display: flex; }
+ .openvibe-navbar .ovnav-chip { all: unset; box-sizing: border-box; cursor: pointer; display: inline-flex; align-items: center; gap: 6px; height: 32px; padding: 0 11px; border-radius: 999px; font: 700 13px/1 inherit; color: var(--chip-c, var(--text-primary, #e6edf7)); background: color-mix(in srgb, var(--chip-c, var(--accent, #3b82f6)) 12%, transparent); border: 1px solid color-mix(in srgb, var(--chip-c, var(--accent, #3b82f6)) 38%, transparent); font-variant-numeric: tabular-nums; }
+ .ovnav-chip[data-tone="gold"] { --chip-c: #fbbf24; } .ovnav-chip[data-tone="green"] { --chip-c: #4ade80; } .ovnav-chip[hidden] { display: none !important; }
+ .openvibe-navbar-dropdown .ud-chips { display: flex; gap: 6px; flex-wrap: nowrap; margin-top: 6px; }
+ .openvibe-navbar-dropdown-header .info { min-width: 0; flex: 1; }
+ .openvibe-navbar-dropdown .ovnav-chip--menu { all: unset; box-sizing: border-box; cursor: pointer; display: inline-flex; align-items: center; gap: 5px; padding: 3px 10px; border-radius: 999px; font: 700 12px/1.2 inherit; color: var(--chip-c, #fbbf24); background: color-mix(in srgb, var(--chip-c, #fbbf24) 12%, transparent); border: 1px solid color-mix(in srgb, var(--chip-c, #fbbf24) 34%, transparent); }
+ .openvibe-navbar .ovnav-burger { all: unset; box-sizing: border-box; cursor: pointer; display: none; width: 38px; height: 38px; border-radius: 11px; flex-direction: column; align-items: center; justify-content: center; gap: 4px; flex: none; }
+ .openvibe-navbar .ovnav-burger span { display: block; width: 18px; height: 2px; border-radius: 2px; background: currentColor; transition: transform .2s, opacity .2s; }
+ .openvibe-navbar .ovnav-burger[aria-expanded="true"] span:nth-child(1) { transform: translateY(6px) rotate(45deg); }
+ .openvibe-navbar .ovnav-burger[aria-expanded="true"] span:nth-child(2) { opacity: 0; }
+ .openvibe-navbar .ovnav-burger[aria-expanded="true"] span:nth-child(3) { transform: translateY(-6px) rotate(-45deg); }
+ .openvibe-navbar .ovnav-burger:hover, .openvibe-navbar .ovnav-burger[aria-expanded="true"] { background: var(--accent-glow, rgba(59,130,246,.14)); }
+ .ovnav-drawer { display: none; position: fixed; top: calc(var(--ovnav-h, 56px) + 6px); right: 8px; width: min(300px, calc(100vw - 16px)); padding: 7px; border-radius: 16px; z-index: 1001; flex-direction: column; gap: 2px;
+ background: var(--bg-elevated, var(--bg-secondary, #111826)); border: 1px solid var(--border, rgba(255,255,255,.12)); box-shadow: 0 22px 48px rgba(0,0,0,.45);
+ max-height: calc(100vh - var(--ovnav-h, 56px) - 16px); max-height: calc(100dvh - var(--ovnav-h, 56px) - 16px - env(safe-area-inset-bottom, 0px)); overflow-y: auto; overscroll-behavior: contain; }
+ .ovnav-drawer.open { display: flex; animation: openvibe-slide-down .2s ease; }
+ .ovnav-drawer a { display: flex; align-items: center; gap: 10px; padding: 11px 12px; border-radius: 10px; color: var(--text-primary, #e6edf7); text-decoration: none; font-size: 14.5px; font-weight: 600; }
+ .ovnav-drawer a.ovnav-sublink { padding-left: 34px; font-weight: 500; color: var(--text-secondary, #a8b3c4); }
+ .ovnav-drawer a:hover, .ovnav-drawer a.active { background: var(--accent-glow, rgba(59,130,246,.14)); }
+ .ovnav-drawer a[hidden] { display: none; }
+ .ovnav-drawer .ud-label { padding: 10px 12px 3px; font-size: 10.5px; font-weight: 800; letter-spacing: .9px; text-transform: uppercase; color: var(--text-muted, #7d8aa0); }
+ .ovnav-drawer-net:empty { display: none; } .ovnav-drawer-net { border-top: 1px solid var(--border, rgba(255,255,255,.08)); margin-top: 4px; }
+ .ovnav-drawer-net a.ovnav-net { display: flex !important; }
+ @media (max-width: 860px) { .openvibe-navbar .ovnav-burger { display: inline-flex; } .openvibe-navbar .openvibe-navbar-links { display: none; } .openvibe-navbar .ovnav-chip .ovnav-ic + .ovnav-chip-v:empty { display: none; } }
+ @media (max-width: 420px) { .openvibe-navbar .ovnav-chip { padding: 0 8px; height: 30px; font-size: 12px; } }
.ovnav-launcher { position: absolute; top: calc(100% + 6px); left: 12px; width: min(440px, calc(100vw - 24px)); background: var(--bg-elevated, var(--bg-secondary, #111826)); border: 1px solid var(--border, rgba(255,255,255,.12)); border-radius: 16px; box-shadow: 0 24px 60px rgba(0,0,0,.5); padding: 12px; z-index: 1000; opacity: 0; transform: translateY(-6px) scale(.98); transform-origin: top left; pointer-events: none; transition: opacity .16s, transform .2s cubic-bezier(.2,1.2,.3,1); }
.ovnav-launcher { position: absolute; top: calc(100% + 6px); left: 12px; width: min(440px, calc(100vw - 24px)); background: var(--bg-elevated, var(--bg-secondary, #111826)); border: 1px solid var(--border, rgba(255,255,255,.12)); border-radius: 16px; box-shadow: 0 24px 60px rgba(0,0,0,.5); padding: 12px; z-index: 1000; opacity: 0; transform: translateY(-6px) scale(.98); transform-origin: top left; pointer-events: none; transition: opacity .16s, transform .2s cubic-bezier(.2,1.2,.3,1); }
.ovnav-launcher.open { opacity: 1; transform: none; pointer-events: auto; }
.ovnav-launcher.open { opacity: 1; transform: none; pointer-events: auto; }
.ovnav-launcher .ovl-q { width: 100%; box-sizing: border-box; padding: 9px 12px; border-radius: 10px; border: 1px solid var(--border, rgba(255,255,255,.12)); background: var(--bg-primary, #0a0f18); color: var(--text-primary, #e6edf7); font: 500 14px/1.2 inherit; outline: none; }
.ovnav-launcher .ovl-q { width: 100%; box-sizing: border-box; padding: 9px 12px; border-radius: 10px; border: 1px solid var(--border, rgba(255,255,255,.12)); background: var(--bg-primary, #0a0f18); color: var(--text-primary, #e6edf7); font: 500 14px/1.2 inherit; outline: none; }
@@ -281,6 +316,20 @@
@@ -281,6 +316,20 @@
'fa-text-height': '<path d="M4 7V5.5h9V7M8.5 5.5v13M6.5 18.5h4M18 6v12M15.5 8.5 18 6l2.5 2.5M15.5 15.5 18 18l2.5-2.5"/>',
'fa-text-height': '<path d="M4 7V5.5h9V7M8.5 5.5v13M6.5 18.5h4M18 6v12M15.5 8.5 18 6l2.5 2.5M15.5 15.5 18 18l2.5-2.5"/>',
'fa-wand-magic-sparkles': '<path d="M5 19 15 9M13.5 7.5l3 3M17.5 4l.6 1.4 1.4.6-1.4.6-.6 1.4-.6-1.4L15.5 6l1.4-.6zM7 5l.5 1.2 1.2.5-1.2.5L7 8.400l-.5-1.200L5.3 6.700l1.2-.5zM18.5 13l.5 1.2 1.2.5-1.2.5-.5 1.2-.5-1.2-1.2-.5 1.2-.5z"/>',
'fa-wand-magic-sparkles': '<path d="M5 19 15 9M13.5 7.5l3 3M17.5 4l.6 1.4 1.4.6-1.4.6-.6 1.4-.6-1.4L15.5 6l1.4-.6zM7 5l.5 1.2 1.2.5-1.2.5L7 8.400l-.5-1.200L5.3 6.700l1.2-.5zM18.5 13l.5 1.2 1.2.5-1.2.5-.5 1.2-.5-1.2-1.2-.5 1.2-.5z"/>',
'fa-coins': '<ellipse cx="12" cy="7.5" rx="6.5" ry="3"/><path d="M5.5 7.5v4.500c0 1.7 2.9 3 6.5 3s6.5-1.3 6.5-3V7.500M5.5 12v4.500c0 1.7 2.9 3 6.5 3s6.5-1.3 6.5-3V12"/>',
'fa-coins': '<ellipse cx="12" cy="7.5" rx="6.5" ry="3"/><path d="M5.5 7.5v4.500c0 1.7 2.9 3 6.5 3s6.5-1.3 6.5-3V7.500M5.5 12v4.500c0 1.7 2.9 3 6.5 3s6.5-1.3 6.5-3V12"/>',
+ 'fa-video': '<rect x="3.5" y="6.5" width="12" height="11" rx="2"/><path d="M15.5 10.5 20.5 8v8l-5-2.500z"/>',
+ 'fa-scissors': '<circle cx="6.5" cy="7.5" r="2.5"/><circle cx="6.5" cy="16.5" r="2.5"/><path d="M8.7 9 20 17M8.7 15 20 7"/>',
+ 'fa-comments': '<path d="M4 6.500h10a1.5 1.5 0 0 1 1.5 1.500v5a1.5 1.5 0 0 1-1.5 1.500H9l-3 2.500v-2.500H4A1.5 1.5 0 0 1 2.5 13V8A1.5 1.5 0 0 1 4 6.500zM18 10.500h2A1.5 1.5 0 0 1 21.5 12v4.500A1.5 1.5 0 0 1 20 18h-1v2.500L16 18h-4"/>',
+ 'fa-hand-fist': '<path d="M7 11V8a1.5 1.5 0 0 1 3 0v2M10 9.500V7a1.5 1.5 0 0 1 3 0v3M13 9.500V8a1.5 1.5 0 0 1 3 0v3M16 10.500a1.5 1.5 0 0 1 3 0V14a5.5 5.5 0 0 1-5.5 5.500h-2A5.5 5.5 0 0 1 6 14v-1.500a1.5 1.5 0 0 1 3 0"/>',
+ 'fa-gamepad': '<path d="M7 9h10a4 4 0 0 1 3.9 4.900l-.6 2.600a2 2 0 0 1-3.5.8L15.5 16h-7l-1.3 1.300a2 2 0 0 1-3.5-.8l-.6-2.600A4 4 0 0 1 7 9zM8 11.500v3M6.5 13h3"/><circle cx="15.5" cy="12" r=".6"/><circle cx="17.5" cy="14" r=".6"/>',
+ 'fa-list-check': '<path d="M4 7l1.5 1.500L8 6M4 12.5 5.5 14 8 11.500M11 7.500h9M11 13h9M4.5 18.500h.01M11 18.500h9"/>',
+ 'fa-id-card': '<rect x="3.5" y="5.5" width="17" height="13" rx="2"/><circle cx="9" cy="11" r="2"/><path d="M6 15.500a3 3 0 0 1 6 0M14.5 10h3.500M14.5 13.500h3.5"/>',
+ 'fa-shirt': '<path d="M8.5 4.5 4 7l1.5 3.500L7.5 10v9.500h9V10l2 .5L20 7l-4.5-2.500a3.5 3.5 0 0 1-7 0z"/>',
+ 'fa-camera': '<path d="M4 8.500h3l1.5-2.500h7L17 8.500h3A1.5 1.5 0 0 1 21.5 10v8A1.5 1.5 0 0 1 20 19.500H4A1.5 1.5 0 0 1 2.5 18v-8A1.5 1.5 0 0 1 4 8.500z"/><circle cx="12" cy="13.5" r="3.2"/>',
+ 'fa-book': '<path d="M6 4.500h12v15H6.500A1.5 1.5 0 0 1 5 18V5.500a1 1 0 0 1 1-1zM5 17.500A1.5 1.5 0 0 1 6.5 16H18M9 8.500h6"/>',
+ 'fa-wallet': '<path d="M4.5 7.500A1.5 1.5 0 0 1 6 6h11.500v3M4.5 7.500V17A1.5 1.5 0 0 0 6 18.500h12.500A1.5 1.5 0 0 0 20 17V10.500A1.5 1.5 0 0 0 18.5 9H6a1.5 1.5 0 0 1-1.5-1.500z"/><circle cx="16.5" cy="13.7" r=".8"/>',
+ 'fa-people-arrows': '<circle cx="6" cy="8" r="2"/><circle cx="18" cy="8" r="2"/><path d="M3 17a3 3 0 0 1 6 0M15 17a3 3 0 0 1 6 0M9.5 12h5M12.5 10l2 2-2 2M11.5 10l-2 2 2 2"/>',
+ 'fa-network-wired': '<rect x="9" y="3.5" width="6" height="4.5" rx="1"/><rect x="3" y="16" width="6" height="4.5" rx="1"/><rect x="15" y="16" width="6" height="4.5" rx="1"/><path d="M12 8v4M6 16v-2.500h12V16"/>',
+ 'fa-people-group': '<circle cx="9" cy="9" r="2.6"/><circle cx="16.5" cy="10" r="2"/><path d="M3.8 18.500a5.2 5.2 0 0 1 10.4 0M14.6 14.200a4 4 0 0 1 5.6 3.8"/>',
'fa-plus': '<path d="M12 5.500v13M5.5 12h13"/>',
'fa-plus': '<path d="M12 5.500v13M5.5 12h13"/>',
'fa-paste': '<rect x="6" y="6" width="12" height="14" rx="1.8"/><path d="M9.5 6V5a1 1 0 0 1 1-1h3a1 1 0 0 1 1 1v1M9 11h6M9 14h6"/>',
'fa-paste': '<rect x="6" y="6" width="12" height="14" rx="1.8"/><path d="M9.5 6V5a1 1 0 0 1 1-1h3a1 1 0 0 1 1 1v1M9 11h6M9 14h6"/>',
'fa-house': '<path d="M4.5 11.5 12 5l7.5 6.500M6.5 10v8.500h11V10"/>',
'fa-house': '<path d="M4.5 11.5 12 5l7.5 6.500M6.5 10v8.500h11V10"/>',
@@ -533,6 +582,44 @@
@@ -533,6 +582,44 @@
} catch { /* */ }
} catch { /* */ }
}
}
+ /** Status chips beside the bell (balances, counters): [{ id, icon, value, valueId, title, tone, onClick, hidden }]. */
+ function chipsHTML(chips, cls) {
+ return (Array.isArray(chips) ? chips : []).filter(Boolean).map(c => `<button type="button" class="${cls || 'ovnav-chip'}" data-chip-id="${escapeAttr(c.id)}"${c.tone ? ` data-tone="${escapeAttr(c.tone)}"` : ''}${c.title ? ` title="${escapeAttr(c.title)}"` : ''}${c.hidden ? ' hidden' : ''}>${c.icon ? navIcon(c.icon) : ''}<span class="ovnav-chip-v"${c.valueId ? ` id="${escapeAttr((cls ? 'menu-' : '') + c.valueId)}"` : ''}>${escapeAttr(c.value == null ? '' : c.value)}</span></button>`).join('');
+ }
+ function bindChips(scope) {
+ (_config.chips || []).concat((_config.menu && _config.menu.headerChips) || []).forEach((c) => {
+ if (!c || typeof c.onClick !== 'function') return;
+ scope.querySelectorAll(`[data-chip-id="${c.id}"]`).forEach(el => { if (!el.__b) { el.__b = true; el.addEventListener('click', (e) => { e.stopPropagation(); c.onClick(e); }); } });
+ });
+ }
+
+ /** Link behaviour: per-link onClick, in-app navigation (onNavigate), dropdowns, the mobile drawer. */
+ function bindLinks(nav, links) {
+ const byId = new Map(flatLinks(links).filter(l => l.id).map(l => [l.id, l]));
+ const drawer = nav.querySelector('#openvibe-drawer'), burger = nav.querySelector('#openvibe-burger');
+ const closeDrawer = () => { if (drawer) drawer.classList.remove('open'); if (burger) burger.setAttribute('aria-expanded', 'false'); };
+ nav.addEventListener('click', (e) => {
+ const a = e.target.closest && e.target.closest('a.ovnav-link, .ovnav-net'); if (!a || !nav.contains(a)) return;
+ if (e.defaultPrevented || e.button !== 0 || e.metaKey || e.ctrlKey || e.shiftKey || e.altKey) return;
+ const item = byId.get(a.getAttribute('data-link-id'));
+ // A parent of a dropdown opens it on touch (there is no hover); a second tap follows the link.
+ const dd = a.parentElement && a.parentElement.classList.contains('ovnav-dd') ? a.parentElement : null;
+ if (dd && matchMedia('(hover: none)').matches && !dd.classList.contains('open')) { e.preventDefault(); nav.querySelectorAll('.ovnav-dd.open').forEach(x => x.classList.remove('open')); dd.classList.add('open'); return; }
+ if (item && typeof item.onClick === 'function') { const r = item.onClick(e); if (r === false) e.preventDefault(); closeDrawer(); if (e.defaultPrevented) return; }
+ if (typeof _config.onNavigate === 'function' && a.target !== '_blank') {
+ let same = false; try { same = new URL(a.href, location.href).origin === location.origin; } catch { /* */ }
+ if (same && _config.onNavigate(a.getAttribute('href'), e) === false) e.preventDefault();
+ }
+ closeDrawer();
+ });
+ document.addEventListener('click', (e) => { if (!e.target.closest || !e.target.closest('.ovnav-dd')) nav.querySelectorAll('.ovnav-dd.open').forEach(x => x.classList.remove('open')); });
+ if (burger && drawer) {
+ burger.addEventListener('click', (e) => { e.stopPropagation(); const open = drawer.classList.toggle('open'); burger.setAttribute('aria-expanded', String(open)); });
+ document.addEventListener('click', (e) => { if (drawer.classList.contains('open') && !drawer.contains(e.target) && !burger.contains(e.target)) closeDrawer(); });
+ regPanel(drawer, 'nav-drawer', closeDrawer);
+ }
+ }
+
/** Dropdown, launcher and notifications share one coordinator (panels.js): one open at a time, Escape, rotation.
/** Dropdown, launcher and notifications share one coordinator (panels.js): one open at a time, Escape, rotation.
* Panels are registered where they are created (no page-wide observers); registrations queue until the script loads. */
* Panels are registered where they are created (no page-wide observers); registrations queue until the script loads. */
const _panelQueue = [];
const _panelQueue = [];
@@ -569,7 +656,7 @@
@@ -569,7 +656,7 @@
/** OpenCoins balance in the menu header (one request when the menu first opens). */
/** OpenCoins balance in the menu header (one request when the menu first opens). */
let _walletLoaded = false;
let _walletLoaded = false;
async function loadWallet(dropdown) {
async function loadWallet(dropdown) {
- if (_walletLoaded || !_config.token) return; _walletLoaded = true;
+ if (_walletLoaded || !_config.token || (_config.menu && _config.menu.headerChips)) return; _walletLoaded = true;
try {
try {
const r = await fetch(`${_config.apiBase}/api/coins/me`, { headers: { Authorization: `Bearer ${_config.token}` }, credentials: 'include' });
const r = await fetch(`${_config.apiBase}/api/coins/me`, { headers: { Authorization: `Bearer ${_config.token}` }, credentials: 'include' });
if (!r.ok) return; const j = await r.json(); const bal = Number(j.balance ?? (j.wallet && j.wallet.balance));
if (!r.ok) return; const j = await r.json(); const bal = Number(j.balance ?? (j.wallet && j.wallet.balance));
@@ -579,16 +666,16 @@
@@ -579,16 +666,16 @@
}
}
/** The network's most used sites, after the page's own links (networkLinks: false turns it off). */
/** The network's most used sites, after the page's own links (networkLinks: false turns it off). */
- function networkLinksHTML(pageLinks) {
+ function networkLinksHTML(pageLinks, inDrawer) {
if (_config.networkLinks === false) return '';
if (_config.networkLinks === false) return '';
const chrome = OVChrome.get((d) => { if (d && _navEl && !_navEl.querySelector('.ovnav-net')) { try { render(); } catch { /* */ } } });
const chrome = OVChrome.get((d) => { if (d && _navEl && !_navEl.querySelector('.ovnav-net')) { try { render(); } catch { /* */ } } });
if (!chrome) return '';
if (!chrome) return '';
const here = currentHost().toLowerCase();
const here = currentHost().toLowerCase();
const taken = new Set((pageLinks || []).map(l => { try { return new URL(l.href, location.href).hostname; } catch { return ''; } }));
const taken = new Set((pageLinks || []).map(l => { try { return new URL(l.href, location.href).hostname; } catch { return ''; } }));
- const max = typeof _config.networkLinks === 'number' ? _config.networkLinks : 4;
+ const max = inDrawer ? 6 : (typeof _config.networkLinks === 'number' ? _config.networkLinks : 4);
const pick = chrome.nav.filter(n => { try { const h = new URL(n.url).hostname; return h !== here && !here.endsWith('.' + h) && !taken.has(h); } catch { return false; } }).slice(0, max);
const pick = chrome.nav.filter(n => { try { const h = new URL(n.url).hostname; return h !== here && !here.endsWith('.' + h) && !taken.has(h); } catch { return false; } }).slice(0, max);
if (!pick.length) return '';
if (!pick.length) return '';
- return `<span class="ovnav-sep" aria-hidden="true"></span>` + pick.map(n => `<a class="ovnav-net" href="${escapeAttr(n.url)}" title="${escapeAttr(n.tagline)}">${escapeAttr(n.name)}</a>`).join('');
+ return (inDrawer ? '<div class="ud-label">Across OpenVibe</div>' : `<span class="ovnav-sep" aria-hidden="true"></span>`) + pick.map(n => `<a class="ovnav-net" href="${escapeAttr(n.url)}" title="${escapeAttr(n.tagline)}">${escapeAttr(n.name)}</a>`).join('');
}
}
/** Display settings in the launcher, for guests and members alike (theme-loader.js owns the state). */
/** Display settings in the launcher, for guests and members alike (theme-loader.js owns the state). */
@@ -619,29 +706,51 @@
@@ -619,29 +706,51 @@
const sc = document.createElement('script'); sc.id = 'ov-notify-loader'; sc.src = 'https://openvibe.network/shared/notification-ui.js'; sc.async = true; sc.onload = () => setTimeout(go, 0); document.head.appendChild(sc);
const sc = document.createElement('script'); sc.id = 'ov-notify-loader'; sc.src = 'https://openvibe.network/shared/notification-ui.js'; sc.async = true; sc.onload = () => setTimeout(go, 0); document.head.appendChild(sc);
}
}
+ /** Custom sections for the user menu: [{ label, items: [{ label, icon, href, id, elId, hidden, danger, value, onClick }] }]. */
+ function sectionsHTML(sections) {
+ return (Array.isArray(sections) ? sections : []).filter(sec => sec && Array.isArray(sec.items) && sec.items.length).map(sec =>
+ `<div class="ud-sec"${sec.id ? ` data-sec-id="${escapeAttr(sec.id)}"` : ''}${sec.hidden ? ' hidden' : ''}>${sec.label ? `<div class="ud-label">${escapeAttr(sec.label)}</div>` : ''}${sec.items.map(menuItemHTML).join('')}</div><div class="sep"></div>`).join('');
+ }
+ const sectionItems = (sections) => (Array.isArray(sections) ? sections : []).reduce((o, sec) => o.concat((sec && sec.items) || []), []);
+
function menuItemHTML(item) {
function menuItemHTML(item) {
if (!item) return '';
if (!item) return '';
if (item.sep) return '<div class="sep"></div>';
if (item.sep) return '<div class="sep"></div>';
const icon = item.icon ? `<span class="icon">${navIcon(item.icon)}</span>` : '<span class="icon"></span>';
const icon = item.icon ? `<span class="icon">${navIcon(item.icon)}</span>` : '<span class="icon"></span>';
const cls = item.danger ? ' class="danger"' : '';
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>`;
+ const id = `${item.id ? ` data-menu-id="${escapeAttr(item.id)}"` : ''}${item.elId ? ` id="${escapeAttr(item.elId)}"` : ''}${item.hidden ? ' hidden' : ''}`;
+ const val = item.value !== undefined ? `<b class="ud-val">${escapeAttr(item.value)}</b>` : '';
+ if (item.href) return `<a href="${escapeAttr(item.href)}"${cls}${id}${item.external ? ' target="_blank" rel="noopener"' : ''}>${icon} ${escapeAttr(item.label)}${val}</a>`;
+ return `<button type="button"${cls}${id}>${icon} ${escapeAttr(item.label)}${val}</button>`;
}
}
function bindMenuItems(container, items) {
function bindMenuItems(container, items) {
for (const item of items) {
for (const item of items) {
if (!item || !item.id || typeof item.onClick !== 'function') continue;
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); });
+ container.querySelector(`[data-menu-id="${item.id}"]`)?.addEventListener('click', (e) => { if (!item.href) e.preventDefault(); const r = item.onClick(e); if (r === false) e.preventDefault(); if (item.keepOpen !== true) container.closest('.openvibe-navbar-dropdown')?.classList.remove('open'); });
}
}
}
}
+ /** One top-level link. Supports { label, href, icon, id, elId, page, hidden, external, dot, dotId, children: [...] }. */
+ function linkHTML(l, inDrawer) {
+ const attrs = `${l.id ? ` data-link-id="${escapeAttr(l.id)}"` : ''}${l.elId ? ` id="${escapeAttr((inDrawer ? 'drawer-' : '') + l.elId)}"` : ''}${l.page ? ` data-page="${escapeAttr(l.page)}"` : ''}${l.hidden ? ' hidden' : ''}${l.external ? ' target="_blank" rel="noopener"' : ''}`;
+ const kids = Array.isArray(l.children) ? l.children.filter(Boolean) : [];
+ const inner = `${l.icon ? `<span class="icon">${navIcon(l.icon)}</span>` : ''}<span class="ovnav-l">${escapeAttr(l.label)}</span>${l.dot !== undefined ? `<span class="ovnav-dot"${l.dotId && !inDrawer ? ` id="${escapeAttr(l.dotId)}"` : ''}${l.dot ? '' : ' hidden'}></span>` : ''}`;
+ const a = `<a href="${escapeAttr(l.href || '#')}" class="ovnav-link${l.active ? ' active' : ''}"${attrs}>${inner}${kids.length && !inDrawer ? '<span class="ovnav-caret" aria-hidden="true">▾</span>' : ''}</a>`;
+ if (!kids.length) return a;
+ const menu = kids.map(k => `<a href="${escapeAttr(k.href || '#')}" class="ovnav-link ovnav-sublink"${k.id ? ` data-link-id="${escapeAttr(k.id)}"` : ''}${k.hidden ? ' hidden' : ''}>${k.icon ? `<span class="icon">${navIcon(k.icon)}</span>` : ''}<span class="ovnav-l">${escapeAttr(k.label)}</span></a>`).join('');
+ return inDrawer ? a + menu : `<div class="ovnav-dd"${l.hidden ? ' hidden' : ''}>${a}<div class="ovnav-dd-menu">${menu}</div></div>`;
+ }
+ const flatLinks = (list) => list.reduce((out, l) => out.concat([l], Array.isArray(l.children) ? l.children : []), []);
+
function currentLinks() {
function currentLinks() {
const list = _runtimeLinks || _config.links || SERVICE_LINKS[_config.service] || [];
const list = _runtimeLinks || _config.links || SERVICE_LINKS[_config.service] || [];
const path = (typeof location !== 'undefined' && location.pathname) || '/';
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 activePage = _activePage;
+ return list.map((l) => Object.assign({}, l, { active: l.active !== undefined ? l.active : (activePage ? l.page === activePage : (l.href === path && path !== '/')) }));
}
}
+ let _activePage = null; // setActive(): single-page apps tell the navbar where they are
const SERVICE_LINKS = {
const SERVICE_LINKS = {
live: [
live: [
{ label: 'Watch', href: '/' },
{ label: 'Watch', href: '/' },
@@ -1122,17 +1231,23 @@
@@ -1122,17 +1231,23 @@
nav.innerHTML = `
nav.innerHTML = `
${brandHTML(brand)}
${brandHTML(brand)}
<div class="openvibe-navbar-links">
<div class="openvibe-navbar-links">
- ${links.map(l => `<a href="${escapeAttr(l.href)}"${l.active ? ' class="active"' : ''}${l.external ? ' target="_blank" rel="noopener"' : ''}>${l.icon ? `<span class="icon">${navIcon(l.icon)}</span>` : ''}${escapeAttr(l.label)}</a>`).join('')}
+ ${links.map(l => linkHTML(l, false)).join('')}
${networkLinksHTML(links)}
${networkLinksHTML(links)}
- ${u && u.role === 'admin' ? `<a href="https://openvibe.network/admin">${navIcon('fa-shield-halved')} Admin</a>` : ''}
+ ${u && u.role === 'admin' && _config.adminLink !== false && !flatLinks(links).some(l => l.id === 'admin') ? `<a href="https://openvibe.network/admin">${navIcon('fa-shield-halved')} 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">
+ ${u ? chipsHTML(_config.chips) : ''}
<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>`}
+ ${links.length ? '<button type="button" class="ovnav-burger" id="openvibe-burger" aria-label="Menu" aria-haspopup="true" aria-expanded="false"><span></span><span></span><span></span></button>' : ''}
</div>
</div>
+ ${links.length ? `<div class="ovnav-drawer" id="openvibe-drawer" role="menu">${links.map(l => linkHTML(l, true)).join('')}<div class="ovnav-drawer-net">${networkLinksHTML(links, true)}</div></div>` : ''}
`;
`;
+ if (_config.className) String(_config.className).split(/\s+/).filter(Boolean).forEach(c => nav.classList.add(c));
+ bindLinks(nav, links);
+ bindChips(nav);
bindLauncher(nav);
bindLauncher(nav);
countView();
countView();
// Pages that wire the bell themselves run right after init(); give them the first go.
// Pages that wire the bell themselves run right after init(); give them the first go.
@@ -1145,6 +1260,7 @@
@@ -1145,6 +1260,7 @@
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 menuCfg = _config.menu || {};
const before = ((_config.menu && _config.menu.before) || []).concat(_runtimeMenu.before);
const before = ((_config.menu && _config.menu.before) || []).concat(_runtimeMenu.before);
const after = ((_config.menu && _config.menu.after) || []).concat(_runtimeMenu.after);
const after = ((_config.menu && _config.menu.after) || []).concat(_runtimeMenu.after);
@@ -1154,11 +1270,11 @@
@@ -1154,11 +1270,11 @@
<div class="info">
<div class="info">
<div class="name">${escapeAttr(u.display_name || u.username)}</div>
<div class="name">${escapeAttr(u.display_name || u.username)}</div>
<div class="email">${isAnon ? '' : '@' + escapeAttr(u.username || '')}</div>
<div class="email">${isAnon ? '' : '@' + escapeAttr(u.username || '')}</div>
- <div class="ud-wallet" id="openvibe-wallet" hidden></div>
+ ${menuCfg.headerChips ? `<div class="ud-chips">${chipsHTML(menuCfg.headerChips, 'ovnav-chip ovnav-chip--menu')}</div>` : '<div class="ud-wallet" id="openvibe-wallet" hidden></div>'}
${isAnon ? `<div class="anon-tag">Anonymous #${u.anon_number || '?'}</div>` : ''}
${isAnon ? `<div class="anon-tag">Anonymous #${u.anon_number || '?'}</div>` : ''}
</div>
</div>
</div>
</div>
- <div class="openvibe-navbar-dropdown-accounts">
+ <div class="openvibe-navbar-dropdown-accounts"${_config.accounts === false ? ' hidden' : ''}>
${otherAccounts.map(a => `
${otherAccounts.map(a => `
<div class="account-item" data-account-id="${a.id}">
<div class="account-item" data-account-id="${a.id}">
${avatarImg(a, 48, '', '')}
${avatarImg(a, 48, '', '')}
@@ -1177,11 +1293,12 @@
@@ -1177,11 +1293,12 @@
<div class="openvibe-navbar-dropdown-recent" id="openvibe-recent" hidden></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.length ? `<div class="ud-label">${escapeAttr((_config.menu && _config.menu.label) || brand.short || 'This site')}</div>${before.map(menuItemHTML).join('')}<div class="sep"></div>` : ''}
${before.length ? `<div class="ud-label">${escapeAttr((_config.menu && _config.menu.label) || brand.short || 'This site')}</div>${before.map(menuItemHTML).join('')}<div class="sep"></div>` : ''}
- <div class="ud-label">You</div>
+ ${sectionsHTML(menuCfg.sections)}
+ ${menuCfg.defaults === false ? '' : `<div class="ud-label">You</div>
<a href="https://openvibe.network/my"><span class="icon">${navIcon('fa-user')}</span> My Account</a>
<a href="https://openvibe.network/my"><span class="icon">${navIcon('fa-user')}</span> My Account</a>
${!isAnon && u.username ? `<a href="https://openvibe.live/@${escapeAttr(u.username)}"><span class="icon">${navIcon('fa-tower-broadcast')}</span> My Channel</a>` : ''}
${!isAnon && u.username ? `<a href="https://openvibe.live/@${escapeAttr(u.username)}"><span class="icon">${navIcon('fa-tower-broadcast')}</span> My Channel</a>` : ''}
<a href="https://openvibe.network/my#notifications"><span class="icon">${navIcon('fa-bell')}</span> Notifications</a>
<a href="https://openvibe.network/my#notifications"><span class="icon">${navIcon('fa-bell')}</span> Notifications</a>
- <div class="sep"></div>
+ <div class="sep"></div>`}
<div class="ud-label">Display</div>
<div class="ud-label">Display</div>
<button type="button" data-ov-display="text"><span class="icon">${navIcon('fa-text-height')}</span> Text size <b class="ud-val"></b></button>
<button type="button" data-ov-display="text"><span class="icon">${navIcon('fa-text-height')}</span> Text size <b class="ud-val"></b></button>
<button type="button" data-ov-display="motion"><span class="icon">${navIcon('fa-wand-magic-sparkles')}</span> Animations <b class="ud-val"></b></button>
<button type="button" data-ov-display="motion"><span class="icon">${navIcon('fa-wand-magic-sparkles')}</span> Animations <b class="ud-val"></b></button>
@@ -1189,16 +1306,19 @@
@@ -1189,16 +1306,19 @@
<div class="sep"></div>
<div class="sep"></div>
<div class="ud-label">Across OpenVibe</div>
<div class="ud-label">Across OpenVibe</div>
${acrossHTML()}
${acrossHTML()}
+ ${menuCfg.defaults === false ? '' : `
<a href="https://openvibe.network/my#history"><span class="icon">${navIcon('fa-clock-rotate-left')}</span> History</a>
<a href="https://openvibe.network/my#history"><span class="icon">${navIcon('fa-clock-rotate-left')}</span> History</a>
<a href="https://openvibe.network/my#linked"><span class="icon">${navIcon('fa-link')}</span> Linked Services</a>
<a href="https://openvibe.network/my#linked"><span class="icon">${navIcon('fa-link')}</span> Linked Services</a>
- ${u.role === 'admin' ? `<a href="https://openvibe.network/admin"><span class="icon">${navIcon('fa-shield-halved')}</span> Admin Panel</a>` : ''}
+ ${u.role === 'admin' ? `<a href="https://openvibe.network/admin"><span class="icon">${navIcon('fa-shield-halved')}</span> Admin Panel</a>` : ''}`}
${after.length ? '<div class="sep"></div>' : ''}${after.map(menuItemHTML).join('')}
${after.length ? '<div class="sep"></div>' : ''}${after.map(menuItemHTML).join('')}
<div class="sep"></div>
<div class="sep"></div>
<button id="openvibe-logout-btn" class="danger"><span class="icon">${navIcon('fa-right-from-bracket')}</span> Sign Out</button>
<button id="openvibe-logout-btn" class="danger"><span class="icon">${navIcon('fa-right-from-bracket')}</span> Sign Out</button>
</div>
</div>
`;
`;
nav.appendChild(dropdown);
nav.appendChild(dropdown);
- bindMenuItems(dropdown, before.concat(after));
+ bindMenuItems(dropdown, before.concat(after, sectionItems(menuCfg.sections)));
+ bindChips(dropdown);
+ if (typeof _config.onNavigate === 'function') dropdown.addEventListener('click', (e) => { const a = e.target.closest && e.target.closest('a[href]'); if (!a || e.defaultPrevented || a.target === '_blank' || e.metaKey || e.ctrlKey) return; let same = false; try { same = new URL(a.href, location.href).origin === location.origin; } catch { /* */ } if (same) { if (_config.onNavigate(a.getAttribute('href'), e) === false) e.preventDefault(); dropdown.classList.remove('open'); } });
bindDisplayRows(dropdown);
bindDisplayRows(dropdown);
regPanel(dropdown, 'user-menu');
regPanel(dropdown, 'user-menu');
if (_config.recent !== false) renderRecent(dropdown.querySelector('#openvibe-recent'));
if (_config.recent !== false) renderRecent(dropdown.querySelector('#openvibe-recent'));
@@ -1272,7 +1392,9 @@
@@ -1272,7 +1392,9 @@
// only a token, or nothing at all — gets the user resolved from
// only a token, or nothing at all — gets the user resolved from
// 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();
+ // auth: 'external' — the site signs people in itself (Live) and tells us with setUser().
+ if (_config.auth === 'external') { if (_config.user) { recordHistory(); enableHandoff(); } }
+ else if (!_config.user) refreshAuthState();
else { recordHistory(); enableHandoff(); }
else { recordHistory(); enableHandoff(); }
return el;
return el;
},
},
@@ -1312,6 +1434,34 @@
@@ -1312,6 +1434,34 @@
/** The resolved brand for this page ({ sub, core, tld, name, short, variant }). */
/** The resolved brand for this page ({ sub, core, tld, name, short, variant }). */
brand() { return resolveBrand(); },
brand() { return resolveBrand(); },
+ /** The signed-in user changed (sites with auth: 'external'). Pass null for signed out. */
+ setUser(user, token) { _config.user = user || null; if (token !== undefined) _config.token = token; _walletLoaded = false; return render(); },
+ /** Single-page apps: mark the link whose `page` matches as active. */
+ setActive(page) { _activePage = page || null; if (!_navEl) return; _navEl.querySelectorAll('.ovnav-link[data-page]').forEach(a => a.classList.toggle('active', a.getAttribute('data-page') === _activePage)); },
+ /** Change a status chip's text (navbar and menu header): setChip('coins', '1,234'). */
+ setChip(id, value, patch) {
+ const lists = [_config.chips || [], (_config.menu && _config.menu.headerChips) || []];
+ lists.forEach(l => l.forEach(c => { if (c && c.id === id) { c.value = value; if (patch) Object.assign(c, patch); } }));
+ document.querySelectorAll(`[data-chip-id="${id}"]`).forEach(el => { const v = el.querySelector('.ovnav-chip-v'); if (v) v.textContent = value == null ? '' : String(value); if (patch && 'hidden' in patch) el.hidden = !!patch.hidden; });
+ },
+ /** Patch a top-level or dropdown link by id: updateLink('broadcast', { hidden: false, dot: true, label }). */
+ updateLink(id, patch) {
+ const list = _runtimeLinks || _config.links || [];
+ const l = flatLinks(list).find(x => x && x.id === id); if (!l) return; Object.assign(l, patch || {});
+ if (!_navEl) return;
+ _navEl.querySelectorAll(`[data-link-id="${id}"]`).forEach(a => {
+ if ('hidden' in patch) { a.hidden = !!patch.hidden; const dd = a.parentElement; if (dd && dd.classList.contains('ovnav-dd')) dd.hidden = !!patch.hidden; }
+ if ('label' in patch) { const t = a.querySelector('.ovnav-l'); if (t) t.textContent = patch.label; }
+ if ('href' in patch) a.setAttribute('href', patch.href);
+ if ('dot' in patch) { const d = a.querySelector('.ovnav-dot'); if (d) d.hidden = !patch.dot; }
+ });
+ },
+ /** Patch a custom menu item by id: updateMenuItem('admin', { hidden: false, value: '3' }). */
+ updateMenuItem(id, patch) {
+ const item = sectionItems(_config.menu && _config.menu.sections).find(x => x && x.id === id); if (item) Object.assign(item, patch || {});
+ document.querySelectorAll(`.openvibe-navbar-dropdown [data-menu-id="${id}"]`).forEach(el => { if ('hidden' in patch) el.hidden = !!patch.hidden; if ('value' in patch) { const v = el.querySelector('.ud-val'); if (v) v.textContent = patch.value; } });
+ },
+
/** The activity island (island.js), loaded on first use: OpenVibeNavbar.activity.start({...}). */
/** The activity island (island.js), loaded on first use: OpenVibeNavbar.activity.start({...}). */
get activity() { return root.OpenVibeIsland || null; },
get activity() { return root.OpenVibeIsland || null; },
set activity(_v) { /* island.js binds itself; nothing to store */ },
set activity(_v) { /* island.js binds itself; nothing to store */ },