openvibers/openvibe.network Commits
Shared footer and tooltip modules for every OpenVibe property
Changes
nnew file mode 100644@@@ -0,0 +1,299 @@++/**++ * openvibe-shared/footer.js — the cross-site footer, the counterpart to navbar.js.++ *++ * OpenVibeFooter.init({ service, variant, links, apiBase, mount })++ * OpenVibeFooter.setVariant('full' | 'compact') switch at runtime (SPA route changes)++ * OpenVibeFooter.buildHTML(cfg) markup only — usable from Node for SSR++ *++ * Why this exists: every property was hand-rolling its own footer, so the network links, the++ * legal links and the styling drifted apart, and a site that gained a new sibling had to be++ * edited by hand. Here the site passes only the links that are *its own*; the Network column,++ * the Legal column, the signed-in row and all the styling come from this module, so adding a++ * property or changing a policy link updates every site at once.++ *++ * Variants:++ * full the multi-column footer, for home and browse pages++ * compact one quiet line, for pages where the footer is not the point — a streamer's++ * channel, a player, a tool someone is mid-task in++ *++ * Signed-in visitors get a cross-site row: their account, their channel, themes and the tools++ * hub, so the network behaves like one product instead of a ring of separate sites. It reads++ * the shared ov_token the same way navbar.js does and never blocks rendering on it.++ */++(function (root, factory) {++ const api = factory();++ if (typeof module === 'object' && module.exports) module.exports = api; // Node (SSR)++ if (typeof window !== 'undefined') window.OpenVibeFooter = api; // browser++})(typeof globalThis !== 'undefined' ? globalThis : this, function () {++ 'use strict';++++ const NETWORK_URL = 'https://openvibe.network';++ const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));++++ // One place that knows what the network contains. Adding a property here gives every site++ // the link, with no edit anywhere else.++ const NETWORK = [++ { id: 'live', name: 'Live streaming', url: 'https://openvibe.live', desc: 'Go live, restream and clip' },++ { id: 'tools', name: 'Free online tools', url: 'https://openvibe.tools', desc: 'The whole toolbox' },++ { id: 'dev', name: 'Developer tools', url: 'https://dev.openvibe.tools', desc: 'Formatters, encoders, validators' },++ { id: 'net', name: 'Network diagnostics', url: 'https://net.openvibe.tools', desc: 'Ping, DNS, Whois, SSL' },++ { id: 'img', name: 'Image converter', url: 'https://img.openvibe.tools', desc: 'Convert, resize, compress' },++ { id: 'audio', name: 'Audio converter', url: 'https://audio.openvibe.tools', desc: 'Convert, trim, pitch-shift' },++ { id: 'docs', name: 'PDF & document tools', url: 'https://docs.openvibe.tools', desc: 'Merge, split, compress' },++ { id: 'text', name: 'Text tools', url: 'https://text.openvibe.tools', desc: 'Fancy text, case, ASCII' },++ { id: 'yt', name: 'YouTube downloader', url: 'https://yt.openvibe.tools', desc: 'Video and audio' },++ { id: 'network', name: 'Account & themes', url: NETWORK_URL, desc: 'One account, every site' },++ ];++++ const LEGAL = [++ { name: 'Terms of Service', path: '/tos', fallback: 'https://openvibe.live/tos' },++ { name: 'Privacy Policy', path: '/privacy', fallback: 'https://openvibe.live/privacy' },++ { name: 'DMCA', path: '/dmca', fallback: 'https://openvibe.live/dmca' },++ ];++++ const SOCIAL = [++ { name: 'GitHub', icon: 'fa-brands fa-github', url: 'https://github.com/OpenVibers' },++ { name: 'Discord', icon: 'fa-brands fa-discord', url: 'https://discord.gg/M6MuRUaeJj' },++ ];++++ const DEFAULTS = {++ service: null, // 'live' | 'tools' | 'dev' | … (auto-detected when omitted)++ brandName: null,++ tagline: 'Free, open source and community run. Built in the open by the people using it.',++ variant: 'full',++ mount: '#ov-footer',++ links: [], // [{ heading, items: [{ name, url, onclick? }] }]++ legalBase: null, // origin that hosts /tos, /privacy, /dmca for this site++ apiBase: NETWORK_URL,++ user: null,++ showNetwork: true,++ showAccount: true,++ sitemap: '/sitemap.xml',++ };++++ let _cfg = { ...DEFAULTS };++ let _mounted = null;++++ function detectService() {++ if (typeof location === 'undefined') return 'network';++ const h = location.hostname;++ if (h.endsWith('openvibe.live')) return 'live';++ if (h.endsWith('openvibe.network')) return 'network';++ if (h.endsWith('openvibe.games')) return 'games';++ const sub = h.replace(/\.openvibe\.tools$/, '');++ return sub === h ? 'tools' : (sub || 'tools');++ }++++ function brandFor(service) {++ const found = NETWORK.find(n => n.id === service);++ if (found) return found.name === 'Account & themes' ? 'OpenVibe' : found.name;++ return 'OpenVibe';++ }++++ function legalHref(item, cfg) {++ // Policies live on whichever site owns them; a tool subdomain links out rather than 404ing.++ if (cfg.legalBase) return cfg.legalBase.replace(/\/$/, '') + item.path;++ if (typeof location !== 'undefined' && location.hostname.endsWith('openvibe.live')) return item.path;++ return item.fallback;++ }++++ // ── Markup ─────────────────────────────────────────────────++ function linkTag(item) {++ const rel = /^https?:\/\//.test(item.url) ? ' target="_blank" rel="noopener"' : '';++ const onclick = item.onclick ? ` onclick="${esc(item.onclick)}"` : '';++ const title = item.desc ? ` title="${esc(item.desc)}"` : '';++ return `<a href="${esc(item.url)}"${rel}${onclick}${title}>${esc(item.name)}</a>`;++ }++++ function buildHTML(cfg) {++ const c = { ...DEFAULTS, ...(cfg || {}) };++ const service = c.service || detectService();++ const brand = c.brandName || brandFor(service);++ const network = NETWORK.filter(n => n.id !== service);++ const year = new Date().getFullYear();++++ if (c.variant === 'compact') {++ return `<div class="ovf-inner ovf-inner--compact">++ <a class="ovf-brand ovf-brand--sm" href="/"><span class="ov-mark" data-size="20"></span><span>${esc(brand)}</span></a>++ <nav class="ovf-compact-links" aria-label="Site links">++ ${(c.links[0] ? c.links[0].items.slice(0, 3) : []).map(linkTag).join('')}++ <a href="${NETWORK_URL}" target="_blank" rel="noopener">Network</a>++ ${LEGAL.map(l => `<a href="${esc(legalHref(l, c))}">${esc(l.name.replace(' of Service', '').replace(' Policy', ''))}</a>`).join('')}++ </nav>++ <span class="ovf-compact-meta">Open source · ${year}</span>++ </div>`;++ }++++ const cols = (c.links || []).map(group => `++ <nav class="ovf-col" aria-label="${esc(group.heading)}">++ <h3>${esc(group.heading)}</h3>++ ${group.items.map(linkTag).join('')}++ </nav>`).join('');++++ return `<div class="ovf-inner">++ <div class="ovf-cols">++ <div class="ovf-col ovf-col--brand">++ <a class="ovf-brand" href="/"><span class="ov-mark" data-size="26"></span><span>${esc(brand)}</span></a>++ <p class="ovf-tagline">${esc(c.tagline)}</p>++ <div class="ovf-social">++ ${SOCIAL.map(s => `<a href="${s.url}" target="_blank" rel="noopener" aria-label="${s.name}" title="${s.name}"><i class="${s.icon}"></i></a>`).join('')}++ <a href="${esc(c.sitemap)}" aria-label="Sitemap" title="Sitemap"><i class="fa-solid fa-sitemap"></i></a>++ </div>++ </div>++ ${cols}++ ${c.showNetwork ? `<nav class="ovf-col" aria-label="The OpenVibe network">++ <h3>Network</h3>++ ${network.slice(0, 6).map(linkTag).join('')}++ <a class="ovf-more" href="${NETWORK_URL}" target="_blank" rel="noopener">Everything else <i class="fa-solid fa-arrow-right"></i></a>++ </nav>` : ''}++ <nav class="ovf-col" aria-label="Legal">++ <h3>Legal</h3>++ ${LEGAL.map(l => `<a href="${esc(legalHref(l, c))}">${esc(l.name)}</a>`).join('')}++ </nav>++ </div>++ <div class="ovf-account" id="ovf-account" hidden></div>++ <div class="ovf-bar">++ <span class="ovf-copy">Open source & community driven · built in the open</span>++ <span class="ovf-legal-inline">++ ${LEGAL.map(l => `<a href="${esc(legalHref(l, c))}">${esc(l.name.replace(' of Service', '').replace(' Policy', ''))}</a>`).join('')}++ </span>++ </div>++ </div>`;++ }++++ // ── Signed-in row: the same account, wherever you are ──────++ function accountHTML(user, service) {++ const name = esc(user.display_name || user.username || 'you');++ const avatar = user.avatar_url++ ? `<img src="${esc(user.avatar_url)}" alt="">`++ : `<span>${esc(String(name).charAt(0).toUpperCase())}</span>`;++ const quick = [++ service !== 'live' ? { name: 'My channel', url: `https://openvibe.live/@${esc(user.username || '')}`, icon: 'fa-tower-broadcast' } : { name: 'My dashboard', url: '/dashboard', icon: 'fa-gauge-high' },++ { name: 'Account', url: `${NETWORK_URL}/my`, icon: 'fa-user' },++ { name: 'Themes', url: `${NETWORK_URL}/themes`, icon: 'fa-palette' },++ { name: 'Tools', url: 'https://openvibe.tools', icon: 'fa-screwdriver-wrench' },++ ];++ return `<span class="ovf-account-who"><span class="ovf-avatar">${avatar}</span> Signed in as <strong>${name}</strong> across OpenVibe</span>++ <span class="ovf-account-links">${quick.map(q => `<a href="${esc(q.url)}"${/^https?:/.test(q.url) ? ' target="_blank" rel="noopener"' : ''}><i class="fa-solid ${q.icon}"></i> ${esc(q.name)}</a>`).join('')}</span>`;++ }++++ function storedToken() {++ try {++ const m = document.cookie.match(/(?:^|; )ov_token=([^;]*)/);++ return (m && decodeURIComponent(m[1])) || localStorage.getItem('ov_token') || localStorage.getItem('token') || null;++ } catch { return null; }++ }++++ async function fillAccount(cfg) {++ const host = document.getElementById('ovf-account');++ if (!host || !cfg.showAccount || cfg.variant === 'compact') return;++ let user = cfg.user;++ if (!user) {++ const token = storedToken();++ if (!token) return;++ try {++ const res = await fetch(`${cfg.apiBase}/api/auth/me`, { headers: { Authorization: 'Bearer ' + token }, credentials: 'include' });++ if (!res.ok) return;++ const data = await res.json();++ user = data.user || data;++ } catch { return; }++ }++ if (!user || !user.username) return;++ host.innerHTML = accountHTML(user, cfg.service || detectService());++ host.hidden = false;++ }++++ // ── Styles (injected once) ─────────────────────────────────++ const CSS = `++.ovf{background:var(--bg-secondary,#12131c);border-top:1px solid var(--border,rgba(255,255,255,.08));margin-top:40px;++ padding:clamp(24px,4vw,40px) clamp(14px,4vw,32px) calc(clamp(20px,3vw,28px) + env(safe-area-inset-bottom,0px));}++.ovf[data-variant="compact"]{margin-top:24px;padding:14px clamp(12px,3vw,24px) calc(14px + env(safe-area-inset-bottom,0px));}++.ovf-inner{max-width:1240px;margin:0 auto}++.ovf-inner--compact{display:flex;flex-wrap:wrap;align-items:center;justify-content:center;gap:8px 18px;text-align:center}++.ovf-cols{display:grid;gap:clamp(20px,3vw,36px);grid-template-columns:minmax(230px,1.6fr) repeat(auto-fit,minmax(120px,1fr));align-items:start}++.ovf-col{display:flex;flex-direction:column;gap:8px;min-width:0}++.ovf-col h3{margin:0 0 2px;font-size:.72rem;font-weight:800;letter-spacing:.1em;text-transform:uppercase;color:var(--text-muted,#8b93ad)}++.ovf-col a{color:var(--text-secondary,#b6bdd2);font-size:.86rem;text-decoration:none;line-height:1.4;width:fit-content;transition:color .15s,transform .15s}++.ovf-col a:hover{color:var(--accent,#a78bfa);transform:translateX(2px)}++.ovf-more{font-weight:600;opacity:.85}.ovf-more i{font-size:.7rem;transition:transform .15s}.ovf-more:hover i{transform:translateX(3px)}++.ovf-brand{display:inline-flex;align-items:center;gap:9px;color:var(--text-primary,#f1f4fb);font-size:1.05rem;font-weight:700;text-decoration:none}++.ovf-brand--sm{font-size:.9rem}++.ovf-tagline{margin:0;font-size:.82rem;line-height:1.55;color:var(--text-muted,#8b93ad);max-width:46ch}++.ovf-social{display:flex;gap:8px;margin-top:4px}++.ovf-social a{width:36px;height:36px;border-radius:10px;display:grid;place-items:center;color:var(--text-secondary,#b6bdd2);++ background:var(--bg-tertiary,#1a1b28);border:1px solid var(--border,rgba(255,255,255,.08));font-size:.95rem;transition:color .15s,border-color .15s,transform .15s}++.ovf-social a:hover{color:var(--accent,#a78bfa);border-color:var(--accent,#a78bfa);transform:translateY(-2px)}++.ovf-account{display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:10px 18px;++ margin-top:clamp(18px,2.5vw,26px);padding:10px 14px;border-radius:14px;++ background:color-mix(in srgb, var(--accent,#a78bfa) 8%, var(--bg-tertiary,#1a1b28));++ border:1px solid color-mix(in srgb, var(--accent,#a78bfa) 26%, var(--border,rgba(255,255,255,.08)));++ animation:ovfIn .4s ease-out both}++@keyframes ovfIn{from{opacity:0;transform:translateY(6px)}to{opacity:1;transform:none}}++.ovf-account-who{display:inline-flex;align-items:center;gap:8px;font-size:.84rem;color:var(--text-secondary,#b6bdd2)}++.ovf-account-who strong{color:var(--text-primary,#f1f4fb)}++.ovf-avatar{width:24px;height:24px;border-radius:50%;overflow:hidden;display:grid;place-items:center;++ background:var(--accent,#a78bfa);color:#fff;font-size:.7rem;font-weight:800;flex:none}++.ovf-avatar img{width:100%;height:100%;object-fit:cover}++.ovf-account-links{display:flex;flex-wrap:wrap;gap:6px}++.ovf-account-links a{display:inline-flex;align-items:center;gap:6px;padding:5px 11px;border-radius:999px;font-size:.78rem;font-weight:600;++ color:var(--text-secondary,#b6bdd2);background:var(--bg-secondary,#12131c);border:1px solid var(--border,rgba(255,255,255,.08));++ text-decoration:none;transition:color .15s,border-color .15s,transform .15s}++.ovf-account-links a:hover{color:var(--accent,#a78bfa);border-color:var(--accent,#a78bfa);transform:translateY(-1px)}++.ovf-account-links i{font-size:.72rem;opacity:.8}++.ovf-bar{display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:10px;++ margin-top:clamp(20px,3vw,30px);padding-top:16px;border-top:1px solid var(--border,rgba(255,255,255,.08));++ padding-inline:clamp(0px, calc(84px - (100vw - 1240px) / 2), 84px)}++.ovf-copy{color:var(--text-muted,#8b93ad);font-size:.78rem}++.ovf-legal-inline,.ovf-compact-links{display:flex;flex-wrap:wrap;gap:14px}++.ovf-legal-inline a,.ovf-compact-links a{color:var(--text-muted,#8b93ad);font-size:.78rem;text-decoration:none;transition:color .15s}++.ovf-legal-inline a:hover,.ovf-compact-links a:hover{color:var(--accent,#a78bfa)}++.ovf-compact-meta{color:var(--text-muted,#8b93ad);font-size:.74rem}++@media (max-width:900px){.ovf-cols{grid-template-columns:repeat(2,minmax(0,1fr))}.ovf-col--brand{grid-column:1/-1}}++@media (max-width:560px){++ .ovf{padding-bottom:calc(96px + env(safe-area-inset-bottom,0px))}++ .ovf[data-variant="compact"]{padding-bottom:calc(84px + env(safe-area-inset-bottom,0px))}++ .ovf-cols{gap:22px 16px}++ .ovf-bar{justify-content:center;text-align:center;padding-inline:0}++ .ovf-legal-inline{justify-content:center}++ .ovf-account{flex-direction:column;align-items:flex-start}++}++@media (prefers-reduced-motion:reduce){.ovf-account{animation:none}.ovf-col a:hover,.ovf-social a:hover,.ovf-account-links a:hover{transform:none}}`;++++ function injectCSS() {++ if (typeof document === 'undefined' || document.getElementById('ovf-css')) return;++ const st = document.createElement('style');++ st.id = 'ovf-css';++ st.textContent = CSS;++ document.head.appendChild(st);++ }++++ // ── Mount ──────────────────────────────────────────────────++ function render() {++ if (!_mounted) return;++ _mounted.className = 'ovf ' + (_mounted.dataset.extraClass || '');++ _mounted.setAttribute('data-variant', _cfg.variant);++ _mounted.innerHTML = buildHTML(_cfg);++ try { if (typeof window !== 'undefined' && typeof window.ovMarkMount === 'function') window.ovMarkMount(_mounted); } catch { /* */ }++ fillAccount(_cfg);++ }++++ function init(cfg = {}) {++ _cfg = { ...DEFAULTS, ..._cfg, ...cfg };++ _cfg.service = _cfg.service || detectService();++ injectCSS();++ const el = typeof _cfg.mount === 'string' ? document.querySelector(_cfg.mount) : _cfg.mount;++ if (!el) return null;++ _mounted = el;++ render();++ return el;++ }++++ function setVariant(variant) {++ if (variant !== 'full' && variant !== 'compact') return;++ if (_cfg.variant === variant) return;++ _cfg.variant = variant;++ render();++ }++++ return { init, setVariant, buildHTML, render, CSS, NETWORK, LEGAL, detectService, get config() { return { ..._cfg }; } };++});nnew file mode 100644@@@ -0,0 +1,154 @@++/**++ * openvibe-shared/tooltip.js — rich tooltips for every OpenVibe property.++ *++ * Markup-driven, so a site never writes tooltip code:++ *++ * <a data-ovtip="Plain text tip">…</a>++ * <a data-ovtip-title="API docs"++ * data-ovtip="<b>Vibe code it.</b> Point an AI at these docs…"++ * data-ovtip-icon="fa-wand-magic-sparkles"++ * data-ovtip-place="top"++ * data-ovtip-accent="#f472b6">…</a>++ *++ * One floating element is reused for every tooltip, positioned with fixed coordinates so it is++ * never clipped by an overflow:hidden ancestor — the usual reason CSS-only tooltips get cut off++ * inside cards and scroll containers. It flips to stay on screen, follows the trigger while the++ * page scrolls, opens on hover, focus and long-press, and closes on Escape, scroll-away or tap++ * elsewhere. Touch devices get a tap-to-open behaviour instead of hover, which is what makes++ * these usable on a phone at all.++ */++(function () {++ 'use strict';++ if (typeof window === 'undefined' || window.OpenVibeTooltip) return;++++ const CSS = `++.ovtip{position:fixed;z-index:30000;max-width:min(330px,calc(100vw - 24px));padding:12px 14px;border-radius:14px;++ background:linear-gradient(165deg,var(--bg-card,#171826) 0%,var(--bg-secondary,#12131c) 100%);++ border:1px solid color-mix(in srgb, var(--ovtip-accent,var(--accent,#a78bfa)) 45%, var(--border,rgba(255,255,255,.1)));++ box-shadow:0 20px 50px rgba(0,0,0,.55),0 0 0 1px rgba(255,255,255,.04),0 0 30px color-mix(in srgb, var(--ovtip-accent,var(--accent,#a78bfa)) 22%, transparent);++ color:var(--text-secondary,#b6bdd2);font-size:.83rem;line-height:1.55;pointer-events:none;opacity:0;++ transform:translateY(6px) scale(.97);transition:opacity .18s ease,transform .22s cubic-bezier(.2,1.3,.3,1);++ overflow:hidden}++.ovtip.is-on{opacity:1;transform:none}++.ovtip.is-interactive{pointer-events:auto}++.ovtip::before{content:'';position:absolute;inset:0;pointer-events:none;++ background:radial-gradient(120% 90% at 0% 0%, color-mix(in srgb, var(--ovtip-accent,var(--accent,#a78bfa)) 16%, transparent), transparent 60%)}++/* A light sweep on open — draws the eye without moving anything around it. */++.ovtip::after{content:'';position:absolute;top:0;bottom:0;left:-60%;width:45%;pointer-events:none;++ background:linear-gradient(100deg,transparent,color-mix(in srgb, var(--ovtip-accent,var(--accent,#a78bfa)) 26%, transparent),transparent)}++.ovtip.is-on::after{animation:ovtipSweep .9s cubic-bezier(.3,.7,.3,1) .1s 1}++@keyframes ovtipSweep{to{transform:translateX(420%)}}++.ovtip-head{display:flex;align-items:center;gap:8px;margin-bottom:6px;position:relative}++.ovtip-ico{width:26px;height:26px;border-radius:8px;display:grid;place-items:center;flex:none;font-size:.78rem;color:#fff;++ background:linear-gradient(135deg,var(--ovtip-accent,var(--accent,#a78bfa)),color-mix(in srgb,var(--ovtip-accent,var(--accent,#a78bfa)) 55%,#000));++ box-shadow:0 4px 12px color-mix(in srgb, var(--ovtip-accent,var(--accent,#a78bfa)) 45%, transparent)}++.ovtip-title{font-size:.9rem;font-weight:800;color:var(--text-primary,#f1f4fb);letter-spacing:-.01em}++.ovtip-body{position:relative}++.ovtip-body b,.ovtip-body strong{color:var(--text-primary,#f1f4fb);font-weight:700}++.ovtip-body em{color:var(--ovtip-accent,var(--accent,#a78bfa));font-style:normal;font-weight:600}++.ovtip-body code{background:var(--bg-tertiary,#1a1b28);padding:1px 6px;border-radius:5px;font-size:.78rem}++.ovtip-foot{margin-top:8px;padding-top:8px;border-top:1px dashed var(--border,rgba(255,255,255,.1));++ font-size:.74rem;color:var(--text-muted,#8b93ad);display:flex;align-items:center;gap:6px;position:relative}++.ovtip-arrow{position:fixed;z-index:30001;width:10px;height:10px;transform:rotate(45deg);opacity:0;transition:opacity .18s;++ background:var(--bg-card,#171826);border:1px solid color-mix(in srgb, var(--ovtip-accent,var(--accent,#a78bfa)) 45%, var(--border,rgba(255,255,255,.1)))}++.ovtip-arrow.is-on{opacity:1}++.ovtip-arrow.from-top{border-top:0;border-left:0}++.ovtip-arrow.from-bottom{border-bottom:0;border-right:0}++@media (prefers-reduced-motion:reduce){.ovtip{transition:opacity .12s}.ovtip.is-on::after{animation:none}}`;++++ let tip = null, arrow = null, current = null, raf = 0, hideTimer = 0;++ const isTouch = () => window.matchMedia('(hover: none)').matches;++++ function ensure() {++ if (tip) return;++ const st = document.createElement('style'); st.id = 'ovtip-css'; st.textContent = CSS; document.head.appendChild(st);++ tip = document.createElement('div'); tip.className = 'ovtip'; tip.setAttribute('role', 'tooltip');++ arrow = document.createElement('div'); arrow.className = 'ovtip-arrow';++ document.body.append(arrow, tip);++ tip.addEventListener('pointerenter', () => clearTimeout(hideTimer));++ tip.addEventListener('pointerleave', () => hide());++ }++++ function content(el) {++ const title = el.getAttribute('data-ovtip-title');++ const icon = el.getAttribute('data-ovtip-icon');++ const body = el.getAttribute('data-ovtip') || '';++ const foot = el.getAttribute('data-ovtip-foot');++ return `${title ? `<div class="ovtip-head">${icon ? `<span class="ovtip-ico"><i class="fa-solid ${icon}"></i></span>` : ''}<span class="ovtip-title">${title}</span></div>` : ''}++ <div class="ovtip-body">${body}</div>++ ${foot ? `<div class="ovtip-foot">${foot}</div>` : ''}`;++ }++++ function place() {++ if (!current || !tip) return;++ if (!current.isConnected) return hide();++ const r = current.getBoundingClientRect();++ // Off-screen trigger: nothing to point at.++ if (r.bottom < -40 || r.top > window.innerHeight + 40) return hide();++ const tw = tip.offsetWidth, th = tip.offsetHeight, gap = 12;++ const prefer = current.getAttribute('data-ovtip-place') || 'top';++ let top, from;++ const fitsAbove = r.top - th - gap > 8;++ const fitsBelow = r.bottom + th + gap < window.innerHeight - 8;++ if ((prefer === 'top' && fitsAbove) || (!fitsBelow && fitsAbove)) { top = r.top - th - gap; from = 'from-bottom'; }++ else { top = r.bottom + gap; from = 'from-top'; }++ let left = r.left + r.width / 2 - tw / 2;++ left = Math.max(12, Math.min(left, window.innerWidth - tw - 12));++ tip.style.top = `${Math.round(top)}px`;++ tip.style.left = `${Math.round(left)}px`;++ const ax = Math.max(left + 14, Math.min(r.left + r.width / 2 - 5, left + tw - 24));++ arrow.className = `ovtip-arrow is-on ${from}`;++ arrow.style.left = `${Math.round(ax)}px`;++ arrow.style.top = `${Math.round(from === 'from-top' ? top - 5 : top + th - 5)}px`;++ raf = requestAnimationFrame(place);++ }++++ function show(el) {++ ensure();++ clearTimeout(hideTimer);++ if (current === el && tip.classList.contains('is-on')) return;++ current = el;++ tip.style.setProperty('--ovtip-accent', el.getAttribute('data-ovtip-accent') || '');++ arrow.style.setProperty('--ovtip-accent', el.getAttribute('data-ovtip-accent') || '');++ tip.innerHTML = content(el);++ tip.classList.toggle('is-interactive', el.hasAttribute('data-ovtip-interactive'));++ tip.classList.remove('is-on'); arrow.classList.remove('is-on');++ cancelAnimationFrame(raf);++ place();++ requestAnimationFrame(() => { tip.classList.add('is-on'); arrow.classList.add('is-on'); });++ el.setAttribute('aria-describedby', 'ovtip');++ }++++ function hide() {++ if (!tip) return;++ cancelAnimationFrame(raf);++ tip.classList.remove('is-on'); arrow.classList.remove('is-on');++ if (current) current.removeAttribute('aria-describedby');++ current = null;++ }++ const hideSoon = () => { clearTimeout(hideTimer); hideTimer = setTimeout(hide, 120); };++++ function trigger(e) { return e.target.closest && e.target.closest('[data-ovtip]'); }++++ function bind() {++ const fromTouch = (e) => e.pointerType === 'touch' || (!e.pointerType && isTouch());++ document.addEventListener('pointerover', (e) => { if (fromTouch(e)) return; const t = trigger(e); if (t) show(t); });++ document.addEventListener('pointerout', (e) => { if (fromTouch(e)) return; if (trigger(e)) hideSoon(); });++ document.addEventListener('focusin', (e) => { const t = trigger(e); if (t) show(t); });++ document.addEventListener('focusout', (e) => { if (trigger(e)) hideSoon(); });++ // Touch: first tap opens the tip, a tap elsewhere closes it. The trigger still works —++ // we never swallow the click, so a tap on an already-open tip follows the link.++ document.addEventListener('click', (e) => {++ const t = trigger(e);++ if (!t) return hide();++ // On touch the first tap opens the tip; a second tap follows the link.++ if (isTouch() && current !== t) { e.preventDefault(); show(t); }++ }, true);++ document.addEventListener('keydown', (e) => { if (e.key === 'Escape') hide(); });++ window.addEventListener('scroll', () => { if (current && isTouch()) hide(); }, { passive: true });++ window.addEventListener('resize', hide, { passive: true });++ }++++ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', bind); else bind();++ window.OpenVibeTooltip = { show, hide, ensure };++})();@@@ -146,7 +146,7 @@ footer{border-top:1px solid var(--border);background:var(--bg-secondary)}<section class="hero"><div class="hero-badge"><span class="ov-mark" data-size="20"></span> The OpenVibe Network — Community-Driven, No Profit</div><h1>One Account.<br><span class="grad">All of OpenVibe.</span></h1>-- <p class="sub">Live streaming, free online tools, games, and media hosting — one free account works across the entire network. No ads, no tracking, no nonsense.</p>++ <p class="sub">Live streaming, free online tools, games, and media hosting — one free account works across the entire network. One login, no nonsense.</p><div class="hero-ctas" id="hero-guest-ctas"><a class="btn btn-primary" href="/login"><i class="fa-solid fa-right-to-bracket"></i> Sign In</a><a class="btn btn-ghost" href="/login?tab=register"><i class="fa-solid fa-user-plus"></i> Create Account</a>
new file mode 100644
new file mode 100644
@@ -0,0 +1,299 @@
@@ -0,0 +1,299 @@
+/**
+ * openvibe-shared/footer.js — the cross-site footer, the counterpart to navbar.js.
+ *
+ * OpenVibeFooter.init({ service, variant, links, apiBase, mount })
+ * OpenVibeFooter.setVariant('full' | 'compact') switch at runtime (SPA route changes)
+ * OpenVibeFooter.buildHTML(cfg) markup only — usable from Node for SSR
+ *
+ * Why this exists: every property was hand-rolling its own footer, so the network links, the
+ * legal links and the styling drifted apart, and a site that gained a new sibling had to be
+ * edited by hand. Here the site passes only the links that are *its own*; the Network column,
+ * the Legal column, the signed-in row and all the styling come from this module, so adding a
+ * property or changing a policy link updates every site at once.
+ *
+ * Variants:
+ * full the multi-column footer, for home and browse pages
+ * compact one quiet line, for pages where the footer is not the point — a streamer's
+ * channel, a player, a tool someone is mid-task in
+ *
+ * Signed-in visitors get a cross-site row: their account, their channel, themes and the tools
+ * hub, so the network behaves like one product instead of a ring of separate sites. It reads
+ * the shared ov_token the same way navbar.js does and never blocks rendering on it.
+ */
+(function (root, factory) {
+ const api = factory();
+ if (typeof module === 'object' && module.exports) module.exports = api; // Node (SSR)
+ if (typeof window !== 'undefined') window.OpenVibeFooter = api; // browser
+})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
+ 'use strict';
+
+ const NETWORK_URL = 'https://openvibe.network';
+ const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
+
+ // One place that knows what the network contains. Adding a property here gives every site
+ // the link, with no edit anywhere else.
+ const NETWORK = [
+ { id: 'live', name: 'Live streaming', url: 'https://openvibe.live', desc: 'Go live, restream and clip' },
+ { id: 'tools', name: 'Free online tools', url: 'https://openvibe.tools', desc: 'The whole toolbox' },
+ { id: 'dev', name: 'Developer tools', url: 'https://dev.openvibe.tools', desc: 'Formatters, encoders, validators' },
+ { id: 'net', name: 'Network diagnostics', url: 'https://net.openvibe.tools', desc: 'Ping, DNS, Whois, SSL' },
+ { id: 'img', name: 'Image converter', url: 'https://img.openvibe.tools', desc: 'Convert, resize, compress' },
+ { id: 'audio', name: 'Audio converter', url: 'https://audio.openvibe.tools', desc: 'Convert, trim, pitch-shift' },
+ { id: 'docs', name: 'PDF & document tools', url: 'https://docs.openvibe.tools', desc: 'Merge, split, compress' },
+ { id: 'text', name: 'Text tools', url: 'https://text.openvibe.tools', desc: 'Fancy text, case, ASCII' },
+ { id: 'yt', name: 'YouTube downloader', url: 'https://yt.openvibe.tools', desc: 'Video and audio' },
+ { id: 'network', name: 'Account & themes', url: NETWORK_URL, desc: 'One account, every site' },
+ ];
+
+ const LEGAL = [
+ { name: 'Terms of Service', path: '/tos', fallback: 'https://openvibe.live/tos' },
+ { name: 'Privacy Policy', path: '/privacy', fallback: 'https://openvibe.live/privacy' },
+ { name: 'DMCA', path: '/dmca', fallback: 'https://openvibe.live/dmca' },
+ ];
+
+ const SOCIAL = [
+ { name: 'GitHub', icon: 'fa-brands fa-github', url: 'https://github.com/OpenVibers' },
+ { name: 'Discord', icon: 'fa-brands fa-discord', url: 'https://discord.gg/M6MuRUaeJj' },
+ ];
+
+ const DEFAULTS = {
+ service: null, // 'live' | 'tools' | 'dev' | … (auto-detected when omitted)
+ brandName: null,
+ tagline: 'Free, open source and community run. Built in the open by the people using it.',
+ variant: 'full',
+ mount: '#ov-footer',
+ links: [], // [{ heading, items: [{ name, url, onclick? }] }]
+ legalBase: null, // origin that hosts /tos, /privacy, /dmca for this site
+ apiBase: NETWORK_URL,
+ user: null,
+ showNetwork: true,
+ showAccount: true,
+ sitemap: '/sitemap.xml',
+ };
+
+ let _cfg = { ...DEFAULTS };
+ let _mounted = null;
+
+ function detectService() {
+ if (typeof location === 'undefined') return 'network';
+ const h = location.hostname;
+ if (h.endsWith('openvibe.live')) return 'live';
+ if (h.endsWith('openvibe.network')) return 'network';
+ if (h.endsWith('openvibe.games')) return 'games';
+ const sub = h.replace(/\.openvibe\.tools$/, '');
+ return sub === h ? 'tools' : (sub || 'tools');
+ }
+
+ function brandFor(service) {
+ const found = NETWORK.find(n => n.id === service);
+ if (found) return found.name === 'Account & themes' ? 'OpenVibe' : found.name;
+ return 'OpenVibe';
+ }
+
+ function legalHref(item, cfg) {
+ // Policies live on whichever site owns them; a tool subdomain links out rather than 404ing.
+ if (cfg.legalBase) return cfg.legalBase.replace(/\/$/, '') + item.path;
+ if (typeof location !== 'undefined' && location.hostname.endsWith('openvibe.live')) return item.path;
+ return item.fallback;
+ }
+
+ // ── Markup ─────────────────────────────────────────────────
+ function linkTag(item) {
+ const rel = /^https?:\/\//.test(item.url) ? ' target="_blank" rel="noopener"' : '';
+ const onclick = item.onclick ? ` onclick="${esc(item.onclick)}"` : '';
+ const title = item.desc ? ` title="${esc(item.desc)}"` : '';
+ return `<a href="${esc(item.url)}"${rel}${onclick}${title}>${esc(item.name)}</a>`;
+ }
+
+ function buildHTML(cfg) {
+ const c = { ...DEFAULTS, ...(cfg || {}) };
+ const service = c.service || detectService();
+ const brand = c.brandName || brandFor(service);
+ const network = NETWORK.filter(n => n.id !== service);
+ const year = new Date().getFullYear();
+
+ if (c.variant === 'compact') {
+ return `<div class="ovf-inner ovf-inner--compact">
+ <a class="ovf-brand ovf-brand--sm" href="/"><span class="ov-mark" data-size="20"></span><span>${esc(brand)}</span></a>
+ <nav class="ovf-compact-links" aria-label="Site links">
+ ${(c.links[0] ? c.links[0].items.slice(0, 3) : []).map(linkTag).join('')}
+ <a href="${NETWORK_URL}" target="_blank" rel="noopener">Network</a>
+ ${LEGAL.map(l => `<a href="${esc(legalHref(l, c))}">${esc(l.name.replace(' of Service', '').replace(' Policy', ''))}</a>`).join('')}
+ </nav>
+ <span class="ovf-compact-meta">Open source · ${year}</span>
+ </div>`;
+ }
+
+ const cols = (c.links || []).map(group => `
+ <nav class="ovf-col" aria-label="${esc(group.heading)}">
+ <h3>${esc(group.heading)}</h3>
+ ${group.items.map(linkTag).join('')}
+ </nav>`).join('');
+
+ return `<div class="ovf-inner">
+ <div class="ovf-cols">
+ <div class="ovf-col ovf-col--brand">
+ <a class="ovf-brand" href="/"><span class="ov-mark" data-size="26"></span><span>${esc(brand)}</span></a>
+ <p class="ovf-tagline">${esc(c.tagline)}</p>
+ <div class="ovf-social">
+ ${SOCIAL.map(s => `<a href="${s.url}" target="_blank" rel="noopener" aria-label="${s.name}" title="${s.name}"><i class="${s.icon}"></i></a>`).join('')}
+ <a href="${esc(c.sitemap)}" aria-label="Sitemap" title="Sitemap"><i class="fa-solid fa-sitemap"></i></a>
+ </div>
+ </div>
+ ${cols}
+ ${c.showNetwork ? `<nav class="ovf-col" aria-label="The OpenVibe network">
+ <h3>Network</h3>
+ ${network.slice(0, 6).map(linkTag).join('')}
+ <a class="ovf-more" href="${NETWORK_URL}" target="_blank" rel="noopener">Everything else <i class="fa-solid fa-arrow-right"></i></a>
+ </nav>` : ''}
+ <nav class="ovf-col" aria-label="Legal">
+ <h3>Legal</h3>
+ ${LEGAL.map(l => `<a href="${esc(legalHref(l, c))}">${esc(l.name)}</a>`).join('')}
+ </nav>
+ </div>
+ <div class="ovf-account" id="ovf-account" hidden></div>
+ <div class="ovf-bar">
+ <span class="ovf-copy">Open source & community driven · built in the open</span>
+ <span class="ovf-legal-inline">
+ ${LEGAL.map(l => `<a href="${esc(legalHref(l, c))}">${esc(l.name.replace(' of Service', '').replace(' Policy', ''))}</a>`).join('')}
+ </span>
+ </div>
+ </div>`;
+ }
+
+ // ── Signed-in row: the same account, wherever you are ──────
+ function accountHTML(user, service) {
+ const name = esc(user.display_name || user.username || 'you');
+ const avatar = user.avatar_url
+ ? `<img src="${esc(user.avatar_url)}" alt="">`
+ : `<span>${esc(String(name).charAt(0).toUpperCase())}</span>`;
+ const quick = [
+ service !== 'live' ? { name: 'My channel', url: `https://openvibe.live/@${esc(user.username || '')}`, icon: 'fa-tower-broadcast' } : { name: 'My dashboard', url: '/dashboard', icon: 'fa-gauge-high' },
+ { name: 'Account', url: `${NETWORK_URL}/my`, icon: 'fa-user' },
+ { name: 'Themes', url: `${NETWORK_URL}/themes`, icon: 'fa-palette' },
+ { name: 'Tools', url: 'https://openvibe.tools', icon: 'fa-screwdriver-wrench' },
+ ];
+ return `<span class="ovf-account-who"><span class="ovf-avatar">${avatar}</span> Signed in as <strong>${name}</strong> across OpenVibe</span>
+ <span class="ovf-account-links">${quick.map(q => `<a href="${esc(q.url)}"${/^https?:/.test(q.url) ? ' target="_blank" rel="noopener"' : ''}><i class="fa-solid ${q.icon}"></i> ${esc(q.name)}</a>`).join('')}</span>`;
+ }
+
+ function storedToken() {
+ try {
+ const m = document.cookie.match(/(?:^|; )ov_token=([^;]*)/);
+ return (m && decodeURIComponent(m[1])) || localStorage.getItem('ov_token') || localStorage.getItem('token') || null;
+ } catch { return null; }
+ }
+
+ async function fillAccount(cfg) {
+ const host = document.getElementById('ovf-account');
+ if (!host || !cfg.showAccount || cfg.variant === 'compact') return;
+ let user = cfg.user;
+ if (!user) {
+ const token = storedToken();
+ if (!token) return;
+ try {
+ const res = await fetch(`${cfg.apiBase}/api/auth/me`, { headers: { Authorization: 'Bearer ' + token }, credentials: 'include' });
+ if (!res.ok) return;
+ const data = await res.json();
+ user = data.user || data;
+ } catch { return; }
+ }
+ if (!user || !user.username) return;
+ host.innerHTML = accountHTML(user, cfg.service || detectService());
+ host.hidden = false;
+ }
+
+ // ── Styles (injected once) ─────────────────────────────────
+ const CSS = `
+.ovf{background:var(--bg-secondary,#12131c);border-top:1px solid var(--border,rgba(255,255,255,.08));margin-top:40px;
+ padding:clamp(24px,4vw,40px) clamp(14px,4vw,32px) calc(clamp(20px,3vw,28px) + env(safe-area-inset-bottom,0px));}
+.ovf[data-variant="compact"]{margin-top:24px;padding:14px clamp(12px,3vw,24px) calc(14px + env(safe-area-inset-bottom,0px));}
+.ovf-inner{max-width:1240px;margin:0 auto}
+.ovf-inner--compact{display:flex;flex-wrap:wrap;align-items:center;justify-content:center;gap:8px 18px;text-align:center}
+.ovf-cols{display:grid;gap:clamp(20px,3vw,36px);grid-template-columns:minmax(230px,1.6fr) repeat(auto-fit,minmax(120px,1fr));align-items:start}
+.ovf-col{display:flex;flex-direction:column;gap:8px;min-width:0}
+.ovf-col h3{margin:0 0 2px;font-size:.72rem;font-weight:800;letter-spacing:.1em;text-transform:uppercase;color:var(--text-muted,#8b93ad)}
+.ovf-col a{color:var(--text-secondary,#b6bdd2);font-size:.86rem;text-decoration:none;line-height:1.4;width:fit-content;transition:color .15s,transform .15s}
+.ovf-col a:hover{color:var(--accent,#a78bfa);transform:translateX(2px)}
+.ovf-more{font-weight:600;opacity:.85}.ovf-more i{font-size:.7rem;transition:transform .15s}.ovf-more:hover i{transform:translateX(3px)}
+.ovf-brand{display:inline-flex;align-items:center;gap:9px;color:var(--text-primary,#f1f4fb);font-size:1.05rem;font-weight:700;text-decoration:none}
+.ovf-brand--sm{font-size:.9rem}
+.ovf-tagline{margin:0;font-size:.82rem;line-height:1.55;color:var(--text-muted,#8b93ad);max-width:46ch}
+.ovf-social{display:flex;gap:8px;margin-top:4px}
+.ovf-social a{width:36px;height:36px;border-radius:10px;display:grid;place-items:center;color:var(--text-secondary,#b6bdd2);
+ background:var(--bg-tertiary,#1a1b28);border:1px solid var(--border,rgba(255,255,255,.08));font-size:.95rem;transition:color .15s,border-color .15s,transform .15s}
+.ovf-social a:hover{color:var(--accent,#a78bfa);border-color:var(--accent,#a78bfa);transform:translateY(-2px)}
+.ovf-account{display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:10px 18px;
+ margin-top:clamp(18px,2.5vw,26px);padding:10px 14px;border-radius:14px;
+ background:color-mix(in srgb, var(--accent,#a78bfa) 8%, var(--bg-tertiary,#1a1b28));
+ border:1px solid color-mix(in srgb, var(--accent,#a78bfa) 26%, var(--border,rgba(255,255,255,.08)));
+ animation:ovfIn .4s ease-out both}
+@keyframes ovfIn{from{opacity:0;transform:translateY(6px)}to{opacity:1;transform:none}}
+.ovf-account-who{display:inline-flex;align-items:center;gap:8px;font-size:.84rem;color:var(--text-secondary,#b6bdd2)}
+.ovf-account-who strong{color:var(--text-primary,#f1f4fb)}
+.ovf-avatar{width:24px;height:24px;border-radius:50%;overflow:hidden;display:grid;place-items:center;
+ background:var(--accent,#a78bfa);color:#fff;font-size:.7rem;font-weight:800;flex:none}
+.ovf-avatar img{width:100%;height:100%;object-fit:cover}
+.ovf-account-links{display:flex;flex-wrap:wrap;gap:6px}
+.ovf-account-links a{display:inline-flex;align-items:center;gap:6px;padding:5px 11px;border-radius:999px;font-size:.78rem;font-weight:600;
+ color:var(--text-secondary,#b6bdd2);background:var(--bg-secondary,#12131c);border:1px solid var(--border,rgba(255,255,255,.08));
+ text-decoration:none;transition:color .15s,border-color .15s,transform .15s}
+.ovf-account-links a:hover{color:var(--accent,#a78bfa);border-color:var(--accent,#a78bfa);transform:translateY(-1px)}
+.ovf-account-links i{font-size:.72rem;opacity:.8}
+.ovf-bar{display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:10px;
+ margin-top:clamp(20px,3vw,30px);padding-top:16px;border-top:1px solid var(--border,rgba(255,255,255,.08));
+ padding-inline:clamp(0px, calc(84px - (100vw - 1240px) / 2), 84px)}
+.ovf-copy{color:var(--text-muted,#8b93ad);font-size:.78rem}
+.ovf-legal-inline,.ovf-compact-links{display:flex;flex-wrap:wrap;gap:14px}
+.ovf-legal-inline a,.ovf-compact-links a{color:var(--text-muted,#8b93ad);font-size:.78rem;text-decoration:none;transition:color .15s}
+.ovf-legal-inline a:hover,.ovf-compact-links a:hover{color:var(--accent,#a78bfa)}
+.ovf-compact-meta{color:var(--text-muted,#8b93ad);font-size:.74rem}
+@media (max-width:900px){.ovf-cols{grid-template-columns:repeat(2,minmax(0,1fr))}.ovf-col--brand{grid-column:1/-1}}
+@media (max-width:560px){
+ .ovf{padding-bottom:calc(96px + env(safe-area-inset-bottom,0px))}
+ .ovf[data-variant="compact"]{padding-bottom:calc(84px + env(safe-area-inset-bottom,0px))}
+ .ovf-cols{gap:22px 16px}
+ .ovf-bar{justify-content:center;text-align:center;padding-inline:0}
+ .ovf-legal-inline{justify-content:center}
+ .ovf-account{flex-direction:column;align-items:flex-start}
+}
+@media (prefers-reduced-motion:reduce){.ovf-account{animation:none}.ovf-col a:hover,.ovf-social a:hover,.ovf-account-links a:hover{transform:none}}`;
+
+ function injectCSS() {
+ if (typeof document === 'undefined' || document.getElementById('ovf-css')) return;
+ const st = document.createElement('style');
+ st.id = 'ovf-css';
+ st.textContent = CSS;
+ document.head.appendChild(st);
+ }
+
+ // ── Mount ──────────────────────────────────────────────────
+ function render() {
+ if (!_mounted) return;
+ _mounted.className = 'ovf ' + (_mounted.dataset.extraClass || '');
+ _mounted.setAttribute('data-variant', _cfg.variant);
+ _mounted.innerHTML = buildHTML(_cfg);
+ try { if (typeof window !== 'undefined' && typeof window.ovMarkMount === 'function') window.ovMarkMount(_mounted); } catch { /* */ }
+ fillAccount(_cfg);
+ }
+
+ function init(cfg = {}) {
+ _cfg = { ...DEFAULTS, ..._cfg, ...cfg };
+ _cfg.service = _cfg.service || detectService();
+ injectCSS();
+ const el = typeof _cfg.mount === 'string' ? document.querySelector(_cfg.mount) : _cfg.mount;
+ if (!el) return null;
+ _mounted = el;
+ render();
+ return el;
+ }
+
+ function setVariant(variant) {
+ if (variant !== 'full' && variant !== 'compact') return;
+ if (_cfg.variant === variant) return;
+ _cfg.variant = variant;
+ render();
+ }
+
+ return { init, setVariant, buildHTML, render, CSS, NETWORK, LEGAL, detectService, get config() { return { ..._cfg }; } };
+});
new file mode 100644
new file mode 100644
@@ -0,0 +1,154 @@
@@ -0,0 +1,154 @@
+/**
+ * openvibe-shared/tooltip.js — rich tooltips for every OpenVibe property.
+ *
+ * Markup-driven, so a site never writes tooltip code:
+ *
+ * <a data-ovtip="Plain text tip">…</a>
+ * <a data-ovtip-title="API docs"
+ * data-ovtip="<b>Vibe code it.</b> Point an AI at these docs…"
+ * data-ovtip-icon="fa-wand-magic-sparkles"
+ * data-ovtip-place="top"
+ * data-ovtip-accent="#f472b6">…</a>
+ *
+ * One floating element is reused for every tooltip, positioned with fixed coordinates so it is
+ * never clipped by an overflow:hidden ancestor — the usual reason CSS-only tooltips get cut off
+ * inside cards and scroll containers. It flips to stay on screen, follows the trigger while the
+ * page scrolls, opens on hover, focus and long-press, and closes on Escape, scroll-away or tap
+ * elsewhere. Touch devices get a tap-to-open behaviour instead of hover, which is what makes
+ * these usable on a phone at all.
+ */
+(function () {
+ 'use strict';
+ if (typeof window === 'undefined' || window.OpenVibeTooltip) return;
+
+ const CSS = `
+.ovtip{position:fixed;z-index:30000;max-width:min(330px,calc(100vw - 24px));padding:12px 14px;border-radius:14px;
+ background:linear-gradient(165deg,var(--bg-card,#171826) 0%,var(--bg-secondary,#12131c) 100%);
+ border:1px solid color-mix(in srgb, var(--ovtip-accent,var(--accent,#a78bfa)) 45%, var(--border,rgba(255,255,255,.1)));
+ box-shadow:0 20px 50px rgba(0,0,0,.55),0 0 0 1px rgba(255,255,255,.04),0 0 30px color-mix(in srgb, var(--ovtip-accent,var(--accent,#a78bfa)) 22%, transparent);
+ color:var(--text-secondary,#b6bdd2);font-size:.83rem;line-height:1.55;pointer-events:none;opacity:0;
+ transform:translateY(6px) scale(.97);transition:opacity .18s ease,transform .22s cubic-bezier(.2,1.3,.3,1);
+ overflow:hidden}
+.ovtip.is-on{opacity:1;transform:none}
+.ovtip.is-interactive{pointer-events:auto}
+.ovtip::before{content:'';position:absolute;inset:0;pointer-events:none;
+ background:radial-gradient(120% 90% at 0% 0%, color-mix(in srgb, var(--ovtip-accent,var(--accent,#a78bfa)) 16%, transparent), transparent 60%)}
+/* A light sweep on open — draws the eye without moving anything around it. */
+.ovtip::after{content:'';position:absolute;top:0;bottom:0;left:-60%;width:45%;pointer-events:none;
+ background:linear-gradient(100deg,transparent,color-mix(in srgb, var(--ovtip-accent,var(--accent,#a78bfa)) 26%, transparent),transparent)}
+.ovtip.is-on::after{animation:ovtipSweep .9s cubic-bezier(.3,.7,.3,1) .1s 1}
+@keyframes ovtipSweep{to{transform:translateX(420%)}}
+.ovtip-head{display:flex;align-items:center;gap:8px;margin-bottom:6px;position:relative}
+.ovtip-ico{width:26px;height:26px;border-radius:8px;display:grid;place-items:center;flex:none;font-size:.78rem;color:#fff;
+ background:linear-gradient(135deg,var(--ovtip-accent,var(--accent,#a78bfa)),color-mix(in srgb,var(--ovtip-accent,var(--accent,#a78bfa)) 55%,#000));
+ box-shadow:0 4px 12px color-mix(in srgb, var(--ovtip-accent,var(--accent,#a78bfa)) 45%, transparent)}
+.ovtip-title{font-size:.9rem;font-weight:800;color:var(--text-primary,#f1f4fb);letter-spacing:-.01em}
+.ovtip-body{position:relative}
+.ovtip-body b,.ovtip-body strong{color:var(--text-primary,#f1f4fb);font-weight:700}
+.ovtip-body em{color:var(--ovtip-accent,var(--accent,#a78bfa));font-style:normal;font-weight:600}
+.ovtip-body code{background:var(--bg-tertiary,#1a1b28);padding:1px 6px;border-radius:5px;font-size:.78rem}
+.ovtip-foot{margin-top:8px;padding-top:8px;border-top:1px dashed var(--border,rgba(255,255,255,.1));
+ font-size:.74rem;color:var(--text-muted,#8b93ad);display:flex;align-items:center;gap:6px;position:relative}
+.ovtip-arrow{position:fixed;z-index:30001;width:10px;height:10px;transform:rotate(45deg);opacity:0;transition:opacity .18s;
+ background:var(--bg-card,#171826);border:1px solid color-mix(in srgb, var(--ovtip-accent,var(--accent,#a78bfa)) 45%, var(--border,rgba(255,255,255,.1)))}
+.ovtip-arrow.is-on{opacity:1}
+.ovtip-arrow.from-top{border-top:0;border-left:0}
+.ovtip-arrow.from-bottom{border-bottom:0;border-right:0}
+@media (prefers-reduced-motion:reduce){.ovtip{transition:opacity .12s}.ovtip.is-on::after{animation:none}}`;
+
+ let tip = null, arrow = null, current = null, raf = 0, hideTimer = 0;
+ const isTouch = () => window.matchMedia('(hover: none)').matches;
+
+ function ensure() {
+ if (tip) return;
+ const st = document.createElement('style'); st.id = 'ovtip-css'; st.textContent = CSS; document.head.appendChild(st);
+ tip = document.createElement('div'); tip.className = 'ovtip'; tip.setAttribute('role', 'tooltip');
+ arrow = document.createElement('div'); arrow.className = 'ovtip-arrow';
+ document.body.append(arrow, tip);
+ tip.addEventListener('pointerenter', () => clearTimeout(hideTimer));
+ tip.addEventListener('pointerleave', () => hide());
+ }
+
+ function content(el) {
+ const title = el.getAttribute('data-ovtip-title');
+ const icon = el.getAttribute('data-ovtip-icon');
+ const body = el.getAttribute('data-ovtip') || '';
+ const foot = el.getAttribute('data-ovtip-foot');
+ return `${title ? `<div class="ovtip-head">${icon ? `<span class="ovtip-ico"><i class="fa-solid ${icon}"></i></span>` : ''}<span class="ovtip-title">${title}</span></div>` : ''}
+ <div class="ovtip-body">${body}</div>
+ ${foot ? `<div class="ovtip-foot">${foot}</div>` : ''}`;
+ }
+
+ function place() {
+ if (!current || !tip) return;
+ if (!current.isConnected) return hide();
+ const r = current.getBoundingClientRect();
+ // Off-screen trigger: nothing to point at.
+ if (r.bottom < -40 || r.top > window.innerHeight + 40) return hide();
+ const tw = tip.offsetWidth, th = tip.offsetHeight, gap = 12;
+ const prefer = current.getAttribute('data-ovtip-place') || 'top';
+ let top, from;
+ const fitsAbove = r.top - th - gap > 8;
+ const fitsBelow = r.bottom + th + gap < window.innerHeight - 8;
+ if ((prefer === 'top' && fitsAbove) || (!fitsBelow && fitsAbove)) { top = r.top - th - gap; from = 'from-bottom'; }
+ else { top = r.bottom + gap; from = 'from-top'; }
+ let left = r.left + r.width / 2 - tw / 2;
+ left = Math.max(12, Math.min(left, window.innerWidth - tw - 12));
+ tip.style.top = `${Math.round(top)}px`;
+ tip.style.left = `${Math.round(left)}px`;
+ const ax = Math.max(left + 14, Math.min(r.left + r.width / 2 - 5, left + tw - 24));
+ arrow.className = `ovtip-arrow is-on ${from}`;
+ arrow.style.left = `${Math.round(ax)}px`;
+ arrow.style.top = `${Math.round(from === 'from-top' ? top - 5 : top + th - 5)}px`;
+ raf = requestAnimationFrame(place);
+ }
+
+ function show(el) {
+ ensure();
+ clearTimeout(hideTimer);
+ if (current === el && tip.classList.contains('is-on')) return;
+ current = el;
+ tip.style.setProperty('--ovtip-accent', el.getAttribute('data-ovtip-accent') || '');
+ arrow.style.setProperty('--ovtip-accent', el.getAttribute('data-ovtip-accent') || '');
+ tip.innerHTML = content(el);
+ tip.classList.toggle('is-interactive', el.hasAttribute('data-ovtip-interactive'));
+ tip.classList.remove('is-on'); arrow.classList.remove('is-on');
+ cancelAnimationFrame(raf);
+ place();
+ requestAnimationFrame(() => { tip.classList.add('is-on'); arrow.classList.add('is-on'); });
+ el.setAttribute('aria-describedby', 'ovtip');
+ }
+
+ function hide() {
+ if (!tip) return;
+ cancelAnimationFrame(raf);
+ tip.classList.remove('is-on'); arrow.classList.remove('is-on');
+ if (current) current.removeAttribute('aria-describedby');
+ current = null;
+ }
+ const hideSoon = () => { clearTimeout(hideTimer); hideTimer = setTimeout(hide, 120); };
+
+ function trigger(e) { return e.target.closest && e.target.closest('[data-ovtip]'); }
+
+ function bind() {
+ const fromTouch = (e) => e.pointerType === 'touch' || (!e.pointerType && isTouch());
+ document.addEventListener('pointerover', (e) => { if (fromTouch(e)) return; const t = trigger(e); if (t) show(t); });
+ document.addEventListener('pointerout', (e) => { if (fromTouch(e)) return; if (trigger(e)) hideSoon(); });
+ document.addEventListener('focusin', (e) => { const t = trigger(e); if (t) show(t); });
+ document.addEventListener('focusout', (e) => { if (trigger(e)) hideSoon(); });
+ // Touch: first tap opens the tip, a tap elsewhere closes it. The trigger still works —
+ // we never swallow the click, so a tap on an already-open tip follows the link.
+ document.addEventListener('click', (e) => {
+ const t = trigger(e);
+ if (!t) return hide();
+ // On touch the first tap opens the tip; a second tap follows the link.
+ if (isTouch() && current !== t) { e.preventDefault(); show(t); }
+ }, true);
+ document.addEventListener('keydown', (e) => { if (e.key === 'Escape') hide(); });
+ window.addEventListener('scroll', () => { if (current && isTouch()) hide(); }, { passive: true });
+ window.addEventListener('resize', hide, { passive: true });
+ }
+
+ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', bind); else bind();
+ window.OpenVibeTooltip = { show, hide, ensure };
+})();
@@ -146,7 +146,7 @@ footer{border-top:1px solid var(--border);background:var(--bg-secondary)}
@@ -146,7 +146,7 @@ footer{border-top:1px solid var(--border);background:var(--bg-secondary)}
<section class="hero">
<section class="hero">
<div class="hero-badge"><span class="ov-mark" data-size="20"></span> The OpenVibe Network — Community-Driven, No Profit</div>
<div class="hero-badge"><span class="ov-mark" data-size="20"></span> The OpenVibe Network — Community-Driven, No Profit</div>
<h1>One Account.<br><span class="grad">All of OpenVibe.</span></h1>
<h1>One Account.<br><span class="grad">All of OpenVibe.</span></h1>
- <p class="sub">Live streaming, free online tools, games, and media hosting — one free account works across the entire network. No ads, no tracking, no nonsense.</p>
+ <p class="sub">Live streaming, free online tools, games, and media hosting — one free account works across the entire network. One login, no nonsense.</p>
<div class="hero-ctas" id="hero-guest-ctas">
<div class="hero-ctas" id="hero-guest-ctas">
<a class="btn btn-primary" href="/login"><i class="fa-solid fa-right-to-bracket"></i> Sign In</a>
<a class="btn btn-primary" href="/login"><i class="fa-solid fa-right-to-bracket"></i> Sign In</a>
<a class="btn btn-ghost" href="/login?tab=register"><i class="fa-solid fa-user-plus"></i> Create Account</a>
<a class="btn btn-ghost" href="/login?tab=register"><i class="fa-solid fa-user-plus"></i> Create Account</a>