openvibers/openvibe.live Commits
Menus coordinated by the shared panel controller; avatar and display name reported to the Network; vendor sync
Changes
@@@ -842,7 +842,7 @@ function openChannelRewards() {function toggleUserMenu() {const dd = document.getElementById('user-dropdown');const open = dd.classList.toggle('show');-- if (open) { closeMobileNav(); dd.scrollTop = 0; } // one panel at a time, always opened from the top++ if (open && !window.OpenVibePanels) closeMobileNav(); // the shared controller does this once it has loaded}function closeMobileNav() {@@@ -855,7 +855,7 @@ function toggleMobileNav() {const hamburger = document.querySelector('.nav-hamburger');navLinks.classList.toggle('show');hamburger?.classList.toggle('open', navLinks.classList.contains('show'));-- if (navLinks.classList.contains('show')) { document.getElementById('user-dropdown')?.classList.remove('show'); navLinks.scrollTop = 0; }++ if (navLinks.classList.contains('show') && !window.OpenVibePanels) document.getElementById('user-dropdown')?.classList.remove('show');}function isModifiedLinkClick(event) {@@@ -1475,13 +1475,21 @@ function positionNavDropdownMenu(dropdown) {menu.style.maxHeight = `${Math.max(120, vh - rect.bottom - pad)}px`;}--// While the user menu or the mobile drawer is open, the floating chat button steps aside (it used to sit on--// top of the menu's last rows). Driven by the panels' own classes, so every open/close path is covered.++// Live's menus are coordinated by the network's shared panel controller (vendor/openvibe-shared/panels.js, the same++// one the shared navbar uses on every other site): one panel open at a time, Escape and rotation close them, and++// <body class="ov-panel-open"> lets the floating chat button step aside. Live keeps its own markup and toggles.{-- const sync = () => document.body.classList.toggle('ov-panel-open',-- !!document.querySelector('#user-dropdown.show, .nav-links.show'));-- const watch = () => { for (const el of [document.getElementById('user-dropdown'), document.querySelector('.nav-links')]) if (el) new MutationObserver(sync).observe(el, { attributes: true, attributeFilter: ['class'] }); };-- if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', watch); else watch();++ const register = () => {++ const P = window.OpenVibePanels; if (!P) return;++ const dd = document.getElementById('user-dropdown'); if (dd) P.register({ el: dd, openClass: 'show', id: 'user-menu' });++ const nl = document.querySelector('.nav-links'); if (nl) P.register({ el: nl, openClass: 'show', id: 'nav-drawer', close: () => closeMobileNav() });++ document.querySelectorAll('.nav-dropdown').forEach((d, i) => P.register({ el: d, openClass: 'open', id: 'nav-dropdown-' + i }));++ };++ const load = () => {++ if (window.OpenVibePanels) return register();++ const sc = document.createElement('script'); sc.src = '/shared/panels.js'; sc.async = true; sc.onload = register; document.head.appendChild(sc);++ };++ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', load); else load();}/** Close every navbar panel (used when the viewport changes under an open menu). */@@@ -1497,8 +1505,6 @@ function closeNavPanels() {const reflow = () => { cancelAnimationFrame(raf); raf = requestAnimationFrame(() => document.querySelectorAll('.nav-dropdown.open, .nav-dropdown:hover').forEach(positionNavDropdownMenu)); };window.addEventListener('resize', reflow, { passive: true });if (window.visualViewport) window.visualViewport.addEventListener('resize', reflow, { passive: true });-- window.addEventListener('orientationchange', closeNavPanels, { passive: true });-- document.addEventListener('keydown', (e) => { if (e.key === 'Escape') closeNavPanels(); });}// Observe hover/open to position dropdown menus@@@ -233,6 +233,7 @@ router.post('/avatar', requireAuth, avatarUpload.single('avatar'), async (req, rdb.updateUserAvatar(req.user.id, screenshotUrl, paste.id || null);const updated = db.getUserById(req.user.id);++ try { require('../utils/notify').reportLinkedAccount(updated); } catch { /* the Network learns the new picture on the next sign-in */ }res.json({ user: sanitizeUser(updated), avatar_url: screenshotUrl });} catch (err) {if (req.file) try { fs.unlinkSync(req.file.path); } catch { }@@@ -435,7 +435,7 @@ app.use((req, res, next) => {// Serve openvibe-shared browser assets at /shared/ — serve directly from the// resolved source directory to avoid write operations (the public/ directory// is read-only under systemd ProtectSystem=strict on production).--const SHARED_BROWSER_FILES = ['theme-loader.js', 'notification-ui.js', 'account-switcher.js', 'openvibe-sw.js', 'footer.js', 'ov-mark.js', 'tooltip.js', 'history.js', 'navbar.js', 'sso-client.js', 'ov-icons.js', 'island.js', 'ui.js'];++const SHARED_BROWSER_FILES = ['theme-loader.js', 'notification-ui.js', 'account-switcher.js', 'openvibe-sw.js', 'footer.js', 'ov-mark.js', 'tooltip.js', 'history.js', 'navbar.js', 'sso-client.js', 'ov-icons.js', 'island.js', 'ui.js', 'panels.js'];let sharedServePath = null;(function resolveSharedAssets() {@@@ -64,10 +64,16 @@ function pushNotification(payload) {// Register this user as having a linked OpenVibe.Live account on openvibe.network so it// appears under their Linked Services. Fire-and-forget + deduped per process.--const _linkedReported = new Set();++const _linkedReported = new Map(); // live user id → what was last reported (so a new avatar or name is sent again)function reportLinkedAccount(user) {-- if (!user?.id || _linkedReported.has(user.id) || !INTERNAL_API_KEY) return;-- _linkedReported.add(user.id);++ if (!user?.id || !INTERNAL_API_KEY) return;++ // The Network adopts this picture and name when the account has none of its own, which is what makes the++ // same face appear on every OpenVibe site. Relative upload paths are made absolute against this site.++ let avatar = user.avatar_url || null;++ if (avatar && avatar.startsWith('/')) avatar = `${String(config.baseUrl || 'https://openvibe.live').replace(/\/$/, '')}${avatar}`;++ const sig = `${avatar || ''}|${user.display_name || ''}|${user.username || ''}`;++ if (_linkedReported.get(user.id) === sig) return;++ _linkedReported.set(user.id, sig);const networkId = toNetworkId(user.id);if (!networkId) { _linkedReported.delete(user.id); return; }_post('/internal/link-account', {@@@ -75,6 +81,8 @@ function reportLinkedAccount(user) {service: 'live',service_user_id: String(user.id),service_username: user.username || user.display_name || null,++ avatar_url: avatar,++ display_name: user.display_name || null,}).catch(() => { _linkedReported.delete(user.id); });}@@@ -151,6 +151,13 @@.openvibe-navbar-dropdown-recent .item .icon { width: 18px; text-align: center; color: var(--accent-light, #60a5fa); flex: none; }.openvibe-navbar-dropdown-recent .item .t { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.openvibe-navbar-dropdown-recent .item .s { margin-left: auto; font-size: 10px; color: var(--text-muted, #707080); flex: none; }++ .openvibe-navbar-dropdown { width: min(300px, calc(100vw - 16px)); }++ .openvibe-navbar-dropdown-menu .ud-label { padding: 8px 10px 3px; font-size: 10.5px; font-weight: 800; letter-spacing: .9px; text-transform: uppercase; color: var(--text-muted, #7d8aa0); }++ .openvibe-navbar-dropdown-menu .ud-val { margin-left: auto; font-weight: 600; font-size: 12.5px; color: var(--text-secondary, #a8b3c4); }++ .openvibe-navbar-dropdown-menu .icon { display: inline-grid; place-items: center; width: 22px; flex: none; }++ .openvibe-navbar-dropdown-header { background: linear-gradient(180deg, color-mix(in srgb, var(--accent, #3b82f6) 10%, transparent), transparent); }++ .openvibe-navbar-dropdown-header img { border-radius: 50%; box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent, #3b82f6) 55%, transparent); }++ .openvibe-navbar-dropdown-header .ud-wallet a { display: inline-flex; align-items: center; gap: 5px; margin-top: 5px; padding: 2px 9px; border-radius: 999px; font-size: 12px; font-weight: 700; text-decoration: none; color: #fbbf24; background: rgba(251,191,36,.12); border: 1px solid rgba(251,191,36,.3); }.openvibe-navbar-dropdown-menu .sep { height: 1px; background: var(--border, #333340); margin: 4px -8px; }.openvibe-navbar-links { display: flex; align-items: center; gap: 4px; margin-left: 8px; }@@@ -270,6 +277,10 @@'fa-shield-halved': '<path d="M12 3.5l7 2.500v5.500c0 4.3-3 7.3-7 9-4-1.7-7-4.7-7-9V6zM12 3.500v17"/>','fa-screwdriver-wrench': '<path d="M14.5 5.5a4 4 0 0 0-5.2 5.2L4.5 15.5a1.8 1.8 0 0 0 2.5 2.5l4.8-4.8a4 4 0 0 0 5.2-5.2l-2.6 2.6-2.1-.5-.5-2.1z"/>','fa-right-from-bracket': '<path d="M10 5H6.500A1.5 1.5 0 0 0 5 6.500v11A1.5 1.5 0 0 0 6.5 19H10M14 8l4 4-4 4M18 12H9.5"/>',++ 'fa-tower-broadcast': '<circle cx="12" cy="12" r="2"/><path d="M7.8 7.8a6 6 0 0 0 0 8.4M16.2 7.8a6 6 0 0 1 0 8.4M5 5a10 10 0 0 0 0 14M19 5a10 10 0 0 1 0 14"/>',++ '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-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"/>',@@@ -488,6 +499,7 @@panel.innerHTML = '<input type="search" class="ovl-q" placeholder="Find a tool or site" aria-label="Find a tool or site"><div class="ovl-body"></div><div class="ovl-display" hidden></div>';bindDisplayControls(panel.querySelector('.ovl-display'));nav.appendChild(panel);++ regPanel(panel, 'launcher', close);if (!root.OpenVibeIcons && !document.getElementById('ov-icons-loader')) { const sc = document.createElement('script'); sc.id = 'ov-icons-loader'; sc.src = 'https://openvibe.network/shared/ov-icons.js'; sc.async = true; document.head.appendChild(sc); }let cat = null; paint(null, '');const input = panel.querySelector('.ovl-q');@@@ -521,6 +533,51 @@} catch { /* */ }}++ /** 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 = [];++ function regPanel(el, id, close) {++ if (!el) return;++ const opts = { el, openClass: 'open', id, close };++ if (root.OpenVibePanels) return root.OpenVibePanels.register(opts);++ _panelQueue.push(opts);++ if (document.getElementById('ov-panels-loader')) return;++ const sc = document.createElement('script'); sc.id = 'ov-panels-loader'; sc.src = 'https://openvibe.network/shared/panels.js'; sc.async = true;++ sc.onload = () => { while (_panelQueue.length) root.OpenVibePanels && root.OpenVibePanels.register(_panelQueue.shift()); };++ document.head.appendChild(sc);++ }++++ /** Sites in the user menu: most used first (chrome data), never the one we are on. */++ function acrossHTML() {++ const chrome = OVChrome.get();++ const here = currentHost().toLowerCase();++ const list = (chrome && chrome.nav.length ? chrome.nav.map(n => ({ name: n.name, url: n.url, icon: n.icon || n.id })) : LAUNCHER_SITES)++ .filter(n => { try { const h = new URL(n.url).hostname; return h !== here && !here.endsWith('.' + h); } catch { return false; } }).slice(0, 5);++ return list.map(n => `<a href="${escapeAttr(n.url)}"><span class="icon"><span class="ov-icon" data-icon="${escapeAttr(n.icon)}" data-size="20" data-fx="none"></span></span> ${escapeAttr(n.name)}</a>`).join('');++ }++++ /** Display rows (text size, animations) cycle their value; state lives in theme-loader.js. */++ function bindDisplayRows(dropdown) {++ const L = root.OpenVibeThemeLoader; const rows = dropdown.querySelectorAll('[data-ov-display]');++ if (!L || !L.display) { rows.forEach(r => { r.hidden = true; }); return; }++ const LABEL = { text: { 100: 'Default', 112: 'Large', 125: 'Largest' }, motion: { auto: 'On', reduced: 'Calm' } };++ const paint = () => { const d = L.display.get(); rows.forEach(r => { const k = r.getAttribute('data-ov-display'); r.querySelector('.ud-val').textContent = LABEL[k][d[k]] || ''; }); };++ rows.forEach(r => r.addEventListener('click', (e) => { e.stopPropagation(); const k = r.getAttribute('data-ov-display'), o = L.display.options[k], cur = L.display.get()[k]; L.display.set({ [k]: o[(o.indexOf(cur) + 1) % o.length] }); paint(); }));++ root.addEventListener('ov:display', paint); paint();++ }++++ /** 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;++ 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));++ const el = dropdown.querySelector('#openvibe-wallet'); if (!el || !isFinite(bal)) return;++ el.innerHTML = `<a href="https://openvibe.network/my#coins" title="OpenCoins">${navIcon('fa-coins')} ${bal.toLocaleString()}</a>`; el.hidden = false;++ } catch { /* the chip is optional */ }++ }++/** The network's most used sites, after the page's own links (networkLinks: false turns it off). */function networkLinksHTML(pageLinks) {if (_config.networkLinks === false) return '';@@@ -1095,8 +1152,9 @@<div class="openvibe-navbar-dropdown-header">${avatarImg(u, 72, '', '')}<div class="info">-- <div class="name">${u.display_name || u.username}</div>-- <div class="email">${u.email || `@${u.username}`}</div>++ <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>${isAnon ? `<div class="anon-tag">Anonymous #${u.anon_number || '?'}</div>` : ''}</div></div>@@@ -1118,13 +1176,22 @@</div><div class="openvibe-navbar-dropdown-recent" id="openvibe-recent" hidden></div><div class="openvibe-navbar-dropdown-menu">-- ${before.map(menuItemHTML).join('')}${before.length ? '<div class="sep"></div>' : ''}++ ${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><a href="https://openvibe.network/my"><span class="icon">${navIcon('fa-user')}</span> My Account</a>-- <a href="https://openvibe.network/my#history"><span class="icon">${navIcon('fa-clock-rotate-left')}</span> History</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="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><a href="https://openvibe.network/themes"><span class="icon">${navIcon('fa-palette')}</span> Themes</a>++ <div class="sep"></div>++ <div class="ud-label">Across OpenVibe</div>++ ${acrossHTML()}++ <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-screwdriver-wrench')}</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>@@@ -1132,11 +1199,14 @@`;nav.appendChild(dropdown);bindMenuItems(dropdown, before.concat(after));++ bindDisplayRows(dropdown);++ regPanel(dropdown, 'user-menu');if (_config.recent !== false) renderRecent(dropdown.querySelector('#openvibe-recent'));// Avatar click toggles dropdownnav.querySelector('#openvibe-avatar-btn').addEventListener('click', () => {dropdown.classList.toggle('open');++ if (dropdown.classList.contains('open')) { loadWallet(dropdown); dropdown.scrollTop = 0; if (root.OpenVibeIcons) root.OpenVibeIcons.mount(dropdown); else if (!document.getElementById('ov-icons-loader')) { const sc = document.createElement('script'); sc.id = 'ov-icons-loader'; sc.src = 'https://openvibe.network/shared/ov-icons.js'; sc.async = true; document.head.appendChild(sc); } }});// Close on outside click@@@ -567,6 +567,7 @@function togglePanel() {if (!_panelEl) createPanel();const opening = !_panelEl.classList.contains('open');++ if (opening && window.OpenVibePanels && !_panelEl.__registered) { _panelEl.__registered = true; window.OpenVibePanels.register({ el: _panelEl, openClass: 'open', id: 'notifications', close: () => { if (_panelEl.classList.contains('open')) togglePanel(); } }); window.OpenVibePanels.closeAll(_panelEl); }if (opening) {// Sit directly under the site's navbar, whatever its height (sticky bars, banners, safe areas).const bar = _bellEl && _bellEl.closest('nav, header, .navbar');nnew file mode 100644@@@ -0,0 +1,53 @@++/**++ * openvibe-shared/panels.js — one coordinator for every dropdown, drawer and popover on a page.++ *++ * Sites keep their own markup and their own open/close toggles. They register each panel here and get the++ * behaviour that should be identical everywhere:++ * • one panel open at a time (opening one closes the others)++ * • Escape and a device rotation close everything++ * • <body class="ov-panel-open"> while any panel is open (floating buttons step aside)++ * • a freshly opened panel starts scrolled to the top++ *++ * OpenVibePanels.register({ el, openClass: 'show', close: () => …, id?: 'user-menu' })++ * OpenVibePanels.closeAll(exceptEl?) · OpenVibePanels.anyOpen()++ *++ * It watches the panel's class instead of wrapping the site's click handlers, so it cannot fight them: a++ * panel is "open" exactly when its element has `openClass`, however that came about.++ */++(function (root) {++ 'use strict';++ if (root.OpenVibePanels) return;++ if (typeof document === 'undefined') { const stub = { register() {}, closeAll() {}, anyOpen: () => false }; if (typeof module !== 'undefined') module.exports = stub; return; }++++ const panels = []; // { el, openClass, close, id }++ const isOpen = (p) => p.el.isConnected && p.el.classList.contains(p.openClass);++ const anyOpen = () => panels.some(isOpen);++ const syncBody = () => document.body && document.body.classList.toggle('ov-panel-open', anyOpen());++++ function closeAll(except) {++ // A panel nested in another (a submenu inside the mobile drawer) never closes its container, or vice versa.++ const related = (a, b) => a && b && (a.contains(b) || b.contains(a));++ for (const p of panels) if (p.el !== except && !related(p.el, except) && isOpen(p)) { try { p.close(); } catch { p.el.classList.remove(p.openClass); } }++ syncBody();++ }++++ function register(opts) {++ const el = opts && opts.el; if (!el || panels.some(p => p.el === el)) return;++ const p = { el, openClass: opts.openClass || 'open', id: opts.id || '', close: typeof opts.close === 'function' ? opts.close : () => el.classList.remove(opts.openClass || 'open') };++ panels.push(p);++ let was = isOpen(p);++ new MutationObserver(() => {++ const now = isOpen(p); if (now === was) return; was = now;++ if (now) { closeAll(el); try { el.scrollTop = 0; } catch { /* */ } }++ syncBody();++ }).observe(el, { attributes: true, attributeFilter: ['class'] });++ syncBody();++ }++++ document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && anyOpen()) closeAll(); });++ root.addEventListener('orientationchange', () => closeAll(), { passive: true });++++ const api = { register, closeAll, anyOpen };++ root.OpenVibePanels = api;++ if (typeof module !== 'undefined' && module.exports) module.exports = api;++})(typeof window !== 'undefined' ? window : globalThis);
@@ -842,7 +842,7 @@ function openChannelRewards() {
@@ -842,7 +842,7 @@ function openChannelRewards() {
function toggleUserMenu() {
function toggleUserMenu() {
const dd = document.getElementById('user-dropdown');
const dd = document.getElementById('user-dropdown');
const open = dd.classList.toggle('show');
const open = dd.classList.toggle('show');
- if (open) { closeMobileNav(); dd.scrollTop = 0; } // one panel at a time, always opened from the top
+ if (open && !window.OpenVibePanels) closeMobileNav(); // the shared controller does this once it has loaded
}
}
function closeMobileNav() {
function closeMobileNav() {
@@ -855,7 +855,7 @@ function toggleMobileNav() {
@@ -855,7 +855,7 @@ function toggleMobileNav() {
const hamburger = document.querySelector('.nav-hamburger');
const hamburger = document.querySelector('.nav-hamburger');
navLinks.classList.toggle('show');
navLinks.classList.toggle('show');
hamburger?.classList.toggle('open', navLinks.classList.contains('show'));
hamburger?.classList.toggle('open', navLinks.classList.contains('show'));
- if (navLinks.classList.contains('show')) { document.getElementById('user-dropdown')?.classList.remove('show'); navLinks.scrollTop = 0; }
+ if (navLinks.classList.contains('show') && !window.OpenVibePanels) document.getElementById('user-dropdown')?.classList.remove('show');
}
}
function isModifiedLinkClick(event) {
function isModifiedLinkClick(event) {
@@ -1475,13 +1475,21 @@ function positionNavDropdownMenu(dropdown) {
@@ -1475,13 +1475,21 @@ function positionNavDropdownMenu(dropdown) {
menu.style.maxHeight = `${Math.max(120, vh - rect.bottom - pad)}px`;
menu.style.maxHeight = `${Math.max(120, vh - rect.bottom - pad)}px`;
}
}
-// While the user menu or the mobile drawer is open, the floating chat button steps aside (it used to sit on
-// top of the menu's last rows). Driven by the panels' own classes, so every open/close path is covered.
+// Live's menus are coordinated by the network's shared panel controller (vendor/openvibe-shared/panels.js, the same
+// one the shared navbar uses on every other site): one panel open at a time, Escape and rotation close them, and
+// <body class="ov-panel-open"> lets the floating chat button step aside. Live keeps its own markup and toggles.
{
{
- const sync = () => document.body.classList.toggle('ov-panel-open',
- !!document.querySelector('#user-dropdown.show, .nav-links.show'));
- const watch = () => { for (const el of [document.getElementById('user-dropdown'), document.querySelector('.nav-links')]) if (el) new MutationObserver(sync).observe(el, { attributes: true, attributeFilter: ['class'] }); };
- if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', watch); else watch();
+ const register = () => {
+ const P = window.OpenVibePanels; if (!P) return;
+ const dd = document.getElementById('user-dropdown'); if (dd) P.register({ el: dd, openClass: 'show', id: 'user-menu' });
+ const nl = document.querySelector('.nav-links'); if (nl) P.register({ el: nl, openClass: 'show', id: 'nav-drawer', close: () => closeMobileNav() });
+ document.querySelectorAll('.nav-dropdown').forEach((d, i) => P.register({ el: d, openClass: 'open', id: 'nav-dropdown-' + i }));
+ };
+ const load = () => {
+ if (window.OpenVibePanels) return register();
+ const sc = document.createElement('script'); sc.src = '/shared/panels.js'; sc.async = true; sc.onload = register; document.head.appendChild(sc);
+ };
+ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', load); else load();
}
}
/** Close every navbar panel (used when the viewport changes under an open menu). */
/** Close every navbar panel (used when the viewport changes under an open menu). */
@@ -1497,8 +1505,6 @@ function closeNavPanels() {
@@ -1497,8 +1505,6 @@ function closeNavPanels() {
const reflow = () => { cancelAnimationFrame(raf); raf = requestAnimationFrame(() => document.querySelectorAll('.nav-dropdown.open, .nav-dropdown:hover').forEach(positionNavDropdownMenu)); };
const reflow = () => { cancelAnimationFrame(raf); raf = requestAnimationFrame(() => document.querySelectorAll('.nav-dropdown.open, .nav-dropdown:hover').forEach(positionNavDropdownMenu)); };
window.addEventListener('resize', reflow, { passive: true });
window.addEventListener('resize', reflow, { passive: true });
if (window.visualViewport) window.visualViewport.addEventListener('resize', reflow, { passive: true });
if (window.visualViewport) window.visualViewport.addEventListener('resize', reflow, { passive: true });
- window.addEventListener('orientationchange', closeNavPanels, { passive: true });
- document.addEventListener('keydown', (e) => { if (e.key === 'Escape') closeNavPanels(); });
}
}
// Observe hover/open to position dropdown menus
// Observe hover/open to position dropdown menus
@@ -233,6 +233,7 @@ router.post('/avatar', requireAuth, avatarUpload.single('avatar'), async (req, r
@@ -233,6 +233,7 @@ router.post('/avatar', requireAuth, avatarUpload.single('avatar'), async (req, r
db.updateUserAvatar(req.user.id, screenshotUrl, paste.id || null);
db.updateUserAvatar(req.user.id, screenshotUrl, paste.id || null);
const updated = db.getUserById(req.user.id);
const updated = db.getUserById(req.user.id);
+ try { require('../utils/notify').reportLinkedAccount(updated); } catch { /* the Network learns the new picture on the next sign-in */ }
res.json({ user: sanitizeUser(updated), avatar_url: screenshotUrl });
res.json({ user: sanitizeUser(updated), avatar_url: screenshotUrl });
} catch (err) {
} catch (err) {
if (req.file) try { fs.unlinkSync(req.file.path); } catch { }
if (req.file) try { fs.unlinkSync(req.file.path); } catch { }
@@ -435,7 +435,7 @@ app.use((req, res, next) => {
@@ -435,7 +435,7 @@ app.use((req, res, next) => {
// Serve openvibe-shared browser assets at /shared/ — serve directly from the
// Serve openvibe-shared browser assets at /shared/ — serve directly from the
// resolved source directory to avoid write operations (the public/ directory
// resolved source directory to avoid write operations (the public/ directory
// is read-only under systemd ProtectSystem=strict on production).
// is read-only under systemd ProtectSystem=strict on production).
-const SHARED_BROWSER_FILES = ['theme-loader.js', 'notification-ui.js', 'account-switcher.js', 'openvibe-sw.js', 'footer.js', 'ov-mark.js', 'tooltip.js', 'history.js', 'navbar.js', 'sso-client.js', 'ov-icons.js', 'island.js', 'ui.js'];
+const SHARED_BROWSER_FILES = ['theme-loader.js', 'notification-ui.js', 'account-switcher.js', 'openvibe-sw.js', 'footer.js', 'ov-mark.js', 'tooltip.js', 'history.js', 'navbar.js', 'sso-client.js', 'ov-icons.js', 'island.js', 'ui.js', 'panels.js'];
let sharedServePath = null;
let sharedServePath = null;
(function resolveSharedAssets() {
(function resolveSharedAssets() {
@@ -64,10 +64,16 @@ function pushNotification(payload) {
@@ -64,10 +64,16 @@ function pushNotification(payload) {
// Register this user as having a linked OpenVibe.Live account on openvibe.network so it
// Register this user as having a linked OpenVibe.Live account on openvibe.network so it
// appears under their Linked Services. Fire-and-forget + deduped per process.
// appears under their Linked Services. Fire-and-forget + deduped per process.
-const _linkedReported = new Set();
+const _linkedReported = new Map(); // live user id → what was last reported (so a new avatar or name is sent again)
function reportLinkedAccount(user) {
function reportLinkedAccount(user) {
- if (!user?.id || _linkedReported.has(user.id) || !INTERNAL_API_KEY) return;
- _linkedReported.add(user.id);
+ if (!user?.id || !INTERNAL_API_KEY) return;
+ // The Network adopts this picture and name when the account has none of its own, which is what makes the
+ // same face appear on every OpenVibe site. Relative upload paths are made absolute against this site.
+ let avatar = user.avatar_url || null;
+ if (avatar && avatar.startsWith('/')) avatar = `${String(config.baseUrl || 'https://openvibe.live').replace(/\/$/, '')}${avatar}`;
+ const sig = `${avatar || ''}|${user.display_name || ''}|${user.username || ''}`;
+ if (_linkedReported.get(user.id) === sig) return;
+ _linkedReported.set(user.id, sig);
const networkId = toNetworkId(user.id);
const networkId = toNetworkId(user.id);
if (!networkId) { _linkedReported.delete(user.id); return; }
if (!networkId) { _linkedReported.delete(user.id); return; }
_post('/internal/link-account', {
_post('/internal/link-account', {
@@ -75,6 +81,8 @@ function reportLinkedAccount(user) {
@@ -75,6 +81,8 @@ function reportLinkedAccount(user) {
service: 'live',
service: 'live',
service_user_id: String(user.id),
service_user_id: String(user.id),
service_username: user.username || user.display_name || null,
service_username: user.username || user.display_name || null,
+ avatar_url: avatar,
+ display_name: user.display_name || null,
}).catch(() => { _linkedReported.delete(user.id); });
}).catch(() => { _linkedReported.delete(user.id); });
}
}
@@ -151,6 +151,13 @@
@@ -151,6 +151,13 @@
.openvibe-navbar-dropdown-recent .item .icon { width: 18px; text-align: center; color: var(--accent-light, #60a5fa); flex: none; }
.openvibe-navbar-dropdown-recent .item .icon { width: 18px; text-align: center; color: var(--accent-light, #60a5fa); flex: none; }
.openvibe-navbar-dropdown-recent .item .t { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.openvibe-navbar-dropdown-recent .item .t { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.openvibe-navbar-dropdown-recent .item .s { margin-left: auto; font-size: 10px; color: var(--text-muted, #707080); flex: none; }
.openvibe-navbar-dropdown-recent .item .s { margin-left: auto; font-size: 10px; color: var(--text-muted, #707080); flex: none; }
+ .openvibe-navbar-dropdown { width: min(300px, calc(100vw - 16px)); }
+ .openvibe-navbar-dropdown-menu .ud-label { padding: 8px 10px 3px; font-size: 10.5px; font-weight: 800; letter-spacing: .9px; text-transform: uppercase; color: var(--text-muted, #7d8aa0); }
+ .openvibe-navbar-dropdown-menu .ud-val { margin-left: auto; font-weight: 600; font-size: 12.5px; color: var(--text-secondary, #a8b3c4); }
+ .openvibe-navbar-dropdown-menu .icon { display: inline-grid; place-items: center; width: 22px; flex: none; }
+ .openvibe-navbar-dropdown-header { background: linear-gradient(180deg, color-mix(in srgb, var(--accent, #3b82f6) 10%, transparent), transparent); }
+ .openvibe-navbar-dropdown-header img { border-radius: 50%; box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent, #3b82f6) 55%, transparent); }
+ .openvibe-navbar-dropdown-header .ud-wallet a { display: inline-flex; align-items: center; gap: 5px; margin-top: 5px; padding: 2px 9px; border-radius: 999px; font-size: 12px; font-weight: 700; text-decoration: none; color: #fbbf24; background: rgba(251,191,36,.12); border: 1px solid rgba(251,191,36,.3); }
.openvibe-navbar-dropdown-menu .sep { height: 1px; background: var(--border, #333340); margin: 4px -8px; }
.openvibe-navbar-dropdown-menu .sep { height: 1px; background: var(--border, #333340); margin: 4px -8px; }
.openvibe-navbar-links { display: flex; align-items: center; gap: 4px; margin-left: 8px; }
.openvibe-navbar-links { display: flex; align-items: center; gap: 4px; margin-left: 8px; }
@@ -270,6 +277,10 @@
@@ -270,6 +277,10 @@
'fa-shield-halved': '<path d="M12 3.5l7 2.500v5.500c0 4.3-3 7.3-7 9-4-1.7-7-4.7-7-9V6zM12 3.500v17"/>',
'fa-shield-halved': '<path d="M12 3.5l7 2.500v5.500c0 4.3-3 7.3-7 9-4-1.7-7-4.7-7-9V6zM12 3.500v17"/>',
'fa-screwdriver-wrench': '<path d="M14.5 5.5a4 4 0 0 0-5.2 5.2L4.5 15.5a1.8 1.8 0 0 0 2.5 2.5l4.8-4.8a4 4 0 0 0 5.2-5.2l-2.6 2.6-2.1-.5-.5-2.1z"/>',
'fa-screwdriver-wrench': '<path d="M14.5 5.5a4 4 0 0 0-5.2 5.2L4.5 15.5a1.8 1.8 0 0 0 2.5 2.5l4.8-4.8a4 4 0 0 0 5.2-5.2l-2.6 2.6-2.1-.5-.5-2.1z"/>',
'fa-right-from-bracket': '<path d="M10 5H6.500A1.5 1.5 0 0 0 5 6.500v11A1.5 1.5 0 0 0 6.5 19H10M14 8l4 4-4 4M18 12H9.5"/>',
'fa-right-from-bracket': '<path d="M10 5H6.500A1.5 1.5 0 0 0 5 6.500v11A1.5 1.5 0 0 0 6.5 19H10M14 8l4 4-4 4M18 12H9.5"/>',
+ 'fa-tower-broadcast': '<circle cx="12" cy="12" r="2"/><path d="M7.8 7.8a6 6 0 0 0 0 8.4M16.2 7.8a6 6 0 0 1 0 8.4M5 5a10 10 0 0 0 0 14M19 5a10 10 0 0 1 0 14"/>',
+ '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-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"/>',
@@ -488,6 +499,7 @@
@@ -488,6 +499,7 @@
panel.innerHTML = '<input type="search" class="ovl-q" placeholder="Find a tool or site" aria-label="Find a tool or site"><div class="ovl-body"></div><div class="ovl-display" hidden></div>';
panel.innerHTML = '<input type="search" class="ovl-q" placeholder="Find a tool or site" aria-label="Find a tool or site"><div class="ovl-body"></div><div class="ovl-display" hidden></div>';
bindDisplayControls(panel.querySelector('.ovl-display'));
bindDisplayControls(panel.querySelector('.ovl-display'));
nav.appendChild(panel);
nav.appendChild(panel);
+ regPanel(panel, 'launcher', close);
if (!root.OpenVibeIcons && !document.getElementById('ov-icons-loader')) { const sc = document.createElement('script'); sc.id = 'ov-icons-loader'; sc.src = 'https://openvibe.network/shared/ov-icons.js'; sc.async = true; document.head.appendChild(sc); }
if (!root.OpenVibeIcons && !document.getElementById('ov-icons-loader')) { const sc = document.createElement('script'); sc.id = 'ov-icons-loader'; sc.src = 'https://openvibe.network/shared/ov-icons.js'; sc.async = true; document.head.appendChild(sc); }
let cat = null; paint(null, '');
let cat = null; paint(null, '');
const input = panel.querySelector('.ovl-q');
const input = panel.querySelector('.ovl-q');
@@ -521,6 +533,51 @@
@@ -521,6 +533,51 @@
} catch { /* */ }
} catch { /* */ }
}
}
+ /** 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 = [];
+ function regPanel(el, id, close) {
+ if (!el) return;
+ const opts = { el, openClass: 'open', id, close };
+ if (root.OpenVibePanels) return root.OpenVibePanels.register(opts);
+ _panelQueue.push(opts);
+ if (document.getElementById('ov-panels-loader')) return;
+ const sc = document.createElement('script'); sc.id = 'ov-panels-loader'; sc.src = 'https://openvibe.network/shared/panels.js'; sc.async = true;
+ sc.onload = () => { while (_panelQueue.length) root.OpenVibePanels && root.OpenVibePanels.register(_panelQueue.shift()); };
+ document.head.appendChild(sc);
+ }
+
+ /** Sites in the user menu: most used first (chrome data), never the one we are on. */
+ function acrossHTML() {
+ const chrome = OVChrome.get();
+ const here = currentHost().toLowerCase();
+ const list = (chrome && chrome.nav.length ? chrome.nav.map(n => ({ name: n.name, url: n.url, icon: n.icon || n.id })) : LAUNCHER_SITES)
+ .filter(n => { try { const h = new URL(n.url).hostname; return h !== here && !here.endsWith('.' + h); } catch { return false; } }).slice(0, 5);
+ return list.map(n => `<a href="${escapeAttr(n.url)}"><span class="icon"><span class="ov-icon" data-icon="${escapeAttr(n.icon)}" data-size="20" data-fx="none"></span></span> ${escapeAttr(n.name)}</a>`).join('');
+ }
+
+ /** Display rows (text size, animations) cycle their value; state lives in theme-loader.js. */
+ function bindDisplayRows(dropdown) {
+ const L = root.OpenVibeThemeLoader; const rows = dropdown.querySelectorAll('[data-ov-display]');
+ if (!L || !L.display) { rows.forEach(r => { r.hidden = true; }); return; }
+ const LABEL = { text: { 100: 'Default', 112: 'Large', 125: 'Largest' }, motion: { auto: 'On', reduced: 'Calm' } };
+ const paint = () => { const d = L.display.get(); rows.forEach(r => { const k = r.getAttribute('data-ov-display'); r.querySelector('.ud-val').textContent = LABEL[k][d[k]] || ''; }); };
+ rows.forEach(r => r.addEventListener('click', (e) => { e.stopPropagation(); const k = r.getAttribute('data-ov-display'), o = L.display.options[k], cur = L.display.get()[k]; L.display.set({ [k]: o[(o.indexOf(cur) + 1) % o.length] }); paint(); }));
+ root.addEventListener('ov:display', paint); paint();
+ }
+
+ /** 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;
+ 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));
+ const el = dropdown.querySelector('#openvibe-wallet'); if (!el || !isFinite(bal)) return;
+ el.innerHTML = `<a href="https://openvibe.network/my#coins" title="OpenCoins">${navIcon('fa-coins')} ${bal.toLocaleString()}</a>`; el.hidden = false;
+ } catch { /* the chip is optional */ }
+ }
+
/** 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) {
if (_config.networkLinks === false) return '';
if (_config.networkLinks === false) return '';
@@ -1095,8 +1152,9 @@
@@ -1095,8 +1152,9 @@
<div class="openvibe-navbar-dropdown-header">
<div class="openvibe-navbar-dropdown-header">
${avatarImg(u, 72, '', '')}
${avatarImg(u, 72, '', '')}
<div class="info">
<div class="info">
- <div class="name">${u.display_name || u.username}</div>
- <div class="email">${u.email || `@${u.username}`}</div>
+ <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>
${isAnon ? `<div class="anon-tag">Anonymous #${u.anon_number || '?'}</div>` : ''}
${isAnon ? `<div class="anon-tag">Anonymous #${u.anon_number || '?'}</div>` : ''}
</div>
</div>
</div>
</div>
@@ -1118,13 +1176,22 @@
@@ -1118,13 +1176,22 @@
</div>
</div>
<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.map(menuItemHTML).join('')}${before.length ? '<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>
<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>
- <a href="https://openvibe.network/my#history"><span class="icon">${navIcon('fa-clock-rotate-left')}</span> History</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="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>
<a href="https://openvibe.network/themes"><span class="icon">${navIcon('fa-palette')}</span> Themes</a>
<a href="https://openvibe.network/themes"><span class="icon">${navIcon('fa-palette')}</span> Themes</a>
+ <div class="sep"></div>
+ <div class="ud-label">Across OpenVibe</div>
+ ${acrossHTML()}
+ <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-screwdriver-wrench')}</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>
@@ -1132,11 +1199,14 @@
@@ -1132,11 +1199,14 @@
`;
`;
nav.appendChild(dropdown);
nav.appendChild(dropdown);
bindMenuItems(dropdown, before.concat(after));
bindMenuItems(dropdown, before.concat(after));
+ bindDisplayRows(dropdown);
+ regPanel(dropdown, 'user-menu');
if (_config.recent !== false) renderRecent(dropdown.querySelector('#openvibe-recent'));
if (_config.recent !== false) renderRecent(dropdown.querySelector('#openvibe-recent'));
// Avatar click toggles dropdown
// Avatar click toggles dropdown
nav.querySelector('#openvibe-avatar-btn').addEventListener('click', () => {
nav.querySelector('#openvibe-avatar-btn').addEventListener('click', () => {
dropdown.classList.toggle('open');
dropdown.classList.toggle('open');
+ if (dropdown.classList.contains('open')) { loadWallet(dropdown); dropdown.scrollTop = 0; if (root.OpenVibeIcons) root.OpenVibeIcons.mount(dropdown); else if (!document.getElementById('ov-icons-loader')) { const sc = document.createElement('script'); sc.id = 'ov-icons-loader'; sc.src = 'https://openvibe.network/shared/ov-icons.js'; sc.async = true; document.head.appendChild(sc); } }
});
});
// Close on outside click
// Close on outside click
@@ -567,6 +567,7 @@
@@ -567,6 +567,7 @@
function togglePanel() {
function togglePanel() {
if (!_panelEl) createPanel();
if (!_panelEl) createPanel();
const opening = !_panelEl.classList.contains('open');
const opening = !_panelEl.classList.contains('open');
+ if (opening && window.OpenVibePanels && !_panelEl.__registered) { _panelEl.__registered = true; window.OpenVibePanels.register({ el: _panelEl, openClass: 'open', id: 'notifications', close: () => { if (_panelEl.classList.contains('open')) togglePanel(); } }); window.OpenVibePanels.closeAll(_panelEl); }
if (opening) {
if (opening) {
// Sit directly under the site's navbar, whatever its height (sticky bars, banners, safe areas).
// Sit directly under the site's navbar, whatever its height (sticky bars, banners, safe areas).
const bar = _bellEl && _bellEl.closest('nav, header, .navbar');
const bar = _bellEl && _bellEl.closest('nav, header, .navbar');
new file mode 100644
new file mode 100644
@@ -0,0 +1,53 @@
@@ -0,0 +1,53 @@
+/**
+ * openvibe-shared/panels.js — one coordinator for every dropdown, drawer and popover on a page.
+ *
+ * Sites keep their own markup and their own open/close toggles. They register each panel here and get the
+ * behaviour that should be identical everywhere:
+ * • one panel open at a time (opening one closes the others)
+ * • Escape and a device rotation close everything
+ * • <body class="ov-panel-open"> while any panel is open (floating buttons step aside)
+ * • a freshly opened panel starts scrolled to the top
+ *
+ * OpenVibePanels.register({ el, openClass: 'show', close: () => …, id?: 'user-menu' })
+ * OpenVibePanels.closeAll(exceptEl?) · OpenVibePanels.anyOpen()
+ *
+ * It watches the panel's class instead of wrapping the site's click handlers, so it cannot fight them: a
+ * panel is "open" exactly when its element has `openClass`, however that came about.
+ */
+(function (root) {
+ 'use strict';
+ if (root.OpenVibePanels) return;
+ if (typeof document === 'undefined') { const stub = { register() {}, closeAll() {}, anyOpen: () => false }; if (typeof module !== 'undefined') module.exports = stub; return; }
+
+ const panels = []; // { el, openClass, close, id }
+ const isOpen = (p) => p.el.isConnected && p.el.classList.contains(p.openClass);
+ const anyOpen = () => panels.some(isOpen);
+ const syncBody = () => document.body && document.body.classList.toggle('ov-panel-open', anyOpen());
+
+ function closeAll(except) {
+ // A panel nested in another (a submenu inside the mobile drawer) never closes its container, or vice versa.
+ const related = (a, b) => a && b && (a.contains(b) || b.contains(a));
+ for (const p of panels) if (p.el !== except && !related(p.el, except) && isOpen(p)) { try { p.close(); } catch { p.el.classList.remove(p.openClass); } }
+ syncBody();
+ }
+
+ function register(opts) {
+ const el = opts && opts.el; if (!el || panels.some(p => p.el === el)) return;
+ const p = { el, openClass: opts.openClass || 'open', id: opts.id || '', close: typeof opts.close === 'function' ? opts.close : () => el.classList.remove(opts.openClass || 'open') };
+ panels.push(p);
+ let was = isOpen(p);
+ new MutationObserver(() => {
+ const now = isOpen(p); if (now === was) return; was = now;
+ if (now) { closeAll(el); try { el.scrollTop = 0; } catch { /* */ } }
+ syncBody();
+ }).observe(el, { attributes: true, attributeFilter: ['class'] });
+ syncBody();
+ }
+
+ document.addEventListener('keydown', (e) => { if (e.key === 'Escape' && anyOpen()) closeAll(); });
+ root.addEventListener('orientationchange', () => closeAll(), { passive: true });
+
+ const api = { register, closeAll, anyOpen };
+ root.OpenVibePanels = api;
+ if (typeof module !== 'undefined' && module.exports) module.exports = api;
+})(typeof window !== 'undefined' ? window : globalThis);