openvibers/openvibe.tools Commits
Index shows every tool by family with planned placeholders; loopback is the apex; legal pages; vendor sync
Changes
@@@ -212,6 +212,9 @@ function sendTool(req, res, file) {}}++// Terms, Privacy and DMCA — the same documents on the apex and on every tool host.++{ const legal = require('openvibe-shared/legal'); app.get(legal.PATHS, legal.handler({ id: 'tools', service: 'tools', host: 'openvibe.tools', name: 'OpenVibe.Tools', profile: 'tools' })); app.get('/tos', (_req, res) => res.redirect(301, '/terms')); }++// The apex is server-rendered from the registry: index, families, tool pages, search,// sitemap.xml, robots.txt, llms.txt and /api/catalog.json (server/pages/site.js).app.use(require('./pages/site').createSiteRouter());@@@ -21,7 +21,7 @@ const NAME = 'OpenVibe.Tools';const esc = seo.esc;const POPULAR = ['yt', 'convert', 'compress', 'mergepdf', 'jsonfmt', 'mp3', 'dns', 'whois', 'fancy', 'logo', 'ssl', 'resize', 'trim', 'regex', 'maps', 'food'];--const icon = (name, size) => `<span class="ov-icon" data-icon="${esc(name)}" data-fx="none" style="--ovi-size:${size}px" aria-hidden="true">${icons.svg(name)}</span>`;++const icon = (name, size) => `<span class="ov-icon" data-icon="${esc(name)}" data-ovi="${esc(icons.resolve(name))}" data-fx="none" style="--ovi-size:${size}px" aria-hidden="true">${icons.svg(name)}</span>`;const hostLabel = (h) => h.replace(/\.openvibe\.tools$/, '') === h ? h : h;function toolCard(t) {@@@ -70,6 +70,19 @@ details{border-bottom:1px solid var(--border,rgba(255,255,255,.08));padding:12px.az{columns:4 220px;column-gap:24px;padding:0;list-style:none}.az li{break-inside:avoid;padding:3px 0}.az a{text-decoration:none}.az small{color:var(--text-muted,#7d8aa0)}.empty{color:var(--text-secondary,#a8b3c4);padding:12px 0}.net{display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:10px}++.kicker{font-size:12.5px!important;font-weight:700;letter-spacing:.7px;text-transform:uppercase;color:var(--accent-light,var(--accent,#60a5fa))!important;margin:0 auto 12px!important}++.stats{display:flex;flex-wrap:wrap;justify-content:center;gap:8px 26px;list-style:none;padding:0;margin:20px 0 0;color:var(--text-muted,#7d8aa0);font-size:14px}.stats b{color:var(--text-primary,#e6edf7);font-size:18px;margin-right:4px}++.jump{position:sticky;top:56px;z-index:20;display:flex;gap:8px;overflow-x:auto;padding:10px 2px;margin:26px 0 0;background:color-mix(in srgb,var(--bg-primary,#0a0f18) 88%,transparent);backdrop-filter:blur(10px);scrollbar-width:none;border-bottom:1px solid var(--border,rgba(255,255,255,.06))}.jump::-webkit-scrollbar{display:none}++.jump a{display:inline-flex;align-items:center;gap:7px;flex:none;padding:6px 12px 6px 7px;border-radius:999px;border:1px solid var(--border,rgba(255,255,255,.1));text-decoration:none;color:var(--text-primary,#e6edf7);font-size:13.5px;font-weight:600;background:var(--bg-secondary,#111826)}++.jump a:hover{border-color:var(--accent,#3b82f6)}.jump i{font-style:normal;font-size:11.5px;color:var(--text-muted,#7d8aa0);font-weight:700}++.fam{scroll-margin-top:120px}.fam h2 a{color:inherit;text-decoration:none}.fam h2 a:hover{color:var(--accent-light,var(--accent,#60a5fa))}.fam-tag{margin:2px 0 0;color:var(--text-secondary,#a8b3c4);font-size:14.5px}.fam .sec-h{margin-bottom:14px;align-items:center}++.tiles{display:grid;grid-template-columns:repeat(auto-fill,minmax(215px,1fr));gap:8px}++.tile{position:relative;display:flex;gap:10px;align-items:center;padding:9px 11px;border-radius:12px;border:1px solid var(--border,rgba(255,255,255,.07));background:var(--bg-secondary,#111826);text-decoration:none;color:inherit;min-width:0;transition:border-color .15s,background .15s}++.tile:hover,.tile:focus-visible{border-color:var(--accent,#3b82f6);background:color-mix(in srgb,var(--accent,#3b82f6) 7%,var(--bg-secondary,#111826));outline:0}++.tile>span:not(.ov-icon){min-width:0}.tile b{display:block;font-size:14px;font-weight:650;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.tile small{display:block;font-size:12px;color:var(--text-muted,#7d8aa0);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}++.tile.is-soon{border-style:dashed;background:none;opacity:.72;cursor:default}.tile.is-soon em{margin-left:auto;font-style:normal;font-size:10.5px;font-weight:800;letter-spacing:.5px;text-transform:uppercase;color:var(--accent-light,var(--accent,#60a5fa));flex:none}++.perks{display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:12px}.perks div{padding:16px;border-radius:14px;border:1px solid var(--border,rgba(255,255,255,.08));background:var(--bg-secondary,#111826)}.perks p{margin:6px 0 0;color:var(--text-secondary,#a8b3c4);font-size:14px}++@media (max-width:560px){.tiles{grid-template-columns:1fr 1fr}.tile small{display:none}.jump{top:52px}}#ov-footer{margin-top:56px}@media (max-width:560px){.grid{grid-template-columns:1fr}.search button{padding:0 14px}}@media (prefers-reduced-motion:reduce){.tool{transition:none}}@@@ -119,17 +132,24 @@ function search(q) {// ── Pages ────────────────────────────────────────────────────function pageIndex() {-- const { tools, families } = registry.get();++ const { tools, families, planned } = registry.get();const byId = new Map(tools.map(t => [t.id, t]));const popular = POPULAR.map(id => byId.get(id)).filter(Boolean);const fams = families.filter(f => f.path);-- const body = `<div class="hero"><h1>Online tools that <span>just work</span></h1>--<p>${tools.length} tools for files, text, code and networks. Convert a video, merge a PDF, look up a domain or format JSON. Open source, community-run, and no account needed to start.</p>++ const tile = (t) => `<a class="tile" href="${esc(t.url)}" title="${esc(t.tagline)}">${icon(t.icon, 28)}<span><b>${esc(t.name)}</b><small>${esc(t.tagline)}</small></span></a>`;++ const soon = (t) => `<span class="tile is-soon" title="Planned">${icon(t.icon, 28)}<span><b>${esc(t.name)}</b><small>${esc(t.tagline)}</small></span><em>Soon</em></span>`;++ const body = `<div class="hero"><p class="kicker">Open source · community-run · no account needed to start</p><h1>The toolbox for <span>everything you do online</span></h1>++<p>Convert a video, squeeze an image, merge a PDF, debug DNS, format JSON. ${tools.length} tools that open instantly, each on an address you can remember, like <a href="https://yt.openvibe.tools/">yt.openvibe.tools</a>.</p>${searchForm('')}--<ul class="chips">${fams.map(f => `<li><a href="${esc(f.path)}">${icon(f.icon, 22)}${esc(f.name)}</a></li>`).join('')}</ul></div>++<ul class="stats"><li><b>${tools.length}</b> tools</li><li><b>${fams.length}</b> families</li><li><b>0</b> installs</li><li><b>1</b> account for all of OpenVibe</li></ul></div>++<nav class="jump" aria-label="Tool families">${fams.map(f => `<a href="#f-${f.id}">${icon(f.icon, 22)}${esc(f.name)}<i>${tools.filter(t => t.family === f.id).length}</i></a>`).join('')}</nav><section id="results" hidden aria-live="polite"><div class="sec-h"><h2>Results</h2></div><div class="grid" id="results-grid"></div><p class="empty" id="results-empty" hidden>No tool matches that yet. <a href="/all-tools">Browse every tool</a>.</p></section>--<section aria-labelledby="pop-h"><div class="sec-h"><h2 id="pop-h">Popular right now</h2><a class="more" href="/all-tools">All ${tools.length} tools</a></div><div class="grid">${popular.map(toolCard).join('')}</div></section>--${fams.map(f => { const list = tools.filter(t => t.family === f.id); return `<section aria-labelledby="f-${f.id}"><div class="sec-h">${icon(f.icon, 34)}<h2 id="f-${f.id}"><a href="${esc(f.path)}" style="color:inherit;text-decoration:none">${esc(f.name)}</a></h2><a class="more" href="${esc(f.path)}">All ${list.length}</a></div><p class="sec-p">${esc(f.description)}</p><div class="grid">${list.slice(0, 8).map(toolCard).join('')}</div></section>`; }).join('')}`;++<section aria-labelledby="pop-h"><div class="sec-h"><h2 id="pop-h">What people reach for</h2><a class="more" href="/all-tools">A to Z list</a></div><div class="grid">${popular.slice(0, 8).map(toolCard).join('')}</div></section>++${fams.map(f => { const list = tools.filter(t => t.family === f.id); const next = planned.filter(t => t.family === f.id); return `<section class="fam" aria-labelledby="f-${f.id}"><div class="sec-h">${icon(f.icon, 40)}<div><h2 id="f-${f.id}"><a href="${esc(f.path)}">${esc(f.name)}</a></h2><p class="fam-tag">${esc(f.tagline)}</p></div><a class="more" href="${esc(f.path)}">About these ${list.length}</a></div><div class="tiles">${list.map(tile).join('')}${next.map(soon).join('')}</div></section>`; }).join('')}++<section aria-labelledby="acct-h"><div class="sec-h"><h2 id="acct-h">Better with an account, fine without</h2></div><div class="perks">++<div><b>Without signing in</b><p>Every tool works. Results stay available for an hour, then they are deleted.</p></div>++<div><b>Signed in</b><p>Results keep for 24 hours, your recent tools follow you across devices, and your theme and notifications come along from the rest of OpenVibe.</p></div>++<div><b>For developers</b><p>The whole catalog is public JSON at <a href="/api/catalog.json">/api/catalog.json</a>, with a plain-text map at <a href="/llms.txt">/llms.txt</a>.</p></div></div></section>`;return shell({ families, body, head: head({title: 'Online Tools for Files, Text, Code & Networks', description: `${tools.length} online tools in one place: video and audio converters, image and PDF tools, developer utilities and network diagnostics. Open source and community-run.`,canonical: SITE + '/', keywords: 'online tools, converter, pdf tools, image converter, developer tools, network tools, youtube downloader',@@@ -44,6 +44,19 @@ function baseTools() {return out;}++// Planned tools: shown on the index as placeholders so people (and crawlers) can see where the++// toolbox is going. They have no host and are never linked as if they worked.++const PLANNED = [++ { id: 'instagram', family: 'media', name: 'Instagram Downloader', tagline: 'Save reels, posts and stories you are allowed to keep', icon: 'download' },++ { id: 'tiktok', family: 'media', name: 'TikTok Downloader', tagline: 'Save TikTok videos without the watermark dance', icon: 'download' },++ { id: 'videoconvert', family: 'media', name: 'Video Converter', tagline: 'MP4, WebM, MOV and GIF, trimmed and resized', icon: 'video' },++ { id: 'screenrecord', family: 'media', name: 'Screen Recorder', tagline: 'Record a tab or a window straight from the browser', icon: 'video' },++ { id: 'qr', family: 'dev', name: 'QR Code Generator', tagline: 'Links, Wi-Fi logins and contact cards as QR codes', icon: 'code' },++ { id: 'password', family: 'dev', name: 'Password Generator', tagline: 'Strong passwords and passphrases, made in your browser', icon: 'ssl' },++ { id: 'speedtest', family: 'net', name: 'Speed Test', tagline: 'Download, upload and latency to the OpenVibe network', icon: 'ping' },++ { id: 'ocr', family: 'img', name: 'Image to Text (OCR)', tagline: 'Pull the text out of screenshots and scans', icon: 'text' },++];++const familyById = new Map(FAMILIES.map(f => [f.id, f]));let overrides = []; // [{ tool_id, host, role }]let overridesAt = 0;@@@ -88,7 +101,9 @@ function build() {claim(x.hosts.short, { kind, role: 'short', item: x });x.hosts.aliases.forEach(a => claim(a, { kind, role: 'alias', item: x }));}-- built = { tools, families, byHost, updated: new Date().toISOString() };++ const live = new Set(tools.map(t => t.id));++ const planned = PLANNED.filter(t => !live.has(t.id));++ built = { tools, families, planned, byHost, updated: new Date().toISOString() };return built;}@@@ -97,7 +112,8 @@ function get() { return built || build(); }/** What is this host? → { kind: 'apex'|'tool'|'family'|'unknown', role, item, tool, canonicalHost, shortHost, port, inZone } */function resolveHost(hostname) {const host = String(hostname || '').toLowerCase().replace(/:\d+$/, '').replace(/^www\./, '');-- if (host === APEX) return { kind: 'apex', host };++ // Loopback is the network's own services asking (catalog, health): that is the apex.++ if (host === APEX || host === 'localhost' || host === '127.0.0.1' || host === '[::1]') return { kind: 'apex', host };const hit = get().byHost.get(host);const inZone = host.endsWith('.' + ZONE);if (!hit) return { kind: 'unknown', host, inZone };@@@ -111,6 +127,7 @@ function catalog() {return {updated: r.updated,families: r.families.map(f => ({ id: f.id, name: f.name, tagline: f.tagline, description: f.description, icon: f.icon, url: f.url, path: f.path, page: f.page, count: r.tools.filter(t => t.family === f.id).length })),++ planned: r.planned.map(t => ({ id: t.id, family: t.family, name: t.name, tagline: t.tagline, icon: t.icon, status: 'planned' })),tools: r.tools.map(t => ({ id: t.id, family: t.family, name: t.name, tagline: t.tagline, description: t.description, keywords: t.keywords, icon: t.icon, hosts: t.hosts, url: t.url, page: `https://${APEX}/tool/${t.id}` })),};}@@@ -27,6 +27,41 @@})(typeof globalThis !== 'undefined' ? globalThis : this, function () {'use strict';++ // ── Shared chrome data (https://openvibe.network/api/chrome) ─────────────────────────────++ // Sites ordered by real use, footer copy and per-site legal links. Cached per host for 30++ // minutes in localStorage and refreshed in the background, so pages paint from cache and the++ // order never jumps while someone is looking at it. Defined once, shared by navbar + footer.++ const OVChrome = root.OpenVibeChrome || (root.OpenVibeChrome = (function () {++ const KEY = 'ov_chrome_v1', TTL = 30 * 60000;++ let inflight = null;++ const host = () => (typeof location !== 'undefined' ? location.hostname : '');++ const https = (u) => { try { return new URL(u).protocol === 'https:'; } catch { return false; } };++ function clean(d) {++ if (!d || !Array.isArray(d.nav)) return null;++ const link = (l) => (l && typeof l.name === 'string' && https(l.url) ? { id: String(l.id || ''), name: l.name.slice(0, 60), url: l.url, icon: String(l.icon || ''), tagline: String(l.tagline || '').slice(0, 80) } : null);++ const f = d.footer || {}, lg = f.legal || {};++ return { nav: d.nav.map(link).filter(Boolean).slice(0, 12), soon: (d.soon || []).map(link).filter(Boolean).slice(0, 24),++ footer: { blurb: String(f.blurb || '').slice(0, 200), discover: (f.discover || []).map(link).filter(Boolean).slice(0, 6), popular: (f.popular || []).map(link).filter(Boolean).slice(0, 10),++ legal: https(lg.terms) && https(lg.privacy) && https(lg.dmca) ? { terms: lg.terms, privacy: lg.privacy, dmca: lg.dmca } : null } };++ }++ function cached() { try { const c = JSON.parse(localStorage.getItem(KEY) || 'null'); return c && c.host === host() && c.data ? c : null; } catch { return null; } }++ function refresh() {++ if (inflight || typeof fetch === 'undefined') return inflight || Promise.resolve(null);++ inflight = fetch('https://openvibe.network/api/chrome?host=' + encodeURIComponent(host()), { credentials: 'omit' })++ .then(r => (r.ok ? r.json() : null)).then(clean)++ .then(d => { if (d) { try { localStorage.setItem(KEY, JSON.stringify({ at: Date.now(), host: host(), data: d })); } catch { /* */ } } return d; })++ .catch(() => null);++ return inflight;++ }++ /** Cached data now (or null); `onFirst` fires once when a first-ever fetch lands. */++ function get(onFirst) {++ const c = cached();++ if (!c || Date.now() - c.at > TTL) { const p = refresh(); if (!c && onFirst) p.then(d => { if (d) onFirst(d); }); }++ return c ? c.data : null;++ }++ return { get, refresh };++ })());++const NETWORK_URL = 'https://openvibe.network';const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));@@@ -50,9 +85,9 @@];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' },++ { key: 'terms', name: 'Terms of Service', path: '/tos', fallback: 'https://openvibe.live/tos' },++ { key: 'privacy', name: 'Privacy Policy', path: '/privacy', fallback: 'https://openvibe.live/privacy' },++ { key: 'dmca', name: 'DMCA', path: '/dmca', fallback: 'https://openvibe.live/dmca' },];const SOCIAL = [@@@ -106,6 +141,9 @@}function legalHref(item, cfg) {++ // Each site answers for itself: the chrome service names this domain's own documents.++ const chrome = cfg._chrome;++ if (chrome && chrome.footer.legal && !cfg.legalBase) return chrome.footer.legal[item.key];// 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;@@@ -114,7 +152,9 @@// ── Markup ─────────────────────────────────────────────────function linkTag(item) {-- const rel = /^https?:\/\//.test(item.url) ? ' target="_blank" rel="noopener"' : '';++ // Our own sites open in this tab (the sign-in hand-off follows the click); the rest of the web in a new one.++ const own = /^https:\/\/([a-z0-9-]+\.)*(openvibe\.[a-z]+|openre\.stream)(\/|$)/i.test(item.url);++ const rel = /^https?:\/\//.test(item.url) && !own ? ' 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>`;@@@ -124,7 +164,14 @@const c = { ...DEFAULTS, ...(cfg || {}) };const service = c.service || detectService();const brand = c.brandName || brandFor(service);-- const network = NETWORK.filter(n => n.id !== service);++ const chrome = c._chrome = OVChrome.get(() => { try { render(); } catch { /* */ } });++ // Most used sites first when the chrome service has spoken; the built-in list otherwise.++ const network = chrome && chrome.nav.length++ ? chrome.nav.filter(n => n.id !== service && !(typeof location !== 'undefined' && new URL(n.url).hostname === location.hostname)).map(n => ({ name: 'OpenVibe.' + n.name, url: n.url, desc: n.tagline }))++ : NETWORK.filter(n => n.id !== service);++ const tagline = (cfg && cfg.tagline) || (chrome && chrome.footer.blurb) || c.tagline;++ const popular = chrome ? chrome.footer.popular : [];++ const discover = chrome ? chrome.footer.discover.filter(d => !network.slice(0, 6).some(n => n.url === d.url)) : [];const year = new Date().getFullYear();if (c.variant === 'compact') {@@@ -149,7 +196,7 @@<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>++ <p class="ovf-tagline">${esc(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>@@@ -161,6 +208,11 @@${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>` : ''}++ ${popular.length ? `<nav class="ovf-col" aria-label="Popular tools">++ <h3>Popular tools</h3>++ ${popular.slice(0, 6).concat(discover.slice(0, 2)).map(linkTag).join('')}++ <a class="ovf-more" href="https://openvibe.tools/all-tools">All tools <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('')}nnew file mode 100644@@@ -0,0 +1,145 @@++'use strict';++// ═══════════════════════════════════════════════════════════════++// openvibe-shared/legal — Terms, Privacy and DMCA for every OpenVibe site.++//++// Each domain answers for itself: /terms, /privacy and /dmca live on the site's own apex and say++// what THAT site does. One template per document, with clauses switched by the site's profile:++// streaming live video, chat, payments tools files processed, then deleted++// ugc posts, pastes, comments games accounts and game state++// hosting stored user media account the identity provider++// info read-only / placeholder site++//++// const legal = require('openvibe-shared/legal');++// app.get(legal.PATHS, legal.handler({ host: 'openvibe.tools', name: 'OpenVibe.Tools', profile: 'tools' }));++// legal.page('privacy', site) → complete HTML document (static generators use this)++//++// Plain language on purpose. These are templates maintained by the project, not legal advice;++// the owner reviews them before relying on them. Facts stated here (what is stored, for how++// long) must match the code — change both together.++// ═══════════════════════════════════════════════════════════════++const UPDATED = '2026-09-18';++const UPDATED_TEXT = 'September 18, 2026';++const CONTACT = '[email protected]';++const PATHS = ['/terms', '/privacy', '/dmca'];++const TITLES = { terms: 'Terms of Service', privacy: 'Privacy Policy', dmca: 'DMCA & Copyright' };++++const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));++const has = (site, ...profiles) => profiles.includes(site.profile);++const userContent = (site) => has(site, 'streaming', 'ugc', 'hosting', 'games');++++function terms(site) {++ const n = esc(site.name);++ const S = [];++ S.push(['The short version', `<p>${n} is part of OpenVibe, an open source, community-run network. Use it for lawful things, be decent to other people, and do not try to break it. We can remove content or accounts that break these terms. The software is provided as it is, without warranties.</p>`]);++ S.push(['Who can use it', `<p>You must be at least 13 years old, or the minimum age of digital consent where you live. If you are under 18, a parent or guardian must agree to these terms for you. You may not use ${n} if you were previously banned from the network.</p>`]);++ if (!has(site, 'info')) S.push(['Your account', `<p>Most of ${n} works without signing in. An OpenVibe account, managed at <a href="https://openvibe.network/">openvibe.network</a>, adds things that need to remember you. You are responsible for what happens under your account; keep your password to yourself and tell us if you think someone else has it.</p>`]);++ if (has(site, 'tools')) S.push(['Files and links you give the tools', `<p>The tools process what you upload or paste and hand you the result. Uploads and results are temporary: they are deleted automatically, normally within one hour for visitors and within 24 hours for signed-in users. Do not rely on ${n} to store anything.</p><p>Only process material you have the right to use. Downloaders are for your own uploads and for content you are allowed to keep; respecting the terms of the site the content comes from is your responsibility. Lookup tools (DNS, WHOIS, ping, port checks) may only be pointed at systems you are allowed to test.</p>`]);++ if (userContent(site)) S.push(['What you post', `<p>You keep ownership of what you ${has(site, 'streaming') ? 'stream, upload, clip or write' : 'upload or write'}. You give OpenVibe a worldwide, non-exclusive, royalty-free licence to host, display, transcode and distribute it so the service can work, including on other OpenVibe sites where your content is meant to appear. The licence ends when you delete the content, except for copies other people made while it was public and backups that expire on their own.</p><p>You are responsible for having the rights to everything you post.</p>`]);++ S.push(['Rules', `<p>OpenVibe leans toward open expression. These limits apply everywhere:</p><ul><li>Nothing illegal where you are or where our servers are (United States).</li><li>No sexual content involving minors, and no sexualisation of minors, ever.</li><li>No credible threats of violence, and no publishing of someone's private information to harm them.</li><li>No content you do not have the rights to.</li><li>No malware, phishing, spam, scraping that ignores robots.txt rate limits, or attempts to disrupt or gain unauthorised access to the service or to other people's systems.</li>${has(site, 'streaming') ? '<li>Streams must be labelled honestly; adult or graphic content belongs behind the mature flag.</li>' : ''}</ul>`]);++ if (has(site, 'streaming')) S.push(['Money', `<p>Donations, tips and purchases of virtual currency are voluntary and final except where the law requires a refund. Virtual currencies have no cash value outside the payout rules shown on the site. Streamers are responsible for their own taxes.</p>`]);++ if (has(site, 'games')) S.push(['Game items and progress', `<p>Items, currency and progress in games have no cash value and may be changed, reset or removed as the games evolve. Cheating, automation that gives an unfair advantage and exploiting bugs can lead to a reset or a ban.</p>`]);++ S.push(['Moderation and ending accounts', `<p>We may remove content, limit features, or suspend accounts that break these terms or put other people or the service at risk. You can stop using ${n} at any time and delete your account from your account settings.</p>`]);++ S.push(['Open source', `<p>OpenVibe's code is public. The code is licensed under the terms in each repository; those licences, not this page, govern your use of the code. These terms cover your use of the hosted service at ${esc(site.host)}.</p>`]);++ S.push(['No warranty, limited liability', `<p>The service is provided "as is" and "as available". To the extent the law allows, OpenVibe and the people who run it are not liable for indirect or consequential losses, lost data, or lost profits arising from your use of ${n}. Nothing here limits liability that cannot be limited by law.</p>`]);++ S.push(['Governing law', `<p>These terms are governed by the laws of the United States and the State of Washington, without regard to conflicts of law. Disputes are handled in the federal or state courts located in Washington State. If part of these terms cannot be enforced, the rest still applies.</p>`]);++ S.push(['Changes and contact', `<p>We may update these terms. Material changes are announced on the site before they take effect. Questions: <a href="mailto:${CONTACT}">${CONTACT}</a>.</p>`]);++ return S;++}++++function privacy(site) {++ const n = esc(site.name);++ const S = [];++ S.push(['The short version', `<p>${n} collects what it needs to work and to keep abuse out, and no more. We do not sell personal information and we do not run advertising trackers.</p>`]);++ const collect = [++ '<li><b>Request logs.</b> Like every web server, ours record your IP address, browser type, the page requested and the time. We use them for security, rate limiting and aggregate traffic statistics.</li>',++ '<li><b>Usage statistics.</b> Page views and a random session identifier, so we can see which parts of the network people use. These are first-party statistics kept on our own servers.</li>',++ ];++ if (!has(site, 'info')) collect.push(`<li><b>Account information, if you sign in.</b> Your OpenVibe account (username, email address, password hash, profile and preferences) is held by <a href="https://openvibe.network/privacy">openvibe.network</a>. ${n} receives your user id, username, avatar and role so it can show you as signed in.</li>`);++ if (has(site, 'tools')) collect.push('<li><b>What you give a tool.</b> Files, text, links, domain names and IP addresses you submit are processed to produce your result. Uploaded files and generated results are deleted automatically, normally within one hour for visitors and 24 hours for signed-in users. Many text and developer tools run entirely in your browser and send nothing to us.</li>');++ if (has(site, 'streaming')) collect.push('<li><b>Streams, clips, VODs and chat.</b> What you broadcast and write is stored so it can be shown, and is public unless you choose otherwise.</li><li><b>Payments.</b> Payment processors handle card details; we receive a record of the transaction, not your card number.</li><li><b>AI features.</b> Public streams and chat may be transcribed and summarised by AI services to create captions, highlights and overviews.</li>');++ if (has(site, 'ugc')) collect.push('<li><b>What you post.</b> Pastes, posts and comments, with the account that made them. Public items are visible to everyone and may be indexed by search engines.</li>');++ if (has(site, 'hosting')) collect.push('<li><b>Media you upload or record.</b> Videos, clips, images and files, with their metadata, stored so the sites that use them can show them.</li>');++ if (has(site, 'games')) collect.push('<li><b>Game state.</b> Your characters, items, progress and in-game chat.</li>');++ if (has(site, 'account')) collect.push('<li><b>Your account.</b> Username, email address, a salted hash of your password, profile, theme and notification preferences, linked services, sign-in history and, if you keep it switched on, your cross-site history.</li>');++ S.push(['What we collect', `<ul>${collect.join('')}</ul>`]);++ S.push(['Cookies and local storage', `<p>We use a small number of first-party cookies: a sign-in token (<code>ov_token</code>), a refresh token, and a hint that remembers whether you were signed in so other OpenVibe sites can sign you in quietly. Your theme, recent pages and similar preferences are kept in your browser's local storage. There are no third-party advertising cookies. Our sites sit behind Cloudflare, which may set its own security cookies.</p>`]);++ S.push(['Who we share with', `<p>Only the services needed to run the network: our hosting and network providers (OVH, Cloudflare), storage providers for media${has(site, 'streaming') ? ', payment processors, AI providers used for transcription and summaries' : ''}, and email delivery for account messages. We disclose information when the law requires it, and we tell you when we are allowed to.</p>`]);++ S.push(['Across OpenVibe sites', `<p>OpenVibe sites share one account. When you are signed in, the site you visit learns who you are from openvibe.network; it does not receive your password or email address. You can see and clear your cross-site history, and switch it off, at <a href="https://openvibe.network/history">openvibe.network/history</a>.</p>`]);++ S.push(['How long we keep things', `<p>Request logs and raw usage events are kept for a limited period and then reduced to totals. ${has(site, 'tools') ? 'Tool uploads and results are deleted within hours. ' : ''}${userContent(site) ? 'Content you post stays until you delete it or your account. ' : ''}Account data stays until you delete your account; backups expire on their own shortly afterwards.</p>`]);++ S.push(['Your choices and rights', `<p>You can use most of ${n} without an account. With an account you can view, change, export and delete your data from your account settings, or ask us to do it. Depending on where you live (for example the EEA, the UK or California) you may have additional rights to access, correct, delete or restrict the use of your data; write to us and we will honour them.</p>`]);++ S.push(['Children', `<p>${n} is not for children under 13, and we do not knowingly collect their information. If you believe a child has given us personal information, contact us and we will delete it.</p>`]);++ S.push(['Changes and contact', `<p>We will post changes here and update the date above. Questions and requests: <a href="mailto:${CONTACT}">${CONTACT}</a>.</p>`]);++ return S;++}++++function dmca(site) {++ const n = esc(site.name);++ const S = [];++ S.push(['Our position', `<p>${n} respects copyright and responds to clear notices of infringement under the Digital Millennium Copyright Act (17 U.S.C. § 512).${has(site, 'tools') ? ` The tools here process material at a user's request and do not keep a library of it: results are deleted automatically within hours.` : ''}${has(site, 'info') ? ` ${n} publishes its own material and does not host uploads from the public.` : ''}</p>`]);++ S.push(['Send a takedown notice', `<p>Email <a href="mailto:${CONTACT}">${CONTACT}</a> with all of the following:</p><ol><li>Your name, address, phone number and email address, and whom you represent.</li><li>The copyrighted work you say was infringed.</li><li>The exact URL on ${esc(site.host)} of the material you want removed. General descriptions are not enough to find it.</li><li>A statement that you have a good-faith belief the use is not authorised by the copyright owner, its agent or the law.</li><li>A statement, under penalty of perjury, that the information is accurate and that you are the owner or authorised to act for the owner.</li><li>Your physical or electronic signature.</li></ol>`]);++ S.push(['What happens next', `<p>We remove or disable access to the material, tell the person who posted it, and give them your notice. We act on complete notices as quickly as we can, normally within a few business days.</p>`]);++ if (!has(site, 'info')) S.push(['Counter-notice', `<p>If your material was removed by mistake or misidentification, send a counter-notice to the same address with: your contact details, the material and where it appeared, a statement under penalty of perjury that you believe it was removed by mistake, your consent to the jurisdiction of the federal court for your district (or for Washington State if you are outside the United States), and your signature. We forward it to the complainant and may restore the material after 10 to 14 business days unless they tell us they have filed a court action.</p>`]);++ S.push(['Repeat infringers and false claims', `<p>Accounts that repeatedly infringe are terminated. Knowingly false notices and counter-notices carry legal liability under § 512(f); we also refuse notices that are clearly abusive.</p>`]);++ return S;++}++++const BUILDERS = { terms, privacy, dmca };++++/** { title, description, sections: [[heading, html]], updated } for one document of one site. */++function build(kind, site) {++ if (!BUILDERS[kind]) throw new Error('Unknown legal document: ' + kind);++ const s = Object.assign({ profile: 'info' }, site);++ return { kind, title: `${TITLES[kind]} — ${s.name}`, heading: TITLES[kind],++ description: { terms: `The rules for using ${s.name}, in plain language.`, privacy: `What ${s.name} collects, why, and the choices you have.`, dmca: `How to report copyright infringement on ${s.name}, and how to respond to a report.` }[kind],++ updated: UPDATED, sections: BUILDERS[kind](s) };++}++++/** Article markup only, for sites that wrap it in their own layout. */++function article(kind, site) {++ const d = build(kind, site);++ const others = Object.keys(TITLES).filter(k => k !== kind);++ return `<article class="ov-legal"><p class="ov-legal-kicker">${esc(site.name)}</p><h1>${esc(d.heading)}</h1><p class="ov-legal-date">Last updated <time datetime="${d.updated}">${UPDATED_TEXT}</time></p>++${d.sections.map(([h, html], i) => `<section><h2 id="s${i + 1}">${esc(h)}</h2>${html}</section>`).join('\n')}++<nav class="ov-legal-more" aria-label="Other policies">${others.map(k => `<a href="/${k}">${esc(TITLES[k])}</a>`).join('')}<a href="https://openvibe.network/">About OpenVibe</a></nav></article>`;++}++++const CSS = `*{box-sizing:border-box}body{margin:0;background:var(--bg-primary,#0a0f18);color:var(--text-primary,#e6edf7);font:400 16px/1.65 Inter,system-ui,-apple-system,Segoe UI,Roboto,sans-serif}++.ov-legal{max-width:760px;margin:0 auto;padding:40px 20px 24px}.ov-legal-kicker{margin:0;font-size:13px;font-weight:700;letter-spacing:.6px;text-transform:uppercase;color:var(--accent-light,var(--accent,#60a5fa))}++.ov-legal h1{font-size:clamp(28px,4.5vw,40px);letter-spacing:-.03em;line-height:1.1;margin:6px 0 4px}.ov-legal-date{color:var(--text-muted,#7d8aa0);font-size:14px;margin:0 0 28px}++.ov-legal h2{font-size:19px;margin:30px 0 8px;letter-spacing:-.01em}.ov-legal p,.ov-legal li{color:var(--text-secondary,#a8b3c4)}.ov-legal li{margin:6px 0}.ov-legal b{color:var(--text-primary,#e6edf7)}++.ov-legal a{color:var(--accent-light,var(--accent,#60a5fa))}.ov-legal code{font-size:13.5px;background:var(--bg-secondary,#111826);padding:1px 6px;border-radius:5px}++.ov-legal-more{display:flex;flex-wrap:wrap;gap:10px;margin-top:40px;padding-top:20px;border-top:1px solid var(--border,rgba(255,255,255,.1))}.ov-legal-more a{padding:8px 14px;border:1px solid var(--border,rgba(255,255,255,.12));border-radius:10px;text-decoration:none;font-weight:600;font-size:14px}`;++++/** A complete page: shared theme, navbar and footer, readable without JavaScript. */++function page(kind, site) {++ const d = build(kind, site);++ const url = `https://${site.host}/${kind}`;++ let head;++ try { head = require('./seo').headTags({ title: d.title, description: d.description, canonical: url, siteName: site.name, type: 'article' }); }++ catch { head = `<title>${esc(d.title)}</title><meta name="description" content="${esc(d.description)}"><link rel="canonical" href="${url}">`; }++ return `<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">++${head}++<script src="https://openvibe.network/shared/theme-loader.js"></script><style>${CSS}</style></head><body>++<noscript><p style="padding:12px 20px;margin:0"><a href="/">${esc(site.name)}</a></p></noscript>++<main>${article(kind, site)}</main><div id="ov-footer"></div>++<script src="https://openvibe.network/shared/navbar.js" defer></script><script src="https://openvibe.network/shared/footer.js" defer></script>++<script>addEventListener('DOMContentLoaded',function(){var t=null;try{t=(document.cookie.match(/(?:^|; )ov_token=([^;]*)/)||[])[1]||localStorage.getItem('ov_token')}catch(e){}++try{OpenVibeNavbar.init({service:${JSON.stringify(site.service || site.id || 'network')},apiBase:'https://openvibe.network',token:t?decodeURIComponent(t):null})}catch(e){}++try{OpenVibeFooter.init({service:${JSON.stringify(site.service || site.id || 'network')},variant:'compact',mount:'#ov-footer',apiBase:'https://openvibe.network'})}catch(e){}});</script>++</body></html>`;++}++++/** Express handler for legal.PATHS. */++function handler(site) {++ const cache = new Map();++ return function ovLegal(req, res, next) {++ const kind = String(req.path || '').replace(/^\/+|\/+$/g, '');++ if (!BUILDERS[kind]) return next();++ if (!cache.has(kind)) cache.set(kind, page(kind, site));++ res.set('Content-Type', 'text/html; charset=utf-8');++ res.set('Cache-Control', 'public, max-age=3600, stale-while-revalidate=86400');++ res.send(cache.get(kind));++ };++}++++module.exports = { PATHS, TITLES, UPDATED, CONTACT, build, article, page, handler, CSS };@@@ -61,11 +61,28 @@color: var(--text-primary, #e0e0e0);}.openvibe-navbar-brand { display: flex; align-items: center; gap: 9px; text-decoration: none; color: inherit; margin-right: 8px; min-width: 0; }-- .openvibe-navbar-brand a { color: inherit; text-decoration: none; }-- .openvibe-navbar-brand a.b-core:hover, .openvibe-navbar-brand a.b-tld:hover, .openvibe-navbar-brand a.b-sub:hover { color: var(--accent-light, var(--accent, #60a5fa)); text-decoration: underline; text-underline-offset: 3px; text-decoration-thickness: 1.5px; }-- .openvibe-navbar-brand a:focus-visible, .ovnav-launch:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: 2px; border-radius: 6px; }-- .ovnav-launch { appearance: none; border: 0; background: transparent; color: var(--text-secondary, #a8b3c4); width: 32px; height: 32px; border-radius: 9px; display: inline-grid; place-items: center; cursor: pointer; flex: none; margin-right: 6px; transition: background .15s, color .15s; }-- .ovnav-launch:hover, .ovnav-launch[aria-expanded="true"] { background: var(--accent-glow, rgba(59,130,246,.14)); color: var(--accent-light, var(--accent, #60a5fa)); }++ /* Brand group: mark + linked wordmark + launcher read as one control. Every property a host++ page might set on bare "nav a" / "a" is reset here, so the brand looks the same everywhere. */++ .openvibe-navbar .openvibe-navbar-brand a, .openvibe-navbar .openvibe-navbar-brand a:hover { all: unset; cursor: pointer; color: inherit; font: inherit; }++ .openvibe-navbar .openvibe-navbar-brand { gap: 8px; padding: 3px 4px 3px 3px; border-radius: 12px; margin-right: 2px; }++ .openvibe-navbar .openvibe-navbar-brand a.flame { display: inline-grid; place-items: center; width: 30px; height: 30px; border-radius: 9px; transition: background .15s; }++ .openvibe-navbar .openvibe-navbar-brand a.flame:hover { background: var(--accent-glow, rgba(59,130,246,.14)); }++ .openvibe-navbar .openvibe-navbar-brand .name { align-items: center; gap: 0; font-size: 15.5px; letter-spacing: -.25px; }++ .openvibe-navbar .openvibe-navbar-brand .name a { padding: 3px 2px; border-radius: 6px; transition: color .15s, background .15s; }++ .openvibe-navbar .openvibe-navbar-brand .name a.b-sub { color: var(--text-primary, #e6edf7); font-weight: 750; }++ .openvibe-navbar .openvibe-navbar-brand .name a.b-core { color: var(--text-primary, #e6edf7); font-weight: 700; }++ .openvibe-navbar .openvibe-navbar-brand .name a.b-tld { color: var(--accent-light, var(--accent, #60a5fa)); font-weight: 700; }++ .openvibe-navbar .openvibe-navbar-brand.has-sub .name a.b-core, .openvibe-navbar .openvibe-navbar-brand.has-sub .name a.b-tld { color: var(--text-secondary, #a8b3c4); font-weight: 600; }++ .openvibe-navbar .openvibe-navbar-brand .name a:hover { color: var(--accent-light, var(--accent, #60a5fa)); background: var(--accent-glow, rgba(59,130,246,.12)); }++ .openvibe-navbar .openvibe-navbar-brand .b-dot { margin: 0; padding: 0 .5px; opacity: .55; }++ .openvibe-navbar .openvibe-navbar-brand a:focus-visible, .ovnav-launch:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: 1px; }++ .ovnav-launch { all: unset; box-sizing: border-box; cursor: pointer; color: var(--text-secondary, #a8b3c4); width: 32px; height: 32px; border-radius: 10px; display: inline-grid; place-items: center; flex: none; margin-right: 10px; border: 1px solid transparent; transition: background .15s, color .15s, border-color .15s; }++ .ovnav-launch:hover, .ovnav-launch[aria-expanded="true"] { background: var(--accent-glow, rgba(59,130,246,.14)); color: var(--accent-light, var(--accent, #60a5fa)); border-color: color-mix(in srgb, var(--accent, #3b82f6) 35%, transparent); }++ .ovnav-launch svg { display: block; transition: transform .25s cubic-bezier(.2,1.4,.3,1); }++ .ovnav-launch[aria-expanded="true"] svg { transform: rotate(45deg) scale(.92); }++ .ovnav-launcher a { all: unset; cursor: pointer; box-sizing: border-box; }++ .openvibe-navbar .ovnav-sep { width: 1px; height: 18px; background: var(--border, rgba(255,255,255,.12)); margin: 0 6px; flex: none; align-self: center; }++ @media (max-width: 1180px) { .openvibe-navbar .ovnav-net, .openvibe-navbar .ovnav-sep { display: none; } }.ovnav-launcher { position: absolute; top: calc(100% + 6px); left: 12px; width: min(440px, calc(100vw - 24px)); max-height: min(560px, calc(100vh - 80px)); overflow: auto; background: var(--bg-elevated, var(--bg-secondary, #111826)); border: 1px solid var(--border, rgba(255,255,255,.12)); border-radius: 16px; box-shadow: 0 24px 60px rgba(0,0,0,.5); padding: 12px; z-index: 1000; opacity: 0; transform: translateY(-6px) scale(.98); transform-origin: top left; pointer-events: none; transition: opacity .16s, transform .2s cubic-bezier(.2,1.2,.3,1); }.ovnav-launcher.open { opacity: 1; transform: none; pointer-events: auto; }.ovnav-launcher .ovl-q { width: 100%; box-sizing: border-box; padding: 9px 12px; border-radius: 10px; border: 1px solid var(--border, rgba(255,255,255,.12)); background: var(--bg-primary, #0a0f18); color: var(--text-primary, #e6edf7); font: 500 14px/1.2 inherit; outline: none; }@@@ -79,6 +96,9 @@.ovnav-launcher .ovl-site:hover, .ovnav-launcher .ovl-fam:hover, .ovnav-launcher a:focus-visible { background: var(--accent-glow, rgba(59,130,246,.14)); outline: none; }.ovnav-launcher b { display: block; font-size: 13px; font-weight: 650; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }.ovnav-launcher small { display: block; font-size: 11px; color: var(--text-muted, #7d8aa0); margin-top: 1px; }++ .ovnav-launcher .ovl-soon { display: flex; flex-wrap: wrap; gap: 6px; padding: 0 4px 4px; }++ .ovnav-launcher .ovl-soon a { font-size: 12px; font-weight: 600; padding: 4px 9px; border-radius: 999px; border: 1px solid var(--border, rgba(255,255,255,.1)); color: var(--text-secondary, #a8b3c4); }++ .ovnav-launcher .ovl-soon a:hover { border-color: var(--accent, #3b82f6); color: var(--text-primary, #e6edf7); background: none; }.ovnav-launcher .ovl-empty { padding: 18px 8px; color: var(--text-secondary, #a8b3c4); font-size: 13px; }@media (max-width: 480px) { .ovnav-launcher { left: 8px; } .ovnav-launcher .ovl-fams { grid-template-columns: 1fr; } }@media (prefers-reduced-motion: reduce) { .ovnav-launcher { transition: none; } }@@@ -529,6 +549,42 @@${_config.launcher === false ? '' : '<button type="button" class="ovnav-launch" id="openvibe-launcher-btn" aria-label="All OpenVibe sites and tools" aria-haspopup="true" aria-expanded="false" title="All OpenVibe sites and tools"><svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true"><g fill="currentColor"><circle cx="5" cy="5" r="2"/><circle cx="12" cy="5" r="2"/><circle cx="19" cy="5" r="2"/><circle cx="5" cy="12" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="19" cy="12" r="2"/><circle cx="5" cy="19" r="2"/><circle cx="12" cy="19" r="2"/><circle cx="19" cy="19" r="2"/></g></svg></button>'}`;}++++ // ── Shared chrome data (https://openvibe.network/api/chrome) ─────────────────────────────++ // Sites ordered by real use, footer copy and per-site legal links. Cached per host for 30++ // minutes in localStorage and refreshed in the background, so pages paint from cache and the++ // order never jumps while someone is looking at it. Defined once, shared by navbar + footer.++ const OVChrome = root.OpenVibeChrome || (root.OpenVibeChrome = (function () {++ const KEY = 'ov_chrome_v1', TTL = 30 * 60000;++ let inflight = null;++ const host = () => (typeof location !== 'undefined' ? location.hostname : '');++ const https = (u) => { try { return new URL(u).protocol === 'https:'; } catch { return false; } };++ function clean(d) {++ if (!d || !Array.isArray(d.nav)) return null;++ const link = (l) => (l && typeof l.name === 'string' && https(l.url) ? { id: String(l.id || ''), name: l.name.slice(0, 60), url: l.url, icon: String(l.icon || ''), tagline: String(l.tagline || '').slice(0, 80) } : null);++ const f = d.footer || {}, lg = f.legal || {};++ return { nav: d.nav.map(link).filter(Boolean).slice(0, 12), soon: (d.soon || []).map(link).filter(Boolean).slice(0, 24),++ footer: { blurb: String(f.blurb || '').slice(0, 200), discover: (f.discover || []).map(link).filter(Boolean).slice(0, 6), popular: (f.popular || []).map(link).filter(Boolean).slice(0, 10),++ legal: https(lg.terms) && https(lg.privacy) && https(lg.dmca) ? { terms: lg.terms, privacy: lg.privacy, dmca: lg.dmca } : null } };++ }++ function cached() { try { const c = JSON.parse(localStorage.getItem(KEY) || 'null'); return c && c.host === host() && c.data ? c : null; } catch { return null; } }++ function refresh() {++ if (inflight || typeof fetch === 'undefined') return inflight || Promise.resolve(null);++ inflight = fetch('https://openvibe.network/api/chrome?host=' + encodeURIComponent(host()), { credentials: 'omit' })++ .then(r => (r.ok ? r.json() : null)).then(clean)++ .then(d => { if (d) { try { localStorage.setItem(KEY, JSON.stringify({ at: Date.now(), host: host(), data: d })); } catch { /* */ } } return d; })++ .catch(() => null);++ return inflight;++ }++ /** Cached data now (or null); `onFirst` fires once when a first-ever fetch lands. */++ function get(onFirst) {++ const c = cached();++ if (!c || Date.now() - c.at > TTL) { const p = refresh(); if (!c && onFirst) p.then(d => { if (d) onFirst(d); }); }++ return c ? c.data : null;++ }++ return { get, refresh };++ })());++// ── Network launcher ─────────────────────────────────────const LAUNCHER_SITES = [{ name: 'Live', desc: 'Streams, clips and chat', icon: 'live', url: 'https://openvibe.live/' },@@@ -575,8 +631,12 @@body.innerHTML = hits.length ? `<div class="ovl-h">Tools</div><div class="ovl-fams">${hits.map(t => tile(t, 'ovl-fam')).join('')}</div>` : '<div class="ovl-empty">No tool matches. <a href="https://openvibe.tools/">Browse them all</a></div>';return;}-- body.innerHTML = `<div class="ovl-h">Sites</div><div class="ovl-sites">${LAUNCHER_SITES.map(x => tile(x, 'ovl-site')).join('')}</div>-- <div class="ovl-h">Tools <a href="https://openvibe.tools/">See all</a></div><div class="ovl-fams">${fams.map(x => tile(x, 'ovl-fam')).join('')}</div>`;++ const chrome = OVChrome.get();++ const sites = chrome && chrome.nav.length ? chrome.nav.map(n => ({ name: n.name, desc: n.tagline, icon: n.icon || n.id, url: n.url })) : LAUNCHER_SITES;++ const soon = chrome ? chrome.soon : [];++ body.innerHTML = `<div class="ovl-h">Sites</div><div class="ovl-sites">${sites.map(x => tile(x, 'ovl-site')).join('')}</div>++ <div class="ovl-h">Tools <a href="https://openvibe.tools/">See all</a></div><div class="ovl-fams">${fams.map(x => tile(x, 'ovl-fam')).join('')}</div>++ ${soon.length ? `<div class="ovl-h">Opening soon</div><div class="ovl-soon">${soon.map(x => `<a href="${escapeAttr(x.url)}">${escapeAttr(x.name)}</a>`).join('')}</div>` : ''}`;}btn.addEventListener('click', async (e) => {e.stopPropagation();@@@ -605,6 +665,19 @@});}++ /** 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 '';++ const chrome = OVChrome.get((d) => { if (d && _navEl && !_navEl.querySelector('.ovnav-net')) { try { render(); } catch { /* */ } } });++ if (!chrome) return '';++ const here = currentHost().toLowerCase();++ const taken = new Set((pageLinks || []).map(l => { try { return new URL(l.href, location.href).hostname; } catch { return ''; } }));++ const max = typeof _config.networkLinks === 'number' ? _config.networkLinks : 4;++ const pick = chrome.nav.filter(n => { try { const h = new URL(n.url).hostname; return h !== here && !here.endsWith('.' + h) && !taken.has(h); } catch { return false; } }).slice(0, max);++ if (!pick.length) return '';++ return `<span class="ovnav-sep" aria-hidden="true"></span>` + pick.map(n => `<a class="ovnav-net" href="${escapeAttr(n.url)}" title="${escapeAttr(n.tagline)}">${escapeAttr(n.name)}</a>`).join('');++ }++// ── Notification bell (mounted by the navbar itself) ─────function mountBell(nav, u) {if (_config.notifications === false || !u || u.is_anon || !_config.token) return;@@@ -775,7 +848,7 @@/** Where "Sign In" goes on this host. */function resolveLoginHref(host, returnUrl) {-- if (_config.loginUrl) return _config.loginUrl;++ if (_config.loginUrl) return String(_config.loginUrl).replace('{url}', encodeURIComponent(returnUrl || '/'));if (onToolsDomain(host)) {// Every *.openvibe.tools host signs in through the gateway apex: it// holds the OAuth state cookie + redirect_uri (both apex-host-only)@@@ -1122,6 +1195,7 @@${brandHTML(brand)}<div class="openvibe-navbar-links">${links.map(l => `<a href="${escapeAttr(l.href)}"${l.active ? ' class="active"' : ''}${l.external ? ' target="_blank" rel="noopener"' : ''}>${l.icon ? `<i class="fa-solid ${escapeAttr(l.icon)} icon"></i>` : ''}${escapeAttr(l.label)}</a>`).join('')}++ ${networkLinksHTML(links)}${u && u.role === 'admin' ? `<a href="https://openvibe.network/admin"><i class="fa-solid fa-shield-halved"></i> Admin</a>` : ''}</div><div class="openvibe-navbar-spacer"></div>@@@ -13,7 +13,8 @@"./notifications": "./notifications.js","./url-resolver": "./url-resolver.js","./seo": "./seo.js",-- "./icons": "./ov-icons.js"++ "./icons": "./ov-icons.js",++ "./legal": "./legal.js"},"license": "MIT","dependencies": {
@@ -212,6 +212,9 @@ function sendTool(req, res, file) {
@@ -212,6 +212,9 @@ function sendTool(req, res, file) {
}
}
}
}
+// Terms, Privacy and DMCA — the same documents on the apex and on every tool host.
+{ const legal = require('openvibe-shared/legal'); app.get(legal.PATHS, legal.handler({ id: 'tools', service: 'tools', host: 'openvibe.tools', name: 'OpenVibe.Tools', profile: 'tools' })); app.get('/tos', (_req, res) => res.redirect(301, '/terms')); }
+
// The apex is server-rendered from the registry: index, families, tool pages, search,
// The apex is server-rendered from the registry: index, families, tool pages, search,
// sitemap.xml, robots.txt, llms.txt and /api/catalog.json (server/pages/site.js).
// sitemap.xml, robots.txt, llms.txt and /api/catalog.json (server/pages/site.js).
app.use(require('./pages/site').createSiteRouter());
app.use(require('./pages/site').createSiteRouter());
@@ -21,7 +21,7 @@ const NAME = 'OpenVibe.Tools';
@@ -21,7 +21,7 @@ const NAME = 'OpenVibe.Tools';
const esc = seo.esc;
const esc = seo.esc;
const POPULAR = ['yt', 'convert', 'compress', 'mergepdf', 'jsonfmt', 'mp3', 'dns', 'whois', 'fancy', 'logo', 'ssl', 'resize', 'trim', 'regex', 'maps', 'food'];
const POPULAR = ['yt', 'convert', 'compress', 'mergepdf', 'jsonfmt', 'mp3', 'dns', 'whois', 'fancy', 'logo', 'ssl', 'resize', 'trim', 'regex', 'maps', 'food'];
-const icon = (name, size) => `<span class="ov-icon" data-icon="${esc(name)}" data-fx="none" style="--ovi-size:${size}px" aria-hidden="true">${icons.svg(name)}</span>`;
+const icon = (name, size) => `<span class="ov-icon" data-icon="${esc(name)}" data-ovi="${esc(icons.resolve(name))}" data-fx="none" style="--ovi-size:${size}px" aria-hidden="true">${icons.svg(name)}</span>`;
const hostLabel = (h) => h.replace(/\.openvibe\.tools$/, '') === h ? h : h;
const hostLabel = (h) => h.replace(/\.openvibe\.tools$/, '') === h ? h : h;
function toolCard(t) {
function toolCard(t) {
@@ -70,6 +70,19 @@ details{border-bottom:1px solid var(--border,rgba(255,255,255,.08));padding:12px
@@ -70,6 +70,19 @@ details{border-bottom:1px solid var(--border,rgba(255,255,255,.08));padding:12px
.az{columns:4 220px;column-gap:24px;padding:0;list-style:none}.az li{break-inside:avoid;padding:3px 0}.az a{text-decoration:none}.az small{color:var(--text-muted,#7d8aa0)}
.az{columns:4 220px;column-gap:24px;padding:0;list-style:none}.az li{break-inside:avoid;padding:3px 0}.az a{text-decoration:none}.az small{color:var(--text-muted,#7d8aa0)}
.empty{color:var(--text-secondary,#a8b3c4);padding:12px 0}
.empty{color:var(--text-secondary,#a8b3c4);padding:12px 0}
.net{display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:10px}
.net{display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:10px}
+.kicker{font-size:12.5px!important;font-weight:700;letter-spacing:.7px;text-transform:uppercase;color:var(--accent-light,var(--accent,#60a5fa))!important;margin:0 auto 12px!important}
+.stats{display:flex;flex-wrap:wrap;justify-content:center;gap:8px 26px;list-style:none;padding:0;margin:20px 0 0;color:var(--text-muted,#7d8aa0);font-size:14px}.stats b{color:var(--text-primary,#e6edf7);font-size:18px;margin-right:4px}
+.jump{position:sticky;top:56px;z-index:20;display:flex;gap:8px;overflow-x:auto;padding:10px 2px;margin:26px 0 0;background:color-mix(in srgb,var(--bg-primary,#0a0f18) 88%,transparent);backdrop-filter:blur(10px);scrollbar-width:none;border-bottom:1px solid var(--border,rgba(255,255,255,.06))}.jump::-webkit-scrollbar{display:none}
+.jump a{display:inline-flex;align-items:center;gap:7px;flex:none;padding:6px 12px 6px 7px;border-radius:999px;border:1px solid var(--border,rgba(255,255,255,.1));text-decoration:none;color:var(--text-primary,#e6edf7);font-size:13.5px;font-weight:600;background:var(--bg-secondary,#111826)}
+.jump a:hover{border-color:var(--accent,#3b82f6)}.jump i{font-style:normal;font-size:11.5px;color:var(--text-muted,#7d8aa0);font-weight:700}
+.fam{scroll-margin-top:120px}.fam h2 a{color:inherit;text-decoration:none}.fam h2 a:hover{color:var(--accent-light,var(--accent,#60a5fa))}.fam-tag{margin:2px 0 0;color:var(--text-secondary,#a8b3c4);font-size:14.5px}.fam .sec-h{margin-bottom:14px;align-items:center}
+.tiles{display:grid;grid-template-columns:repeat(auto-fill,minmax(215px,1fr));gap:8px}
+.tile{position:relative;display:flex;gap:10px;align-items:center;padding:9px 11px;border-radius:12px;border:1px solid var(--border,rgba(255,255,255,.07));background:var(--bg-secondary,#111826);text-decoration:none;color:inherit;min-width:0;transition:border-color .15s,background .15s}
+.tile:hover,.tile:focus-visible{border-color:var(--accent,#3b82f6);background:color-mix(in srgb,var(--accent,#3b82f6) 7%,var(--bg-secondary,#111826));outline:0}
+.tile>span:not(.ov-icon){min-width:0}.tile b{display:block;font-size:14px;font-weight:650;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.tile small{display:block;font-size:12px;color:var(--text-muted,#7d8aa0);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
+.tile.is-soon{border-style:dashed;background:none;opacity:.72;cursor:default}.tile.is-soon em{margin-left:auto;font-style:normal;font-size:10.5px;font-weight:800;letter-spacing:.5px;text-transform:uppercase;color:var(--accent-light,var(--accent,#60a5fa));flex:none}
+.perks{display:grid;grid-template-columns:repeat(auto-fit,minmax(240px,1fr));gap:12px}.perks div{padding:16px;border-radius:14px;border:1px solid var(--border,rgba(255,255,255,.08));background:var(--bg-secondary,#111826)}.perks p{margin:6px 0 0;color:var(--text-secondary,#a8b3c4);font-size:14px}
+@media (max-width:560px){.tiles{grid-template-columns:1fr 1fr}.tile small{display:none}.jump{top:52px}}
#ov-footer{margin-top:56px}
#ov-footer{margin-top:56px}
@media (max-width:560px){.grid{grid-template-columns:1fr}.search button{padding:0 14px}}
@media (max-width:560px){.grid{grid-template-columns:1fr}.search button{padding:0 14px}}
@media (prefers-reduced-motion:reduce){.tool{transition:none}}
@media (prefers-reduced-motion:reduce){.tool{transition:none}}
@@ -119,17 +132,24 @@ function search(q) {
@@ -119,17 +132,24 @@ function search(q) {
// ── Pages ────────────────────────────────────────────────────
// ── Pages ────────────────────────────────────────────────────
function pageIndex() {
function pageIndex() {
- const { tools, families } = registry.get();
+ const { tools, families, planned } = registry.get();
const byId = new Map(tools.map(t => [t.id, t]));
const byId = new Map(tools.map(t => [t.id, t]));
const popular = POPULAR.map(id => byId.get(id)).filter(Boolean);
const popular = POPULAR.map(id => byId.get(id)).filter(Boolean);
const fams = families.filter(f => f.path);
const fams = families.filter(f => f.path);
- const body = `<div class="hero"><h1>Online tools that <span>just work</span></h1>
-<p>${tools.length} tools for files, text, code and networks. Convert a video, merge a PDF, look up a domain or format JSON. Open source, community-run, and no account needed to start.</p>
+ const tile = (t) => `<a class="tile" href="${esc(t.url)}" title="${esc(t.tagline)}">${icon(t.icon, 28)}<span><b>${esc(t.name)}</b><small>${esc(t.tagline)}</small></span></a>`;
+ const soon = (t) => `<span class="tile is-soon" title="Planned">${icon(t.icon, 28)}<span><b>${esc(t.name)}</b><small>${esc(t.tagline)}</small></span><em>Soon</em></span>`;
+ const body = `<div class="hero"><p class="kicker">Open source · community-run · no account needed to start</p><h1>The toolbox for <span>everything you do online</span></h1>
+<p>Convert a video, squeeze an image, merge a PDF, debug DNS, format JSON. ${tools.length} tools that open instantly, each on an address you can remember, like <a href="https://yt.openvibe.tools/">yt.openvibe.tools</a>.</p>
${searchForm('')}
${searchForm('')}
-<ul class="chips">${fams.map(f => `<li><a href="${esc(f.path)}">${icon(f.icon, 22)}${esc(f.name)}</a></li>`).join('')}</ul></div>
+<ul class="stats"><li><b>${tools.length}</b> tools</li><li><b>${fams.length}</b> families</li><li><b>0</b> installs</li><li><b>1</b> account for all of OpenVibe</li></ul></div>
+<nav class="jump" aria-label="Tool families">${fams.map(f => `<a href="#f-${f.id}">${icon(f.icon, 22)}${esc(f.name)}<i>${tools.filter(t => t.family === f.id).length}</i></a>`).join('')}</nav>
<section id="results" hidden aria-live="polite"><div class="sec-h"><h2>Results</h2></div><div class="grid" id="results-grid"></div><p class="empty" id="results-empty" hidden>No tool matches that yet. <a href="/all-tools">Browse every tool</a>.</p></section>
<section id="results" hidden aria-live="polite"><div class="sec-h"><h2>Results</h2></div><div class="grid" id="results-grid"></div><p class="empty" id="results-empty" hidden>No tool matches that yet. <a href="/all-tools">Browse every tool</a>.</p></section>
-<section aria-labelledby="pop-h"><div class="sec-h"><h2 id="pop-h">Popular right now</h2><a class="more" href="/all-tools">All ${tools.length} tools</a></div><div class="grid">${popular.map(toolCard).join('')}</div></section>
-${fams.map(f => { const list = tools.filter(t => t.family === f.id); return `<section aria-labelledby="f-${f.id}"><div class="sec-h">${icon(f.icon, 34)}<h2 id="f-${f.id}"><a href="${esc(f.path)}" style="color:inherit;text-decoration:none">${esc(f.name)}</a></h2><a class="more" href="${esc(f.path)}">All ${list.length}</a></div><p class="sec-p">${esc(f.description)}</p><div class="grid">${list.slice(0, 8).map(toolCard).join('')}</div></section>`; }).join('')}`;
+<section aria-labelledby="pop-h"><div class="sec-h"><h2 id="pop-h">What people reach for</h2><a class="more" href="/all-tools">A to Z list</a></div><div class="grid">${popular.slice(0, 8).map(toolCard).join('')}</div></section>
+${fams.map(f => { const list = tools.filter(t => t.family === f.id); const next = planned.filter(t => t.family === f.id); return `<section class="fam" aria-labelledby="f-${f.id}"><div class="sec-h">${icon(f.icon, 40)}<div><h2 id="f-${f.id}"><a href="${esc(f.path)}">${esc(f.name)}</a></h2><p class="fam-tag">${esc(f.tagline)}</p></div><a class="more" href="${esc(f.path)}">About these ${list.length}</a></div><div class="tiles">${list.map(tile).join('')}${next.map(soon).join('')}</div></section>`; }).join('')}
+<section aria-labelledby="acct-h"><div class="sec-h"><h2 id="acct-h">Better with an account, fine without</h2></div><div class="perks">
+<div><b>Without signing in</b><p>Every tool works. Results stay available for an hour, then they are deleted.</p></div>
+<div><b>Signed in</b><p>Results keep for 24 hours, your recent tools follow you across devices, and your theme and notifications come along from the rest of OpenVibe.</p></div>
+<div><b>For developers</b><p>The whole catalog is public JSON at <a href="/api/catalog.json">/api/catalog.json</a>, with a plain-text map at <a href="/llms.txt">/llms.txt</a>.</p></div></div></section>`;
return shell({ families, body, head: head({
return shell({ families, body, head: head({
title: 'Online Tools for Files, Text, Code & Networks', description: `${tools.length} online tools in one place: video and audio converters, image and PDF tools, developer utilities and network diagnostics. Open source and community-run.`,
title: 'Online Tools for Files, Text, Code & Networks', description: `${tools.length} online tools in one place: video and audio converters, image and PDF tools, developer utilities and network diagnostics. Open source and community-run.`,
canonical: SITE + '/', keywords: 'online tools, converter, pdf tools, image converter, developer tools, network tools, youtube downloader',
canonical: SITE + '/', keywords: 'online tools, converter, pdf tools, image converter, developer tools, network tools, youtube downloader',
@@ -44,6 +44,19 @@ function baseTools() {
@@ -44,6 +44,19 @@ function baseTools() {
return out;
return out;
}
}
+// Planned tools: shown on the index as placeholders so people (and crawlers) can see where the
+// toolbox is going. They have no host and are never linked as if they worked.
+const PLANNED = [
+ { id: 'instagram', family: 'media', name: 'Instagram Downloader', tagline: 'Save reels, posts and stories you are allowed to keep', icon: 'download' },
+ { id: 'tiktok', family: 'media', name: 'TikTok Downloader', tagline: 'Save TikTok videos without the watermark dance', icon: 'download' },
+ { id: 'videoconvert', family: 'media', name: 'Video Converter', tagline: 'MP4, WebM, MOV and GIF, trimmed and resized', icon: 'video' },
+ { id: 'screenrecord', family: 'media', name: 'Screen Recorder', tagline: 'Record a tab or a window straight from the browser', icon: 'video' },
+ { id: 'qr', family: 'dev', name: 'QR Code Generator', tagline: 'Links, Wi-Fi logins and contact cards as QR codes', icon: 'code' },
+ { id: 'password', family: 'dev', name: 'Password Generator', tagline: 'Strong passwords and passphrases, made in your browser', icon: 'ssl' },
+ { id: 'speedtest', family: 'net', name: 'Speed Test', tagline: 'Download, upload and latency to the OpenVibe network', icon: 'ping' },
+ { id: 'ocr', family: 'img', name: 'Image to Text (OCR)', tagline: 'Pull the text out of screenshots and scans', icon: 'text' },
+];
+
const familyById = new Map(FAMILIES.map(f => [f.id, f]));
const familyById = new Map(FAMILIES.map(f => [f.id, f]));
let overrides = []; // [{ tool_id, host, role }]
let overrides = []; // [{ tool_id, host, role }]
let overridesAt = 0;
let overridesAt = 0;
@@ -88,7 +101,9 @@ function build() {
@@ -88,7 +101,9 @@ function build() {
claim(x.hosts.short, { kind, role: 'short', item: x });
claim(x.hosts.short, { kind, role: 'short', item: x });
x.hosts.aliases.forEach(a => claim(a, { kind, role: 'alias', item: x }));
x.hosts.aliases.forEach(a => claim(a, { kind, role: 'alias', item: x }));
}
}
- built = { tools, families, byHost, updated: new Date().toISOString() };
+ const live = new Set(tools.map(t => t.id));
+ const planned = PLANNED.filter(t => !live.has(t.id));
+ built = { tools, families, planned, byHost, updated: new Date().toISOString() };
return built;
return built;
}
}
@@ -97,7 +112,8 @@ function get() { return built || build(); }
@@ -97,7 +112,8 @@ function get() { return built || build(); }
/** What is this host? → { kind: 'apex'|'tool'|'family'|'unknown', role, item, tool, canonicalHost, shortHost, port, inZone } */
/** What is this host? → { kind: 'apex'|'tool'|'family'|'unknown', role, item, tool, canonicalHost, shortHost, port, inZone } */
function resolveHost(hostname) {
function resolveHost(hostname) {
const host = String(hostname || '').toLowerCase().replace(/:\d+$/, '').replace(/^www\./, '');
const host = String(hostname || '').toLowerCase().replace(/:\d+$/, '').replace(/^www\./, '');
- if (host === APEX) return { kind: 'apex', host };
+ // Loopback is the network's own services asking (catalog, health): that is the apex.
+ if (host === APEX || host === 'localhost' || host === '127.0.0.1' || host === '[::1]') return { kind: 'apex', host };
const hit = get().byHost.get(host);
const hit = get().byHost.get(host);
const inZone = host.endsWith('.' + ZONE);
const inZone = host.endsWith('.' + ZONE);
if (!hit) return { kind: 'unknown', host, inZone };
if (!hit) return { kind: 'unknown', host, inZone };
@@ -111,6 +127,7 @@ function catalog() {
@@ -111,6 +127,7 @@ function catalog() {
return {
return {
updated: r.updated,
updated: r.updated,
families: r.families.map(f => ({ id: f.id, name: f.name, tagline: f.tagline, description: f.description, icon: f.icon, url: f.url, path: f.path, page: f.page, count: r.tools.filter(t => t.family === f.id).length })),
families: r.families.map(f => ({ id: f.id, name: f.name, tagline: f.tagline, description: f.description, icon: f.icon, url: f.url, path: f.path, page: f.page, count: r.tools.filter(t => t.family === f.id).length })),
+ planned: r.planned.map(t => ({ id: t.id, family: t.family, name: t.name, tagline: t.tagline, icon: t.icon, status: 'planned' })),
tools: r.tools.map(t => ({ id: t.id, family: t.family, name: t.name, tagline: t.tagline, description: t.description, keywords: t.keywords, icon: t.icon, hosts: t.hosts, url: t.url, page: `https://${APEX}/tool/${t.id}` })),
tools: r.tools.map(t => ({ id: t.id, family: t.family, name: t.name, tagline: t.tagline, description: t.description, keywords: t.keywords, icon: t.icon, hosts: t.hosts, url: t.url, page: `https://${APEX}/tool/${t.id}` })),
};
};
}
}
@@ -27,6 +27,41 @@
@@ -27,6 +27,41 @@
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
})(typeof globalThis !== 'undefined' ? globalThis : this, function () {
'use strict';
'use strict';
+ // ── Shared chrome data (https://openvibe.network/api/chrome) ─────────────────────────────
+ // Sites ordered by real use, footer copy and per-site legal links. Cached per host for 30
+ // minutes in localStorage and refreshed in the background, so pages paint from cache and the
+ // order never jumps while someone is looking at it. Defined once, shared by navbar + footer.
+ const OVChrome = root.OpenVibeChrome || (root.OpenVibeChrome = (function () {
+ const KEY = 'ov_chrome_v1', TTL = 30 * 60000;
+ let inflight = null;
+ const host = () => (typeof location !== 'undefined' ? location.hostname : '');
+ const https = (u) => { try { return new URL(u).protocol === 'https:'; } catch { return false; } };
+ function clean(d) {
+ if (!d || !Array.isArray(d.nav)) return null;
+ const link = (l) => (l && typeof l.name === 'string' && https(l.url) ? { id: String(l.id || ''), name: l.name.slice(0, 60), url: l.url, icon: String(l.icon || ''), tagline: String(l.tagline || '').slice(0, 80) } : null);
+ const f = d.footer || {}, lg = f.legal || {};
+ return { nav: d.nav.map(link).filter(Boolean).slice(0, 12), soon: (d.soon || []).map(link).filter(Boolean).slice(0, 24),
+ footer: { blurb: String(f.blurb || '').slice(0, 200), discover: (f.discover || []).map(link).filter(Boolean).slice(0, 6), popular: (f.popular || []).map(link).filter(Boolean).slice(0, 10),
+ legal: https(lg.terms) && https(lg.privacy) && https(lg.dmca) ? { terms: lg.terms, privacy: lg.privacy, dmca: lg.dmca } : null } };
+ }
+ function cached() { try { const c = JSON.parse(localStorage.getItem(KEY) || 'null'); return c && c.host === host() && c.data ? c : null; } catch { return null; } }
+ function refresh() {
+ if (inflight || typeof fetch === 'undefined') return inflight || Promise.resolve(null);
+ inflight = fetch('https://openvibe.network/api/chrome?host=' + encodeURIComponent(host()), { credentials: 'omit' })
+ .then(r => (r.ok ? r.json() : null)).then(clean)
+ .then(d => { if (d) { try { localStorage.setItem(KEY, JSON.stringify({ at: Date.now(), host: host(), data: d })); } catch { /* */ } } return d; })
+ .catch(() => null);
+ return inflight;
+ }
+ /** Cached data now (or null); `onFirst` fires once when a first-ever fetch lands. */
+ function get(onFirst) {
+ const c = cached();
+ if (!c || Date.now() - c.at > TTL) { const p = refresh(); if (!c && onFirst) p.then(d => { if (d) onFirst(d); }); }
+ return c ? c.data : null;
+ }
+ return { get, refresh };
+ })());
+
const NETWORK_URL = 'https://openvibe.network';
const NETWORK_URL = 'https://openvibe.network';
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
@@ -50,9 +85,9 @@
@@ -50,9 +85,9 @@
];
];
const LEGAL = [
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' },
+ { key: 'terms', name: 'Terms of Service', path: '/tos', fallback: 'https://openvibe.live/tos' },
+ { key: 'privacy', name: 'Privacy Policy', path: '/privacy', fallback: 'https://openvibe.live/privacy' },
+ { key: 'dmca', name: 'DMCA', path: '/dmca', fallback: 'https://openvibe.live/dmca' },
];
];
const SOCIAL = [
const SOCIAL = [
@@ -106,6 +141,9 @@
@@ -106,6 +141,9 @@
}
}
function legalHref(item, cfg) {
function legalHref(item, cfg) {
+ // Each site answers for itself: the chrome service names this domain's own documents.
+ const chrome = cfg._chrome;
+ if (chrome && chrome.footer.legal && !cfg.legalBase) return chrome.footer.legal[item.key];
// Policies live on whichever site owns them; a tool subdomain links out rather than 404ing.
// 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 (cfg.legalBase) return cfg.legalBase.replace(/\/$/, '') + item.path;
if (typeof location !== 'undefined' && location.hostname.endsWith('openvibe.live')) return item.path;
if (typeof location !== 'undefined' && location.hostname.endsWith('openvibe.live')) return item.path;
@@ -114,7 +152,9 @@
@@ -114,7 +152,9 @@
// ── Markup ─────────────────────────────────────────────────
// ── Markup ─────────────────────────────────────────────────
function linkTag(item) {
function linkTag(item) {
- const rel = /^https?:\/\//.test(item.url) ? ' target="_blank" rel="noopener"' : '';
+ // Our own sites open in this tab (the sign-in hand-off follows the click); the rest of the web in a new one.
+ const own = /^https:\/\/([a-z0-9-]+\.)*(openvibe\.[a-z]+|openre\.stream)(\/|$)/i.test(item.url);
+ const rel = /^https?:\/\//.test(item.url) && !own ? ' target="_blank" rel="noopener"' : '';
const onclick = item.onclick ? ` onclick="${esc(item.onclick)}"` : '';
const onclick = item.onclick ? ` onclick="${esc(item.onclick)}"` : '';
const title = item.desc ? ` title="${esc(item.desc)}"` : '';
const title = item.desc ? ` title="${esc(item.desc)}"` : '';
return `<a href="${esc(item.url)}"${rel}${onclick}${title}>${esc(item.name)}</a>`;
return `<a href="${esc(item.url)}"${rel}${onclick}${title}>${esc(item.name)}</a>`;
@@ -124,7 +164,14 @@
@@ -124,7 +164,14 @@
const c = { ...DEFAULTS, ...(cfg || {}) };
const c = { ...DEFAULTS, ...(cfg || {}) };
const service = c.service || detectService();
const service = c.service || detectService();
const brand = c.brandName || brandFor(service);
const brand = c.brandName || brandFor(service);
- const network = NETWORK.filter(n => n.id !== service);
+ const chrome = c._chrome = OVChrome.get(() => { try { render(); } catch { /* */ } });
+ // Most used sites first when the chrome service has spoken; the built-in list otherwise.
+ const network = chrome && chrome.nav.length
+ ? chrome.nav.filter(n => n.id !== service && !(typeof location !== 'undefined' && new URL(n.url).hostname === location.hostname)).map(n => ({ name: 'OpenVibe.' + n.name, url: n.url, desc: n.tagline }))
+ : NETWORK.filter(n => n.id !== service);
+ const tagline = (cfg && cfg.tagline) || (chrome && chrome.footer.blurb) || c.tagline;
+ const popular = chrome ? chrome.footer.popular : [];
+ const discover = chrome ? chrome.footer.discover.filter(d => !network.slice(0, 6).some(n => n.url === d.url)) : [];
const year = new Date().getFullYear();
const year = new Date().getFullYear();
if (c.variant === 'compact') {
if (c.variant === 'compact') {
@@ -149,7 +196,7 @@
@@ -149,7 +196,7 @@
<div class="ovf-cols">
<div class="ovf-cols">
<div class="ovf-col ovf-col--brand">
<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>
<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>
+ <p class="ovf-tagline">${esc(tagline)}</p>
<div class="ovf-social">
<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('')}
${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>
<a href="${esc(c.sitemap)}" aria-label="Sitemap" title="Sitemap"><i class="fa-solid fa-sitemap"></i></a>
@@ -161,6 +208,11 @@
@@ -161,6 +208,11 @@
${network.slice(0, 6).map(linkTag).join('')}
${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>
<a class="ovf-more" href="${NETWORK_URL}" target="_blank" rel="noopener">Everything else <i class="fa-solid fa-arrow-right"></i></a>
</nav>` : ''}
</nav>` : ''}
+ ${popular.length ? `<nav class="ovf-col" aria-label="Popular tools">
+ <h3>Popular tools</h3>
+ ${popular.slice(0, 6).concat(discover.slice(0, 2)).map(linkTag).join('')}
+ <a class="ovf-more" href="https://openvibe.tools/all-tools">All tools <i class="fa-solid fa-arrow-right"></i></a>
+ </nav>` : ''}
<nav class="ovf-col" aria-label="Legal">
<nav class="ovf-col" aria-label="Legal">
<h3>Legal</h3>
<h3>Legal</h3>
${LEGAL.map(l => `<a href="${esc(legalHref(l, c))}">${esc(l.name)}</a>`).join('')}
${LEGAL.map(l => `<a href="${esc(legalHref(l, c))}">${esc(l.name)}</a>`).join('')}
new file mode 100644
new file mode 100644
@@ -0,0 +1,145 @@
@@ -0,0 +1,145 @@
+'use strict';
+// ═══════════════════════════════════════════════════════════════
+// openvibe-shared/legal — Terms, Privacy and DMCA for every OpenVibe site.
+//
+// Each domain answers for itself: /terms, /privacy and /dmca live on the site's own apex and say
+// what THAT site does. One template per document, with clauses switched by the site's profile:
+// streaming live video, chat, payments tools files processed, then deleted
+// ugc posts, pastes, comments games accounts and game state
+// hosting stored user media account the identity provider
+// info read-only / placeholder site
+//
+// const legal = require('openvibe-shared/legal');
+// app.get(legal.PATHS, legal.handler({ host: 'openvibe.tools', name: 'OpenVibe.Tools', profile: 'tools' }));
+// legal.page('privacy', site) → complete HTML document (static generators use this)
+//
+// Plain language on purpose. These are templates maintained by the project, not legal advice;
+// the owner reviews them before relying on them. Facts stated here (what is stored, for how
+// long) must match the code — change both together.
+// ═══════════════════════════════════════════════════════════════
+const UPDATED = '2026-09-18';
+const UPDATED_TEXT = 'September 18, 2026';
+const CONTACT = '[email protected]';
+const PATHS = ['/terms', '/privacy', '/dmca'];
+const TITLES = { terms: 'Terms of Service', privacy: 'Privacy Policy', dmca: 'DMCA & Copyright' };
+
+const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
+const has = (site, ...profiles) => profiles.includes(site.profile);
+const userContent = (site) => has(site, 'streaming', 'ugc', 'hosting', 'games');
+
+function terms(site) {
+ const n = esc(site.name);
+ const S = [];
+ S.push(['The short version', `<p>${n} is part of OpenVibe, an open source, community-run network. Use it for lawful things, be decent to other people, and do not try to break it. We can remove content or accounts that break these terms. The software is provided as it is, without warranties.</p>`]);
+ S.push(['Who can use it', `<p>You must be at least 13 years old, or the minimum age of digital consent where you live. If you are under 18, a parent or guardian must agree to these terms for you. You may not use ${n} if you were previously banned from the network.</p>`]);
+ if (!has(site, 'info')) S.push(['Your account', `<p>Most of ${n} works without signing in. An OpenVibe account, managed at <a href="https://openvibe.network/">openvibe.network</a>, adds things that need to remember you. You are responsible for what happens under your account; keep your password to yourself and tell us if you think someone else has it.</p>`]);
+ if (has(site, 'tools')) S.push(['Files and links you give the tools', `<p>The tools process what you upload or paste and hand you the result. Uploads and results are temporary: they are deleted automatically, normally within one hour for visitors and within 24 hours for signed-in users. Do not rely on ${n} to store anything.</p><p>Only process material you have the right to use. Downloaders are for your own uploads and for content you are allowed to keep; respecting the terms of the site the content comes from is your responsibility. Lookup tools (DNS, WHOIS, ping, port checks) may only be pointed at systems you are allowed to test.</p>`]);
+ if (userContent(site)) S.push(['What you post', `<p>You keep ownership of what you ${has(site, 'streaming') ? 'stream, upload, clip or write' : 'upload or write'}. You give OpenVibe a worldwide, non-exclusive, royalty-free licence to host, display, transcode and distribute it so the service can work, including on other OpenVibe sites where your content is meant to appear. The licence ends when you delete the content, except for copies other people made while it was public and backups that expire on their own.</p><p>You are responsible for having the rights to everything you post.</p>`]);
+ S.push(['Rules', `<p>OpenVibe leans toward open expression. These limits apply everywhere:</p><ul><li>Nothing illegal where you are or where our servers are (United States).</li><li>No sexual content involving minors, and no sexualisation of minors, ever.</li><li>No credible threats of violence, and no publishing of someone's private information to harm them.</li><li>No content you do not have the rights to.</li><li>No malware, phishing, spam, scraping that ignores robots.txt rate limits, or attempts to disrupt or gain unauthorised access to the service or to other people's systems.</li>${has(site, 'streaming') ? '<li>Streams must be labelled honestly; adult or graphic content belongs behind the mature flag.</li>' : ''}</ul>`]);
+ if (has(site, 'streaming')) S.push(['Money', `<p>Donations, tips and purchases of virtual currency are voluntary and final except where the law requires a refund. Virtual currencies have no cash value outside the payout rules shown on the site. Streamers are responsible for their own taxes.</p>`]);
+ if (has(site, 'games')) S.push(['Game items and progress', `<p>Items, currency and progress in games have no cash value and may be changed, reset or removed as the games evolve. Cheating, automation that gives an unfair advantage and exploiting bugs can lead to a reset or a ban.</p>`]);
+ S.push(['Moderation and ending accounts', `<p>We may remove content, limit features, or suspend accounts that break these terms or put other people or the service at risk. You can stop using ${n} at any time and delete your account from your account settings.</p>`]);
+ S.push(['Open source', `<p>OpenVibe's code is public. The code is licensed under the terms in each repository; those licences, not this page, govern your use of the code. These terms cover your use of the hosted service at ${esc(site.host)}.</p>`]);
+ S.push(['No warranty, limited liability', `<p>The service is provided "as is" and "as available". To the extent the law allows, OpenVibe and the people who run it are not liable for indirect or consequential losses, lost data, or lost profits arising from your use of ${n}. Nothing here limits liability that cannot be limited by law.</p>`]);
+ S.push(['Governing law', `<p>These terms are governed by the laws of the United States and the State of Washington, without regard to conflicts of law. Disputes are handled in the federal or state courts located in Washington State. If part of these terms cannot be enforced, the rest still applies.</p>`]);
+ S.push(['Changes and contact', `<p>We may update these terms. Material changes are announced on the site before they take effect. Questions: <a href="mailto:${CONTACT}">${CONTACT}</a>.</p>`]);
+ return S;
+}
+
+function privacy(site) {
+ const n = esc(site.name);
+ const S = [];
+ S.push(['The short version', `<p>${n} collects what it needs to work and to keep abuse out, and no more. We do not sell personal information and we do not run advertising trackers.</p>`]);
+ const collect = [
+ '<li><b>Request logs.</b> Like every web server, ours record your IP address, browser type, the page requested and the time. We use them for security, rate limiting and aggregate traffic statistics.</li>',
+ '<li><b>Usage statistics.</b> Page views and a random session identifier, so we can see which parts of the network people use. These are first-party statistics kept on our own servers.</li>',
+ ];
+ if (!has(site, 'info')) collect.push(`<li><b>Account information, if you sign in.</b> Your OpenVibe account (username, email address, password hash, profile and preferences) is held by <a href="https://openvibe.network/privacy">openvibe.network</a>. ${n} receives your user id, username, avatar and role so it can show you as signed in.</li>`);
+ if (has(site, 'tools')) collect.push('<li><b>What you give a tool.</b> Files, text, links, domain names and IP addresses you submit are processed to produce your result. Uploaded files and generated results are deleted automatically, normally within one hour for visitors and 24 hours for signed-in users. Many text and developer tools run entirely in your browser and send nothing to us.</li>');
+ if (has(site, 'streaming')) collect.push('<li><b>Streams, clips, VODs and chat.</b> What you broadcast and write is stored so it can be shown, and is public unless you choose otherwise.</li><li><b>Payments.</b> Payment processors handle card details; we receive a record of the transaction, not your card number.</li><li><b>AI features.</b> Public streams and chat may be transcribed and summarised by AI services to create captions, highlights and overviews.</li>');
+ if (has(site, 'ugc')) collect.push('<li><b>What you post.</b> Pastes, posts and comments, with the account that made them. Public items are visible to everyone and may be indexed by search engines.</li>');
+ if (has(site, 'hosting')) collect.push('<li><b>Media you upload or record.</b> Videos, clips, images and files, with their metadata, stored so the sites that use them can show them.</li>');
+ if (has(site, 'games')) collect.push('<li><b>Game state.</b> Your characters, items, progress and in-game chat.</li>');
+ if (has(site, 'account')) collect.push('<li><b>Your account.</b> Username, email address, a salted hash of your password, profile, theme and notification preferences, linked services, sign-in history and, if you keep it switched on, your cross-site history.</li>');
+ S.push(['What we collect', `<ul>${collect.join('')}</ul>`]);
+ S.push(['Cookies and local storage', `<p>We use a small number of first-party cookies: a sign-in token (<code>ov_token</code>), a refresh token, and a hint that remembers whether you were signed in so other OpenVibe sites can sign you in quietly. Your theme, recent pages and similar preferences are kept in your browser's local storage. There are no third-party advertising cookies. Our sites sit behind Cloudflare, which may set its own security cookies.</p>`]);
+ S.push(['Who we share with', `<p>Only the services needed to run the network: our hosting and network providers (OVH, Cloudflare), storage providers for media${has(site, 'streaming') ? ', payment processors, AI providers used for transcription and summaries' : ''}, and email delivery for account messages. We disclose information when the law requires it, and we tell you when we are allowed to.</p>`]);
+ S.push(['Across OpenVibe sites', `<p>OpenVibe sites share one account. When you are signed in, the site you visit learns who you are from openvibe.network; it does not receive your password or email address. You can see and clear your cross-site history, and switch it off, at <a href="https://openvibe.network/history">openvibe.network/history</a>.</p>`]);
+ S.push(['How long we keep things', `<p>Request logs and raw usage events are kept for a limited period and then reduced to totals. ${has(site, 'tools') ? 'Tool uploads and results are deleted within hours. ' : ''}${userContent(site) ? 'Content you post stays until you delete it or your account. ' : ''}Account data stays until you delete your account; backups expire on their own shortly afterwards.</p>`]);
+ S.push(['Your choices and rights', `<p>You can use most of ${n} without an account. With an account you can view, change, export and delete your data from your account settings, or ask us to do it. Depending on where you live (for example the EEA, the UK or California) you may have additional rights to access, correct, delete or restrict the use of your data; write to us and we will honour them.</p>`]);
+ S.push(['Children', `<p>${n} is not for children under 13, and we do not knowingly collect their information. If you believe a child has given us personal information, contact us and we will delete it.</p>`]);
+ S.push(['Changes and contact', `<p>We will post changes here and update the date above. Questions and requests: <a href="mailto:${CONTACT}">${CONTACT}</a>.</p>`]);
+ return S;
+}
+
+function dmca(site) {
+ const n = esc(site.name);
+ const S = [];
+ S.push(['Our position', `<p>${n} respects copyright and responds to clear notices of infringement under the Digital Millennium Copyright Act (17 U.S.C. § 512).${has(site, 'tools') ? ` The tools here process material at a user's request and do not keep a library of it: results are deleted automatically within hours.` : ''}${has(site, 'info') ? ` ${n} publishes its own material and does not host uploads from the public.` : ''}</p>`]);
+ S.push(['Send a takedown notice', `<p>Email <a href="mailto:${CONTACT}">${CONTACT}</a> with all of the following:</p><ol><li>Your name, address, phone number and email address, and whom you represent.</li><li>The copyrighted work you say was infringed.</li><li>The exact URL on ${esc(site.host)} of the material you want removed. General descriptions are not enough to find it.</li><li>A statement that you have a good-faith belief the use is not authorised by the copyright owner, its agent or the law.</li><li>A statement, under penalty of perjury, that the information is accurate and that you are the owner or authorised to act for the owner.</li><li>Your physical or electronic signature.</li></ol>`]);
+ S.push(['What happens next', `<p>We remove or disable access to the material, tell the person who posted it, and give them your notice. We act on complete notices as quickly as we can, normally within a few business days.</p>`]);
+ if (!has(site, 'info')) S.push(['Counter-notice', `<p>If your material was removed by mistake or misidentification, send a counter-notice to the same address with: your contact details, the material and where it appeared, a statement under penalty of perjury that you believe it was removed by mistake, your consent to the jurisdiction of the federal court for your district (or for Washington State if you are outside the United States), and your signature. We forward it to the complainant and may restore the material after 10 to 14 business days unless they tell us they have filed a court action.</p>`]);
+ S.push(['Repeat infringers and false claims', `<p>Accounts that repeatedly infringe are terminated. Knowingly false notices and counter-notices carry legal liability under § 512(f); we also refuse notices that are clearly abusive.</p>`]);
+ return S;
+}
+
+const BUILDERS = { terms, privacy, dmca };
+
+/** { title, description, sections: [[heading, html]], updated } for one document of one site. */
+function build(kind, site) {
+ if (!BUILDERS[kind]) throw new Error('Unknown legal document: ' + kind);
+ const s = Object.assign({ profile: 'info' }, site);
+ return { kind, title: `${TITLES[kind]} — ${s.name}`, heading: TITLES[kind],
+ description: { terms: `The rules for using ${s.name}, in plain language.`, privacy: `What ${s.name} collects, why, and the choices you have.`, dmca: `How to report copyright infringement on ${s.name}, and how to respond to a report.` }[kind],
+ updated: UPDATED, sections: BUILDERS[kind](s) };
+}
+
+/** Article markup only, for sites that wrap it in their own layout. */
+function article(kind, site) {
+ const d = build(kind, site);
+ const others = Object.keys(TITLES).filter(k => k !== kind);
+ return `<article class="ov-legal"><p class="ov-legal-kicker">${esc(site.name)}</p><h1>${esc(d.heading)}</h1><p class="ov-legal-date">Last updated <time datetime="${d.updated}">${UPDATED_TEXT}</time></p>
+${d.sections.map(([h, html], i) => `<section><h2 id="s${i + 1}">${esc(h)}</h2>${html}</section>`).join('\n')}
+<nav class="ov-legal-more" aria-label="Other policies">${others.map(k => `<a href="/${k}">${esc(TITLES[k])}</a>`).join('')}<a href="https://openvibe.network/">About OpenVibe</a></nav></article>`;
+}
+
+const CSS = `*{box-sizing:border-box}body{margin:0;background:var(--bg-primary,#0a0f18);color:var(--text-primary,#e6edf7);font:400 16px/1.65 Inter,system-ui,-apple-system,Segoe UI,Roboto,sans-serif}
+.ov-legal{max-width:760px;margin:0 auto;padding:40px 20px 24px}.ov-legal-kicker{margin:0;font-size:13px;font-weight:700;letter-spacing:.6px;text-transform:uppercase;color:var(--accent-light,var(--accent,#60a5fa))}
+.ov-legal h1{font-size:clamp(28px,4.5vw,40px);letter-spacing:-.03em;line-height:1.1;margin:6px 0 4px}.ov-legal-date{color:var(--text-muted,#7d8aa0);font-size:14px;margin:0 0 28px}
+.ov-legal h2{font-size:19px;margin:30px 0 8px;letter-spacing:-.01em}.ov-legal p,.ov-legal li{color:var(--text-secondary,#a8b3c4)}.ov-legal li{margin:6px 0}.ov-legal b{color:var(--text-primary,#e6edf7)}
+.ov-legal a{color:var(--accent-light,var(--accent,#60a5fa))}.ov-legal code{font-size:13.5px;background:var(--bg-secondary,#111826);padding:1px 6px;border-radius:5px}
+.ov-legal-more{display:flex;flex-wrap:wrap;gap:10px;margin-top:40px;padding-top:20px;border-top:1px solid var(--border,rgba(255,255,255,.1))}.ov-legal-more a{padding:8px 14px;border:1px solid var(--border,rgba(255,255,255,.12));border-radius:10px;text-decoration:none;font-weight:600;font-size:14px}`;
+
+/** A complete page: shared theme, navbar and footer, readable without JavaScript. */
+function page(kind, site) {
+ const d = build(kind, site);
+ const url = `https://${site.host}/${kind}`;
+ let head;
+ try { head = require('./seo').headTags({ title: d.title, description: d.description, canonical: url, siteName: site.name, type: 'article' }); }
+ catch { head = `<title>${esc(d.title)}</title><meta name="description" content="${esc(d.description)}"><link rel="canonical" href="${url}">`; }
+ return `<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
+${head}
+<script src="https://openvibe.network/shared/theme-loader.js"></script><style>${CSS}</style></head><body>
+<noscript><p style="padding:12px 20px;margin:0"><a href="/">${esc(site.name)}</a></p></noscript>
+<main>${article(kind, site)}</main><div id="ov-footer"></div>
+<script src="https://openvibe.network/shared/navbar.js" defer></script><script src="https://openvibe.network/shared/footer.js" defer></script>
+<script>addEventListener('DOMContentLoaded',function(){var t=null;try{t=(document.cookie.match(/(?:^|; )ov_token=([^;]*)/)||[])[1]||localStorage.getItem('ov_token')}catch(e){}
+try{OpenVibeNavbar.init({service:${JSON.stringify(site.service || site.id || 'network')},apiBase:'https://openvibe.network',token:t?decodeURIComponent(t):null})}catch(e){}
+try{OpenVibeFooter.init({service:${JSON.stringify(site.service || site.id || 'network')},variant:'compact',mount:'#ov-footer',apiBase:'https://openvibe.network'})}catch(e){}});</script>
+</body></html>`;
+}
+
+/** Express handler for legal.PATHS. */
+function handler(site) {
+ const cache = new Map();
+ return function ovLegal(req, res, next) {
+ const kind = String(req.path || '').replace(/^\/+|\/+$/g, '');
+ if (!BUILDERS[kind]) return next();
+ if (!cache.has(kind)) cache.set(kind, page(kind, site));
+ res.set('Content-Type', 'text/html; charset=utf-8');
+ res.set('Cache-Control', 'public, max-age=3600, stale-while-revalidate=86400');
+ res.send(cache.get(kind));
+ };
+}
+
+module.exports = { PATHS, TITLES, UPDATED, CONTACT, build, article, page, handler, CSS };
@@ -61,11 +61,28 @@
@@ -61,11 +61,28 @@
color: var(--text-primary, #e0e0e0);
color: var(--text-primary, #e0e0e0);
}
}
.openvibe-navbar-brand { display: flex; align-items: center; gap: 9px; text-decoration: none; color: inherit; margin-right: 8px; min-width: 0; }
.openvibe-navbar-brand { display: flex; align-items: center; gap: 9px; text-decoration: none; color: inherit; margin-right: 8px; min-width: 0; }
- .openvibe-navbar-brand a { color: inherit; text-decoration: none; }
- .openvibe-navbar-brand a.b-core:hover, .openvibe-navbar-brand a.b-tld:hover, .openvibe-navbar-brand a.b-sub:hover { color: var(--accent-light, var(--accent, #60a5fa)); text-decoration: underline; text-underline-offset: 3px; text-decoration-thickness: 1.5px; }
- .openvibe-navbar-brand a:focus-visible, .ovnav-launch:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: 2px; border-radius: 6px; }
- .ovnav-launch { appearance: none; border: 0; background: transparent; color: var(--text-secondary, #a8b3c4); width: 32px; height: 32px; border-radius: 9px; display: inline-grid; place-items: center; cursor: pointer; flex: none; margin-right: 6px; transition: background .15s, color .15s; }
- .ovnav-launch:hover, .ovnav-launch[aria-expanded="true"] { background: var(--accent-glow, rgba(59,130,246,.14)); color: var(--accent-light, var(--accent, #60a5fa)); }
+ /* Brand group: mark + linked wordmark + launcher read as one control. Every property a host
+ page might set on bare "nav a" / "a" is reset here, so the brand looks the same everywhere. */
+ .openvibe-navbar .openvibe-navbar-brand a, .openvibe-navbar .openvibe-navbar-brand a:hover { all: unset; cursor: pointer; color: inherit; font: inherit; }
+ .openvibe-navbar .openvibe-navbar-brand { gap: 8px; padding: 3px 4px 3px 3px; border-radius: 12px; margin-right: 2px; }
+ .openvibe-navbar .openvibe-navbar-brand a.flame { display: inline-grid; place-items: center; width: 30px; height: 30px; border-radius: 9px; transition: background .15s; }
+ .openvibe-navbar .openvibe-navbar-brand a.flame:hover { background: var(--accent-glow, rgba(59,130,246,.14)); }
+ .openvibe-navbar .openvibe-navbar-brand .name { align-items: center; gap: 0; font-size: 15.5px; letter-spacing: -.25px; }
+ .openvibe-navbar .openvibe-navbar-brand .name a { padding: 3px 2px; border-radius: 6px; transition: color .15s, background .15s; }
+ .openvibe-navbar .openvibe-navbar-brand .name a.b-sub { color: var(--text-primary, #e6edf7); font-weight: 750; }
+ .openvibe-navbar .openvibe-navbar-brand .name a.b-core { color: var(--text-primary, #e6edf7); font-weight: 700; }
+ .openvibe-navbar .openvibe-navbar-brand .name a.b-tld { color: var(--accent-light, var(--accent, #60a5fa)); font-weight: 700; }
+ .openvibe-navbar .openvibe-navbar-brand.has-sub .name a.b-core, .openvibe-navbar .openvibe-navbar-brand.has-sub .name a.b-tld { color: var(--text-secondary, #a8b3c4); font-weight: 600; }
+ .openvibe-navbar .openvibe-navbar-brand .name a:hover { color: var(--accent-light, var(--accent, #60a5fa)); background: var(--accent-glow, rgba(59,130,246,.12)); }
+ .openvibe-navbar .openvibe-navbar-brand .b-dot { margin: 0; padding: 0 .5px; opacity: .55; }
+ .openvibe-navbar .openvibe-navbar-brand a:focus-visible, .ovnav-launch:focus-visible { outline: 2px solid var(--accent, #3b82f6); outline-offset: 1px; }
+ .ovnav-launch { all: unset; box-sizing: border-box; cursor: pointer; color: var(--text-secondary, #a8b3c4); width: 32px; height: 32px; border-radius: 10px; display: inline-grid; place-items: center; flex: none; margin-right: 10px; border: 1px solid transparent; transition: background .15s, color .15s, border-color .15s; }
+ .ovnav-launch:hover, .ovnav-launch[aria-expanded="true"] { background: var(--accent-glow, rgba(59,130,246,.14)); color: var(--accent-light, var(--accent, #60a5fa)); border-color: color-mix(in srgb, var(--accent, #3b82f6) 35%, transparent); }
+ .ovnav-launch svg { display: block; transition: transform .25s cubic-bezier(.2,1.4,.3,1); }
+ .ovnav-launch[aria-expanded="true"] svg { transform: rotate(45deg) scale(.92); }
+ .ovnav-launcher a { all: unset; cursor: pointer; box-sizing: border-box; }
+ .openvibe-navbar .ovnav-sep { width: 1px; height: 18px; background: var(--border, rgba(255,255,255,.12)); margin: 0 6px; flex: none; align-self: center; }
+ @media (max-width: 1180px) { .openvibe-navbar .ovnav-net, .openvibe-navbar .ovnav-sep { display: none; } }
.ovnav-launcher { position: absolute; top: calc(100% + 6px); left: 12px; width: min(440px, calc(100vw - 24px)); max-height: min(560px, calc(100vh - 80px)); overflow: auto; background: var(--bg-elevated, var(--bg-secondary, #111826)); border: 1px solid var(--border, rgba(255,255,255,.12)); border-radius: 16px; box-shadow: 0 24px 60px rgba(0,0,0,.5); padding: 12px; z-index: 1000; opacity: 0; transform: translateY(-6px) scale(.98); transform-origin: top left; pointer-events: none; transition: opacity .16s, transform .2s cubic-bezier(.2,1.2,.3,1); }
.ovnav-launcher { position: absolute; top: calc(100% + 6px); left: 12px; width: min(440px, calc(100vw - 24px)); max-height: min(560px, calc(100vh - 80px)); overflow: auto; background: var(--bg-elevated, var(--bg-secondary, #111826)); border: 1px solid var(--border, rgba(255,255,255,.12)); border-radius: 16px; box-shadow: 0 24px 60px rgba(0,0,0,.5); padding: 12px; z-index: 1000; opacity: 0; transform: translateY(-6px) scale(.98); transform-origin: top left; pointer-events: none; transition: opacity .16s, transform .2s cubic-bezier(.2,1.2,.3,1); }
.ovnav-launcher.open { opacity: 1; transform: none; pointer-events: auto; }
.ovnav-launcher.open { opacity: 1; transform: none; pointer-events: auto; }
.ovnav-launcher .ovl-q { width: 100%; box-sizing: border-box; padding: 9px 12px; border-radius: 10px; border: 1px solid var(--border, rgba(255,255,255,.12)); background: var(--bg-primary, #0a0f18); color: var(--text-primary, #e6edf7); font: 500 14px/1.2 inherit; outline: none; }
.ovnav-launcher .ovl-q { width: 100%; box-sizing: border-box; padding: 9px 12px; border-radius: 10px; border: 1px solid var(--border, rgba(255,255,255,.12)); background: var(--bg-primary, #0a0f18); color: var(--text-primary, #e6edf7); font: 500 14px/1.2 inherit; outline: none; }
@@ -79,6 +96,9 @@
@@ -79,6 +96,9 @@
.ovnav-launcher .ovl-site:hover, .ovnav-launcher .ovl-fam:hover, .ovnav-launcher a:focus-visible { background: var(--accent-glow, rgba(59,130,246,.14)); outline: none; }
.ovnav-launcher .ovl-site:hover, .ovnav-launcher .ovl-fam:hover, .ovnav-launcher a:focus-visible { background: var(--accent-glow, rgba(59,130,246,.14)); outline: none; }
.ovnav-launcher b { display: block; font-size: 13px; font-weight: 650; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ovnav-launcher b { display: block; font-size: 13px; font-weight: 650; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ovnav-launcher small { display: block; font-size: 11px; color: var(--text-muted, #7d8aa0); margin-top: 1px; }
.ovnav-launcher small { display: block; font-size: 11px; color: var(--text-muted, #7d8aa0); margin-top: 1px; }
+ .ovnav-launcher .ovl-soon { display: flex; flex-wrap: wrap; gap: 6px; padding: 0 4px 4px; }
+ .ovnav-launcher .ovl-soon a { font-size: 12px; font-weight: 600; padding: 4px 9px; border-radius: 999px; border: 1px solid var(--border, rgba(255,255,255,.1)); color: var(--text-secondary, #a8b3c4); }
+ .ovnav-launcher .ovl-soon a:hover { border-color: var(--accent, #3b82f6); color: var(--text-primary, #e6edf7); background: none; }
.ovnav-launcher .ovl-empty { padding: 18px 8px; color: var(--text-secondary, #a8b3c4); font-size: 13px; }
.ovnav-launcher .ovl-empty { padding: 18px 8px; color: var(--text-secondary, #a8b3c4); font-size: 13px; }
@media (max-width: 480px) { .ovnav-launcher { left: 8px; } .ovnav-launcher .ovl-fams { grid-template-columns: 1fr; } }
@media (max-width: 480px) { .ovnav-launcher { left: 8px; } .ovnav-launcher .ovl-fams { grid-template-columns: 1fr; } }
@media (prefers-reduced-motion: reduce) { .ovnav-launcher { transition: none; } }
@media (prefers-reduced-motion: reduce) { .ovnav-launcher { transition: none; } }
@@ -529,6 +549,42 @@
@@ -529,6 +549,42 @@
${_config.launcher === false ? '' : '<button type="button" class="ovnav-launch" id="openvibe-launcher-btn" aria-label="All OpenVibe sites and tools" aria-haspopup="true" aria-expanded="false" title="All OpenVibe sites and tools"><svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true"><g fill="currentColor"><circle cx="5" cy="5" r="2"/><circle cx="12" cy="5" r="2"/><circle cx="19" cy="5" r="2"/><circle cx="5" cy="12" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="19" cy="12" r="2"/><circle cx="5" cy="19" r="2"/><circle cx="12" cy="19" r="2"/><circle cx="19" cy="19" r="2"/></g></svg></button>'}`;
${_config.launcher === false ? '' : '<button type="button" class="ovnav-launch" id="openvibe-launcher-btn" aria-label="All OpenVibe sites and tools" aria-haspopup="true" aria-expanded="false" title="All OpenVibe sites and tools"><svg viewBox="0 0 24 24" width="18" height="18" aria-hidden="true"><g fill="currentColor"><circle cx="5" cy="5" r="2"/><circle cx="12" cy="5" r="2"/><circle cx="19" cy="5" r="2"/><circle cx="5" cy="12" r="2"/><circle cx="12" cy="12" r="2"/><circle cx="19" cy="12" r="2"/><circle cx="5" cy="19" r="2"/><circle cx="12" cy="19" r="2"/><circle cx="19" cy="19" r="2"/></g></svg></button>'}`;
}
}
+
+ // ── Shared chrome data (https://openvibe.network/api/chrome) ─────────────────────────────
+ // Sites ordered by real use, footer copy and per-site legal links. Cached per host for 30
+ // minutes in localStorage and refreshed in the background, so pages paint from cache and the
+ // order never jumps while someone is looking at it. Defined once, shared by navbar + footer.
+ const OVChrome = root.OpenVibeChrome || (root.OpenVibeChrome = (function () {
+ const KEY = 'ov_chrome_v1', TTL = 30 * 60000;
+ let inflight = null;
+ const host = () => (typeof location !== 'undefined' ? location.hostname : '');
+ const https = (u) => { try { return new URL(u).protocol === 'https:'; } catch { return false; } };
+ function clean(d) {
+ if (!d || !Array.isArray(d.nav)) return null;
+ const link = (l) => (l && typeof l.name === 'string' && https(l.url) ? { id: String(l.id || ''), name: l.name.slice(0, 60), url: l.url, icon: String(l.icon || ''), tagline: String(l.tagline || '').slice(0, 80) } : null);
+ const f = d.footer || {}, lg = f.legal || {};
+ return { nav: d.nav.map(link).filter(Boolean).slice(0, 12), soon: (d.soon || []).map(link).filter(Boolean).slice(0, 24),
+ footer: { blurb: String(f.blurb || '').slice(0, 200), discover: (f.discover || []).map(link).filter(Boolean).slice(0, 6), popular: (f.popular || []).map(link).filter(Boolean).slice(0, 10),
+ legal: https(lg.terms) && https(lg.privacy) && https(lg.dmca) ? { terms: lg.terms, privacy: lg.privacy, dmca: lg.dmca } : null } };
+ }
+ function cached() { try { const c = JSON.parse(localStorage.getItem(KEY) || 'null'); return c && c.host === host() && c.data ? c : null; } catch { return null; } }
+ function refresh() {
+ if (inflight || typeof fetch === 'undefined') return inflight || Promise.resolve(null);
+ inflight = fetch('https://openvibe.network/api/chrome?host=' + encodeURIComponent(host()), { credentials: 'omit' })
+ .then(r => (r.ok ? r.json() : null)).then(clean)
+ .then(d => { if (d) { try { localStorage.setItem(KEY, JSON.stringify({ at: Date.now(), host: host(), data: d })); } catch { /* */ } } return d; })
+ .catch(() => null);
+ return inflight;
+ }
+ /** Cached data now (or null); `onFirst` fires once when a first-ever fetch lands. */
+ function get(onFirst) {
+ const c = cached();
+ if (!c || Date.now() - c.at > TTL) { const p = refresh(); if (!c && onFirst) p.then(d => { if (d) onFirst(d); }); }
+ return c ? c.data : null;
+ }
+ return { get, refresh };
+ })());
+
// ── Network launcher ─────────────────────────────────────
// ── Network launcher ─────────────────────────────────────
const LAUNCHER_SITES = [
const LAUNCHER_SITES = [
{ name: 'Live', desc: 'Streams, clips and chat', icon: 'live', url: 'https://openvibe.live/' },
{ name: 'Live', desc: 'Streams, clips and chat', icon: 'live', url: 'https://openvibe.live/' },
@@ -575,8 +631,12 @@
@@ -575,8 +631,12 @@
body.innerHTML = hits.length ? `<div class="ovl-h">Tools</div><div class="ovl-fams">${hits.map(t => tile(t, 'ovl-fam')).join('')}</div>` : '<div class="ovl-empty">No tool matches. <a href="https://openvibe.tools/">Browse them all</a></div>';
body.innerHTML = hits.length ? `<div class="ovl-h">Tools</div><div class="ovl-fams">${hits.map(t => tile(t, 'ovl-fam')).join('')}</div>` : '<div class="ovl-empty">No tool matches. <a href="https://openvibe.tools/">Browse them all</a></div>';
return;
return;
}
}
- body.innerHTML = `<div class="ovl-h">Sites</div><div class="ovl-sites">${LAUNCHER_SITES.map(x => tile(x, 'ovl-site')).join('')}</div>
- <div class="ovl-h">Tools <a href="https://openvibe.tools/">See all</a></div><div class="ovl-fams">${fams.map(x => tile(x, 'ovl-fam')).join('')}</div>`;
+ const chrome = OVChrome.get();
+ const sites = chrome && chrome.nav.length ? chrome.nav.map(n => ({ name: n.name, desc: n.tagline, icon: n.icon || n.id, url: n.url })) : LAUNCHER_SITES;
+ const soon = chrome ? chrome.soon : [];
+ body.innerHTML = `<div class="ovl-h">Sites</div><div class="ovl-sites">${sites.map(x => tile(x, 'ovl-site')).join('')}</div>
+ <div class="ovl-h">Tools <a href="https://openvibe.tools/">See all</a></div><div class="ovl-fams">${fams.map(x => tile(x, 'ovl-fam')).join('')}</div>
+ ${soon.length ? `<div class="ovl-h">Opening soon</div><div class="ovl-soon">${soon.map(x => `<a href="${escapeAttr(x.url)}">${escapeAttr(x.name)}</a>`).join('')}</div>` : ''}`;
}
}
btn.addEventListener('click', async (e) => {
btn.addEventListener('click', async (e) => {
e.stopPropagation();
e.stopPropagation();
@@ -605,6 +665,19 @@
@@ -605,6 +665,19 @@
});
});
}
}
+ /** 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 '';
+ const chrome = OVChrome.get((d) => { if (d && _navEl && !_navEl.querySelector('.ovnav-net')) { try { render(); } catch { /* */ } } });
+ if (!chrome) return '';
+ const here = currentHost().toLowerCase();
+ const taken = new Set((pageLinks || []).map(l => { try { return new URL(l.href, location.href).hostname; } catch { return ''; } }));
+ const max = typeof _config.networkLinks === 'number' ? _config.networkLinks : 4;
+ const pick = chrome.nav.filter(n => { try { const h = new URL(n.url).hostname; return h !== here && !here.endsWith('.' + h) && !taken.has(h); } catch { return false; } }).slice(0, max);
+ if (!pick.length) return '';
+ return `<span class="ovnav-sep" aria-hidden="true"></span>` + pick.map(n => `<a class="ovnav-net" href="${escapeAttr(n.url)}" title="${escapeAttr(n.tagline)}">${escapeAttr(n.name)}</a>`).join('');
+ }
+
// ── Notification bell (mounted by the navbar itself) ─────
// ── Notification bell (mounted by the navbar itself) ─────
function mountBell(nav, u) {
function mountBell(nav, u) {
if (_config.notifications === false || !u || u.is_anon || !_config.token) return;
if (_config.notifications === false || !u || u.is_anon || !_config.token) return;
@@ -775,7 +848,7 @@
@@ -775,7 +848,7 @@
/** Where "Sign In" goes on this host. */
/** Where "Sign In" goes on this host. */
function resolveLoginHref(host, returnUrl) {
function resolveLoginHref(host, returnUrl) {
- if (_config.loginUrl) return _config.loginUrl;
+ if (_config.loginUrl) return String(_config.loginUrl).replace('{url}', encodeURIComponent(returnUrl || '/'));
if (onToolsDomain(host)) {
if (onToolsDomain(host)) {
// Every *.openvibe.tools host signs in through the gateway apex: it
// Every *.openvibe.tools host signs in through the gateway apex: it
// holds the OAuth state cookie + redirect_uri (both apex-host-only)
// holds the OAuth state cookie + redirect_uri (both apex-host-only)
@@ -1122,6 +1195,7 @@
@@ -1122,6 +1195,7 @@
${brandHTML(brand)}
${brandHTML(brand)}
<div class="openvibe-navbar-links">
<div class="openvibe-navbar-links">
${links.map(l => `<a href="${escapeAttr(l.href)}"${l.active ? ' class="active"' : ''}${l.external ? ' target="_blank" rel="noopener"' : ''}>${l.icon ? `<i class="fa-solid ${escapeAttr(l.icon)} icon"></i>` : ''}${escapeAttr(l.label)}</a>`).join('')}
${links.map(l => `<a href="${escapeAttr(l.href)}"${l.active ? ' class="active"' : ''}${l.external ? ' target="_blank" rel="noopener"' : ''}>${l.icon ? `<i class="fa-solid ${escapeAttr(l.icon)} icon"></i>` : ''}${escapeAttr(l.label)}</a>`).join('')}
+ ${networkLinksHTML(links)}
${u && u.role === 'admin' ? `<a href="https://openvibe.network/admin"><i class="fa-solid fa-shield-halved"></i> Admin</a>` : ''}
${u && u.role === 'admin' ? `<a href="https://openvibe.network/admin"><i class="fa-solid fa-shield-halved"></i> Admin</a>` : ''}
</div>
</div>
<div class="openvibe-navbar-spacer"></div>
<div class="openvibe-navbar-spacer"></div>
@@ -13,7 +13,8 @@
@@ -13,7 +13,8 @@
"./notifications": "./notifications.js",
"./notifications": "./notifications.js",
"./url-resolver": "./url-resolver.js",
"./url-resolver": "./url-resolver.js",
"./seo": "./seo.js",
"./seo": "./seo.js",
- "./icons": "./ov-icons.js"
+ "./icons": "./ov-icons.js",
+ "./legal": "./legal.js"
},
},
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {