openvibers/openvibe.network Commits
Shared user menu: grouped sections, display controls, ranked sites, channel link, wallet chip; shared panel coordinator (panels.js); accounts adopt a linked site's avatar and name when they have none
Changes
@@@ -6,7 +6,7 @@"scripts": {"start": "node server/index.js","dev": "NODE_ENV=development node server/index.js",-- "test": "node test/registry-bootstrap.test.js && node packages/openvibe-shared/test/theme-parity.test.js && node test/nginx-generator-live.test.js && node test/admin-refresh.test.js && node packages/openvibe-shared/test/url-resolver.test.js && node packages/openvibe-shared/test/url-resolver-deploy.test.js && node packages/openvibe-shared/test/navbar-auth.test.js && node packages/openvibe-shared/test/navbar-brand.test.js && node test/history-sso.test.js && node test/sso-check.test.js && node test/fedcm.test.js && node test/domains.test.js && node packages/openvibe-shared/test/shared-modules.test.js && node test/chrome.test.js",++ "test": "node test/registry-bootstrap.test.js && node packages/openvibe-shared/test/theme-parity.test.js && node test/nginx-generator-live.test.js && node test/admin-refresh.test.js && node packages/openvibe-shared/test/url-resolver.test.js && node packages/openvibe-shared/test/url-resolver-deploy.test.js && node packages/openvibe-shared/test/navbar-auth.test.js && node packages/openvibe-shared/test/navbar-brand.test.js && node test/history-sso.test.js && node test/sso-check.test.js && node test/fedcm.test.js && node test/domains.test.js && node packages/openvibe-shared/test/shared-modules.test.js && node test/chrome.test.js && node packages/openvibe-shared/test/panels.test.js","reset-db": "node server/reset-db.js","grant-admin": "node server/grant-admin.js"},@@@ -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);nnew file mode 100644@@@ -0,0 +1,21 @@++'use strict';++// panels.js: one open at a time, nested panels do not close their container, Escape closes all.++const assert = require('assert'); const vm = require('vm'); const fs = require('fs'); const path = require('path');++const observers = []; const listeners = {};++const mk = (name, parent) => { const cls = new Set(); const el = { name, parent, isConnected: true, scrollTop: 5,++ classList: { contains: c => cls.has(c), add: c => { cls.add(c); fire(el); }, remove: c => { cls.delete(c); fire(el); }, toggle: (c, f) => { (f === undefined ? !cls.has(c) : f) ? cls.add(c) : cls.delete(c); fire(el); } },++ contains: (o) => { for (let x = o; x; x = x.parent) if (x === el) return true; return false; } }; return el; };++const fire = (el) => observers.filter(o => o.el === el).forEach(o => o.cb());++const body = mk('body');++const ctx = vm.createContext({ console, MutationObserver: function (cb) { this.observe = (el) => observers.push({ el, cb }); },++ document: { body, addEventListener: (t, f) => { listeners[t] = f; } } });++ctx.window = ctx; ctx.globalThis = ctx; ctx.addEventListener = () => {};++vm.runInContext(fs.readFileSync(path.join(__dirname, '..', 'panels.js'), 'utf8'), ctx);++const P = ctx.OpenVibePanels;++const menu = mk('menu'), drawer = mk('drawer'), sub = mk('sub', drawer);++P.register({ el: menu, openClass: 'show' }); P.register({ el: drawer, openClass: 'show' }); P.register({ el: sub, openClass: 'open' });++menu.classList.add('show'); assert.ok(body.classList.contains('ov-panel-open')); assert.equal(menu.scrollTop, 0, 'opens scrolled to the top');++drawer.classList.add('show'); assert.ok(!menu.classList.contains('show'), 'opening one closes the other');++sub.classList.add('open'); assert.ok(drawer.classList.contains('show'), 'a nested panel keeps its container open');++listeners.keydown({ key: 'Escape' }); assert.ok(!drawer.classList.contains('show') && !sub.classList.contains('open') && !body.classList.contains('ov-panel-open'), 'Escape closes everything');++console.log('panels: all checks passed');@@@ -20,7 +20,7 @@ console.log('shared modules: all checks passed');// The browser bundles must at least evaluate in a bare global (this caught a missing `root` once).{const vm = require('vm'); const fs = require('fs'); const path = require('path');-- for (const f of ['footer.js', 'navbar.js', 'island.js', 'ui.js', 'ov-icons.js', 'history.js', 'sso-client.js']) {++ for (const f of ['footer.js', 'navbar.js', 'island.js', 'ui.js', 'ov-icons.js', 'history.js', 'sso-client.js', 'panels.js']) {const ctx = vm.createContext({ console });ctx.globalThis = ctx; ctx.window = ctx; ctx.self = ctx;ctx.localStorage = { getItem: () => null, setItem() {} }; ctx.location = { hostname: 'openvibe.tools', href: 'https://openvibe.tools/' };@@@ -105,7 +105,7 @@ router.get('/users/:id/theme', (req, res) => {// When a user connects their OpenVibe.Live or OpenVibe.Games account,// the service reports the link here.router.post('/link-account', (req, res) => {-- const { user_id, service, service_user_id, service_username } = req.body;++ const { user_id, service, service_user_id, service_username, avatar_url, display_name } = req.body;if (!user_id || !service || !service_user_id) {return res.status(400).json({ error: 'user_id, service, and service_user_id required' });}@@@ -120,6 +120,20 @@ router.post('/link-account', (req, res) => {linked_at = CURRENT_TIMESTAMP`).run(user_id, service, service_user_id, service_username || null, service_user_id, service_username || null);++ // People set their picture on the site they use (usually Live). When the network account has none, adopt it,++ // so every other site shows the same face. Never overwrites a picture or name the user set here, and only++ // accepts https URLs on our own sites.++ try {++ const me = db.prepare('SELECT avatar_url, display_name, username FROM users WHERE id = ?').get(user_id);++ if (me) {++ let okAvatar = null;++ try { const u = new URL(String(avatar_url || '')); if (u.protocol === 'https:' && /(^|\.)openvibe\.(live|media|network|games|community|tools)$/.test(u.hostname) && String(avatar_url).length < 500) okAvatar = u.toString(); } catch { /* not a URL */ }++ if (okAvatar && !me.avatar_url) db.prepare('UPDATE users SET avatar_url = ? WHERE id = ?').run(okAvatar, user_id);++ const name = String(display_name || '').replace(/[\u0000-\u001f<>]/g, '').trim().slice(0, 60);++ if (name && (!me.display_name || me.display_name === me.username)) db.prepare('UPDATE users SET display_name = ? WHERE id = ?').run(name, user_id);++ }++ } catch (err) { console.warn('[Internal] profile adopt failed:', err.message); }++res.json({ success: true });});
@@ -6,7 +6,7 @@
@@ -6,7 +6,7 @@
"scripts": {
"scripts": {
"start": "node server/index.js",
"start": "node server/index.js",
"dev": "NODE_ENV=development node server/index.js",
"dev": "NODE_ENV=development node server/index.js",
- "test": "node test/registry-bootstrap.test.js && node packages/openvibe-shared/test/theme-parity.test.js && node test/nginx-generator-live.test.js && node test/admin-refresh.test.js && node packages/openvibe-shared/test/url-resolver.test.js && node packages/openvibe-shared/test/url-resolver-deploy.test.js && node packages/openvibe-shared/test/navbar-auth.test.js && node packages/openvibe-shared/test/navbar-brand.test.js && node test/history-sso.test.js && node test/sso-check.test.js && node test/fedcm.test.js && node test/domains.test.js && node packages/openvibe-shared/test/shared-modules.test.js && node test/chrome.test.js",
+ "test": "node test/registry-bootstrap.test.js && node packages/openvibe-shared/test/theme-parity.test.js && node test/nginx-generator-live.test.js && node test/admin-refresh.test.js && node packages/openvibe-shared/test/url-resolver.test.js && node packages/openvibe-shared/test/url-resolver-deploy.test.js && node packages/openvibe-shared/test/navbar-auth.test.js && node packages/openvibe-shared/test/navbar-brand.test.js && node test/history-sso.test.js && node test/sso-check.test.js && node test/fedcm.test.js && node test/domains.test.js && node packages/openvibe-shared/test/shared-modules.test.js && node test/chrome.test.js && node packages/openvibe-shared/test/panels.test.js",
"reset-db": "node server/reset-db.js",
"reset-db": "node server/reset-db.js",
"grant-admin": "node server/grant-admin.js"
"grant-admin": "node server/grant-admin.js"
},
},
@@ -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);
new file mode 100644
new file mode 100644
@@ -0,0 +1,21 @@
@@ -0,0 +1,21 @@
+'use strict';
+// panels.js: one open at a time, nested panels do not close their container, Escape closes all.
+const assert = require('assert'); const vm = require('vm'); const fs = require('fs'); const path = require('path');
+const observers = []; const listeners = {};
+const mk = (name, parent) => { const cls = new Set(); const el = { name, parent, isConnected: true, scrollTop: 5,
+ classList: { contains: c => cls.has(c), add: c => { cls.add(c); fire(el); }, remove: c => { cls.delete(c); fire(el); }, toggle: (c, f) => { (f === undefined ? !cls.has(c) : f) ? cls.add(c) : cls.delete(c); fire(el); } },
+ contains: (o) => { for (let x = o; x; x = x.parent) if (x === el) return true; return false; } }; return el; };
+const fire = (el) => observers.filter(o => o.el === el).forEach(o => o.cb());
+const body = mk('body');
+const ctx = vm.createContext({ console, MutationObserver: function (cb) { this.observe = (el) => observers.push({ el, cb }); },
+ document: { body, addEventListener: (t, f) => { listeners[t] = f; } } });
+ctx.window = ctx; ctx.globalThis = ctx; ctx.addEventListener = () => {};
+vm.runInContext(fs.readFileSync(path.join(__dirname, '..', 'panels.js'), 'utf8'), ctx);
+const P = ctx.OpenVibePanels;
+const menu = mk('menu'), drawer = mk('drawer'), sub = mk('sub', drawer);
+P.register({ el: menu, openClass: 'show' }); P.register({ el: drawer, openClass: 'show' }); P.register({ el: sub, openClass: 'open' });
+menu.classList.add('show'); assert.ok(body.classList.contains('ov-panel-open')); assert.equal(menu.scrollTop, 0, 'opens scrolled to the top');
+drawer.classList.add('show'); assert.ok(!menu.classList.contains('show'), 'opening one closes the other');
+sub.classList.add('open'); assert.ok(drawer.classList.contains('show'), 'a nested panel keeps its container open');
+listeners.keydown({ key: 'Escape' }); assert.ok(!drawer.classList.contains('show') && !sub.classList.contains('open') && !body.classList.contains('ov-panel-open'), 'Escape closes everything');
+console.log('panels: all checks passed');
@@ -20,7 +20,7 @@ console.log('shared modules: all checks passed');
@@ -20,7 +20,7 @@ console.log('shared modules: all checks passed');
// The browser bundles must at least evaluate in a bare global (this caught a missing `root` once).
// The browser bundles must at least evaluate in a bare global (this caught a missing `root` once).
{
{
const vm = require('vm'); const fs = require('fs'); const path = require('path');
const vm = require('vm'); const fs = require('fs'); const path = require('path');
- for (const f of ['footer.js', 'navbar.js', 'island.js', 'ui.js', 'ov-icons.js', 'history.js', 'sso-client.js']) {
+ for (const f of ['footer.js', 'navbar.js', 'island.js', 'ui.js', 'ov-icons.js', 'history.js', 'sso-client.js', 'panels.js']) {
const ctx = vm.createContext({ console });
const ctx = vm.createContext({ console });
ctx.globalThis = ctx; ctx.window = ctx; ctx.self = ctx;
ctx.globalThis = ctx; ctx.window = ctx; ctx.self = ctx;
ctx.localStorage = { getItem: () => null, setItem() {} }; ctx.location = { hostname: 'openvibe.tools', href: 'https://openvibe.tools/' };
ctx.localStorage = { getItem: () => null, setItem() {} }; ctx.location = { hostname: 'openvibe.tools', href: 'https://openvibe.tools/' };
@@ -105,7 +105,7 @@ router.get('/users/:id/theme', (req, res) => {
@@ -105,7 +105,7 @@ router.get('/users/:id/theme', (req, res) => {
// When a user connects their OpenVibe.Live or OpenVibe.Games account,
// When a user connects their OpenVibe.Live or OpenVibe.Games account,
// the service reports the link here.
// the service reports the link here.
router.post('/link-account', (req, res) => {
router.post('/link-account', (req, res) => {
- const { user_id, service, service_user_id, service_username } = req.body;
+ const { user_id, service, service_user_id, service_username, avatar_url, display_name } = req.body;
if (!user_id || !service || !service_user_id) {
if (!user_id || !service || !service_user_id) {
return res.status(400).json({ error: 'user_id, service, and service_user_id required' });
return res.status(400).json({ error: 'user_id, service, and service_user_id required' });
}
}
@@ -120,6 +120,20 @@ router.post('/link-account', (req, res) => {
@@ -120,6 +120,20 @@ router.post('/link-account', (req, res) => {
linked_at = CURRENT_TIMESTAMP
linked_at = CURRENT_TIMESTAMP
`).run(user_id, service, service_user_id, service_username || null, service_user_id, service_username || null);
`).run(user_id, service, service_user_id, service_username || null, service_user_id, service_username || null);
+ // People set their picture on the site they use (usually Live). When the network account has none, adopt it,
+ // so every other site shows the same face. Never overwrites a picture or name the user set here, and only
+ // accepts https URLs on our own sites.
+ try {
+ const me = db.prepare('SELECT avatar_url, display_name, username FROM users WHERE id = ?').get(user_id);
+ if (me) {
+ let okAvatar = null;
+ try { const u = new URL(String(avatar_url || '')); if (u.protocol === 'https:' && /(^|\.)openvibe\.(live|media|network|games|community|tools)$/.test(u.hostname) && String(avatar_url).length < 500) okAvatar = u.toString(); } catch { /* not a URL */ }
+ if (okAvatar && !me.avatar_url) db.prepare('UPDATE users SET avatar_url = ? WHERE id = ?').run(okAvatar, user_id);
+ const name = String(display_name || '').replace(/[\u0000-\u001f<>]/g, '').trim().slice(0, 60);
+ if (name && (!me.display_name || me.display_name === me.username)) db.prepare('UPDATE users SET display_name = ? WHERE id = ?').run(name, user_id);
+ }
+ } catch (err) { console.warn('[Internal] profile adopt failed:', err.message); }
+
res.json({ success: true });
res.json({ success: true });
});
});