openvibers/openvibe.tools Commits
Tool registry with host roles (canonical/short/alias), owner domain overrides and satellite proxying; unknown subdomains leave for the index; server-rendered index, family and tool pages, search, sitemap, robots, llms.txt and catalog.json; YT: progress stream fixed, poll fallback, cancel, activity island
Changes
nnew file mode 100644@@@ -0,0 +1,120 @@++'use strict';++++// ═══════════════════════════════════════════════════════════════++// Host roles for the satellite apps (shared-contracts §1).++//++// The Tools gateway owns the catalog of hosts. When it forwards a request it says which tool++// the host belongs to and what role the host plays:++//++// X-OV-Tool catalog id of the tool ("yt", "png", …)++// X-OV-Host-Role canonical | short | alias++// X-OV-Canonical-Host the host every canonical / og:url / JSON-LD url uses++// X-OV-Short-Host the host people type++//++// A satellite keeps its own domain map for branding, so without the headers it behaves as it++// always has (each host is its own canonical). With them, the canonical host comes from the++// gateway — which is how a custom domain becomes canonical without the satellite knowing it.++//++// No dependencies: every satellite requires this file by relative path.++// ═══════════════════════════════════════════════════════════════++++const TOOLS_HOME = 'https://openvibe.tools/';++const HOST_RE = /^(?=.{1,253}$)([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/;++const TOOL_RE = /^[a-z0-9][a-z0-9._-]{0,63}$/;++++function cleanHost(value) {++ const h = String(value || '').trim().toLowerCase().replace(/:\d+$/, '').replace(/\.$/, '');++ return HOST_RE.test(h) ? h : '';++}++++/** Request hostname, lower-cased, without the port ('' when absent). */++function requestHost(req) {++ return String((req.headers && req.headers.host) || '').trim().toLowerCase().replace(/:\d+$/, '');++}++++/** Loopback / LAN / bare names: development, health checks and service-to-service calls. */++function isLocalHost(host) {++ if (!host) return true;++ if (host === 'localhost' || host.endsWith('.localhost') || host.endsWith('.local')) return true;++ if (/^\[?[0-9a-f:]+\]?$/i.test(host)) return true; // IPv6 literal++ if (/^\d{1,3}(\.\d{1,3}){3}$/.test(host)) return true; // IPv4 literal++ return !host.includes('.');++}++++/**++ * What the gateway said about this request.++ * @returns {{ viaGateway: boolean, tool: string, role: string, canonicalHost: string, shortHost: string }}++ */++function hostRole(req) {++ const h = req.headers || {};++ const toolRaw = String(h['x-ov-tool'] || '').trim().toLowerCase();++ const tool = TOOL_RE.test(toolRaw) ? toolRaw : '';++ const roleRaw = String(h['x-ov-host-role'] || '').trim().toLowerCase();++ const role = ['canonical', 'short', 'alias'].includes(roleRaw) ? roleRaw : '';++ return {++ viaGateway: !!tool,++ tool,++ role,++ canonicalHost: cleanHost(h['x-ov-canonical-host']),++ shortHost: cleanHost(h['x-ov-short-host']),++ };++}++++/** The host canonical URLs are built on: the gateway's when it named one, else `fallbackHost`. */++function canonicalHostFor(req, fallbackHost) {++ const info = req.ovHost || hostRole(req);++ return info.canonicalHost || cleanHost(fallbackHost) || requestHost(req);++}++++/** `https://<canonical host><pathname>` — query strings never belong in a canonical. */++function canonicalUrlFor(req, fallbackHost, pathname) {++ let p = pathname == null ? String(req.path || '/') : String(pathname);++ if (!p.startsWith('/')) p = `/${p}`;++ return `https://${canonicalHostFor(req, fallbackHost)}${p}`;++}++++/**++ * Express middleware. Sets `req.ovHost` and turns away hosts this satellite does not serve.++ *++ * @param {object} opts++ * @param {(host: string) => boolean} opts.knows is this hostname in the satellite's own map?++ * @param {(host: string) => string} [opts.aliasOf] where an alias in the satellite's own map points ('' = not an alias)++ * @param {string[]} [opts.skip] path prefixes that are never redirected (APIs, health checks)++ */++function hostGuard(opts) {++ const knows = opts.knows;++ const aliasOf = opts.aliasOf || (() => '');++ const skip = opts.skip || ['/api/'];++ const extra = new Set(String(process.env.OV_EXTRA_HOSTS || '').split(',').map(s => s.trim().toLowerCase()).filter(Boolean));++++ return function ovHostGuard(req, res, next) {++ const info = hostRole(req);++ const host = requestHost(req);++ info.host = host;++ req.ovHost = info;++++ if (skip.some(p => req.path.startsWith(p))) return next();++ if (req.method !== 'GET' && req.method !== 'HEAD') return next();++++ if (info.viaGateway) {++ // The gateway redirects aliases itself; this only covers a request that slipped past it.++ if (info.role === 'alias') {++ const target = info.shortHost || info.canonicalHost;++ if (target && target !== host) return res.redirect(301, `https://${target}${req.originalUrl || '/'}`);++ }++ return next();++ }++++ if (isLocalHost(host) || extra.has(host)) return next();++++ const target = cleanHost(aliasOf(host));++ if (target && target !== host) return res.redirect(301, `https://${target}${req.originalUrl || '/'}`);++ if (knows(host)) return next();++++ // Not ours: never render a default page under a made-up brand.++ res.setHeader('Cache-Control', 'public, max-age=300');++ return res.redirect(301, TOOLS_HOME);++ };++}++++module.exports = { hostRole, hostGuard, canonicalHostFor, canonicalUrlFor, requestHost, isLocalHost, cleanHost, TOOLS_HOME };ddeleted file mode 100644@@@ -1,744 +0,0 @@--<!DOCTYPE html>--<html lang="en">--<head>-- <meta charset="UTF-8">-- <meta name="viewport" content="width=device-width, initial-scale=1.0">-- <title>OpenVibe.Tools — Online Tools Directory</title>-- <meta name="description" content="OpenVibe.Tools: 200+ online tools. Network diagnostics, developer utilities, pastes, image & audio converters, text generators, logo makers, PDF tools, survival maps, and more. One OpenVibe account, everywhere.">-- <meta name="keywords" content="openvibe tools, online tools, network diagnostics, developer tools, image converter, audio converter, text generator, logo maker, pdf tools, pastebin">-- <meta name="author" content="OpenVibe">-- <meta name="theme-color" content="#8b5cf6">---- <!-- Open Graph -->-- <meta property="og:type" content="website">-- <meta property="og:url" content="https://openvibe.tools/">-- <meta property="og:title" content="OpenVibe.Tools — Online Tools Directory">-- <meta property="og:description" content="200+ online tools: network diagnostics, developer utilities, image & audio converters, text generators, PDF tools, and more.">-- <meta property="og:site_name" content="OpenVibe.Tools">-- <meta property="og:locale" content="en_US">---- <!-- Twitter -->-- <meta name="twitter:card" content="summary">-- <meta name="twitter:url" content="https://openvibe.tools/">-- <meta name="twitter:title" content="OpenVibe.Tools — Online Tools Directory">-- <meta name="twitter:description" content="200+ online tools under one roof. Part of the OpenVibe network.">---- <link rel="canonical" href="https://openvibe.tools/">-- <link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect fill='%238b5cf6' width='32' height='32' rx='8'/%3E%3Ccircle cx='16' cy='10' r='3' fill='white'/%3E%3Ccircle cx='9' cy='22' r='3' fill='white'/%3E%3Ccircle cx='23' cy='22' r='3' fill='white'/%3E%3Cpath d='M16 10L9 22M16 10l7 12M9 22h14' stroke='white' stroke-width='1.6'/%3E%3C/svg%3E">---- <link rel="preconnect" href="https://fonts.googleapis.com">-- <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>--<!-- Critical theme bootstrap (ov-theme-boot) — MUST stay inline and above the stylesheets.-- Paints the cached theme synchronously so the default palette never flashes. Reads-- localStorage only: no network, nothing to block on. theme-loader.js still runs after-- and reconciles with the account. -->--<script>--(function(){try{var raw=localStorage.getItem('ov_theme');if(!raw)return;var t=JSON.parse(raw),v=t&&t.variables;if(!v)return;var el=document.documentElement;for(var k in v)if(k.charAt(0)==='-')el.style.setProperty(k,v[k]);if(t.id)el.setAttribute('data-theme',t.id);}catch(_){}})();--</script>-- <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&display=swap" rel="stylesheet">-- <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">---- <!-- Shared OpenVibe assets (served by the Network) -->-- <script src="https://openvibe.network/shared/theme-loader.js"></script>---- <script type="application/ld+json">-- {-- "@context": "https://schema.org",-- "@type": "WebSite",-- "name": "OpenVibe.Tools",-- "url": "https://openvibe.tools/",-- "description": "200+ online tools: network diagnostics, developer utilities, converters, generators, and more.",-- "publisher": { "@type": "Organization", "name": "OpenVibe", "url": "https://openvibe.network" }-- }-- </script>---- <style>-- :root {-- --bg-primary: #0e0e13;-- --bg-secondary: #131318;-- --bg-card: rgba(24, 24, 34, 0.85);-- --bg-hover: #232333;-- --text-primary: #f1f2fb;-- --text-secondary: #a5a8c4;-- --text-muted: #62657f;-- --accent: #8b5cf6;-- --accent-light: #a78bfa;-- --accent-dark: #6d28d9;-- --secondary: #22d3ee;-- --border: rgba(255,255,255,0.07);-- --border-light: rgba(255,255,255,0.13);-- --shadow: 0 24px 64px rgba(0,0,0,0.4);-- --radius: 20px;-- --radius-sm: 14px;-- }---- *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }-- html { background: var(--bg-primary); scroll-behavior: smooth; }-- body {-- font-family: 'Inter', system-ui, -apple-system, sans-serif;-- background:-- radial-gradient(1100px 500px at 85% -10%, rgba(139,92,246,0.14), transparent 60%),-- radial-gradient(900px 420px at 0% 10%, rgba(34,211,238,0.08), transparent 55%),-- var(--bg-primary);-- color: var(--text-primary);-- line-height: 1.6;-- min-height: 100vh;-- -webkit-font-smoothing: antialiased;-- }-- a { color: inherit; text-decoration: none; }-- .shell { max-width: 1180px; margin: 0 auto; padding: 0 24px; }---- .btn {-- display: inline-flex; align-items: center; gap: 8px; cursor: pointer;-- padding: 10px 18px; border-radius: 12px; font-weight: 700; font-size: 0.9rem;-- border: 1px solid var(--border-light); background: transparent; color: var(--text-primary);-- transition: all 0.18s ease; font-family: inherit;-- }-- .btn:hover { background: var(--bg-hover); transform: translateY(-1px); }-- .btn-primary {-- background: linear-gradient(135deg, var(--accent), var(--accent-dark));-- border-color: transparent; color: #fff; box-shadow: 0 8px 22px rgba(139,92,246,0.35);-- }-- .btn-primary:hover { filter: brightness(1.1); background: linear-gradient(135deg, var(--accent), var(--accent-dark)); }---- /* ─── Hero ─── */-- .hero { padding: 64px 0 40px; text-align: center; }-- .hero h1 {-- font-size: clamp(2.2rem, 5.5vw, 3.6rem); font-weight: 900; letter-spacing: -0.03em; line-height: 1.12;-- }-- .hero h1 .grad {-- background: linear-gradient(120deg, var(--accent-light), var(--secondary));-- -webkit-background-clip: text; background-clip: text; color: transparent;-- }-- .hero p.tag { margin: 18px auto 0; max-width: 640px; color: var(--text-secondary); font-size: 1.08rem; }-- .hero-search {-- margin: 30px auto 0; max-width: 560px; position: relative;-- }-- .hero-search input {-- width: 100%; padding: 16px 20px 16px 50px; font: inherit; font-size: 1rem;-- color: var(--text-primary); background: var(--bg-card); cursor: pointer;-- border: 1px solid var(--border-light); border-radius: 16px; outline: none;-- transition: border-color 0.18s ease;-- }-- .hero-search input:hover { border-color: rgba(139,92,246,0.5); }-- .hero-search i { position: absolute; left: 20px; top: 50%; transform: translateY(-50%); color: var(--text-muted); }-- .hero-search kbd {-- position: absolute; right: 14px; top: 50%; transform: translateY(-50%);-- font-family: inherit; font-size: 0.72rem; font-weight: 700; color: var(--text-muted);-- border: 1px solid var(--border-light); border-radius: 6px; padding: 3px 7px;-- }-- .hero-stats { display: flex; gap: 26px; justify-content: center; margin-top: 28px; flex-wrap: wrap; }-- .hero-stat { text-align: center; }-- .hero-stat b { display: block; font-size: 1.35rem; font-weight: 900; color: var(--accent-light); }-- .hero-stat span { font-size: 0.74rem; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.06em; }---- /* ─── Section titles ─── */-- .section { padding: 44px 0; }-- .section-title { margin-bottom: 28px; }-- .section-title h2 { font-size: 1.55rem; font-weight: 800; letter-spacing: -0.02em; }-- .section-title p { color: var(--text-secondary); font-size: 0.95rem; margin-top: 4px; }---- /* ─── Tool cards ─── */-- .tools-grid {-- display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 18px;-- }-- .tool-card {-- position: relative; overflow: hidden; display: flex; flex-direction: column; gap: 10px;-- padding: 24px; border-radius: var(--radius); background: var(--bg-card);-- border: 1px solid var(--border); transition: all 0.2s ease;-- }-- .tool-card:hover { transform: translateY(-3px); border-color: color-mix(in srgb, var(--card-accent, var(--accent)) 45%, transparent); box-shadow: var(--shadow); }-- .tool-card::before {-- content: ''; position: absolute; inset: 0 auto 0 0; width: 3px;-- background: var(--card-accent, var(--accent)); opacity: 0; transition: opacity 0.2s ease;-- }-- .tool-card:hover::before { opacity: 1; }-- .tool-card .icon {-- width: 46px; height: 46px; border-radius: 13px; display: grid; place-items: center; font-size: 1.2rem;-- background: color-mix(in srgb, var(--card-accent, var(--accent)) 13%, transparent);-- color: var(--card-accent, var(--accent));-- }-- .tool-card h3 { font-size: 1.08rem; font-weight: 800; }-- .tool-card .domain { font-size: 0.78rem; font-weight: 600; color: var(--card-accent, var(--accent-light)); }-- .tool-card p { color: var(--text-secondary); font-size: 0.88rem; }-- .tool-card .aliases { margin-top: auto; padding-top: 8px; font-size: 0.74rem; color: var(--text-muted); }---- /* ─── Network strip ─── */-- .net-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 18px; }-- .net-card {-- display: flex; align-items: center; gap: 16px; padding: 20px 22px;-- border-radius: var(--radius-sm); background: var(--bg-card); border: 1px solid var(--border);-- transition: all 0.2s ease;-- }-- .net-card:hover { transform: translateY(-2px); border-color: color-mix(in srgb, var(--card-accent, var(--accent)) 45%, transparent); }-- .net-card .icon {-- width: 44px; height: 44px; border-radius: 12px; display: grid; place-items: center; font-size: 1.15rem;-- background: color-mix(in srgb, var(--card-accent, var(--accent)) 13%, transparent);-- color: var(--card-accent, var(--accent)); flex-shrink: 0;-- }-- .net-card h3 { font-size: 0.98rem; font-weight: 800; }-- .net-card p { color: var(--text-secondary); font-size: 0.8rem; }---- /* ─── Directory modal ─── */-- .dir-overlay {-- position: fixed; inset: 0; z-index: 60; display: none;-- background: rgba(8,8,14,0.78); backdrop-filter: blur(8px); padding: 4vh 16px;-- }-- .dir-overlay.open { display: flex; justify-content: center; }-- .dir-panel {-- width: 100%; max-width: 940px; max-height: 92vh; display: flex; flex-direction: column;-- background: var(--bg-secondary); border: 1px solid var(--border-light);-- border-radius: var(--radius); box-shadow: var(--shadow); overflow: hidden;-- }-- .dir-head { display: flex; align-items: center; gap: 14px; padding: 18px 22px; border-bottom: 1px solid var(--border); }-- .dir-head h2 { font-size: 1.05rem; font-weight: 800; flex: 1; }-- .dir-head .dir-count { font-size: 0.78rem; color: var(--text-muted); font-weight: 600; }-- .dir-close {-- width: 34px; height: 34px; border-radius: 10px; display: grid; place-items: center; cursor: pointer;-- background: transparent; border: 1px solid var(--border-light); color: var(--text-secondary); font-size: 0.95rem;-- }-- .dir-close:hover { background: var(--bg-hover); color: var(--text-primary); }-- .dir-search-row { padding: 14px 22px 6px; }-- .dir-search-row input {-- width: 100%; padding: 12px 16px; font: inherit; font-size: 0.95rem; color: var(--text-primary);-- background: var(--bg-card); border: 1px solid var(--border-light); border-radius: 12px; outline: none;-- }-- .dir-search-row input:focus { border-color: var(--accent); }-- .dir-filters { display: flex; gap: 8px; padding: 10px 22px; flex-wrap: wrap; }-- .dir-filter-btn {-- padding: 6px 13px; border-radius: 999px; font-size: 0.78rem; font-weight: 700; cursor: pointer;-- background: transparent; border: 1px solid var(--border-light); color: var(--text-secondary); font-family: inherit;-- text-transform: capitalize;-- }-- .dir-filter-btn.active { background: var(--accent); border-color: var(--accent); color: #fff; }-- .dir-body { overflow-y: auto; padding: 8px 22px 24px; }-- .dir-category { margin-top: 18px; }-- .dir-cat-head { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; }-- .dir-cat-head i { color: var(--accent-light); }-- .dir-cat-head h3 { font-size: 0.92rem; font-weight: 800; }-- .dir-cat-count { font-size: 0.7rem; font-weight: 700; color: var(--text-muted); border: 1px solid var(--border); padding: 1px 8px; border-radius: 999px; }-- .dir-tools { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 8px; }-- .dir-tool {-- display: flex; align-items: center; gap: 11px; padding: 10px 12px; border-radius: 11px;-- border: 1px solid transparent; transition: all 0.14s ease;-- }-- .dir-tool:hover { background: var(--bg-hover); border-color: var(--border-light); }-- .dir-tool-icon {-- width: 32px; height: 32px; border-radius: 9px; display: grid; place-items: center; font-size: 0.82rem;-- background: color-mix(in srgb, var(--tool-color, var(--accent)) 13%, transparent);-- color: var(--tool-color, var(--accent)); flex-shrink: 0;-- }-- .dir-tool-name { font-size: 0.83rem; font-weight: 700; line-height: 1.3; }-- .dir-tool-domain { font-size: 0.7rem; color: var(--text-muted); }-- .dir-empty { padding: 40px; text-align: center; color: var(--text-muted); }-- .dir-empty i { display: block; font-size: 1.6rem; margin-bottom: 10px; }---- /* ─── Footer ─── */---- @media (max-width: 640px) {-- .hero { padding: 40px 0 28px; }-- .hero-stats { gap: 18px; }-- }-- </style>--</head>--<body>-- <div class="shell">-- <!-- ─── Hero ─── -->-- <section class="hero">-- <h1>Every tool you need,<br><span class="grad">one vibe apart.</span></h1>-- <p class="tag">200+ online tools — network diagnostics, developer utilities, converters, generators, maps, and more. One OpenVibe account works everywhere. Open source, built in the open.</p>-- <div class="hero-search" onclick="openDirectory()">-- <i class="fa-solid fa-magnifying-glass"></i>-- <input type="text" placeholder="Search tools — try 'mp3', 'resize', 'zalgo', 'whois'…" readonly>-- <kbd>Ctrl K</kbd>-- </div>-- <div class="hero-stats">-- <div class="hero-stat"><b id="stat-tools">200+</b><span>Tools</span></div>-- <div class="hero-stat"><b>10</b><span>Suites</span></div>-- <div class="hero-stat"><b>1</b><span>Account</span></div>-- <div class="hero-stat"><b>Open</b><span>source</span></div>-- </div>-- </section>---- <!-- ─── Tool suites ─── -->-- <section class="section" id="tools">-- <div class="section-title">-- <h2>The tool suites</h2>-- <p>Each suite lives on its own subdomain — and most individual tools have a memorable alias of their own.</p>-- </div>-- <div class="tools-grid">-- <a class="tool-card" href="https://net.openvibe.tools" style="--card-accent:#22d3ee">-- <div class="icon"><i class="fa-solid fa-network-wired"></i></div>-- <h3>Net.OpenVibe</h3>-- <div class="domain">net.openvibe.tools</div>-- <p>38 network & internet diagnostics — DNS, GeoIP, ping, traceroute, SSL, Whois/RDAP, headers, port checks.</p>-- <div class="aliases">Also: dns. · ping. · ssl. · whois. · myip. · traceroute. & 30 more</div>-- </a>-- <a class="tool-card" href="https://dev.openvibe.tools" style="--card-accent:#a78bfa">-- <div class="icon"><i class="fa-solid fa-code"></i></div>-- <h3>Dev.OpenVibe</h3>-- <div class="domain">dev.openvibe.tools</div>-- <p>26 developer & SEO tools — JSON, YAML, Base64, JWT, regex, diff, hashes, timestamps, OpenGraph.</p>-- <div class="aliases">Also: json. · base64. · jwt. · regex. · uuid. · cron. & 20 more</div>-- </a>-- <a class="tool-card" href="https://pastes.openvibe.tools" style="--card-accent:#f59e0b">-- <div class="icon"><i class="fa-solid fa-clipboard"></i></div>-- <h3>Paste.OpenVibe</h3>-- <div class="domain">pastes.openvibe.tools</div>-- <p>Share code snippets and screenshots. Syntax highlighting, comments, likes, forks, burn-after-read.</p>-- <div class="aliases">Backed by OpenVibe.Media</div>-- </a>-- <a class="tool-card" href="https://maps.openvibe.tools" style="--card-accent:#22c55e">-- <div class="icon"><i class="fa-solid fa-map-location-dot"></i></div>-- <h3>Maps.OpenVibe</h3>-- <div class="domain">maps.openvibe.tools</div>-- <p>Survival map for North America — free campsites, shelter, water, food resources, weather, and terrain.</p>-- <div class="aliases">18+ live data sources</div>-- </a>-- <a class="tool-card" href="https://food.openvibe.tools" style="--card-accent:#ef4444">-- <div class="icon"><i class="fa-solid fa-utensils"></i></div>-- <h3>Food.OpenVibe</h3>-- <div class="domain">food.openvibe.tools</div>-- <p>Budget grocery finder, meal planning, and food bank locator for North America.</p>-- <div class="aliases">Pairs with Maps.OpenVibe</div>-- </a>-- <a class="tool-card" href="https://img.openvibe.tools" style="--card-accent:#3b82f6">-- <div class="icon"><i class="fa-solid fa-images"></i></div>-- <h3>Img.OpenVibe</h3>-- <div class="domain">img.openvibe.tools</div>-- <p>Convert, compress, resize, and crop images. PNG, JPG, WebP, AVIF, HEIC, SVG, GIF, ICO, and more.</p>-- <div class="aliases">Also: png. · jpg. · webp. · avif. · convert. · favicon. & 10 more</div>-- </a>-- <a class="tool-card" href="https://yt.openvibe.tools" style="--card-accent:#f43f5e">-- <div class="icon"><i class="fa-solid fa-circle-play"></i></div>-- <h3>YT.OpenVibe</h3>-- <div class="domain">yt.openvibe.tools</div>-- <p>Download YouTube videos and audio in the format and quality you want.</p>-- <div class="aliases">Video & audio extraction</div>-- </a>-- <a class="tool-card" href="https://audio.openvibe.tools" style="--card-accent:#06b6d4">-- <div class="icon"><i class="fa-solid fa-headphones"></i></div>-- <h3>Audio.OpenVibe</h3>-- <div class="domain">audio.openvibe.tools</div>-- <p>Convert between 10+ audio formats and apply 25+ effects — trim, pitch, reverb, EQ, vocal removal.</p>-- <div class="aliases">Also: mp3. · wav. · flac. · trim. · pitch. · ringtone. & 30 more</div>-- </a>-- <a class="tool-card" href="https://text.openvibe.tools" style="--card-accent:#f472b6">-- <div class="icon"><i class="fa-solid fa-pen-fancy"></i></div>-- <h3>Text.OpenVibe + Logo.OpenVibe</h3>-- <div class="domain">text.openvibe.tools · logo.openvibe.tools</div>-- <p>Fancy text, zalgo, unicode, kaomoji, counters, sorters — plus title cards, wordmarks, badges, and watermarks.</p>-- <div class="aliases">Also: fancy. · zalgo. · case. · symbols. · title. · badge. & 30 more</div>-- </a>-- <a class="tool-card" href="https://docs.openvibe.tools" style="--card-accent:#10b981">-- <div class="icon"><i class="fa-solid fa-file-pdf"></i></div>-- <h3>Docs.OpenVibe</h3>-- <div class="domain">docs.openvibe.tools</div>-- <p>PDF & document tools — merge, split, compress, rotate, watermark, protect, unlock, images to PDF and back.</p>-- <div class="aliases">Also: pdf.openvibe.tools</div>-- </a>-- </div>-- </section>---- <!-- ─── Rest of the network ─── -->-- <section class="section" id="network">-- <div class="section-title">-- <h2>The rest of the OpenVibe network</h2>-- <p>One account. All of OpenVibe.</p>-- </div>-- <div class="net-grid">-- <a class="net-card" href="https://openvibe.live" style="--card-accent:#e74c3c">-- <div class="icon"><i class="fa-solid fa-tower-broadcast"></i></div>-- <div><h3>OpenVibe.Live</h3><p>Open live streaming — browser or OBS, clips, VODs, chat.</p></div>-- </a>-- <a class="net-card" href="https://openvibe.games" style="--card-accent:#8b5cf6">-- <div class="icon"><i class="fa-solid fa-gamepad"></i></div>-- <div><h3>OpenVibe.Games</h3><p>Scraplandia — browser RPG, crafting, collaborative canvas.</p></div>-- </a>-- <a class="net-card" href="https://openvibe.network" style="--card-accent:#22d3ee">-- <div class="icon"><i class="fa-solid fa-circle-nodes"></i></div>-- <div><h3>OpenVibe.Network</h3><p>Your account, themes, notifications — the home of your identity.</p></div>-- </a>-- </div>-- </section>-- </div>---- <!-- ─── All Tools Directory modal ─── -->-- <div class="dir-overlay" id="dir-overlay" onclick="if(event.target===this)closeDirectory()">-- <div class="dir-panel">-- <div class="dir-head">-- <h2><i class="fa-solid fa-grip" style="color:var(--accent-light);margin-right:8px"></i>All tools</h2>-- <span class="dir-count" id="dir-count"></span>-- <button class="dir-close" onclick="closeDirectory()" aria-label="Close"><i class="fa-solid fa-xmark"></i></button>-- </div>-- <div class="dir-search-row">-- <input type="text" id="dir-search" placeholder="Search 200+ tools and subdomains…" autocomplete="off">-- </div>-- <div class="dir-filters" id="dir-filters"></div>-- <div class="dir-body" id="dir-body"></div>-- </div>-- </div>---- <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>-- 'use strict';---- /* ─── All Tools Directory data (subdomain aliases per suite) ─── */-- const DIR_DATA = [-- { cat: 'Hubs', icon: 'fa-circle-nodes', color: '#8b5cf6', tools: [-- { name: 'OpenVibe.Tools', domain: 'openvibe.tools', icon: 'fa-circle-nodes' },-- { name: 'Net.OpenVibe Hub', domain: 'net.openvibe.tools', icon: 'fa-network-wired' },-- { name: 'Dev.OpenVibe Hub', domain: 'dev.openvibe.tools', icon: 'fa-code' },-- { name: 'Paste.OpenVibe', domain: 'pastes.openvibe.tools', icon: 'fa-clipboard' },-- { name: 'Maps.OpenVibe', domain: 'maps.openvibe.tools', icon: 'fa-map-location-dot' },-- { name: 'Food.OpenVibe', domain: 'food.openvibe.tools', icon: 'fa-utensils' },-- { name: 'Img.OpenVibe Hub', domain: 'img.openvibe.tools', icon: 'fa-images' },-- { name: 'YT.OpenVibe', domain: 'yt.openvibe.tools', icon: 'fa-circle-play' },-- { name: 'Audio.OpenVibe Hub', domain: 'audio.openvibe.tools', icon: 'fa-headphones' },-- { name: 'Text.OpenVibe Hub', domain: 'text.openvibe.tools', icon: 'fa-pen-fancy' },-- { name: 'Logo.OpenVibe Hub', domain: 'logo.openvibe.tools', icon: 'fa-wand-magic-sparkles' },-- { name: 'Docs.OpenVibe Hub', domain: 'docs.openvibe.tools', icon: 'fa-file-pdf' },-- ]},-- { cat: 'Image', icon: 'fa-images', color: '#3b82f6', tools: [-- { name: 'OpenVibePNG', domain: 'png.openvibe.tools', icon: 'fa-file-image' },-- { name: 'OpenVibeJPG', domain: 'jpg.openvibe.tools', icon: 'fa-file-image' },-- { name: 'OpenVibeWebP', domain: 'webp.openvibe.tools', icon: 'fa-file-image' },-- { name: 'OpenVibeAVIF', domain: 'avif.openvibe.tools', icon: 'fa-file-image' },-- { name: 'OpenVibeHEIC', domain: 'heic.openvibe.tools', icon: 'fa-file-image' },-- { name: 'OpenVibeSVG', domain: 'svg.openvibe.tools', icon: 'fa-bezier-curve' },-- { name: 'OpenVibeGIF', domain: 'gif.openvibe.tools', icon: 'fa-film' },-- { name: 'OpenVibeICO', domain: 'ico.openvibe.tools', icon: 'fa-icons' },-- { name: 'OpenVibeTIFF', domain: 'tiff.openvibe.tools', icon: 'fa-file-image' },-- { name: 'OpenVibeBMP', domain: 'bmp.openvibe.tools', icon: 'fa-file-image' },-- { name: 'OpenVibeConvert', domain: 'convert.openvibe.tools', icon: 'fa-arrows-rotate' },-- { name: 'OpenVibeCompress', domain: 'compress.openvibe.tools', icon: 'fa-compress' },-- { name: 'OpenVibeResize', domain: 'resize.openvibe.tools', icon: 'fa-up-right-and-down-left-from-center' },-- { name: 'OpenVibeCrop', domain: 'crop.openvibe.tools', icon: 'fa-crop-simple' },-- { name: 'OpenVibeFavicon', domain: 'favicon.openvibe.tools', icon: 'fa-icons' },-- ]},-- { cat: 'Audio — Formats', icon: 'fa-headphones', color: '#06b6d4', tools: [-- { name: 'OpenVibeMP3', domain: 'mp3.openvibe.tools', icon: 'fa-file-audio' },-- { name: 'OpenVibeWAV', domain: 'wav.openvibe.tools', icon: 'fa-file-audio' },-- { name: 'OpenVibeFLAC', domain: 'flac.openvibe.tools', icon: 'fa-file-audio' },-- { name: 'OpenVibeOGG', domain: 'ogg.openvibe.tools', icon: 'fa-file-audio' },-- { name: 'OpenVibeM4A', domain: 'm4a.openvibe.tools', icon: 'fa-file-audio' },-- { name: 'OpenVibeOpus', domain: 'opus.openvibe.tools', icon: 'fa-file-audio' },-- { name: 'OpenVibeAAC', domain: 'aac.openvibe.tools', icon: 'fa-file-audio' },-- { name: 'OpenVibeWMA', domain: 'wma.openvibe.tools', icon: 'fa-file-audio' },-- { name: 'OpenVibeAIFF', domain: 'aiff.openvibe.tools', icon: 'fa-file-audio' },-- { name: 'OpenVibeAC3', domain: 'ac3.openvibe.tools', icon: 'fa-file-audio' },-- ]},-- { cat: 'Audio — Effects', icon: 'fa-sliders', color: '#06b6d4', tools: [-- { name: 'OpenVibeTrim', domain: 'trim.openvibe.tools', icon: 'fa-scissors' },-- { name: 'OpenVibeMerge', domain: 'merge.openvibe.tools', icon: 'fa-code-merge' },-- { name: 'OpenVibePitch', domain: 'pitch.openvibe.tools', icon: 'fa-wave-square' },-- { name: 'OpenVibeSpeed', domain: 'speed.openvibe.tools', icon: 'fa-gauge-high' },-- { name: 'OpenVibeReverse Text', domain: 'reversetext.openvibe.tools', icon: 'fa-backward' },-- { name: 'OpenVibeNormalize', domain: 'normalize.openvibe.tools', icon: 'fa-chart-line' },-- { name: 'OpenVibeFade', domain: 'fade.openvibe.tools', icon: 'fa-volume-low' },-- { name: 'OpenVibeLoop', domain: 'loop.openvibe.tools', icon: 'fa-repeat' },-- { name: 'OpenVibeBass', domain: 'bass.openvibe.tools', icon: 'fa-volume-high' },-- { name: 'OpenVibeEQ', domain: 'equalizer.openvibe.tools', icon: 'fa-sliders' },-- { name: 'OpenVibeEcho', domain: 'echo.openvibe.tools', icon: 'fa-circle-radiation' },-- { name: 'OpenVibeReverb', domain: 'reverb.openvibe.tools', icon: 'fa-church' },-- { name: 'OpenVibeChorus', domain: 'chorus.openvibe.tools', icon: 'fa-users' },-- { name: 'OpenVibeDistortion', domain: 'distortion.openvibe.tools', icon: 'fa-bolt' },-- { name: 'OpenVibeCompressor', domain: 'compressor.openvibe.tools', icon: 'fa-down-left-and-up-right-to-center' },-- { name: 'OpenVibeStereo', domain: 'stereo.openvibe.tools', icon: 'fa-headphones-simple' },-- { name: 'OpenVibeSilence', domain: 'silence.openvibe.tools', icon: 'fa-volume-xmark' },-- { name: 'OpenVibeBitcrusher', domain: 'bitcrusher.openvibe.tools', icon: 'fa-microchip' },-- { name: 'OpenVibeNoise', domain: 'noise.openvibe.tools', icon: 'fa-broom' },-- { name: 'OpenVibeVoiceFX', domain: 'voice.openvibe.tools', icon: 'fa-microphone' },-- ]},-- { cat: 'Audio — Specialized', icon: 'fa-wand-magic-sparkles', color: '#06b6d4', tools: [-- { name: 'OpenVibeVocal', domain: 'vocal.openvibe.tools', icon: 'fa-microphone-lines' },-- { name: 'OpenVibeKaraoke', domain: 'karaoke.openvibe.tools', icon: 'fa-music' },-- { name: 'OpenVibeExtract', domain: 'extract.openvibe.tools', icon: 'fa-file-export' },-- { name: 'OpenVibeWaveform', domain: 'waveform.openvibe.tools', icon: 'fa-wave-square' },-- { name: 'OpenVibeRingtone', domain: 'ringtone.openvibe.tools', icon: 'fa-bell' },-- { name: 'OpenVibePodcast', domain: 'podcast.openvibe.tools', icon: 'fa-podcast' },-- { name: 'OpenVibeMeta', domain: 'metadata.openvibe.tools', icon: 'fa-tags' },-- ]},-- { cat: 'Text — Generators', icon: 'fa-pen-fancy', color: '#f472b6', tools: [-- { name: 'OpenVibeFancy', domain: 'fancy.openvibe.tools', icon: 'fa-wand-sparkles' },-- { name: 'OpenVibeZalgo', domain: 'zalgo.openvibe.tools', icon: 'fa-skull' },-- { name: 'OpenVibeSmallCaps', domain: 'smallcaps.openvibe.tools', icon: 'fa-font' },-- { name: 'OpenVibeCursive', domain: 'cursive.openvibe.tools', icon: 'fa-italic' },-- { name: 'OpenVibeGothic', domain: 'gothic.openvibe.tools', icon: 'fa-book-skull' },-- { name: 'OpenVibeWide', domain: 'wide.openvibe.tools', icon: 'fa-text-width' },-- { name: 'OpenVibeMono', domain: 'monospaced.openvibe.tools', icon: 'fa-code' },-- { name: 'OpenVibeBubble', domain: 'bubble.openvibe.tools', icon: 'fa-circle' },-- ]},-- { cat: 'Text — Encoding', icon: 'fa-lock', color: '#f472b6', tools: [-- { name: 'OpenVibeBraille', domain: 'braille.openvibe.tools', icon: 'fa-braille' },-- { name: 'OpenVibeMorse', domain: 'morse.openvibe.tools', icon: 'fa-tower-cell' },-- { name: 'OpenVibeBinary', domain: 'binary.openvibe.tools', icon: 'fa-hashtag' },-- ]},-- { cat: 'Text — Utilities', icon: 'fa-screwdriver-wrench', color: '#f472b6', tools: [-- { name: 'OpenVibeCase', domain: 'case.openvibe.tools', icon: 'fa-font' },-- { name: 'OpenVibeClean', domain: 'clean.openvibe.tools', icon: 'fa-broom' },-- { name: 'OpenVibeCount', domain: 'count.openvibe.tools', icon: 'fa-calculator' },-- { name: 'OpenVibeSort', domain: 'sort.openvibe.tools', icon: 'fa-arrow-down-a-z' },-- { name: 'OpenVibeDedupe', domain: 'dedupe.openvibe.tools', icon: 'fa-filter' },-- { name: 'OpenVibeSlug', domain: 'slug.openvibe.tools', icon: 'fa-link' },-- { name: 'OpenVibeCompare', domain: 'compare.openvibe.tools', icon: 'fa-code-compare' },-- { name: 'OpenVibeMarkdown', domain: 'markdown.openvibe.tools', icon: 'fa-file-lines' },-- { name: 'OpenVibeJSON', domain: 'json.openvibe.tools', icon: 'fa-code' },-- { name: 'OpenVibeEscape', domain: 'escape.openvibe.tools', icon: 'fa-shield' },-- ]},-- { cat: 'Text — Symbols & Identity', icon: 'fa-star', color: '#f472b6', tools: [-- { name: 'OpenVibeSymbols', domain: 'symbols.openvibe.tools', icon: 'fa-icons' },-- { name: 'OpenVibeUnicode', domain: 'unicode.openvibe.tools', icon: 'fa-globe' },-- { name: 'OpenVibeKaomoji', domain: 'kaomoji.openvibe.tools', icon: 'fa-face-smile' },-- { name: 'OpenVibeEmojis', domain: 'emojis.openvibe.tools', icon: 'fa-face-grin' },-- { name: 'OpenVibeBio', domain: 'bio.openvibe.tools', icon: 'fa-address-card' },-- { name: 'OpenVibeNickname', domain: 'nickname.openvibe.tools', icon: 'fa-id-badge' },-- { name: 'OpenVibeASCII Art', domain: 'ascii.openvibe.tools', icon: 'fa-terminal' },-- { name: 'OpenVibeBanner', domain: 'banner.openvibe.tools', icon: 'fa-heading' },-- ]},-- { cat: 'Logo & Design', icon: 'fa-wand-magic-sparkles', color: '#f472b6', tools: [-- { name: 'OpenVibeTitle', domain: 'title.openvibe.tools', icon: 'fa-heading' },-- { name: 'OpenVibeWordmark', domain: 'wordmark.openvibe.tools', icon: 'fa-signature' },-- { name: 'OpenVibeTransparent', domain: 'transparent.openvibe.tools', icon: 'fa-chess-board' },-- { name: 'OpenVibeBadge', domain: 'badge.openvibe.tools', icon: 'fa-certificate' },-- { name: 'OpenVibeThumbnail', domain: 'thumbnail.openvibe.tools', icon: 'fa-photo-film' },-- { name: 'OpenVibeWatermark', domain: 'watermark.openvibe.tools', icon: 'fa-droplet' },-- { name: 'OpenVibeNeon', domain: 'neon.openvibe.tools', icon: 'fa-lightbulb' },-- { name: 'OpenVibeOverlay', domain: 'overlay.openvibe.tools', icon: 'fa-layer-group' },-- { name: 'OpenVibeLowerThird', domain: 'lowerthird.openvibe.tools', icon: 'fa-closed-captioning' },-- ]},-- { cat: 'PDF & Documents', icon: 'fa-file-pdf', color: '#10b981', tools: [-- { name: 'OpenVibePDF', domain: 'pdf.openvibe.tools', icon: 'fa-file-pdf' },-- { name: 'Merge PDFs', domain: 'docs.openvibe.tools/merge', icon: 'fa-object-group' },-- { name: 'Split PDF', domain: 'docs.openvibe.tools/split', icon: 'fa-scissors' },-- { name: 'Compress PDF', domain: 'docs.openvibe.tools/compress', icon: 'fa-compress' },-- { name: 'Rotate PDF', domain: 'docs.openvibe.tools/rotate', icon: 'fa-rotate' },-- { name: 'Reorder Pages', domain: 'docs.openvibe.tools/reorder', icon: 'fa-arrow-down-1-9' },-- { name: 'Watermark PDF', domain: 'docs.openvibe.tools/watermark', icon: 'fa-droplet' },-- { name: 'Protect PDF', domain: 'docs.openvibe.tools/protect', icon: 'fa-lock' },-- { name: 'Unlock PDF', domain: 'docs.openvibe.tools/unlock', icon: 'fa-unlock' },-- { name: 'Images to PDF', domain: 'docs.openvibe.tools/img2pdf', icon: 'fa-images' },-- { name: 'PDF to Images', domain: 'docs.openvibe.tools/pdf2img', icon: 'fa-photo-film' },-- { name: 'PDF Metadata', domain: 'docs.openvibe.tools/metadata', icon: 'fa-tags' },-- ]},-- { cat: 'Mexican / Latino', icon: 'fa-pepper-hot', color: '#ef4444', tools: [-- { name: 'ICE Alerts', domain: 'ice.openvibe.tools', icon: 'fa-shield-halved' },-- { name: 'OpenVibePeso', domain: 'peso.openvibe.tools', icon: 'fa-coins' },-- { name: 'OpenVibeSpanish', domain: 'spanish.openvibe.tools', icon: 'fa-language' },-- { name: 'OpenVibeSlang', domain: 'slang.openvibe.tools', icon: 'fa-pepper-hot' },-- ]},-- { cat: 'Network — Diagnostics', icon: 'fa-network-wired', color: '#22d3ee', tools: [-- { name: 'OpenVibeLookup', domain: 'lookup.openvibe.tools', icon: 'fa-magnifying-glass' },-- { name: 'OpenVibeMyIP', domain: 'myip.openvibe.tools', icon: 'fa-location-crosshairs' },-- { name: 'OpenVibeGeoIP', domain: 'geoip.openvibe.tools', icon: 'fa-earth-americas' },-- { name: 'OpenVibeIP', domain: 'ip.openvibe.tools', icon: 'fa-at' },-- { name: 'OpenVibeIPv4', domain: 'ipv4.openvibe.tools', icon: 'fa-hashtag' },-- { name: 'OpenVibeIPv6', domain: 'ipv6.openvibe.tools', icon: 'fa-code' },-- { name: 'OpenVibeHostname', domain: 'hostname.openvibe.tools', icon: 'fa-server' },-- { name: 'OpenVibeISP', domain: 'isp.openvibe.tools', icon: 'fa-building' },-- { name: 'OpenVibeASN', domain: 'asn.openvibe.tools', icon: 'fa-diagram-project' },-- { name: 'OpenVibeReverseDNS', domain: 'rdns.openvibe.tools', icon: 'fa-rotate-left' },-- { name: 'OpenVibeWhois', domain: 'whois.openvibe.tools', icon: 'fa-address-book' },-- { name: 'OpenVibeRDAP', domain: 'rdap.openvibe.tools', icon: 'fa-id-card' },-- ]},-- { cat: 'Network — DNS', icon: 'fa-sitemap', color: '#22d3ee', tools: [-- { name: 'OpenVibeDNS', domain: 'dns.openvibe.tools', icon: 'fa-sitemap' },-- { name: 'OpenVibeDig', domain: 'dig.openvibe.tools', icon: 'fa-terminal' },-- { name: 'OpenVibeNSLookup', domain: 'nslookup.openvibe.tools', icon: 'fa-magnifying-glass-arrow-right' },-- { name: 'OpenVibeDNSPropagation', domain: 'dnspropagation.openvibe.tools', icon: 'fa-globe' },-- { name: 'OpenVibeMX', domain: 'mx.openvibe.tools', icon: 'fa-envelope' },-- { name: 'OpenVibeTXT', domain: 'txt.openvibe.tools', icon: 'fa-file-lines' },-- { name: 'OpenVibeNS', domain: 'ns.openvibe.tools', icon: 'fa-server' },-- { name: 'OpenVibeSPF', domain: 'spf.openvibe.tools', icon: 'fa-shield-halved' },-- { name: 'OpenVibeDKIM', domain: 'dkim.openvibe.tools', icon: 'fa-key' },-- { name: 'OpenVibeDMARC', domain: 'dmarc.openvibe.tools', icon: 'fa-user-shield' },-- ]},-- { cat: 'Network — Active', icon: 'fa-satellite-dish', color: '#22d3ee', tools: [-- { name: 'OpenVibePing', domain: 'ping.openvibe.tools', icon: 'fa-satellite-dish' },-- { name: 'OpenVibeTraceroute', domain: 'traceroute.openvibe.tools', icon: 'fa-route' },-- { name: 'OpenVibeMTR', domain: 'mtr.openvibe.tools', icon: 'fa-chart-line' },-- { name: 'OpenVibePortCheck', domain: 'port.openvibe.tools', icon: 'fa-door-open' },-- { name: 'OpenVibeHeaders', domain: 'headers.openvibe.tools', icon: 'fa-list' },-- { name: 'OpenVibeRedirects', domain: 'redirects.openvibe.tools', icon: 'fa-share' },-- { name: 'OpenVibeSSL', domain: 'ssl.openvibe.tools', icon: 'fa-lock' },-- { name: 'OpenVibeCurl', domain: 'curl.openvibe.tools', icon: 'fa-download' },-- { name: 'OpenVibeHTTPStatus', domain: 'httpstatus.openvibe.tools', icon: 'fa-circle-check' },-- { name: 'OpenVibeLatency', domain: 'latency.openvibe.tools', icon: 'fa-gauge-high' },-- { name: 'OpenVibeBlacklist', domain: 'blacklist.openvibe.tools', icon: 'fa-ban' },-- { name: 'OpenVibeReputation', domain: 'reputation.openvibe.tools', icon: 'fa-star' },-- { name: 'OpenVibeSMTP', domain: 'smtp.openvibe.tools', icon: 'fa-paper-plane' },-- { name: 'OpenVibeUptime', domain: 'uptime.openvibe.tools', icon: 'fa-heart-pulse' },-- { name: 'OpenVibeRobots', domain: 'robots.openvibe.tools', icon: 'fa-robot' },-- { name: 'OpenVibeSitemap', domain: 'sitemap.openvibe.tools', icon: 'fa-sitemap' },-- ]},-- { cat: 'Developer — Data & Formats', icon: 'fa-database', color: '#a78bfa', tools: [-- { name: 'OpenVibeJSON', domain: 'jsonfmt.openvibe.tools', icon: 'fa-code' },-- { name: 'OpenVibeYAML', domain: 'yaml.openvibe.tools', icon: 'fa-file-code' },-- { name: 'OpenVibeXML', domain: 'xml.openvibe.tools', icon: 'fa-file-code' },-- { name: 'OpenVibeCSV', domain: 'csv.openvibe.tools', icon: 'fa-table' },-- { name: 'OpenVibeSQL', domain: 'sql.openvibe.tools', icon: 'fa-database' },-- { name: 'OpenVibeHTML', domain: 'html.openvibe.tools', icon: 'fa-file-code' },-- { name: 'OpenVibeMarkdown', domain: 'md.openvibe.tools', icon: 'fa-file-lines' },-- ]},-- { cat: 'Developer — Encoding & Crypto', icon: 'fa-lock', color: '#a78bfa', tools: [-- { name: 'OpenVibeBase64', domain: 'base64.openvibe.tools', icon: 'fa-lock' },-- { name: 'OpenVibeURL Encoder', domain: 'url.openvibe.tools', icon: 'fa-link' },-- { name: 'OpenVibeJWT', domain: 'jwt.openvibe.tools', icon: 'fa-key' },-- { name: 'OpenVibeUUID', domain: 'uuid.openvibe.tools', icon: 'fa-fingerprint' },-- { name: 'OpenVibeHash', domain: 'hash.openvibe.tools', icon: 'fa-hashtag' },-- { name: 'OpenVibeHex', domain: 'hex.openvibe.tools', icon: 'fa-barcode' },-- { name: 'OpenVibeEscape', domain: 'entities.openvibe.tools', icon: 'fa-shield-halved' },-- ]},-- { cat: 'Developer — Quality & Time', icon: 'fa-wand-magic-sparkles', color: '#a78bfa', tools: [-- { name: 'OpenVibeBeautify', domain: 'beautify.openvibe.tools', icon: 'fa-wand-magic-sparkles' },-- { name: 'OpenVibeMinify', domain: 'minify.openvibe.tools', icon: 'fa-compress' },-- { name: 'OpenVibeDiff', domain: 'codediff.openvibe.tools', icon: 'fa-code-compare' },-- { name: 'OpenVibeRegex', domain: 'regex.openvibe.tools', icon: 'fa-magnifying-glass' },-- { name: 'OpenVibeSlug', domain: 'slugify.openvibe.tools', icon: 'fa-link' },-- { name: 'OpenVibeLorem', domain: 'lorem.openvibe.tools', icon: 'fa-paragraph' },-- { name: 'OpenVibeTimestamp', domain: 'timestamp.openvibe.tools', icon: 'fa-clock' },-- { name: 'OpenVibeCron', domain: 'cron.openvibe.tools', icon: 'fa-calendar-check' },-- ]},-- { cat: 'Developer — HTTP & SEO', icon: 'fa-terminal', color: '#a78bfa', tools: [-- { name: 'OpenVibeCurl Converter', domain: 'curlconvert.openvibe.tools', icon: 'fa-terminal' },-- { name: 'OpenVibeWebhook', domain: 'webhook.openvibe.tools', icon: 'fa-satellite-dish' },-- { name: 'OpenVibeColor', domain: 'color.openvibe.tools', icon: 'fa-palette' },-- { name: 'OpenVibeOpenGraph', domain: 'opengraph.openvibe.tools', icon: 'fa-share-nodes' },-- ]},-- { cat: 'Network Services', icon: 'fa-circle-nodes', color: '#8b5cf6', tools: [-- { name: 'OpenVibe.Live', domain: 'openvibe.live', icon: 'fa-tower-broadcast' },-- { name: 'OpenVibe.Games', domain: 'openvibe.games', icon: 'fa-gamepad' },-- { name: 'OpenVibe.Network', domain: 'openvibe.network', icon: 'fa-circle-nodes' },-- ]},-- ];---- /* ─── Directory modal ─── */-- let dirFilter = 'all';-- function openDirectory() {-- const overlay = document.getElementById('dir-overlay');-- overlay.classList.add('open');-- document.body.style.overflow = 'hidden';-- renderDirectory();-- document.getElementById('dir-search').value = '';-- document.getElementById('dir-search').focus();-- }-- function closeDirectory() {-- document.getElementById('dir-overlay').classList.remove('open');-- document.body.style.overflow = '';-- }-- document.addEventListener('keydown', e => {-- if (e.key === 'Escape' && document.getElementById('dir-overlay').classList.contains('open')) closeDirectory();-- if ((e.ctrlKey || e.metaKey) && e.key === 'k') { e.preventDefault(); openDirectory(); }-- });---- function escHtml(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }-- function highlightMatch(text, q) {-- if (!q) return escHtml(text);-- const idx = text.toLowerCase().indexOf(q);-- if (idx === -1) return escHtml(text);-- return escHtml(text.slice(0, idx)) + '<mark style="background:rgba(139,92,246,0.3);color:inherit;border-radius:2px;padding:0 1px">' + escHtml(text.slice(idx, idx + q.length)) + '</mark>' + escHtml(text.slice(idx + q.length));-- }---- function renderDirectory(query) {-- const q = (query || '').toLowerCase().trim();-- const filtersEl = document.getElementById('dir-filters');-- const bodyEl = document.getElementById('dir-body');---- const catNames = ['all', ...new Set(DIR_DATA.map(c => c.cat.split(' — ')[0]))];-- filtersEl.innerHTML = catNames.map(c =>-- `<button class="dir-filter-btn${(dirFilter === c) ? ' active' : ''}" onclick="setDirFilter('${c.replace(/'/g, "\\'")}')">${c}</button>`-- ).join('');---- let totalShown = 0;-- let html = '';-- for (const cat of DIR_DATA) {-- const baseCat = cat.cat.split(' — ')[0];-- if (dirFilter !== 'all' && baseCat !== dirFilter) continue;-- const filtered = cat.tools.filter(t =>-- !q || t.name.toLowerCase().includes(q) || t.domain.toLowerCase().includes(q) || cat.cat.toLowerCase().includes(q)-- );-- if (!filtered.length) continue;-- totalShown += filtered.length;-- html += `<div class="dir-category">-- <div class="dir-cat-head">-- <i class="fa-solid ${cat.icon}"></i>-- <h3>${cat.cat}</h3>-- <span class="dir-cat-count">${filtered.length}</span>-- </div>-- <div class="dir-tools">-- ${filtered.map(t => `<a class="dir-tool" href="https://${t.domain}" target="_blank" rel="noopener" style="--tool-color:${cat.color}">-- <div class="dir-tool-icon"><i class="fa-solid ${t.icon}"></i></div>-- <div>-- <div class="dir-tool-name">${highlightMatch(t.name, q)}</div>-- <div class="dir-tool-domain">${highlightMatch(t.domain, q)}</div>-- </div>-- </a>`).join('')}-- </div>-- </div>`;-- }-- if (!totalShown) {-- html = `<div class="dir-empty"><i class="fa-solid fa-magnifying-glass"></i>No tools match "${escHtml(q)}"</div>`;-- }-- bodyEl.innerHTML = html;-- document.getElementById('dir-count').textContent = totalShown + ' tools';-- }-- function setDirFilter(f) { dirFilter = f; renderDirectory(document.getElementById('dir-search').value); }-- document.getElementById('dir-search').addEventListener('input', e => renderDirectory(e.target.value));---- // Live tool count in the hero-- document.getElementById('stat-tools').textContent =-- DIR_DATA.reduce((sum, c) => sum + c.tools.length, 0) + '+';---- /* ─── Shared chrome (navbar + footer from openvibe.network) ─── */-- function getCookie(n) {-- const m = document.cookie.match(new RegExp('(?:^|; )' + n + '=([^;]*)'));-- return m ? decodeURIComponent(m[1]) : null;-- }---- // The navbar owns the account UI (sign-in, avatar, menu) and boots from the shared-- // ov_token cookie straight away — no /auth/me round trip before it can render.-- function initChrome() {-- if (window.OpenVibeNavbar) {-- try {-- OpenVibeNavbar.init({-- service: 'tools',-- apiBase: 'https://openvibe.network',-- token: getCookie('ov_token') || localStorage.getItem('ov_token'),-- history: { type: 'tool', title: 'OpenVibe.Tools' },-- silentLogin: 'https://openvibe.tools/auth/login?silent=1&next={url}',-- });-- } catch { /* non-critical */ }-- }-- if (window.OpenVibeFooter) {-- try {-- OpenVibeFooter.init({-- service: 'tools', variant: 'full', mount: '#ov-footer', apiBase: 'https://openvibe.network',-- links: [-- { heading: 'Tool suites', items: [-- { name: 'Net.OpenVibe', url: 'https://net.openvibe.tools' },-- { name: 'Dev.OpenVibe', url: 'https://dev.openvibe.tools' },-- { name: 'Paste.OpenVibe', url: 'https://pastes.openvibe.tools' },-- { name: 'Img.OpenVibe', url: 'https://img.openvibe.tools' },-- { name: 'Audio.OpenVibe', url: 'https://audio.openvibe.tools' },-- { name: 'Text.OpenVibe', url: 'https://text.openvibe.tools' },-- ] },-- { heading: 'More suites', items: [-- { name: 'Logo.OpenVibe', url: 'https://logo.openvibe.tools' },-- { name: 'Docs.OpenVibe', url: 'https://docs.openvibe.tools' },-- { name: 'YT.OpenVibe', url: 'https://yt.openvibe.tools' },-- { name: 'Maps.OpenVibe', url: 'https://maps.openvibe.tools' },-- { name: 'Food.OpenVibe', url: 'https://food.openvibe.tools' },-- { name: 'All tools', url: '#', onclick: 'openDirectory(); return false;' },-- ] },-- ],-- });-- } catch { /* non-critical */ }-- }-- }-- window.addEventListener('DOMContentLoaded', initChrome);-- </script>--</body>--</html>nnew file mode 100644@@@ -0,0 +1,60 @@++/* openvibe.tools — progressive enhancement for the server-rendered pages.++ * Without this file every page still works: search is a plain GET form. With it: shared navbar and++ * footer, instant search from the cached catalog, and the query kept in the URL (?q=) so results++ * can be shared, reloaded and reached with Back. */++(function () {++ 'use strict';++ var cookie = function (n) { var m = document.cookie.match(new RegExp('(?:^|; )' + n + '=([^;]*)')); return m ? decodeURIComponent(m[1]) : null; };++ var token = null; try { token = cookie('ov_token') || localStorage.getItem('ov_token'); } catch (e) { token = cookie('ov_token'); }++ try { if (window.OpenVibeNavbar) OpenVibeNavbar.init({ service: 'tools', apiBase: 'https://openvibe.network', token: token, history: { type: 'tool', title: document.title }, silentLogin: 'https://openvibe.tools/auth/login?silent=1&next={url}' }); } catch (e) { /* non-critical */ }++ try { if (window.OpenVibeFooter) OpenVibeFooter.init({ service: 'tools', variant: 'full', mount: '#ov-footer', apiBase: 'https://openvibe.network' }); } catch (e) { /* */ }++++ var input = document.getElementById('q'), results = document.getElementById('results');++ if (!input || !results) return;++ var grid = document.getElementById('results-grid'), empty = document.getElementById('results-empty');++ var catalog = null, loading = null, others = [].slice.call(document.querySelectorAll('main > section:not(#results)'));++++ function load() {++ if (catalog) return Promise.resolve(catalog);++ if (loading) return loading;++ loading = fetch('/api/catalog.json').then(function (r) { return r.json(); }).then(function (j) {++ catalog = j.tools.map(function (t) { return { t: t, name: t.name.toLowerCase(), kw: (t.keywords || []).join(' ').toLowerCase(), hay: [t.tagline, t.description, t.id].join(' ').toLowerCase() }; });++ return catalog;++ }).catch(function () { loading = null; return []; });++ return loading;++ }++ function find(q) {++ var terms = q.toLowerCase().split(/[^a-z0-9.+#]+/).filter(Boolean).slice(0, 8);++ return catalog.map(function (c) {++ var s = 0;++ for (var i = 0; i < terms.length; i++) { var w = terms[i]; var hit = 0; if (c.name.indexOf(w) >= 0) hit += 5; if (c.t.id === w) hit += 4; if (c.kw.indexOf(w) >= 0) hit += 3; else if (c.hay.indexOf(w) >= 0) hit += 1; if (!hit) return null; s += hit; }++ return { s: s, t: c.t };++ }).filter(Boolean).sort(function (a, b) { return b.s - a.s || a.t.name.localeCompare(b.t.name); }).slice(0, 24).map(function (x) { return x.t; });++ }++ function card(t) {++ var a = document.createElement('a'); a.className = 'tool'; a.href = t.url;++ var ic = document.createElement('span'); ic.className = 'ov-icon'; ic.dataset.icon = t.icon; ic.dataset.fx = 'none'; ic.style.setProperty('--ovi-size', '36px');++ var box = document.createElement('span'); box.className = 'tool-t';++ var b = document.createElement('b'); b.textContent = t.name; var s = document.createElement('small'); s.textContent = t.tagline; var i = document.createElement('i'); i.textContent = t.hosts.short || t.hosts.canonical;++ box.appendChild(b); box.appendChild(s); box.appendChild(i); a.appendChild(ic); a.appendChild(box); return a;++ }++ function show(q, push) {++ q = q.trim();++ var url = new URL(location.href); if (q) url.searchParams.set('q', q); else url.searchParams.delete('q');++ if (url.href !== location.href) history[push ? 'pushState' : 'replaceState']({ q: q }, '', url);++ if (!q) { results.hidden = true; others.forEach(function (s) { s.hidden = false; }); return; }++ load().then(function () {++ if (input.value.trim() !== q) return;++ var hits = find(q); grid.replaceChildren.apply(grid, hits.map(card)); empty.hidden = hits.length > 0;++ results.hidden = false; others.forEach(function (s) { s.hidden = true; });++ if (window.OpenVibeIcons) OpenVibeIcons.mount(grid); else if (!document.getElementById('ov-icons-loader')) { var sc = document.createElement('script'); sc.id = 'ov-icons-loader'; sc.src = 'https://openvibe.network/shared/ov-icons.js'; document.head.appendChild(sc); }++ });++ }++ var timer = null;++ input.addEventListener('focus', load, { once: true });++ input.addEventListener('input', function () { clearTimeout(timer); timer = setTimeout(function () { show(input.value, false); }, 90); });++ input.form.addEventListener('submit', function (e) { e.preventDefault(); show(input.value, true); var first = grid.querySelector('a'); if (first && input.value.trim()) first.focus(); });++ window.addEventListener('popstate', function () { var q = new URL(location.href).searchParams.get('q') || ''; input.value = q; show(q, false); });++ document.addEventListener('keydown', function (e) { if (e.key === '/' && document.activeElement !== input && !/input|textarea|select/i.test(document.activeElement.tagName)) { e.preventDefault(); input.focus(); } });++ var q0 = new URL(location.href).searchParams.get('q'); if (q0) { input.value = q0; show(q0, false); }++})();ddeleted file mode 100644@@@ -1,45 +0,0 @@--# openvibe.tools — General Index Rules--User-agent: *--Allow: /--Allow: /api/brand--Allow: /api/health----# Disallow API and transient paths--Disallow: /api/--Disallow: /auth/--Disallow: /.well-known/--Disallow: /node_modules/--Disallow: /tmp/--Disallow: /temp/----# Aggressive Bot Rate Limiting--User-agent: AhrefsBot--Disallow: /--Crawl-delay: 10----User-agent: SemrushBot--Disallow: /--Crawl-delay: 10----User-agent: DotBot--Disallow: /--Crawl-delay: 10----User-agent: MJ12bot--Disallow: /--Crawl-delay: 30----# Friendly Bots — Normal Rate--User-agent: Googlebot--Crawl-delay: 0----User-agent: Bingbot--Crawl-delay: 1----User-agent: DuckDuckBot--Crawl-delay: 1----# Sitemaps--Sitemap: https://openvibe.tools/sitemap.xml----Host: https://openvibe.toolsddeleted file mode 100644@@@ -1,459 +0,0 @@--<?xml version="1.0" encoding="UTF-8"?>--<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">-- <url>-- <loc>https://openvibe.tools/</loc>-- <lastmod>2026-08-17</lastmod>-- <changefreq>weekly</changefreq>-- <priority>1.0</priority>-- </url>-- <url>-- <loc>https://net.openvibe.tools/</loc>-- <lastmod>2026-08-17</lastmod>-- <changefreq>weekly</changefreq>-- <priority>0.9</priority>-- </url>-- <url>-- <loc>https://dev.openvibe.tools/</loc>-- <lastmod>2026-08-17</lastmod>-- <changefreq>weekly</changefreq>-- <priority>0.9</priority>-- </url>-- <url>-- <loc>https://pastes.openvibe.tools/</loc>-- <lastmod>2026-08-17</lastmod>-- <changefreq>weekly</changefreq>-- <priority>0.9</priority>-- </url>-- <url>-- <loc>https://maps.openvibe.tools/</loc>-- <lastmod>2026-08-17</lastmod>-- <changefreq>weekly</changefreq>-- <priority>0.9</priority>-- </url>-- <url>-- <loc>https://food.openvibe.tools/</loc>-- <lastmod>2026-08-17</lastmod>-- <changefreq>weekly</changefreq>-- <priority>0.9</priority>-- </url>-- <url>-- <loc>https://img.openvibe.tools/</loc>-- <lastmod>2026-08-17</lastmod>-- <changefreq>weekly</changefreq>-- <priority>0.9</priority>-- </url>-- <url>-- <loc>https://yt.openvibe.tools/</loc>-- <lastmod>2026-08-17</lastmod>-- <changefreq>weekly</changefreq>-- <priority>0.9</priority>-- </url>-- <url>-- <loc>https://audio.openvibe.tools/</loc>-- <lastmod>2026-08-17</lastmod>-- <changefreq>weekly</changefreq>-- <priority>0.9</priority>-- </url>-- <url>-- <loc>https://text.openvibe.tools/</loc>-- <lastmod>2026-08-17</lastmod>-- <changefreq>weekly</changefreq>-- <priority>0.9</priority>-- </url>-- <url>-- <loc>https://logo.openvibe.tools/</loc>-- <lastmod>2026-08-17</lastmod>-- <changefreq>weekly</changefreq>-- <priority>0.9</priority>-- </url>-- <url>-- <loc>https://docs.openvibe.tools/</loc>-- <lastmod>2026-08-17</lastmod>-- <changefreq>weekly</changefreq>-- <priority>0.9</priority>-- </url>-- <url>-- <loc>https://lookup.openvibe.tools/</loc>-- <lastmod>2026-08-17</lastmod>-- <changefreq>weekly</changefreq>-- <priority>0.7</priority>-- </url>-- <url>-- <loc>https://myip.openvibe.tools/</loc>-- <lastmod>2026-08-17</lastmod>-- <changefreq>weekly</changefreq>-- <priority>0.7</priority>-- </url>-- <url>-- <loc>https://ip.openvibe.tools/</loc>-- <lastmod>2026-08-17</lastmod>-- <changefreq>weekly</changefreq>-- <priority>0.7</priority>-- </url>-- <url>-- <loc>https://ipv4.openvibe.tools/</loc>-- <lastmod>2026-08-17</lastmod>-- <changefreq>weekly</changefreq>-- <priority>0.7</priority>-- </url>-- <url>-- <loc>https://ipv6.openvibe.tools/</loc>-- <lastmod>2026-08-17</lastmod>-- <changefreq>weekly</changefreq>-- <priority>0.7</priority>-- </url>-- <url>-- <loc>https://geoip.openvibe.tools/</loc>-- <lastmod>2026-08-17</lastmod>-- <changefreq>weekly</changefreq>-- <priority>0.7</priority>-- </url>-- <url>-- <loc>https://hostname.openvibe.tools/</loc>-- <lastmod>2026-08-17</lastmod>-- <changefreq>weekly</changefreq>-- <priority>0.7</priority>-- </url>-- <url>-- <loc>https://isp.openvibe.tools/</loc>-- <lastmod>2026-08-17</lastmod>-- <changefreq>weekly</changefreq>-- <priority>0.7</priority>-- </url>-- <url>-- <loc>https://asn.openvibe.tools/</loc>-- <lastmod>2026-08-17</lastmod>-- <changefreq>weekly</changefreq>-- <priority>0.7</priority>-- </url>-- <url>-- <loc>https://rdns.openvibe.tools/</loc>-- <lastmod>2026-08-17</lastmod>-- <changefreq>weekly</changefreq>-- <priority>0.7</priority>-- </url>-- <url>-- <loc>https://whois.openvibe.tools/</loc>-- <lastmod>2026-08-17</lastmod>-- <changefreq>weekly</changefreq>-- <priority>0.7</priority>-- </url>-- <url>-- <loc>https://rdap.openvibe.tools/</loc>-- <lastmod>2026-08-17</lastmod>-- <changefreq>weekly</changefreq>-- <priority>0.7</priority>-- </url>-- <url>-- <loc>https://dns.openvibe.tools/</loc>-- <lastmod>2026-08-17</lastmod>-- <changefreq>weekly</changefreq>-- <priority>0.7</priority>-- </url>-- <url>-- <loc>https://dig.openvibe.tools/</loc>-- <lastmod>2026-08-17</lastmod>-- <changefreq>weekly</changefreq>-- <priority>0.7</priority>-- </url>-- <url>-- <loc>https://nslookup.openvibe.tools/</loc>-- <lastmod>2026-08-17</lastmod>-- <changefreq>weekly</changefreq>-- <priority>0.7</priority>-- </url>-- <url>-- <loc>https://dnspropagation.openvibe.tools/</loc>-- <lastmod>2026-08-17</lastmod>-- <changefreq>weekly</changefreq>-- <priority>0.7</priority>-- </url>-- <url>-- <loc>https://mx.openvibe.tools/</loc>-- <lastmod>2026-08-17</lastmod>-- <changefreq>weekly</changefreq>-- <priority>0.7</priority>-- </url>-- <url>-- <loc>https://txt.openvibe.tools/</loc>-- <lastmod>2026-08-17</lastmod>-- <changefreq>weekly</changefreq>-- <priority>0.7</priority>-- </url>-- <url>-- <loc>https://ns.openvibe.tools/</loc>-- <lastmod>2026-08-17</lastmod>-- <changefreq>weekly</changefreq>-- <priority>0.7</priority>-- </url>-- <url>-- <loc>https://spf.openvibe.tools/</loc>-- <lastmod>2026-08-17</lastmod>-- <changefreq>weekly</changefreq>-- <priority>0.7</priority>-- </url>-- <url>-- <loc>https://dkim.openvibe.tools/</loc>-- <lastmod>2026-08-17</lastmod>-- <changefreq>weekly</changefreq>-- <priority>0.7</priority>-- </url>-- <url>-- <loc>https://dmarc.openvibe.tools/</loc>-- <lastmod>2026-08-17</lastmod>-- <changefreq>weekly</changefreq>-- <priority>0.7</priority>-- </url>-- <url>-- <loc>https://ping.openvibe.tools/</loc>-- <lastmod>2026-08-17</lastmod>-- <changefreq>weekly</changefreq>-- <priority>0.7</priority>-- </url>-- <url>-- <loc>https://traceroute.openvibe.tools/</loc>-- <lastmod>2026-08-17</lastmod>-- <changefreq>weekly</changefreq>-- <priority>0.7</priority>-- </url>-- <url>-- <loc>https://mtr.openvibe.tools/</loc>-- <lastmod>2026-08-17</lastmod>-- <changefreq>weekly</changefreq>-- <priority>0.7</priority>-- </url>-- <url>-- <loc>https://port.openvibe.tools/</loc>-- <lastmod>2026-08-17</lastmod>-- <changefreq>weekly</changefreq>-- <priority>0.7</priority>-- </url>-- <url>-- <loc>https://headers.openvibe.tools/</loc>-- <lastmod>2026-08-17</lastmod>-- <changefreq>weekly</changefreq>-- <priority>0.7</priority>-- </url>-- <url>-- <loc>https://redirects.openvibe.tools/</loc>-- <lastmod>2026-08-17</lastmod>-- <changefreq>weekly</changefreq>-- <priority>0.7</priority>-- </url>-- <url>-- <loc>https://ssl.openvibe.tools/</loc>-- <lastmod>2026-08-17</lastmod>-- <changefreq>weekly</changefreq>-- <priority>0.7</priority>-- </url>-- <url>-- <loc>https://curl.openvibe.tools/</loc>-- <lastmod>2026-08-17</lastmod>-- <changefreq>weekly</changefreq>-- <priority>0.7</priority>-- </url>-- <url>-- <loc>https://httpstatus.openvibe.tools/</loc>-- <lastmod>2026-08-17</lastmod>-- <changefreq>weekly</changefreq>-- <priority>0.7</priority>-- </url>-- <url>-- <loc>https://latency.openvibe.tools/</loc>-- <lastmod>2026-08-17</lastmod>-- <changefreq>weekly</changefreq>-- <priority>0.7</priority>-- </url>-- <url>-- <loc>https://blacklist.openvibe.tools/</loc>-- <lastmod>2026-08-17</lastmod>-- <changefreq>weekly</changefreq>-- <priority>0.7</priority>-- </url>-- <url>-- <loc>https://reputation.openvibe.tools/</loc>-- <lastmod>2026-08-17</lastmod>-- <changefreq>weekly</changefreq>-- <priority>0.7</priority>-- </url>-- <url>-- <loc>https://smtp.openvibe.tools/</loc>-- <lastmod>2026-08-17</lastmod>-- <changefreq>weekly</changefreq>-- <priority>0.7</priority>-- </url>-- <url>-- <loc>https://uptime.openvibe.tools/</loc>-- <lastmod>2026-08-17</lastmod>-- <changefreq>weekly</changefreq>-- <priority>0.7</priority>-- </url>-- <url>-- <loc>https://robots.openvibe.tools/</loc>-- <lastmod>2026-08-17</lastmod>-- <changefreq>weekly</changefreq>-- <priority>0.7</priority>-- </url>-- <url>-- <loc>https://sitemap.openvibe.tools/</loc>-- <lastmod>2026-08-17</lastmod>-- <changefreq>weekly</changefreq>-- <priority>0.7</priority>-- </url>-- <url>-- <loc>https://json.openvibe.tools/</loc>-- <lastmod>2026-08-17</lastmod>-- <changefreq>weekly</changefreq>-- <priority>0.7</priority>-- </url>-- <url>-- <loc>https://yaml.openvibe.tools/</loc>-- <lastmod>2026-08-17</lastmod>-- <changefreq>weekly</changefreq>-- <priority>0.7</priority>-- </url>-- <url>-- <loc>https://xml.openvibe.tools/</loc>-- <lastmod>2026-08-17</lastmod>-- <changefreq>weekly</changefreq>-- <priority>0.7</priority>-- </url>-- <url>-- <loc>https://csv.openvibe.tools/</loc>-- <lastmod>2026-08-17</lastmod>-- <changefreq>weekly</changefreq>-- <priority>0.7</priority>-- </url>-- <url>-- <loc>https://sql.openvibe.tools/</loc>-- <lastmod>2026-08-17</lastmod>-- <changefreq>weekly</changefreq>-- <priority>0.7</priority>-- </url>-- <url>-- <loc>https://markdown.openvibe.tools/</loc>-- <lastmod>2026-08-17</lastmod>-- <changefreq>weekly</changefreq>-- <priority>0.7</priority>-- </url>-- <url>-- <loc>https://html.openvibe.tools/</loc>-- <lastmod>2026-08-17</lastmod>-- <changefreq>weekly</changefreq>-- <priority>0.7</priority>-- </url>-- <url>-- <loc>https://base64.openvibe.tools/</loc>-- <lastmod>2026-08-17</lastmod>-- <changefreq>weekly</changefreq>-- <priority>0.7</priority>-- </url>-- <url>-- <loc>https://url.openvibe.tools/</loc>-- <lastmod>2026-08-17</lastmod>-- <changefreq>weekly</changefreq>-- <priority>0.7</priority>-- </url>-- <url>-- <loc>https://jwt.openvibe.tools/</loc>-- <lastmod>2026-08-17</lastmod>-- <changefreq>weekly</changefreq>-- <priority>0.7</priority>-- </url>-- <url>-- <loc>https://uuid.openvibe.tools/</loc>-- <lastmod>2026-08-17</lastmod>-- <changefreq>weekly</changefreq>-- <priority>0.7</priority>-- </url>-- <url>-- <loc>https://hash.openvibe.tools/</loc>-- <lastmod>2026-08-17</lastmod>-- <changefreq>weekly</changefreq>-- <priority>0.7</priority>-- </url>-- <url>-- <loc>https://hex.openvibe.tools/</loc>-- <lastmod>2026-08-17</lastmod>-- <changefreq>weekly</changefreq>-- <priority>0.7</priority>-- </url>-- <url>-- <loc>https://escape.openvibe.tools/</loc>-- <lastmod>2026-08-17</lastmod>-- <changefreq>weekly</changefreq>-- <priority>0.7</priority>-- </url>-- <url>-- <loc>https://timestamp.openvibe.tools/</loc>-- <lastmod>2026-08-17</lastmod>-- <changefreq>weekly</changefreq>-- <priority>0.7</priority>-- </url>-- <url>-- <loc>https://cron.openvibe.tools/</loc>-- <lastmod>2026-08-17</lastmod>-- <changefreq>weekly</changefreq>-- <priority>0.7</priority>-- </url>-- <url>-- <loc>https://beautify.openvibe.tools/</loc>-- <lastmod>2026-08-17</lastmod>-- <changefreq>weekly</changefreq>-- <priority>0.7</priority>-- </url>-- <url>-- <loc>https://minify.openvibe.tools/</loc>-- <lastmod>2026-08-17</lastmod>-- <changefreq>weekly</changefreq>-- <priority>0.7</priority>-- </url>-- <url>-- <loc>https://diff.openvibe.tools/</loc>-- <lastmod>2026-08-17</lastmod>-- <changefreq>weekly</changefreq>-- <priority>0.7</priority>-- </url>-- <url>-- <loc>https://regex.openvibe.tools/</loc>-- <lastmod>2026-08-17</lastmod>-- <changefreq>weekly</changefreq>-- <priority>0.7</priority>-- </url>-- <url>-- <loc>https://slug.openvibe.tools/</loc>-- <lastmod>2026-08-17</lastmod>-- <changefreq>weekly</changefreq>-- <priority>0.7</priority>-- </url>-- <url>-- <loc>https://lorem.openvibe.tools/</loc>-- <lastmod>2026-08-17</lastmod>-- <changefreq>weekly</changefreq>-- <priority>0.7</priority>-- </url>-- <url>-- <loc>https://curl.openvibe.tools/</loc>-- <lastmod>2026-08-17</lastmod>-- <changefreq>weekly</changefreq>-- <priority>0.7</priority>-- </url>-- <url>-- <loc>https://webhook.openvibe.tools/</loc>-- <lastmod>2026-08-17</lastmod>-- <changefreq>weekly</changefreq>-- <priority>0.7</priority>-- </url>-- <url>-- <loc>https://color.openvibe.tools/</loc>-- <lastmod>2026-08-17</lastmod>-- <changefreq>weekly</changefreq>-- <priority>0.7</priority>-- </url>-- <url>-- <loc>https://opengraph.openvibe.tools/</loc>-- <lastmod>2026-08-17</lastmod>-- <changefreq>weekly</changefreq>-- <priority>0.7</priority>-- </url>--</urlset>@@@ -52,6 +52,17 @@ app.use(helmet({},crossOriginEmbedderPolicy: false,}));++// ── Host roles: aliases redirect, unknown hosts leave, satellite hosts are proxied ──++const registry = require('./registry');++const { hostRoles } = require('./registry/host-middleware');++registry.start();++app.use(hostRoles({++ hostOf: getRequestHost,++ enforce: config.isProduction || process.env.OV_ENFORCE_HOSTS === '1',++ // Gateway-served subdomains that predate the catalog (tool aliases, the pastes hand-off).++ isLegacyHost: (sub) => NET_TOOL_MAP.has(sub) || !!NET_ALIASES[sub] || DEV_TOOL_MAP.has(sub) || !!DEV_ALIASES[sub] || ['pastes', 'paste', 'my', 'login'].includes(sub),++}));++app.use(cookieParser());app.use(express.json({ limit: '1mb' }));app.use(express.urlencoded({ extended: true }));@@@ -166,6 +177,8 @@ app.use('/api/dev', rateLimit({ windowMs: 60_000, max: 60 }), createDevRoutes(nu// ── Host-header subdomain routing ────────────────────────────function subdomainOf(req) {++ // A mirror or custom domain is the tool it points at.++ if (req.ovHost && req.ovHost.tool && !getRequestHost(req).endsWith('.openvibe.tools')) return req.ovHost.tool;return getRequestHost(req).replace(/\.openvibe\.tools$/, '');}@@@ -186,26 +199,22 @@ function isStaticPath(reqPath) {// Every tool subdomain gets its OWN title, description, canonical, structured data and a// crawlable content block (server/seo). They used to share the hub's <head>, which told search// engines that ~65 distinct tools were one page.--const { renderTool, buildSitemap, SATELLITES } = require('./seo/render');++const { renderTool } = require('./seo/render');const NET_HTML = path.join(__dirname, '..', 'public', 'net.html');const DEV_HTML = path.join(__dirname, '..', 'public', 'dev.html');function sendTool(req, res, file) {try {res.set('Content-Type', 'text/html; charset=utf-8');-- return res.send(renderTool(file, subdomainOf(req)));++ return res.send(renderTool(file, subdomainOf(req), req.ovHost && req.ovHost.canonicalHost));} catch (err) {console.error('[SEO] render failed:', err.message);return res.sendFile(file);}}--// Generated sitemap — covers both hubs, every tool subdomain and the satellite apps, so it can--// never drift from the tool catalogs the way a hand-maintained file does.--app.get('/sitemap.xml', (_req, res) => {-- res.set('Content-Type', 'application/xml; charset=utf-8');-- res.set('Cache-Control', 'public, max-age=3600');-- res.send(buildSitemap(SATELLITES));--});++// 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());// Net tool subdomains → net.html SPAapp.use((req, res, next) => {@@@ -253,7 +262,8 @@ app.get('*', (req, res) => {if (req.path.startsWith('/api/')) {return res.status(404).json({ error: 'Not found' });}-- res.sendFile(path.join(__dirname, '..', 'public', 'index.html'));++ // Unknown page: a real 404 that still helps (search + every family), never a soft-200 copy of the index.++ res.status(404).set('Cache-Control', 'no-store').type('html').send(require('./pages/site').renderNotFound());});// ── Start ────────────────────────────────────────────────────nnew file mode 100644@@@ -0,0 +1,235 @@++'use strict';++// ═══════════════════════════════════════════════════════════════++// openvibe.tools, server-rendered from the registry.++// / index: search, families, popular tools++// /<family-path> one family: every tool, intro, FAQ++// /tool/<id> one tool: what it does, where it lives (primary host + mirrors), related++// /all-tools A–Z++// /search?q= results (works as a plain form; JS only makes it instant)++// /sitemap.xml /robots.txt /llms.txt /api/catalog.json++// Every page is complete HTML without JavaScript. Pages are rendered once per registry version++// and served from memory with an ETag.++// ═══════════════════════════════════════════════════════════════++const crypto = require('crypto');++const express = require('express');++const seo = require('openvibe-shared/seo');++const icons = require('openvibe-shared/icons');++const registry = require('../registry');++++const SITE = 'https://openvibe.tools';++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 hostLabel = (h) => h.replace(/\.openvibe\.tools$/, '') === h ? h : h;++++function toolCard(t) {++ return `<a class="tool" href="${esc(t.url)}" data-k="${esc([t.name, t.tagline, ...(t.keywords || [])].join(' ').toLowerCase())}">++ ${icon(t.icon, 36)}<span class="tool-t"><b>${esc(t.name)}</b><small>${esc(t.tagline)}</small><i>${esc(hostLabel(t.hosts.short || t.hosts.canonical))}</i></span></a>`;++}++++const CSS = `++*{box-sizing:border-box}html{-webkit-text-size-adjust:100%}++body{margin:0;background:var(--bg-primary,#0a0f18);color:var(--text-primary,#e6edf7);font:400 16px/1.55 Inter,system-ui,-apple-system,Segoe UI,Roboto,sans-serif}++a{color:var(--accent-light,var(--accent,#60a5fa))}++.wrap{max-width:1180px;margin:0 auto;padding:0 20px}++.topbar{display:flex;align-items:center;gap:14px;padding:12px 20px;border-bottom:1px solid var(--border,rgba(255,255,255,.08))}++.topbar a{color:inherit;text-decoration:none;font-weight:700}.topbar nav{display:flex;gap:14px;margin-left:auto;font-size:14px;flex-wrap:wrap}.topbar nav a{font-weight:500;color:var(--text-secondary,#a8b3c4)}++.hero{padding:clamp(28px,6vw,64px) 0 8px;text-align:center}++.hero h1{font-size:clamp(30px,5.2vw,52px);line-height:1.08;letter-spacing:-.03em;margin:0 0 12px;font-weight:800}++.hero h1 span{color:var(--accent-light,var(--accent,#60a5fa))}++.hero p{max-width:680px;margin:0 auto 22px;color:var(--text-secondary,#a8b3c4);font-size:clamp(15px,1.6vw,18px)}++.search{display:flex;max-width:640px;margin:0 auto;background:var(--bg-secondary,#111826);border:1px solid var(--border,rgba(255,255,255,.12));border-radius:14px;padding:5px;gap:4px}++.search:focus-within{border-color:var(--accent,#3b82f6);box-shadow:0 0 0 4px var(--accent-glow,rgba(59,130,246,.18))}++.search input{flex:1;min-width:0;background:none;border:0;outline:0;color:inherit;font:inherit;padding:10px 12px}++.search button{border:0;border-radius:10px;background:var(--accent,#3b82f6);color:var(--on-accent,#fff);font:700 14px/1 inherit;padding:0 18px;cursor:pointer}++.chips{display:flex;flex-wrap:wrap;justify-content:center;gap:8px;margin:18px auto 0;max-width:820px;padding:0;list-style:none}++.chips a{display:inline-flex;align-items:center;gap:7px;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}++.chips a:hover{border-color:var(--accent,#3b82f6)}++h2{font-size:clamp(20px,2.4vw,26px);letter-spacing:-.02em;margin:0}++section{padding:clamp(24px,4vw,44px) 0 0}++.sec-h{display:flex;align-items:center;gap:12px;margin-bottom:6px}.sec-h a.more{margin-left:auto;font-size:14px;font-weight:600;text-decoration:none;white-space:nowrap}++.sec-p{color:var(--text-secondary,#a8b3c4);margin:0 0 16px;max-width:820px}++.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(250px,1fr));gap:10px}++.tool{display:flex;gap:12px;align-items:flex-start;padding:12px;border-radius:14px;border:1px solid var(--border,rgba(255,255,255,.08));background:var(--bg-secondary,#111826);text-decoration:none;color:inherit;transition:border-color .15s,transform .15s}++.tool:hover,.tool:focus-visible{border-color:var(--accent,#3b82f6);transform:translateY(-1px);outline:0}++.tool-t{min-width:0;display:block}.tool b{display:block;font-size:15px}.tool small{display:block;color:var(--text-secondary,#a8b3c4);font-size:13px;line-height:1.35;margin-top:2px}++.tool i{display:block;font-style:normal;font-size:11.5px;color:var(--text-muted,#7d8aa0);margin-top:5px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}++.tool[hidden]{display:none}++.crumbs{font-size:13.5px;color:var(--text-muted,#7d8aa0);padding-top:18px}.crumbs a{color:inherit}++.page-h{display:flex;gap:16px;align-items:center;padding:14px 0 4px}.page-h h1{font-size:clamp(26px,4vw,40px);letter-spacing:-.03em;margin:0;line-height:1.1}++.lead{font-size:clamp(15px,1.5vw,18px);color:var(--text-secondary,#a8b3c4);max-width:820px}++.cta{display:inline-flex;align-items:center;gap:8px;background:var(--accent,#3b82f6);color:var(--on-accent,#fff);font-weight:700;text-decoration:none;padding:12px 20px;border-radius:12px;margin:6px 0}++.hosts{list-style:none;padding:0;margin:8px 0;display:flex;flex-direction:column;gap:6px;max-width:640px}++.hosts li{display:flex;gap:10px;align-items:center;padding:9px 12px;border:1px solid var(--border,rgba(255,255,255,.08));border-radius:10px;flex-wrap:wrap}++.hosts li.primary{border-color:var(--accent,#3b82f6);background:var(--accent-glow,rgba(59,130,246,.1))}++.hosts .tag{font-size:11px;font-weight:700;letter-spacing:.5px;text-transform:uppercase;color:var(--text-muted,#7d8aa0);margin-left:auto}++.kw{display:flex;flex-wrap:wrap;gap:6px;padding:0;list-style:none}.kw li{font-size:12.5px;padding:3px 9px;border-radius:999px;background:var(--bg-secondary,#111826);color:var(--text-secondary,#a8b3c4)}++details{border-bottom:1px solid var(--border,rgba(255,255,255,.08));padding:12px 0;max-width:820px}summary{cursor:pointer;font-weight:650}details p{color:var(--text-secondary,#a8b3c4);margin:8px 0 0}++.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}++#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}}++${icons.CSS}`;++++const NETWORK = [++ ['live', 'OpenVibe.Live', 'Live streams, clips and chat', 'https://openvibe.live/'],++ ['community', 'OpenVibe.Community', 'Pastes, posts and people', 'https://openvibe.community/'],++ ['games', 'OpenVibe.Games', 'Browser games', 'https://openvibe.games/'],++ ['media', 'OpenVibe.Media', 'VODs, clips and files', 'https://openvibe.media/'],++ ['network', 'OpenVibe.Network', 'One account for every site', 'https://openvibe.network/'],++];++++function shell({ head, body, families }) {++ return `<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="theme-color" content="#3b82f6">++${head}++<link rel="icon" type="image/svg+xml" href="https://openvibe.network/assets/favicon.svg">++<link rel="alternate" type="application/json" href="${SITE}/api/catalog.json" title="Tool catalog">++<link rel="preconnect" href="https://openvibe.network">++<script src="https://openvibe.network/shared/theme-loader.js"></script>++<style>${CSS}</style></head><body>++<noscript><header class="topbar"><a href="${SITE}/">${NAME}</a><nav>${families.filter(f => f.path).map(f => `<a href="${esc(f.path)}">${esc(f.name)}</a>`).join('')}<a href="/all-tools">All tools</a></nav></header></noscript>++<main class="wrap">${body}++<section aria-labelledby="net-h"><div class="sec-h"><h2 id="net-h">The rest of OpenVibe</h2></div><p class="sec-p">One account works on every site. Open source and community-run.</p>++<div class="net">${NETWORK.map(([ic, n, d, u]) => `<a class="tool" href="${u}">${icon(ic, 36)}<span class="tool-t"><b>${n}</b><small>${d}</small></span></a>`).join('')}</div></section>++</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 src="/js/site.js" defer></script>++</body></html>`;++}++++function head(o) {++ return seo.headTags(Object.assign({ siteName: NAME, image: 'https://openvibe.network/assets/og-tools.png', type: 'website', locale: 'en_US' }, o));++}++++const searchForm = (q) => `<form class="search" action="/search" method="get" role="search"><input type="search" name="q" id="q" value="${esc(q || '')}" placeholder="What do you need to do? Try “merge pdf” or “dns lookup”" aria-label="Search tools" autocomplete="off"><button type="submit">Search</button></form>`;++++function score(t, terms) {++ let s = 0; const name = t.name.toLowerCase(); const hay = [t.tagline, t.description, ...(t.keywords || []), t.id].join(' ').toLowerCase();++ for (const w of terms) { if (name.includes(w)) s += 5; if (t.id === w) s += 4; if ((t.keywords || []).some(k => k.includes(w))) s += 3; else if (hay.includes(w)) s += 1; else if (!name.includes(w)) return 0; }++ return s;++}++function search(q) {++ const terms = String(q || '').toLowerCase().split(/[^a-z0-9.+#]+/).filter(Boolean).slice(0, 8);++ if (!terms.length) return [];++ return registry.get().tools.map(t => [score(t, terms), t]).filter(x => x[0] > 0).sort((a, b) => b[0] - a[0] || a[1].name.localeCompare(b[1].name)).slice(0, 60).map(x => x[1]);++}++++// ── Pages ────────────────────────────────────────────────────++function pageIndex() {++ const { tools, families } = 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>++${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>++<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('')}`;++ 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',++ jsonLd: [seo.jsonLd.organization(), seo.jsonLd.website({ name: NAME, url: SITE + '/', description: 'Online tools for files, text, code and networks.', searchUrl: SITE + '/search?q={search_term_string}' }),++ seo.jsonLd.itemList('Tool families', fams.map(f => ({ name: f.name, url: SITE + f.path, description: f.tagline })))] }) });++}++++function pageFamily(f) {++ const { tools, families } = registry.get();++ const list = tools.filter(t => t.family === f.id);++ const url = SITE + f.path;++ const body = `<nav class="crumbs" aria-label="Breadcrumb"><a href="/">Tools</a> › ${esc(f.name)}</nav>++<div class="page-h">${icon(f.icon, 56)}<h1>${esc(f.name)}</h1></div><p class="lead">${esc(f.description)}</p>++${f.hosts ? `<a class="cta" href="${esc(f.url)}">Open ${esc(f.name)}</a>` : ''}++<section><div class="sec-h"><h2>${list.length} tools</h2></div><p class="sec-p">${esc(f.intro || '')}</p><div class="grid">${list.map(toolCard).join('')}</div></section>++${f.faq && f.faq.length ? `<section><div class="sec-h"><h2>Questions</h2></div>${f.faq.map(([q, a]) => `<details><summary>${esc(q)}</summary><p>${esc(a)}</p></details>`).join('')}</section>` : ''}`;++ return shell({ families, body, head: head({ title: `${f.name}: ${f.tagline}`, description: f.description, canonical: url, keywords: list.slice(0, 12).map(t => t.name.toLowerCase()).join(', '),++ jsonLd: [seo.jsonLd.breadcrumbs([{ name: 'Tools', url: SITE + '/' }, { name: f.name, url }]), seo.jsonLd.itemList(f.name, list.map(t => ({ name: t.name, url: t.url, description: t.tagline }))),++ ...(f.faq && f.faq.length ? [seo.jsonLd.faq(f.faq.map(([q, a]) => ({ q, a })))] : [])] }) });++}++++function pageTool(t) {++ const { tools, families } = registry.get();++ const f = families.find(x => x.id === t.family);++ const url = `${SITE}/tool/${t.id}`;++ const related = tools.filter(x => x.family === t.family && x.id !== t.id).slice(0, 8);++ const hosts = [[t.hosts.canonical, 'Primary'], ...(t.hosts.short ? [[t.hosts.short, 'Short link']] : []), ...t.hosts.aliases.map(a => [a, 'Mirror'])];++ const body = `<nav class="crumbs" aria-label="Breadcrumb"><a href="/">Tools</a> › ${f && f.path ? `<a href="${esc(f.path)}">${esc(f.name)}</a> › ` : ''}${esc(t.name)}</nav>++<div class="page-h">${icon(t.icon, 56)}<h1>${esc(t.name)}</h1></div><p class="lead">${esc(t.description)}</p>++<a class="cta" href="${esc(t.url)}">Open ${esc(t.name)}</a>++<section><div class="sec-h"><h2>Where to find it</h2></div><p class="sec-p">Every address below opens the same tool. The primary address is the one to bookmark and share.</p>++<ul class="hosts">${hosts.map(([h, tag], i) => `<li${i === 0 ? ' class="primary"' : ''}><a href="https://${esc(h)}/"${i === 0 ? '' : ' rel="nofollow"'}>${esc(h)}</a><span class="tag">${tag}</span></li>`).join('')}</ul></section>++${t.keywords && t.keywords.length ? `<section><div class="sec-h"><h2>People also call this</h2></div><ul class="kw">${t.keywords.map(k => `<li>${esc(k)}</li>`).join('')}</ul></section>` : ''}++${related.length ? `<section><div class="sec-h"><h2>Related tools</h2>${f && f.path ? `<a class="more" href="${esc(f.path)}">All ${esc(f.name)}</a>` : ''}</div><div class="grid">${related.map(toolCard).join('')}</div></section>` : ''}`;++ return shell({ families, body, head: head({ title: `${t.name}: ${t.tagline}`, description: t.description, canonical: url, keywords: (t.keywords || []).join(', '),++ jsonLd: [seo.jsonLd.breadcrumbs([{ name: 'Tools', url: SITE + '/' }, ...(f && f.path ? [{ name: f.name, url: SITE + f.path }] : []), { name: t.name, url }]),++ seo.jsonLd.softwareApp({ name: t.name, url: t.url, description: t.description, category: 'UtilitiesApplication', keywords: t.keywords })] }) });++}++++function pageAll() {++ const { tools, families } = registry.get();++ const sorted = [...tools].sort((a, b) => a.name.localeCompare(b.name));++ const body = `<nav class="crumbs" aria-label="Breadcrumb"><a href="/">Tools</a> › All tools</nav><div class="page-h">${icon('tools', 56)}<h1>All ${tools.length} tools, A to Z</h1></div>++${searchForm('')}<section><ul class="az">${sorted.map(t => `<li><a href="${esc(t.url)}">${esc(t.name)}</a> <small><a href="/tool/${esc(t.id)}" aria-label="About ${esc(t.name)}">about</a></small></li>`).join('')}</ul></section>`;++ return shell({ families, body, head: head({ title: `All ${tools.length} Online Tools, A to Z`, description: 'The complete list of tools on OpenVibe.Tools: converters, downloaders, PDF and image tools, text utilities, developer tools and network diagnostics.', canonical: SITE + '/all-tools',++ jsonLd: [seo.jsonLd.breadcrumbs([{ name: 'Tools', url: SITE + '/' }, { name: 'All tools', url: SITE + '/all-tools' }])] }) });++}++++function pageSearch(q) {++ const { families } = registry.get();++ const hits = search(q);++ const body = `<nav class="crumbs" aria-label="Breadcrumb"><a href="/">Tools</a> › Search</nav><div class="page-h"><h1>${q ? `Tools for “${esc(q)}”` : 'Search tools'}</h1></div>${searchForm(q)}++<section>${hits.length ? `<div class="grid">${hits.map(toolCard).join('')}</div>` : `<p class="empty">${q ? 'No tool matches that yet.' : 'Type what you need to do.'} <a href="/all-tools">Browse every tool</a>.</p>`}</section>`;++ return shell({ families, body, head: head({ title: q ? `Tools for “${seo.clip(q, 30)}”` : 'Search tools', description: 'Search every tool on OpenVibe.Tools.', canonical: SITE + '/search', robots: 'noindex,follow' }) });++}++++// ── Cache + router ───────────────────────────────────────────++const cache = new Map();++function cached(key, render) {++ const ver = registry.get().updated;++ let e = cache.get(key);++ if (!e || e.ver !== ver) { const html = render(); e = { ver, html, etag: '"' + crypto.createHash('sha1').update(html).digest('base64url').slice(0, 20) + '"' }; cache.set(key, e); }++ return e;++}++function send(req, res, key, render, type) {++ const e = cached(key, render);++ res.set('Content-Type', type || 'text/html; charset=utf-8');++ res.set('Cache-Control', 'public, max-age=300, stale-while-revalidate=86400');++ res.set('ETag', e.etag);++ if (req.headers['if-none-match'] === e.etag) return res.status(304).end();++ res.send(e.html);++}++++function sitemapEntries() {++ const { tools, families } = registry.get();++ const today = new Date().toISOString().slice(0, 10);++ return [{ loc: SITE + '/', changefreq: 'daily', priority: 1 }, { loc: SITE + '/all-tools', changefreq: 'weekly', priority: 0.8 },++ ...families.filter(f => f.path).map(f => ({ loc: SITE + f.path, changefreq: 'weekly', priority: 0.9 })),++ ...families.filter(f => f.hosts).map(f => ({ loc: f.url, changefreq: 'weekly', priority: 0.9 })),++ ...tools.filter(t => !t.external).flatMap(t => [{ loc: t.url, changefreq: 'weekly', priority: 0.8 }, { loc: `${SITE}/tool/${t.id}`, changefreq: 'monthly', priority: 0.5 }]),++ ].map(u => Object.assign({ lastmod: today }, u));++}++++function createSiteRouter() {++ const router = express.Router();++ const apexOnly = (req, res, next) => (!req.ovHost || req.ovHost.kind === 'apex' || (req.ovHost.kind === 'unknown' && !req.ovHost.inZone) ? next() : next('router'));++ router.use(apexOnly);++ router.get('/api/catalog.json', (req, res) => { res.set('Access-Control-Allow-Origin', '*'); send(req, res, 'catalog', () => JSON.stringify(registry.catalog()), 'application/json; charset=utf-8'); });++ router.get('/', (req, res) => send(req, res, '/', pageIndex));++ router.get('/all-tools', (req, res) => send(req, res, '/all-tools', pageAll));++ router.get('/search', (req, res) => { res.set('Cache-Control', 'no-store'); res.type('html').send(pageSearch(String(req.query.q || '').slice(0, 80))); });++ router.get('/tool/:id', (req, res, next) => { const t = registry.get().tools.find(x => x.id === req.params.id); return t ? send(req, res, '/tool/' + t.id, () => pageTool(t)) : next(); });++ router.get('/sitemap.xml', (req, res) => send(req, res, 'sitemap', () => seo.sitemapXml(sitemapEntries()), 'application/xml; charset=utf-8'));++ router.get('/robots.txt', (req, res) => send(req, res, 'robots', () => seo.robotsTxt({ sitemaps: [SITE + '/sitemap.xml'], disallow: ['/api/', '/auth/'], allow: ['/api/catalog.json'], allowAI: true }), 'text/plain; charset=utf-8'));++ router.get('/llms.txt', (req, res) => send(req, res, 'llms', () => { const { tools, families } = registry.get(); return seo.llmsTxt({ name: NAME, summary: `${tools.length} online tools for files, text, code and networks. Open source and community-run. A machine-readable catalog is at ${SITE}/api/catalog.json.`,++ sections: families.filter(f => f.path).map(f => ({ title: f.name, links: tools.filter(t => t.family === f.id).map(t => ({ title: t.name, url: t.url, note: t.tagline })) })) }); }, 'text/plain; charset=utf-8'));++ router.get('/:slug', (req, res, next) => { const f = registry.get().families.find(x => x.path === '/' + req.params.slug); return f ? send(req, res, f.path, () => pageFamily(f)) : next(); });++ return router;++}++++const renderNotFound = () => pageSearch('').replace('<h1>Search tools</h1>', '<h1>That page is not here</h1>').replace(/<title>[^<]*<\/title>/, '<title>Page not found | OpenVibe.Tools</title>');++++module.exports = { createSiteRouter, search, sitemapEntries, renderNotFound };nnew file mode 100644@@@ -0,0 +1,146 @@++'use strict';++++// ═══════════════════════════════════════════════════════════════++// Plain-language copy for the tools served by the satellite apps.++//++// Row: [id, name, tagline (≤ 60 chars), description, keywords, icon]++// id the tool's existing subdomain label (png → png.openvibe.tools); unique site-wide++// keywords the searches people actually type, comma separated++// icon an ov-icons name (contract §4); unknown names fall back to the OV mark++//++// The satellites' own host maps decide WHICH hosts exist and which are mirrors of each other++// (registry/sources.js); this file only says what each tool is called and what it does.++// ═══════════════════════════════════════════════════════════════++++const IMG = [++ ['convert', 'Image Converter', 'Change a picture from one format to another', 'Convert pictures between PNG, JPG, WebP, AVIF, HEIC, SVG, GIF, ICO, TIFF and BMP. Drop in one file or a batch, pick the format you need and download the converted images.', 'image converter, convert image, png to jpg, jpg to png, webp to jpg, heic to jpg, convert picture format', 'image'],++ ['compress', 'Image Compressor', 'Make image files smaller without visible loss', 'Shrink JPG, PNG, WebP and AVIF files so pages load faster and attachments fit in an email. Choose a quality level, compare the size before and after, and download the smaller file.', 'image compressor, compress image, reduce image size, compress jpg, compress png, make photo smaller, optimize images', 'image'],++ ['resize', 'Image Resizer', 'Resize a picture to exact pixels or a percentage', 'Set a width, a height or a percentage and get a resized copy of your picture. Keeps the aspect ratio unless you ask it not to. Handy for profile photos, product shots and forum size limits.', 'image resizer, resize image, resize photo, change image size, resize picture online, resize to pixels', 'image'],++ ['crop', 'Image Cropper', 'Crop a picture to a ratio or a custom size', 'Cut a picture down to the part you want. Use presets such as 16:9, 4:3 and 1:1 for thumbnails and avatars, or type exact dimensions.', 'crop image, image cropper, crop photo, crop picture online, crop to square, 16:9 crop', 'image'],++ ['png', 'PNG Converter', 'Convert any picture to PNG', 'Turn JPG, WebP, AVIF, HEIC, GIF, BMP or TIFF pictures into PNG files. PNG keeps sharp edges and transparency, which makes it the right choice for logos, screenshots and graphics.', 'convert to png, jpg to png, webp to png, heic to png, png converter, image to png', 'image'],++ ['jpg', 'JPG Converter', 'Convert any picture to JPG', 'Turn PNG, WebP, AVIF, HEIC, GIF, BMP or TIFF pictures into JPG files that open everywhere. Pick the quality to balance sharpness against file size.', 'convert to jpg, png to jpg, webp to jpg, heic to jpg, jpg converter, jpeg converter, image to jpeg', 'image'],++ ['webp', 'WebP Converter', 'Convert pictures to WebP for faster websites', 'Convert PNG, JPG, AVIF, HEIC, GIF and TIFF pictures to WebP, the format browsers load fastest. Typical files come out 25–35% smaller than the same JPG.', 'convert to webp, png to webp, jpg to webp, webp converter, image to webp', 'image'],++ ['avif', 'AVIF Converter', 'Convert pictures to AVIF, the smallest modern format', 'Convert PNG, JPG, WebP, HEIC and TIFF pictures to AVIF. AVIF gives the smallest files at the same visual quality and is supported by every current browser.', 'convert to avif, jpg to avif, png to avif, avif converter, image to avif', 'image'],++ ['heic', 'HEIC Converter', 'Open iPhone HEIC photos as JPG or PNG', 'iPhones save photos as HEIC, which many apps and websites refuse. Convert HEIC and HEIF photos to JPG, PNG or WebP so you can share, upload or print them.', 'heic to jpg, heic converter, heic to png, iphone photo converter, heif to jpg, open heic file', 'image'],++ ['svg', 'SVG Converter', 'Turn SVG graphics into PNG or JPG, and back', 'Render an SVG drawing as a PNG, JPG or WebP at the size you choose, or wrap a bitmap in an SVG. Useful when a site or an app does not accept vector files.', 'svg to png, svg converter, svg to jpg, convert svg, png to svg', 'image'],++ ['gif', 'GIF Converter', 'Convert pictures to and from GIF', 'Save a picture as a GIF or turn a GIF into PNG, JPG or WebP. Animated GIFs can be converted to animated WebP for a much smaller file.', 'gif converter, convert to gif, gif to png, gif to webp, png to gif, jpg to gif', 'image'],++ ['ico', 'ICO Converter', 'Make Windows .ico icon files from any picture', 'Convert a PNG, JPG or SVG into an ICO file containing the standard icon sizes, ready for a Windows shortcut, an application or a website.', 'ico converter, png to ico, convert to ico, make ico file, icon converter', 'image'],++ ['favicon', 'Favicon Generator', 'Create website favicons in every size', 'Upload a logo and get the complete favicon set a modern website needs: favicon.ico, PNG icons from 16 to 512 pixels and an SVG version.', 'favicon generator, make favicon, favicon maker, png to favicon, website icon generator', 'image'],++ ['tiff', 'TIFF Converter', 'Convert pictures to and from TIFF', 'Convert scans and print files between TIFF and PNG, JPG or WebP. Useful when a printer asks for TIFF or when a TIFF scan is too large to email.', 'tiff converter, tiff to jpg, tiff to png, convert to tiff, jpg to tiff', 'image'],++ ['bmp', 'BMP Converter', 'Convert pictures to and from BMP', 'Convert old Windows bitmap files to PNG or JPG, or save a picture as BMP for software that only reads bitmaps.', 'bmp converter, bmp to jpg, bmp to png, convert to bmp, png to bmp', 'image'],++];++++const AUDIO = [++ ['mp3', 'MP3 Converter', 'Convert any audio or video file to MP3', 'Turn WAV, FLAC, M4A, OGG, WMA and other recordings, or the sound of a video, into an MP3 that plays on every device. Choose the bitrate from 64 to 320 kbps.', 'mp3 converter, convert to mp3, wav to mp3, m4a to mp3, flac to mp3, mp4 to mp3, audio to mp3', 'audio'],++ ['wav', 'WAV Converter', 'Convert audio to uncompressed WAV', 'Convert MP3, FLAC, M4A, OGG and other audio to WAV for editing, sampling or burning to CD. Pick the sample rate and bit depth your software expects.', 'wav converter, mp3 to wav, convert to wav, flac to wav, m4a to wav', 'audio'],++ ['flac', 'FLAC Converter', 'Convert audio to lossless FLAC', 'Convert WAV, AIFF and other recordings to FLAC, which keeps every detail at roughly half the size of WAV. Also converts FLAC to other formats.', 'flac converter, wav to flac, convert to flac, flac to mp3, lossless audio converter', 'audio'],++ ['ogg', 'OGG Converter', 'Convert audio to OGG Vorbis', 'Convert MP3, WAV, FLAC and M4A files to OGG Vorbis, the open format used by games, Linux apps and many web players.', 'ogg converter, mp3 to ogg, convert to ogg, wav to ogg, ogg vorbis converter', 'audio'],++ ['m4a', 'M4A Converter', 'Convert audio to M4A for Apple devices', 'Convert MP3, WAV, FLAC and OGG files to M4A (AAC), the format iPhones, iTunes and Apple Music handle best.', 'm4a converter, mp3 to m4a, convert to m4a, wav to m4a, m4a to mp3', 'audio'],++ ['opus', 'Opus Converter', 'Convert audio to Opus for small, clear files', 'Convert recordings to Opus, the codec used by Discord, WhatsApp and WebRTC. It sounds clean at bitrates where MP3 falls apart, which makes it ideal for voice.', 'opus converter, convert to opus, mp3 to opus, wav to opus, opus audio', 'audio'],++ ['aac', 'AAC Converter', 'Convert audio to AAC', 'Convert MP3, WAV, FLAC and OGG files to raw AAC for video editors, streaming encoders and devices that expect .aac files.', 'aac converter, convert to aac, mp3 to aac, wav to aac', 'audio'],++ ['wma', 'WMA Converter', 'Convert audio to and from WMA', 'Convert old Windows Media Audio files to MP3 or WAV, or make a WMA for a car stereo or player that needs one.', 'wma converter, wma to mp3, convert wma, wma to wav', 'audio'],++ ['aiff', 'AIFF Converter', 'Convert audio to AIFF for Mac audio software', 'Convert WAV, FLAC and MP3 files to AIFF, the uncompressed format used by Logic, GarageBand and other Mac audio tools.', 'aiff converter, convert to aiff, wav to aiff, aiff to mp3, aiff to wav', 'audio'],++ ['ac3', 'AC3 Converter', 'Convert audio to AC3 (Dolby Digital)', 'Convert stereo or surround audio to AC3 for DVDs, Blu-ray authoring and home cinema receivers.', 'ac3 converter, convert to ac3, dolby digital converter, wav to ac3', 'audio'],++ ['extract', 'Audio Extractor', 'Pull the soundtrack out of a video file', 'Upload an MP4, MKV, MOV or WebM video and download just its audio as MP3, WAV or M4A. Good for saving a talk, a song or an interview from a recording.', 'extract audio from video, mp4 to mp3, video to audio, video to mp3 converter, rip audio from video', 'audio'],++ ['trim', 'Audio Trimmer', 'Cut a clip out of a song or recording', 'Mark a start and an end on the waveform and download only that part. Use it to cut silence off a recording, make a sample or shorten a voice memo.', 'audio trimmer, cut audio, trim mp3, mp3 cutter, audio cutter, cut song', 'audio'],++ ['merge', 'Audio Joiner', 'Join several audio files into one', 'Upload two or more recordings, put them in order and download a single file. Works across formats, so an MP3 and a WAV can be joined.', 'audio joiner, merge audio, combine mp3, join songs, merge mp3 files', 'audio'],++ ['ringtone', 'Ringtone Maker', 'Turn part of a song into a phone ringtone', 'Pick up to 30 seconds of any track, add a fade, and download an M4R ringtone for iPhone or an MP3 for Android.', 'ringtone maker, make ringtone, mp3 to ringtone, iphone ringtone maker, m4r converter', 'audio'],++ ['speed', 'Audio Speed Changer', 'Speed up or slow down audio without changing pitch', 'Play a recording faster or slower while voices stay natural. Useful for practising music, transcribing interviews and getting through long lectures.', 'change audio speed, speed up audio, slow down audio, slow down song, tempo changer', 'audio'],++ ['pitch', 'Pitch Changer', 'Raise or lower the pitch of a track', 'Shift a song up or down by semitones without changing its speed. Musicians use it to move a backing track into a comfortable key.', 'pitch changer, change pitch, pitch shifter, transpose song, change key of song', 'audio'],++ ['reverse', 'Reverse Audio', 'Play a sound file backwards', 'Upload a recording and download it reversed. Popular for sound design, hidden messages in songs and just hearing what a word sounds like backwards.', 'reverse audio, play audio backwards, reverse mp3, reverse song, backwards audio', 'audio'],++ ['normalize', 'Volume Normalizer', 'Bring a recording up to a standard loudness', 'Make quiet recordings louder and even out tracks that differ in volume. Targets the loudness used by streaming services and podcast apps.', 'normalize audio, volume normalizer, make audio louder, increase mp3 volume, loudness normalization', 'audio'],++ ['noise', 'Noise Reducer', 'Remove hiss, hum and background noise', 'Clean up a voice recording made in a noisy room. Reduces steady background sounds such as fans, air conditioning, hiss and mains hum while keeping speech clear.', 'noise reduction, remove background noise, denoise audio, clean up audio, remove hiss', 'audio'],++ ['silence', 'Silence Remover', 'Cut the quiet gaps out of a recording', 'Automatically removes long pauses from lectures, podcasts and voice memos so the recording is shorter and easier to listen to.', 'remove silence, silence remover, cut silence from audio, trim silence, remove pauses', 'audio'],++ ['vocal', 'Vocal Remover', 'Remove or isolate the vocals in a song', 'Make a karaoke backing track by taking the singer out of a song, or keep only the vocal for a remix. Works best on stereo studio recordings.', 'vocal remover, remove vocals, karaoke maker, isolate vocals, instrumental maker, acapella extractor', 'audio'],++ ['fade', 'Audio Fader', 'Add a fade in or fade out', 'Give a clip a smooth start and finish instead of an abrupt cut. Set the fade length in seconds for each end.', 'fade in audio, fade out audio, audio fade, add fade to mp3', 'audio'],++ ['loop', 'Audio Looper', 'Repeat a clip any number of times', 'Turn a short sample into a longer track by repeating it, with an optional crossfade so the joins cannot be heard.', 'loop audio, audio looper, repeat audio, loop mp3, make audio loop', 'audio'],++ ['bass', 'Bass Booster', 'Add more low end to a track', 'Boost the bass of a song for small speakers, headphones or a car stereo. Choose how much and at which frequency.', 'bass booster, boost bass, bass boost mp3, add bass to song', 'audio'],++ ['equalizer', 'Audio Equalizer', 'Adjust bass, mids and treble', 'A multi-band equalizer with presets for voice, music and podcasts. Fix a muffled recording or tame harsh highs, then download the result.', 'audio equalizer, online equalizer, eq audio, adjust treble, adjust bass', 'audio'],++ ['compressor', 'Audio Compressor', 'Even out loud and quiet parts', 'Dynamic range compression makes whispers audible and shouts less painful. Useful for podcasts, streams and recordings of meetings.', 'audio compressor, dynamic range compression, even out volume, compress audio dynamics', 'audio'],++ ['podcast', 'Podcast Optimizer', 'Make speech recordings sound broadcast-ready', 'One click applies noise reduction, levelling and loudness normalization tuned for spoken word, then exports at the settings podcast hosts recommend.', 'podcast audio, improve podcast sound, podcast editor, voice enhancer, podcast loudness', 'audio'],++ ['voice', 'Voice Changer', 'Change how a recorded voice sounds', 'Apply effects to a voice recording: deeper, higher, robot, radio, telephone and more. Upload a clip and download the changed version.', 'voice changer, change voice, robot voice, deep voice effect, voice effects', 'audio'],++ ['echo', 'Echo Effect', 'Add echo or delay to audio', 'Add a repeating echo to a voice or instrument. Set the delay time and how quickly the repeats die away.', 'add echo to audio, echo effect, delay effect, audio echo', 'audio'],++ ['reverb', 'Reverb Effect', 'Make audio sound like it is in a room or a hall', 'Add space to a dry recording with room, hall and cathedral reverbs. Control how much of the effect is mixed in.', 'add reverb, reverb effect, reverb audio, hall reverb, slowed and reverb', 'audio'],++ ['chorus', 'Chorus Effect', 'Thicken a voice or instrument', 'The chorus effect layers slightly detuned copies of a sound to make it wider and richer. A classic on guitars, synths and backing vocals.', 'chorus effect, add chorus to audio, audio chorus', 'audio'],++ ['distortion', 'Distortion Effect', 'Add grit, overdrive or fuzz', 'Run audio through overdrive and distortion, from warm saturation to heavy fuzz. Useful for guitars, drums and sound design.', 'distortion effect, distort audio, overdrive effect, fuzz effect', 'audio'],++ ['bitcrusher', 'Bitcrusher', 'Give audio a lo-fi, retro game sound', 'Reduce the bit depth and sample rate of a recording for the crunchy sound of old consoles and samplers.', 'bitcrusher, lo-fi effect, 8-bit audio, bitcrush audio, retro sound effect', 'audio'],++ ['stereo', 'Stereo & Mono Converter', 'Convert stereo to mono, swap or widen channels', 'Fold a stereo file down to mono, turn a mono recording into two channels, swap left and right, or widen the stereo image.', 'stereo to mono, mono to stereo, convert to mono, swap audio channels', 'audio'],++ ['waveform', 'Waveform Image Generator', 'Turn a sound file into a waveform picture', 'Generate a PNG of the waveform of any recording, in your colours and size. Used for podcast covers, video overlays and audiograms.', 'waveform generator, audio waveform image, sound wave picture, waveform png', 'audio'],++ ['metadata', 'Audio Tag Editor', 'Edit the title, artist, album and cover of a file', 'Read and change the ID3 tags of MP3 files and the metadata of FLAC, M4A and OGG files, including the cover art, without re-encoding the audio.', 'mp3 tag editor, edit id3 tags, audio metadata editor, change album art, edit mp3 info', 'audio'],++];++++const DOCS = [++ ['mergepdf', 'Merge PDF', 'Combine several PDF files into one', 'Upload two or more PDFs, drag them into the order you want and download a single combined document. Bookmarks and page sizes are kept.', 'merge pdf, combine pdf, join pdf files, pdf merger, put pdfs together', 'pdf'],++ ['splitpdf', 'Split PDF', 'Pull pages out of a PDF or cut it into parts', 'Extract a range of pages, split a document every few pages or save each page as its own file.', 'split pdf, extract pages from pdf, separate pdf pages, pdf splitter, cut pdf', 'pdf'],++ ['compresspdf', 'Compress PDF', 'Make a PDF small enough to email or upload', 'Reduce the size of a PDF by recompressing its images and removing unused data. Choose between lighter compression that keeps print quality and stronger compression for the smallest file.', 'compress pdf, reduce pdf size, make pdf smaller, pdf compressor, shrink pdf', 'pdf'],++ ['rotatepdf', 'Rotate PDF', 'Turn sideways or upside-down pages the right way up', 'Rotate every page or only the ones you select by 90, 180 or 270 degrees, and save the PDF so it stays that way.', 'rotate pdf, turn pdf pages, rotate pdf pages permanently, fix upside down pdf', 'pdf'],++ ['reorderpdf', 'Reorder PDF Pages', 'Rearrange or delete the pages of a PDF', 'See thumbnails of every page, drag them into a new order, remove the ones you do not need and download the new document.', 'reorder pdf pages, rearrange pdf, delete pdf pages, organize pdf, move pdf pages', 'pdf'],++ ['watermarkpdf', 'Watermark PDF', 'Stamp text such as DRAFT or your name on every page', 'Add a text watermark to a PDF. Choose the wording, size, colour, angle and transparency, and apply it to all pages or a range.', 'watermark pdf, add watermark to pdf, stamp pdf, pdf watermark', 'pdf'],++ ['protectpdf', 'Protect PDF', 'Add a password to a PDF', 'Encrypt a PDF so it can only be opened with the password you set. You can also restrict printing and copying.', 'password protect pdf, encrypt pdf, lock pdf, add password to pdf, secure pdf', 'pdf'],++ ['unlockpdf', 'Unlock PDF', 'Remove the password from a PDF you can open', 'Enter the password once and download a copy of the PDF that no longer asks for it. For documents you own or have permission to unlock.', 'unlock pdf, remove pdf password, pdf password remover, decrypt pdf', 'pdf'],++ ['image2pdf', 'Image to PDF', 'Turn JPG and PNG pictures into a PDF', 'Combine photos, scans or screenshots into one PDF. Set the page size, orientation and margins, and order the pictures before converting.', 'jpg to pdf, image to pdf, png to pdf, convert photo to pdf, pictures to pdf, scan to pdf', 'pdf'],++ ['pdf2jpg', 'PDF to Image', 'Save the pages of a PDF as JPG or PNG pictures', 'Convert every page of a PDF, or just the pages you choose, into JPG or PNG images at the resolution you need.', 'pdf to jpg, pdf to png, pdf to image, convert pdf to picture, pdf page to jpg', 'pdf'],++];++++const TEXT = [++ ['fancy', 'Fancy Text Generator', 'Stylish Unicode fonts to copy and paste', 'Type a word and copy it in dozens of styles: cursive, gothic, bold, small caps, wide, monospace and more. The result is ordinary text, so it works in Instagram bios, Discord names, TikTok captions and chat.', 'fancy text generator, fancy fonts, cool text, font copy and paste, instagram fonts, cursive text generator, small caps generator', 'text'],++ ['zalgo', 'Glitch Text Generator', 'Make creepy, corrupted-looking Zalgo text', 'Adds stacks of combining marks above and below your letters for the glitchy, cursed look. Control how wild it gets, then copy and paste.', 'zalgo text, glitch text generator, cursed text, creepy text, corrupted text generator', 'text'],++ ['bubble', 'Bubble Text Generator', 'Write in circled and squared letters', 'Convert text to ⓑⓤⓑⓑⓛⓔ letters, filled circles or squares that can be pasted anywhere Unicode is accepted.', 'bubble text, bubble letters generator, circle text, circled letters, square text', 'text'],++ ['ascii', 'ASCII Art Text Generator', 'Big banner text made from characters', 'Turn a word into large ASCII art lettering using classic FIGlet fonts. Made for READMEs, terminal banners, code comments and forum signatures.', 'ascii art generator, text to ascii, figlet generator, ascii text banner, ascii letters', 'text'],++ ['symbols', 'Symbols to Copy and Paste', 'Hearts, stars, arrows and special characters', 'A searchable sheet of Unicode symbols grouped by kind: arrows, stars, hearts, currency, maths, music and box drawing. Click one to copy it.', 'symbols copy and paste, special characters, heart symbol, star symbol, arrow symbols, text symbols', 'text'],++ ['kaomoji', 'Kaomoji & Emoticons', 'Japanese text faces to copy (╯°□°)╯', 'Hundreds of kaomoji sorted by mood: happy, sad, angry, shrug, table flip, animals and more. Click to copy.', 'kaomoji, japanese emoticons, text faces, lenny face, shrug emoticon, copy paste emoticons', 'text'],++ ['unicode', 'Unicode Inspector', 'See exactly which characters are in a piece of text', 'Paste text to list every character with its code point, name, category and UTF-8 bytes. Finds invisible characters, look-alike letters and stray control codes.', 'unicode lookup, unicode character inspector, what character is this, code point lookup, find invisible characters', 'text'],++ ['braille', 'Braille Translator', 'Convert text to Braille and back', 'Translate English text into Unicode Braille cells, or read Braille back into letters. For learning, labels and design work.', 'braille translator, text to braille, braille converter, braille alphabet', 'text'],++ ['morse', 'Morse Code Translator', 'Translate text to Morse code and play it', 'Convert text to dots and dashes or decode Morse back to text. Plays the message as audio beeps at the speed you choose.', 'morse code translator, text to morse, morse code decoder, morse code generator', 'text'],++ ['binary', 'Binary & Hex Translator', 'Convert text to binary, hex, octal and back', 'See any text as binary, hexadecimal, octal or decimal byte values, and turn a string of ones and zeros back into words.', 'binary translator, text to binary, binary to text, binary code converter, hex to text', 'text'],++ ['case', 'Case Converter', 'UPPERCASE, lowercase, Title Case, camelCase…', 'Fix text typed with caps lock on, format a headline in title case, or convert names to camelCase, snake_case and kebab-case for code.', 'case converter, uppercase to lowercase, title case converter, capitalize text, camelcase converter, change text case', 'text'],++ ['count', 'Word Counter', 'Count words, characters, sentences and lines', 'Paste text to see words, characters with and without spaces, sentences, paragraphs, lines and reading time. Shows how much room is left for a tweet, a meta description or an SMS.', 'word counter, character counter, count words, letter count, character count online, line counter', 'text'],++ ['reversetext', 'Reverse Text Generator', 'Flip text backwards, upside down or mirrored', 'Reverse the letters, reverse the word order, turn text upside down or mirror it using look-alike Unicode characters.', 'reverse text, backwards text generator, upside down text, mirror text, flip text', 'text'],++ ['clean', 'Text Cleaner', 'Strip invisible characters, odd spacing and smart quotes', 'Fix text pasted from Word, PDFs and web pages: remove zero-width and control characters, collapse extra spaces and line breaks, straighten curly quotes and strip Zalgo marks.', 'text cleaner, remove invisible characters, remove extra spaces, remove line breaks, strip formatting, clean text', 'text'],++ ['sort', 'Sort & Dedupe Lines', 'Sort a list and remove duplicate lines', 'Sort lines alphabetically, numerically or by length, reverse or shuffle them, and remove duplicates and blank lines.', 'sort lines, remove duplicate lines, alphabetize list, dedupe list, sort text alphabetically', 'text'],++ ['compare', 'Text Compare', 'Find the differences between two texts', 'Paste two versions side by side and see what was added, removed and changed, highlighted down to the word.', 'text compare, diff checker, compare two texts, find difference between two files, text diff online', 'text'],++ ['json', 'JSON Formatter', 'Format, validate and minify JSON', 'Paste messy JSON to pretty-print it with clean indentation, find syntax errors with their line number, collapse and expand nodes, or minify it for production. Runs in your browser.', 'json formatter, json validator, json beautifier, json viewer, pretty print json, json minify, json lint', 'json'],++ ['markdown', 'Markdown Editor', 'Write Markdown with a live preview', 'Type Markdown on the left and see the formatted result on the right. Supports tables, task lists and code blocks, and exports clean HTML.', 'markdown editor, markdown preview, markdown to html, online markdown viewer, readme editor', 'code'],++ ['escape', 'Escape & Unescape Text', 'Encode text for HTML, URLs, JSON and JavaScript', 'Escape or unescape HTML entities, URL percent-encoding, JSON strings, JavaScript strings and Base64 in one place.', 'html escape, html entities encoder, unescape html, url encode, json escape string, escape characters', 'code'],++ ['slug', 'URL Slug Generator', 'Turn a title into a clean URL slug', 'Convert a headline into a lowercase, hyphenated slug with accents and punctuation removed, ready for a blog post or product URL.', 'slug generator, url slug, slugify, seo friendly url generator, title to slug', 'code'],++ ['bio', 'Bio Generator', 'Write a short bio for a social profile', 'Answer a few prompts and get ready-to-paste bios for Instagram, TikTok, X, Twitch and Discord, with optional fancy fonts and symbols.', 'bio generator, instagram bio ideas, tiktok bio, social media bio maker, twitch bio', 'text'],++ ['nickname', 'Nickname Generator', 'Gamer tags and usernames that are not taken yet', 'Generate usernames and gamer tags from a word, a style or pure randomness. Add decorations for games that allow special characters.', 'nickname generator, username generator, gamertag generator, cool usernames, name ideas for games', 'text'],++ ['logo', 'Logo Maker', 'Make a simple logo, title card or badge', 'The starting point for the graphics tools: text logos, stream title cards, thumbnails, badges, watermarks and transparent text PNGs, all made in the browser and exported as images.', 'logo maker, text logo maker, make a logo online, channel logo maker, simple logo generator', 'logo'],++ ['wordmark', 'Wordmark Generator', 'Design a text-only logo, including neon styles', 'Set your name in a typeface, adjust spacing, colour, outline and glow, and export a transparent PNG or SVG wordmark.', 'wordmark generator, text logo, neon text generator, name logo maker, typography logo', 'logo'],++ ['title', 'Title Card Maker', 'Title screens for videos and streams', 'Make a “Starting soon”, “Be right back” or episode title card at 1080p or 4K with your own text, colours and background.', 'title card maker, starting soon screen, stream title screen, video title maker, intro card', 'logo'],++ ['thumbnail', 'Thumbnail Maker', 'YouTube thumbnails, covers and channel art', 'Lay out a 1280×720 YouTube thumbnail, a stream cover or channel banner with big readable text over your picture, and export it as PNG or JPG.', 'thumbnail maker, youtube thumbnail maker, channel art maker, cover image maker, stream thumbnail', 'logo'],++ ['badge', 'Badge & Sticker Maker', 'Round badges, labels and stickers', 'Design circular and shield badges or die-cut style stickers with text on a curve, icons and borders. Export with a transparent background.', 'badge maker, sticker maker, make a badge online, emblem maker, sub badge maker', 'logo'],++ ['watermark', 'Watermark Maker', 'Watermarks, overlays and lower thirds for video', 'Create a transparent watermark, a stream overlay label or a lower-third name strap and export it as a PNG ready to drop into OBS or a video editor.', 'watermark maker, lower third maker, stream overlay maker, make a watermark, transparent logo overlay', 'logo'],++ ['transparent', 'Transparent Text PNG', 'Text as a PNG with no background', 'Type text, choose a font, colour and outline, and download it as a PNG with a transparent background for thumbnails, overlays and memes.', 'transparent text png, text to png, text png generator, transparent text maker', 'logo'],++ ['peso', 'Peso to Dollar Converter', 'Convert Mexican pesos and US dollars', 'Convert between MXN and USD at the current rate, with a quick table of common amounts for shopping and travel.', 'peso to dollar, mxn to usd, dollar to peso, pesos a dolares, currency converter mexico', 'text'],++ ['spanish', 'Spanish–English Phrasebook', 'Everyday phrases in Spanish and English', 'Common phrases for greetings, directions, food, work, emergencies and paperwork, side by side in Spanish and English with pronunciation hints.', 'spanish phrases, english to spanish phrases, spanish phrasebook, frases en ingles, basic spanish', 'text'],++ ['slang', 'Mexican Slang Dictionary', 'What Mexican Spanish slang words mean', 'Look up words and expressions you will hear in Mexico but not find in a textbook, with meanings, examples and a note on how polite each one is.', 'mexican slang, mexican spanish slang words, what does wey mean, slang mexicano, mexican expressions', 'text'],++ ['ice', 'ICE Alerts & Know Your Rights', 'Rights information for immigration encounters', 'Plain-language know-your-rights cards in English and Spanish for encounters with immigration enforcement, plus hotline numbers and what to do if someone is detained.', 'know your rights ice, immigration rights card, ice encounter rights, conoce tus derechos, red card', 'text'],++];++++const MEDIA = [++ ['yt', 'YouTube Downloader', 'Save videos and audio from YouTube', 'Paste a YouTube link and download the video as MP4 in the resolution you choose, or just the audio as MP3 or M4A. A progress bar shows the download being prepared. For your own uploads and videos you are allowed to keep.', 'youtube downloader, youtube to mp3, youtube to mp4, download youtube video, youtube video downloader, yt to mp3, save youtube video', 'youtube'],++];++++const PLACES = [++ ['maps', 'Survival Map', 'Campsites, shelters, water and help across North America', 'A map of the things you need when money is short or you are living on the road: dispersed and no-fee campsites, shelters, food banks, water, showers and public resources across the United States, Canada and Mexico.', 'survival map, dispersed camping map, boondocking map, homeless shelters near me, food banks near me, camping on public land', 'map'],++ ['food', 'Budget Food Finder', 'Cheap groceries, meal plans and food banks', 'Plan meals around a tight budget: compare the cost of staple foods, build a week of meals that meets your nutrition needs for the least money, and find food banks near you.', 'cheap meal plan, budget grocery list, eat on a budget, food banks near me, cheapest healthy food, meal planner budget', 'food'],++];++++const PASTES = [++ ['pastes', 'Pastes', 'Share text and code snippets with a link', 'Pastes now live on OpenVibe.Community. Post text or code, get a short link to share, and let people comment. Existing paste links keep working.', 'pastebin, paste text online, share code snippet, text sharing', 'paste'],++];++++function rows(family, list) {++ return list.map(([id, name, tagline, description, keywords, icon]) => ({++ id, family, name, tagline, description, icon,++ keywords: keywords.split(',').map((k) => k.trim()).filter(Boolean),++ }));++}++++const SATELLITE_COPY = [++ ...rows('img', IMG), ...rows('audio', AUDIO), ...rows('docs', DOCS), ...rows('text', TEXT),++ ...rows('media', MEDIA), ...rows('places', PLACES), ...rows('pastes', PASTES),++];++++module.exports = { SATELLITE_COPY };nnew file mode 100644@@@ -0,0 +1,110 @@++'use strict';++++// ═══════════════════════════════════════════════════════════════++// Tool families — the sections of openvibe.tools.++//++// id stable key used by tools (`tool.family`) and by the Network launcher++// path the family page on the apex (openvibe.tools/<path>)++// hub the subdomain that serves the family's own hub app, when it has one++// port the satellite that serves the family (null = this gateway)++// ═══════════════════════════════════════════════════════════════++++const FAMILIES = [++ {++ id: 'net', name: 'Network Tools', icon: 'network', path: '/network-tools', hub: 'net', hubAliases: ['network'], port: null,++ tagline: 'Look up DNS, IPs, domains, certificates and mail servers',++ description: 'Diagnostics for anyone who runs a website, a mail server or a home network. Look up DNS records, find out who owns an IP address or a domain, check an SSL certificate, trace a route, test a port and see whether a site is reachable from other parts of the world.',++ intro: 'These tools answer the questions that come up when something on the internet does not work: where does this domain point, why is mail bouncing, is the certificate about to expire, is the site down for everyone. Each one takes a domain, an IP address or a URL and gives a readable answer in a few seconds.',++ faq: [++ ['Do I need to install anything?', 'No. Every lookup runs from our servers and the result appears in your browser, so the tools work on a phone, a locked-down work laptop or a Chromebook.'],++ ['Where do the lookups run from?', 'DNS, WHOIS and certificate checks run from the OpenVibe servers. Ping, traceroute and latency tests can run from several regions so you can compare results around the world.'],++ ['Can I check a server inside my own network?', 'No. The checks come from the public internet, so the target has to be reachable from outside your network.'],++ ],++ },++ {++ id: 'dev', name: 'Developer Tools', icon: 'code', path: '/developer-tools', hub: 'dev', hubAliases: ['code', 'build', 'debug'], port: null,++ tagline: 'Format, convert, encode and test data while you code',++ description: 'Small utilities for everyday programming: format and validate JSON, YAML, XML and SQL, decode Base64 and JWTs, test regular expressions, compare two files, generate UUIDs and hashes, read cron expressions and convert timestamps.',++ intro: 'Paste something in, get the answer, move on. Most of these tools do their work inside your browser, so the data you paste is not uploaded. The few that need the network, such as the Open Graph preview and the webhook inspector, say so on the page.',++ faq: [++ ['Is the data I paste sent to a server?', 'Formatters, encoders, generators and the diff tool run in your browser. Tools that have to fetch a URL for you make one request to that URL and nothing else.'],++ ['Do the tools work offline?', 'Once a page has loaded, the browser-side tools keep working without a connection.'],++ ['Is there a size limit?', 'Browser-side tools are limited only by your device memory. Documents of a few megabytes are fine.'],++ ],++ },++ {++ id: 'img', name: 'Image Tools', icon: 'image', path: '/image-tools', hub: 'img', hubAliases: [], port: 4012,++ tagline: 'Convert, compress, resize and crop pictures',++ description: 'Change an image from one format to another, make it smaller, resize it to exact pixel dimensions or crop it to a ratio. Works with PNG, JPG, WebP, AVIF, HEIC photos from iPhones, SVG, GIF, ICO, TIFF and BMP.',++ intro: 'Pick the tool named after what you want to end up with: a PNG, a smaller file, a square crop, a favicon. Drop one or more pictures on the page, adjust the quality or size if you want to, and download the result.',++ faq: [++ ['Which image formats can I upload?', 'PNG, JPG, WebP, AVIF, HEIC and HEIF, SVG, GIF, ICO, TIFF and BMP are accepted by every image tool.'],++ ['What happens to my pictures?', 'Files are processed on our server, kept only long enough for you to download the result, and then deleted.'],++ ['Can I convert several images at once?', 'Yes. Drop a batch of files and download the results one by one or together.'],++ ],++ },++ {++ id: 'audio', name: 'Audio Tools', icon: 'audio', path: '/audio-tools', hub: 'audio', hubAliases: ['convert.audio'], port: 4014,++ tagline: 'Convert, cut, clean up and add effects to sound files',++ description: 'Convert recordings between MP3, WAV, FLAC, OGG, M4A and other formats, trim a clip, join files, change speed or pitch, remove background noise, make a ringtone or pull the soundtrack out of a video.',++ intro: 'Upload a sound file (or a video, for the extractor), choose what to do with it and download the result. Format converters let you pick the bitrate; the editing tools show a preview before you commit.',++ faq: [++ ['Which audio formats are supported?', 'MP3, WAV, FLAC, OGG, M4A, AAC, Opus, WMA, AIFF and AC3, plus the audio track of common video files such as MP4, MKV and WebM.'],++ ['Will converting reduce the quality?', 'Converting to a lossless format such as WAV or FLAC keeps everything. Converting to MP3, AAC, OGG or Opus discards some detail; pick a higher bitrate to keep more of it.'],++ ['How large can a file be?', 'Uploads of up to 100 MB are accepted, which covers most songs, podcasts and voice recordings.'],++ ],++ },++ {++ id: 'docs', name: 'PDF & Document Tools', icon: 'pdf', path: '/pdf-tools', hub: 'docs', hubAliases: ['pdf'], port: 4016,++ tagline: 'Merge, split, compress, protect and convert PDFs',++ description: 'Everyday PDF jobs without desktop software: combine several PDFs into one, pull pages out, shrink a file so it fits in an email, rotate or reorder pages, add a watermark or a password, and turn pictures into a PDF or a PDF into pictures.',++ intro: 'Each tool does one job. Upload your PDF or images, set the options, and download the new file. Your original is never changed.',++ faq: [++ ['Are my documents kept?', 'No. Files are processed, offered for download and then removed from the server.'],++ ['Can I unlock a PDF without the password?', 'No. The unlock tool removes protection from a PDF when you know its password; it does not break into files.'],++ ['Is there a page limit?', 'There is no page limit. The upload size limit is 100 MB per file.'],++ ],++ },++ {++ id: 'text', name: 'Text & Logo Tools', icon: 'text', path: '/text-tools', hub: 'text', hubAliases: ['type', 'fonts'], port: 4015,++ tagline: 'Fancy fonts, text clean-up, counters, logos and banners',++ description: 'Generators and fixers for words: fancy Unicode fonts for social profiles, case converters, word counters, line sorters, text comparison, Morse, Braille and binary translators, plus simple makers for logos, stream titles, thumbnails, badges and watermarks.',++ intro: 'Type or paste your text and copy the result. Everything here works instantly in the browser. The logo and graphics makers export a PNG you can use on a stream, a channel or a profile.',++ faq: [++ ['How do the fancy fonts work?', 'They are not fonts. The generators swap your letters for look-alike Unicode characters, which is why the result can be pasted into a bio, a username or a chat message.'],++ ['Will the styled text show up everywhere?', 'Almost every modern phone and browser displays these characters. A few older devices show empty boxes for the rarer styles.'],++ ['Can I use the logos I make commercially?', 'Yes. What you make is yours. Check the licence of any font or image you add yourself.'],++ ],++ },++ {++ id: 'media', name: 'Video Tools (YouTube)', icon: 'youtube', path: '/video-tools', hub: null, hubAliases: [], port: 4013,++ tagline: 'Save YouTube videos and audio',++ description: 'Download a YouTube video as an MP4 in the resolution you choose, or keep only the sound as an MP3 or M4A file. Useful for watching offline, archiving your own uploads and saving talks or music you have the right to keep.',++ intro: 'Paste a video link, choose video or audio and the quality, and the file is prepared on our server while a progress bar shows how far along it is.',++ faq: [++ ['Which sites are supported?', 'YouTube videos, Shorts and music links. Live streams can be saved once they have ended.'],++ ['Is downloading from YouTube allowed?', 'Download only videos you own, videos in the public domain or under a licence that permits it, or where your local law allows a personal copy.'],++ ['Why does a long video take a while?', 'The server fetches the video and the audio separately and joins them. A one-hour 1080p video usually takes a minute or two.'],++ ],++ },++ {++ id: 'places', name: 'Maps & Food', icon: 'map', path: '/maps-and-food', hub: null, hubAliases: [], port: null,++ tagline: 'Find water, shelter, supplies and somewhere to eat',++ description: 'Two practical maps. The survival map shows drinking water, public toilets, shelters, hospitals, fuel and other essentials near any place on earth. The food finder shows restaurants, cafes, markets and food banks around you.',++ intro: 'Both tools use open map data. Allow location access or search for a town, then filter the map to the kind of place you need.',++ faq: [++ ['Where does the map data come from?', 'OpenStreetMap and other open datasets, maintained by volunteers around the world. Coverage is best in towns and cities.'],++ ['Do I have to share my location?', 'No. You can search for any place by name instead.'],++ ],++ },++ {++ id: 'pastes', name: 'Pastes', icon: 'paste', path: null, hub: null, hubAliases: [], port: null, external: 'https://openvibe.community/',++ tagline: 'Share text and code snippets with a link',++ description: 'Pastes now live on OpenVibe.Community. Post a snippet of text or code, get a short link, and let people comment on it.',++ intro: '', faq: [],++ },++];++++const FAMILY_MAP = new Map(FAMILIES.map((f) => [f.id, f]));++++module.exports = { FAMILIES, FAMILY_MAP };nnew file mode 100644@@@ -0,0 +1,65 @@++'use strict';++// Host roles at the door (shared-contracts §1). Runs before body parsing so proxied uploads and++// SSE streams pass through untouched.++// alias → 301 short host (same path)++// unknown *.openvibe.tools → 301 https://openvibe.tools/ (no made-up brands)++// unknown custom domain → 404++// tool/family on a satellite → streamed to the satellite with X-OV-* headers++// everything else → req.ovHost for the gateway's own pages++const http = require('http');++const registry = require('./index');++++const HOP = new Set(['connection', 'keep-alive', 'proxy-authenticate', 'proxy-authorization', 'te', 'trailer', 'transfer-encoding', 'upgrade']);++++function proxyTo(port, info, req, res) {++ const headers = {};++ for (const [k, v] of Object.entries(req.headers)) if (!HOP.has(k) && !k.startsWith('x-ov-')) headers[k] = v;++ headers['x-ov-tool'] = info.tool;++ headers['x-ov-host-role'] = info.role;++ headers['x-ov-canonical-host'] = info.canonicalHost;++ headers['x-ov-short-host'] = info.shortHost;++ headers['x-forwarded-host'] = info.host;++ headers['x-forwarded-proto'] = 'https';++ const prior = req.headers['x-forwarded-for'];++ headers['x-forwarded-for'] = (prior ? prior + ', ' : '') + (req.socket.remoteAddress || '');++ const up = http.request({ host: '127.0.0.1', port, method: req.method, path: req.originalUrl || req.url, headers }, (r) => {++ const out = {};++ for (const [k, v] of Object.entries(r.headers)) if (!HOP.has(k)) out[k] = v;++ res.writeHead(r.statusCode || 502, out);++ r.pipe(res);++ });++ up.setTimeout(10 * 60_000, () => up.destroy(new Error('upstream timeout')));++ up.on('error', () => { if (!res.headersSent) res.status(502).type('text/plain').send('This tool is restarting. Try again in a moment.'); else res.destroy(); });++ res.on('close', () => up.destroy());++ req.pipe(up);++}++++/**++ * @param {object} opts++ * @param {(host: string) => string} opts.hostOf request → hostname++ * @param {(sub: string) => boolean} opts.isLegacyHost gateway-served subdomains that are not catalog tools (aliases, pastes…)++ * @param {boolean} opts.enforce false in development: unknown hosts fall through++ */++function hostRoles(opts) {++ return function ovHostRoles(req, res, next) {++ const host = opts.hostOf(req);++ const info = registry.resolveHost(host);++ req.ovHost = info;++ if (info.kind === 'apex') return next();++ if (info.kind === 'unknown') {++ if (!opts.enforce) return next();++ if (!info.inZone) return res.status(404).type('text/plain').send('Not an OpenVibe site.');++ if (opts.isLegacyHost(info.host.slice(0, -('.' + registry.ZONE).length))) return next();++ res.set('Cache-Control', 'public, max-age=300');++ return res.redirect(301, `https://${registry.APEX}/`);++ }++ if (info.role === 'alias') {++ res.set('Cache-Control', 'public, max-age=300');++ return res.redirect(301, `https://${info.shortHost}${req.originalUrl || '/'}`);++ }++ if (info.port) return proxyTo(info.port, info, req, res);++ next();++ };++}++++module.exports = { hostRoles, proxyTo };nnew file mode 100644@@@ -0,0 +1,141 @@++'use strict';++// ═══════════════════════════════════════════════════════════════++// The tool registry (docs: OpenVibe.Network/docs/shared-contracts.md §1).++//++// One list of every tool and family on openvibe.tools, each with its hosts:++// canonical the host links, sitemaps and rel=canonical use (may be a custom domain)++// short the host a person types — serves 200, canonical points at the canonical host++// aliases 301 → short (or canonical when there is no short)++// Code defaults below ← owner overrides from https://openvibe.network/api/domains (60 s refresh,++// last good copy kept). Anything else under *.openvibe.tools is not a tool: the gateway sends it++// to the index instead of rendering a made-up brand.++// ═══════════════════════════════════════════════════════════════++const { FAMILIES } = require('./families');++const { SATELLITE_COPY } = require('./copy-satellites');++const { CATALOG: SEO } = require('../seo/catalog');++++const ZONE = 'openvibe.tools';++const APEX = ZONE;++const DOMAINS_URL = process.env.OV_DOMAINS_URL || 'https://openvibe.network/api/domains';++const HOST_RE = /^(?=.{1,253}$)([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/;++++// Human-first defaults: the descriptive host is canonical, the one people already type is short.++const HOST_DEFAULTS = {++ yt: { canonical: 'youtube-downloader', short: 'yt', aliases: ['youtube', 'ytdl', 'youtubedownloader', 'youtube-download'] },++};++const FAMILY_ICON = { net: 'dns', dev: 'code' };++const NET_ICONS = { ip: 'ip', myip: 'ip', dns: 'dns', whois: 'whois', ssl: 'ssl', ping: 'ping', traceroute: 'ping' };++++/** 'yt' → 'yt.openvibe.tools'; a full hostname is kept as it is. */++const toHost = (h) => { const s = String(h || '').trim().toLowerCase(); return s.includes('.') && HOST_RE.test(s) ? s : `${s}.${ZONE}`; };++const firstSentence = (s, max = 60) => { const t = String(s || '').split(/(?<=[.!?])\s/)[0].replace(/\.$/, ''); return t.length <= max ? t : t.slice(0, max).replace(/\s+\S*$/, ''); };++++function baseTools() {++ const out = [];++ for (const rec of SEO.values()) {++ if (rec.hub) continue;++ out.push({++ id: rec.sub, family: rec.family, name: rec.name, tagline: firstSentence(rec.desc), description: rec.about || rec.desc,++ keywords: String(rec.kw || '').split(',').map(k => k.trim()).filter(Boolean),++ icon: (rec.family === 'net' && NET_ICONS[rec.sub]) || (rec.sub.includes('json') ? 'json' : FAMILY_ICON[rec.family]) || 'tools',++ });++ }++ for (const t of SATELLITE_COPY) out.push({ ...t });++ return out;++}++++const familyById = new Map(FAMILIES.map(f => [f.id, f]));++let overrides = []; // [{ tool_id, host, role }]++let overridesAt = 0;++let built = null; // { tools, families, byHost, updated }++++function build() {++ const byHost = new Map();++ const claim = (host, entry) => { if (host && !byHost.has(host)) byHost.set(host, entry); };++ const ov = new Map(); // tool_id → { canonical, short, aliases[] }++ for (const o of overrides) {++ const e = ov.get(o.tool_id) || { aliases: [] }; ov.set(o.tool_id, e);++ if (o.role === 'canonical') e.canonical = o.host; else if (o.role === 'short') e.short = o.host; else e.aliases.push(o.host);++ }++ const hostsFor = (id, def) => {++ const d = def || {}; const o = ov.get(id) || { aliases: [] };++ const canonical = o.canonical || toHost(d.canonical || id);++ let short = o.short || (d.short ? toHost(d.short) : null);++ const aliases = new Set([...(d.aliases || []).map(toHost), ...o.aliases]);++ // A host that lost its role to an override still works: it becomes an alias.++ const defCanon = toHost(d.canonical || id);++ if (o.canonical && defCanon !== canonical) { if (!short) short = defCanon; else aliases.add(defCanon); }++ if (o.short && d.short && toHost(d.short) !== short) aliases.add(toHost(d.short));++ if (short === canonical) short = null;++ aliases.delete(canonical); if (short) aliases.delete(short);++ return { canonical, short, aliases: [...aliases] };++ };++++ const tools = baseTools().map(t => {++ const fam = familyById.get(t.family);++ const hosts = hostsFor(t.id, HOST_DEFAULTS[t.id]);++ const external = t.id === 'pastes' ? 'https://openvibe.community/pastes' : null;++ return { ...t, hosts, url: external || `https://${hosts.canonical}/`, port: fam ? fam.port : null, external };++ });++ const families = FAMILIES.map(f => {++ const hosts = f.hub ? hostsFor(f.id, { canonical: f.hub, aliases: f.hubAliases }) : null;++ return { ...f, hosts, url: f.external || (hosts ? `https://${hosts.canonical}/` : `https://${APEX}${f.path}`), page: f.path ? `https://${APEX}${f.path}` : null };++ });++ for (const list of [tools, families]) for (const x of list) {++ if (!x.hosts || x.external) continue;++ const kind = list === tools ? 'tool' : 'family';++ claim(x.hosts.canonical, { kind, role: 'canonical', item: x });++ 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() };++ return built;++}++++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 };++ const hit = get().byHost.get(host);++ const inZone = host.endsWith('.' + ZONE);++ if (!hit) return { kind: 'unknown', host, inZone };++ const h = hit.item.hosts;++ return { kind: hit.kind, role: hit.role, item: hit.item, tool: hit.item.id, host, inZone, canonicalHost: h.canonical, shortHost: h.short || h.canonical, port: hit.item.port || null };++}++++/** The public catalog (GET /api/catalog.json). */++function catalog() {++ const r = get();++ 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 })),++ 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}` })),++ };++}++++function setOverrides(rows) {++ const known = new Set([...baseTools().map(t => t.id), ...FAMILIES.map(f => f.id)]);++ const seen = new Set();++ overrides = (Array.isArray(rows) ? rows : []).filter(o => o && known.has(o.tool_id) && HOST_RE.test(String(o.host || '')) && ['canonical', 'short', 'alias'].includes(o.role) && o.host !== APEX && !seen.has(o.host) && seen.add(o.host))++ .map(o => ({ tool_id: o.tool_id, host: String(o.host).toLowerCase(), role: o.role }));++ build();++}++++async function refresh(fetchImpl = fetch) {++ try {++ const ac = new AbortController(); const t = setTimeout(() => ac.abort(), 4000);++ const r = await fetchImpl(DOMAINS_URL, { signal: ac.signal, headers: { accept: 'application/json' } }); clearTimeout(t);++ if (!r.ok) throw new Error('HTTP ' + r.status);++ const j = await r.json();++ const next = JSON.stringify(j.domains || []);++ if (next !== JSON.stringify(overrides.map(o => ({ tool_id: o.tool_id, host: o.host, role: o.role }))) || !overridesAt) setOverrides(j.domains || []);++ overridesAt = Date.now();++ return true;++ } catch { return false; /* keep the last good copy */ }++}++++function start() { refresh(); const t = setInterval(refresh, 60_000); t.unref(); return t; }++++module.exports = { get, catalog, resolveHost, setOverrides, refresh, start, ZONE, APEX, FAMILIES };@@@ -97,13 +97,14 @@ function contentBlock(rec, related) {* Render the SPA file for one subdomain with its own metadata and content.* Returns the original HTML untouched when the subdomain is not catalogued.*/--function renderTool(file, sub) {++function renderTool(file, sub, canonicalHost) {const rec = seoFor(sub);if (!rec) return baseHtml(file);-- const key = `${file}::${sub}`;++ const key = `${file}::${sub}::${canonicalHost || ''}`;if (_rendered.has(key)) return _rendered.get(key);-- const url = `https://${sub}.openvibe.tools`;++ // The registry decides the canonical host (a custom domain may own the tool).++ const url = `https://${canonicalHost || sub + '.openvibe.tools'}`;const related = relatedTo(sub, 6);const title = `${rec.title} | ${BRAND}`;let html = baseHtml(file);nnew file mode 100644@@@ -0,0 +1,36 @@++'use strict';++const assert = require('assert');++const registry = require('../server/registry');++const site = require('../server/pages/site');++++const c = registry.catalog();++assert.ok(c.tools.length > 100 && c.families.length >= 8, 'catalog is populated');++assert.equal(new Set(c.tools.map(t => t.id)).size, c.tools.length, 'tool ids are unique');++for (const t of c.tools) { assert.ok(t.name && t.tagline && t.description && t.icon && t.hosts.canonical, `complete record: ${t.id}`); assert.ok(!/\b(free|\$0|no ads|ad-free)\b/i.test(t.tagline), `no cost claims: ${t.id}`); }++++const r = (h) => registry.resolveHost(h);++assert.equal(r('openvibe.tools').kind, 'apex');++assert.equal(r('www.openvibe.tools').kind, 'apex');++assert.deepEqual([r('yt.openvibe.tools').role, r('youtube.openvibe.tools').role, r('youtube-downloader.openvibe.tools').role], ['short', 'alias', 'canonical']);++assert.equal(r('yt.openvibe.tools').port, 4013);++assert.equal(r('poopy.openvibe.tools').kind, 'unknown', 'made-up subdomains are not tools');++assert.equal(r('poopy.openvibe.tools').inZone, true);++assert.equal(r('evil.example').inZone, false);++++// Owner overrides: a custom domain becomes canonical, the old canonical keeps working as an alias.++registry.setOverrides([{ tool_id: 'yt', host: 'youtubedownloadonline.com', role: 'canonical' }, { tool_id: 'nope', host: 'x.com', role: 'alias' }, { tool_id: 'yt', host: 'openvibe.tools', role: 'alias' }, { tool_id: 'yt', host: 'bad host', role: 'alias' }]);++const yt = registry.catalog().tools.find(t => t.id === 'yt');++assert.equal(yt.hosts.canonical, 'youtubedownloadonline.com');++assert.ok(yt.hosts.aliases.includes('youtube-downloader.openvibe.tools'));++assert.equal(r('youtubedownloadonline.com').role, 'canonical');++assert.equal(r('x.com').kind, 'unknown', 'override for an unknown tool is ignored');++assert.equal(r('openvibe.tools').kind, 'apex', 'the apex cannot be claimed');++assert.ok(site.sitemapEntries().some(u => u.loc === 'https://youtubedownloadonline.com/'), 'sitemap uses the canonical host');++assert.ok(!site.sitemapEntries().some(u => u.loc === 'https://yt.openvibe.tools/'), 'short hosts stay out of the sitemap');++registry.setOverrides([]);++assert.equal(registry.catalog().tools.find(t => t.id === 'yt').hosts.canonical, 'youtube-downloader.openvibe.tools');++++assert.equal(site.search('merge pdf')[0].id, 'mergepdf');++assert.equal(site.search('youtube to mp3')[0].id, 'yt');++assert.deepEqual(site.search(''), []);++console.log('registry + site: all checks passed');@@@ -3,22 +3,7 @@<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0">-- <title>YT.OpenVibe — YouTube Downloader</title>-- <meta name="description" content="Download YouTube videos and audio. MP4, WebM, MP3, M4A, OPUS, FLAC. No sign-up required.">-- <link rel="canonical" href="https://yt.openvibe.tools/">-- <meta name="robots" content="index, follow, max-image-preview:large, max-snippet:-1">-- <meta property="og:title" content="YT.OpenVibe — YouTube Downloader">-- <meta property="og:description" content="Download YouTube videos and audio. MP4, WebM, MP3, M4A, OPUS, FLAC. No sign-up required.">-- <meta property="og:type" content="website">-- <meta property="og:url" content="https://yt.openvibe.tools/">-- <meta property="og:image" content="https://yt.openvibe.tools/og.png">-- <meta property="og:image:width" content="1200">-- <meta property="og:image:height" content="630">-- <meta property="og:site_name" content="OpenVibe">-- <meta name="twitter:card" content="summary_large_image">-- <meta name="twitter:title" content="YT.OpenVibe — YouTube Downloader">-- <meta name="twitter:description" content="Download YouTube videos and audio. MP4, WebM, MP3, M4A, OPUS, FLAC. No sign-up required.">-- <meta name="twitter:image" content="https://yt.openvibe.tools/og.png">++ <!--OV:HEAD--><link rel="preconnect" href="https://fonts.googleapis.com"><link rel="preconnect" href="https://fonts.gstatic.com" crossorigin><!-- Critical theme bootstrap (ov-theme-boot) — MUST stay inline and above the stylesheets.@@@ -116,6 +101,7 @@</div><!-- Footer -->++ <!--OV:CONTENT--><div id="ov-footer"></div><script src="https://openvibe.network/shared/theme-loader.js"></script>@@@ -123,6 +109,9 @@<script src="https://openvibe.network/shared/footer.js"></script><script src="https://openvibe.network/shared/notification-ui.js"></script><script src="https://openvibe.network/shared/account-switcher.js"></script>++ <script src="https://openvibe.network/shared/ov-icons.js" defer></script>++ <script src="https://openvibe.network/shared/island.js" defer></script>++ <script src="/js/download-state.js"></script><script src="/js/app.js"></script></body></html>@@@ -203,142 +203,117 @@}// --- Start Download ---++ // One reducer (download-state.js) turns SSE frames and polls into view state; render() paints it++ // on the page and mirrors it into the navbar activity island when that module is present.++ const DS = window.YTDownloadState;++ let dlState = null, pollTimer = null, watchdog = null, tracking = null;++ const island = () => window.OpenVibeIsland || null;++async function startDownload() {if (!videoInfo) return;---- hideAll();-- show(videoCard);-- show(progressSec);-- progressBar.style.width = '0%';-- progressBar.classList.remove('indeterminate');-- progressStats.textContent = '';-- progressTitle.textContent = 'Starting download...';++ hideAll(); show(videoCard); show(progressSec);downloadBtn.disabled = true;++ stopTracking();++ dlState = DS.initial({ title: videoInfo.title || '' });++ render();try {const res = await fetch('/api/download', {-- method: 'POST',-- headers: getAuthHeaders(),-- body: JSON.stringify({-- url: urlInput.value.trim(),-- quality: selectedQuality,-- }),++ method: 'POST', headers: getAuthHeaders(),++ body: JSON.stringify({ url: urlInput.value.trim(), quality: selectedQuality, title: videoInfo.title || '' }),});-- const data = await res.json();---- if (!res.ok) {-- showError(data.error || 'Failed to start download');-- downloadBtn.disabled = false;-- return;-- }--++ const data = await res.json().catch(() => ({}));++ if (!res.ok || !data.id) { fail(data.error || 'Failed to start download'); return; }downloadId = data.id;++ const isl = island();++ if (isl) isl.start({ id: 'yt-' + data.id, title: 'Starting download', subtitle: videoInfo.title || '', icon: 'youtube', image: videoInfo.thumbnail || '', progress: null, state: 'busy',++ actions: [{ label: 'Cancel', onClick: () => cancelDownload(data.id) }] });subscribeProgress(data.id);} catch (err) {-- showError('Network error — please check your connection');-- downloadBtn.disabled = false;++ fail('Network error. Please check your connection.');}}-- // --- SSE Progress ----- function subscribeProgress(id) {-- if (sseSource) { sseSource.close(); sseSource = null; }---- sseSource = new EventSource(`/api/status/${id}/stream`);---- sseSource.addEventListener('progress', (e) => {-- try {-- const d = JSON.parse(e.data);-- updateProgress(d);-- } catch {}-- });---- sseSource.addEventListener('complete', (e) => {-- try {-- const d = JSON.parse(e.data);-- onDownloadComplete(d);-- } catch {}-- closeSse();-- });---- sseSource.addEventListener('error_event', (e) => {-- try {-- const d = JSON.parse(e.data);-- showError(d.error || 'Download failed');-- } catch {-- showError('Download failed unexpectedly');-- }-- closeSse();-- downloadBtn.disabled = false;-- });++ function fail(msg) { apply({ status: 'error', error: msg }); }-- sseSource.onerror = () => {-- // SSE disconnected — fall back to polling-- closeSse();-- pollFallback(id);-- };++ function apply(msg) {++ if (!dlState) return;++ const before = dlState;++ dlState = DS.reduce(dlState, msg);++ if (dlState !== before) render();++ if (dlState.terminal) stopTracking();}-- function closeSse() {-- if (sseSource) { sseSource.close(); sseSource = null; }-- }---- function updateProgress(d) {-- progressTitle.textContent = 'Downloading...';---- if (d.progress != null && d.progress >= 0) {-- progressBar.classList.remove('indeterminate');-- progressBar.style.width = d.progress + '%';++ function subscribeProgress(id) {++ tracking = id;++ const startedAt = Date.now();++ let gotFrame = false;++ const onFrame = (e) => { if (tracking !== id) return; gotFrame = true; try { apply(Object.assign({ id }, JSON.parse(e.data))); } catch { /* */ } };++ if (window.EventSource) {++ sseSource = new EventSource(`/api/status/${id}/stream`);++ // Named events carry the same JSON as the unnamed message; the reducer is idempotent.++ sseSource.onmessage = onFrame;++ ['progress', 'complete', 'failed'].forEach((n) => sseSource.addEventListener(n, onFrame));++ sseSource.onerror = () => { if (tracking === id && !(dlState && dlState.terminal)) { closeSse(); poll(id, startedAt); } };++ // A proxy that buffers the stream would leave us silent: start polling after 4 s without a frame.++ watchdog = setTimeout(() => { if (!gotFrame && tracking === id) { closeSse(); poll(id, startedAt); } }, 4000);} else {-- progressBar.classList.add('indeterminate');++ poll(id, startedAt);}---- const parts = [];-- if (d.progress != null && d.progress >= 0) parts.push(d.progress.toFixed(1) + '%');-- if (d.speed) parts.push(d.speed);-- if (d.eta) parts.push('ETA ' + d.eta);-- if (d.filesize) parts.push(formatSize(d.filesize));-- progressStats.textContent = parts.join(' • ');}-- function onDownloadComplete(d) {-- hideAll();-- show(doneSec);++ function poll(id, startedAt) {++ if (tracking !== id || pollTimer) return;++ const step = async () => {++ pollTimer = null;++ if (tracking !== id) return;++ try {++ const res = await fetch(`/api/status/${id}`, { headers: getAuthHeaders(), cache: 'no-store' });++ if (res.status === 404) return fail('Download not found or expired');++ if (res.ok) apply(Object.assign({ id }, await res.json()));++ } catch { /* transient: keep trying until the deadline */ }++ if (tracking !== id) return;++ const delay = DS.pollDelay(Date.now() - startedAt);++ if (delay == null) return fail('The download took too long. Please try again.');++ pollTimer = setTimeout(step, delay);++ };++ step();++ }-- const parts = [];-- if (d.filename) parts.push(d.filename);-- if (d.filesize) parts.push(formatSize(d.filesize));-- doneInfo.textContent = parts.join(' — ') || 'Your file is ready';++ function closeSse() { if (sseSource) { sseSource.close(); sseSource = null; } }++ function stopTracking() { tracking = null; closeSse(); clearTimeout(pollTimer); pollTimer = null; clearTimeout(watchdog); watchdog = null; }-- saveBtn.href = `/api/download/${downloadId}`;-- saveBtn.download = d.filename || '';-- downloadBtn.disabled = false;++ async function cancelDownload(id) {++ try { await fetch(`/api/download/${id}`, { method: 'DELETE', headers: getAuthHeaders() }); } catch { /* */ }++ if (tracking === id) apply({ status: 'error', error: 'Cancelled', cancelled: true });}-- // --- Polling Fallback (if SSE drops) ----- function pollFallback(id) {-- const interval = setInterval(async () => {-- try {-- const res = await fetch(`/api/status/${id}`, { headers: getAuthHeaders() });-- if (!res.ok) { clearInterval(interval); showError('Download failed'); return; }-- const d = await res.json();---- if (d.status === 'downloading') {-- updateProgress(d);-- } else if (d.status === 'complete') {-- clearInterval(interval);-- onDownloadComplete(d);-- } else if (d.status === 'error') {-- clearInterval(interval);-- showError(d.error || 'Download failed');-- downloadBtn.disabled = false;-- }-- } catch {-- clearInterval(interval);-- showError('Lost connection');-- downloadBtn.disabled = false;-- }-- }, 1000);++ function render() {++ const s = dlState; if (!s) return;++ const isl = island(); const iid = downloadId ? 'yt-' + downloadId : null;++ if (s.phase === 'done') {++ hideAll(); show(doneSec);++ const parts = [];++ if (s.file.filename) parts.push(s.file.filename);++ if (s.file.size) parts.push(formatSize(s.file.size));++ doneInfo.textContent = parts.join(' · ') || 'Your file is ready';++ saveBtn.href = s.file.url || `/api/download/${downloadId}`;++ saveBtn.download = s.file.filename || '';++ downloadBtn.disabled = false;++ if (isl && iid) isl.finish(iid, { state: 'ok', title: 'Ready', subtitle: s.title, detail: parts.join(' · '), actions: [{ label: 'Save file', href: saveBtn.href, download: true }], ttl: 15000 });++ return;++ }++ if (s.phase === 'error') {++ downloadBtn.disabled = false;++ if (s.cancelled) { hideAll(); show(videoCard); } else showError(s.error);++ if (isl && iid) { if (s.cancelled) isl.finish(iid, { state: 'info', title: 'Cancelled', ttl: 2500 }); else isl.fail(iid, { title: 'Download failed', detail: s.error }); }++ return;++ }++ progressTitle.textContent = DS.label(s);++ if (s.progress == null) { progressBar.classList.add('indeterminate'); progressBar.style.width = ''; }++ else { progressBar.classList.remove('indeterminate'); progressBar.style.width = (s.progress * 100).toFixed(1) + '%'; }++ progressStats.textContent = s.detail;++ if (isl && iid) isl.update(iid, { title: DS.label(s).replace('…', ''), progress: s.progress, detail: s.detail });}// --- Reset ---nnew file mode 100644@@@ -0,0 +1,129 @@++/* YT.OpenVibe — download state reducer.++ *++ * One function turns every status message into view state, whether it arrived as a named SSE++ * event (progress / complete / failed), an unnamed SSE message, or a poll of /api/status/:id.++ * The wire format is the same JSON everywhere:++ * { status: 'downloading'|'done'|'error', phase, progress: 0–100|null, speed, eta, error,++ * cancelled, title, download: { url, size, ext, filename } }++ * Pure and DOM-free so it runs under `node --test` as well as in the page.++ */++(function (root, factory) {++ if (typeof module === 'object' && module.exports) module.exports = factory();++ else root.YTDownloadState = factory();++})(typeof self !== 'undefined' ? self : this, function () {++ 'use strict';++++ var POLL_MAX_MS = 10 * 60 * 1000;++++ function initial(extra) {++ var s = {++ phase: 'starting', // starting | downloading | processing | done | error++ progress: null, // 0–1, or null while unknown (indeterminate bar)++ percent: null, // 0–100 for display++ speed: '',++ eta: '',++ detail: '',++ title: '',++ file: null, // { url, size, ext, filename } once done++ error: null,++ cancelled: false,++ terminal: false,++ };++ if (extra) for (var k in extra) s[k] = extra[k];++ return s;++ }++++ function clean(v) {++ var t = String(v == null ? '' : v).trim();++ return /^(n\/?a|unknown|none)?$/i.test(t) ? '' : t;++ }++++ function detailLine(s) {++ var parts = [];++ if (s.phase === 'processing') return 'Converting…';++ if (s.percent != null) parts.push(s.percent.toFixed(1) + '%');++ if (s.speed) parts.push(s.speed);++ if (s.eta) parts.push('ETA ' + s.eta);++ return parts.join(' · ');++ }++++ /**++ * @param {object} state previous state (from initial() or reduce())++ * @param {object} msg a status payload, or { status: 'error', error } made up by the client++ * @returns {object} next state — the same object when nothing may change any more++ */++ function reduce(state, msg) {++ if (!state) state = initial();++ if (state.terminal || !msg || typeof msg !== 'object') return state;++++ var next = {};++ for (var k in state) next[k] = state[k];++ if (msg.title) next.title = String(msg.title);++++ // 'complete' is what the first client waited for and the server never said; accept both.++ var status = msg.status === 'complete' ? 'done' : msg.status;++ if (!status && msg.error) status = 'error';++++ if (status === 'done') {++ var d = msg.download || {};++ next.phase = 'done';++ next.progress = 1;++ next.percent = 100;++ next.speed = '';++ next.eta = '';++ next.file = {++ url: d.url || (msg.id ? '/api/download/' + msg.id : ''),++ size: d.size || 0,++ ext: d.ext || '',++ filename: d.filename || '',++ };++ next.terminal = true;++ } else if (status === 'error') {++ next.phase = 'error';++ next.cancelled = !!msg.cancelled;++ next.error = clean(msg.error) || 'Download failed';++ next.speed = '';++ next.eta = '';++ next.terminal = true;++ } else if (status === 'downloading') {++ var p = typeof msg.progress === 'number' && isFinite(msg.progress) ? Math.max(0, Math.min(100, msg.progress)) : null;++ // Never run backwards, and never drop back to "unknown" once a figure was shown.++ if (p != null && (next.percent == null || p > next.percent)) next.percent = p;++ next.progress = next.percent == null ? null : next.percent / 100;++ next.speed = clean(msg.speed);++ next.eta = clean(msg.eta);++ if (msg.phase === 'processing') next.phase = 'processing';++ else if (next.percent != null && next.percent > 0) next.phase = next.phase === 'processing' ? 'processing' : 'downloading';++ else if (next.phase !== 'processing') next.phase = msg.phase === 'downloading' ? 'downloading' : 'starting';++ // Converting has no percentage of its own: the bar goes indeterminate again.++ if (next.phase === 'processing') next.progress = null;++ } else {++ return state;++ }++++ next.detail = detailLine(next);++ return next;++ }++++ /** Headline for a state. */++ function label(s) {++ switch (s.phase) {++ case 'starting': return 'Starting download…';++ case 'downloading': return 'Downloading…';++ case 'processing': return 'Converting…';++ case 'done': return 'Ready';++ case 'error': return s.cancelled ? 'Cancelled' : 'Download failed';++ default: return '';++ }++ }++++ /** Poll delay: every second for the first ten seconds, easing out to three. null = give up. */++ function pollDelay(elapsedMs) {++ if (elapsedMs >= POLL_MAX_MS) return null;++ if (elapsedMs < 10000) return 1000;++ if (elapsedMs < 20000) return 2000;++ return 3000;++ }++++ return { initial: initial, reduce: reduce, label: label, pollDelay: pollDelay, POLL_MAX_MS: POLL_MAX_MS };++});@@@ -105,6 +105,7 @@ function getInfo(url) {}try {const info = JSON.parse(stdout);++ rememberTitle(cleanUrl, info.title);resolve({id: info.id,title: info.title,@@@ -158,28 +159,134 @@ function friendlyDownloadError(line) {if (/Sign in to confirm|not a bot|cookies/i.test(t)) return 'YouTube is asking this server to prove it is not a bot — try again in a few minutes';if (/Private video|members-only|login required/i.test(t)) return 'This video is private or members-only';if (/Video unavailable|has been removed|not available/i.test(t)) return 'This video is unavailable';-- if (/age|confirm your age/i.test(t)) return 'Age-restricted videos cannot be downloaded';++ if (/\bage[- ]restrict|confirm your age/i.test(t)) return 'Age-restricted videos cannot be downloaded';if (/ffmpeg|ffprobe|Postprocessing/i.test(t)) return `Conversion failed on the server (${t.slice(0, 120)})`;if (/Requested format is not available/i.test(t)) return 'That quality is not available for this video — try another';if (/HTTP Error 4\d\d/i.test(t)) return `YouTube refused the request (${t.match(/HTTP Error \d+/)[0]})`;return t.replace(/^ERROR:\s*/i, '').slice(0, 160) || 'Download failed';}++// ── File names ───────────────────────────────────────────────++/**++ * A video title as a file name people recognise: no path separators, control characters or++ * characters Windows refuses, collapsed whitespace, no trailing dots, bounded length.++ */++function sanitizeTitle(title) {++ let t = String(title == null ? '' : title).normalize('NFC');++ t = t.replace(/[\u0000-\u001f\u007f<>:"/\\|?*]+/g, ' ');++ t = t.replace(/\s+/g, ' ').trim();++ t = t.replace(/^[.\s]+|[.\s]+$/g, '');++ if (t.length > 120) t = t.slice(0, 120).trim();++ if (/^(con|prn|aux|nul|com\d|lpt\d)$/i.test(t)) t = `${t}_`;++ return t;++}++++function downloadFilename(title, ext) {++ const base = sanitizeTitle(title) || 'youtube-video';++ return `${base}.${ext || 'mp4'}`;++}++++/** Content-Disposition with an ASCII fallback and the real name as RFC 5987 filename*. */++function contentDisposition(filename) {++ const ascii = String(filename).normalize('NFKD').replace(/[^\x20-\x7e]/g, '').replace(/["\\]/g, '').replace(/\s+/g, ' ').trim();++ const ext = path.extname(String(filename));++ const fallback = ascii && ascii !== ext ? ascii : `youtube-video${ext}`;++ const encoded = encodeURIComponent(filename).replace(/['()*]/g, c => `%${c.charCodeAt(0).toString(16).toUpperCase()}`);++ return `attachment; filename="${fallback}"; filename*=UTF-8''${encoded}`;++}++++// Titles seen by getInfo, so a download started right after can be named without asking again.++const titleCache = new Map(); // clean url → title++function rememberTitle(cleanUrl, title) {++ if (!title) return;++ if (titleCache.size > 500) titleCache.delete(titleCache.keys().next().value);++ titleCache.set(cleanUrl, title);++}++++// ── Progress parsing ─────────────────────────────────────────++const cleanField = (v) => {++ const t = String(v || '').trim();++ return /^(n\/?a|unknown|none|unknown b\/s)?$/i.test(t) ? '' : t;++};++++/**++ * Fold one yt-dlp stdout line into the entry. Lines are ours (see the --print / --progress-template++ * arguments): OVM|sizes|approxSizes|titleJSON, OVP|percent|speed|eta, OVX|pp, OVF|filepath.++ *++ * A merged video is two transfers (video, then audio) and yt-dlp reports each 0 → 100 %, so the++ * raw number runs backwards halfway through. Parts are weighted by their byte sizes when yt-dlp++ * knows them (85/15 otherwise) and the overall figure never decreases.++ */++function applyLine(entry, line) {++ if (line.startsWith('OVP|')) {++ const [, pctRaw, speed, eta] = line.split('|');++ const pct = parseFloat(pctRaw);++ if (!Number.isFinite(pct)) return;++ if (entry._lastPct != null && pct < entry._lastPct - 40 && entry._part < entry._weights.length - 1) entry._part++;++ entry._lastPct = pct;++ const before = entry._weights.slice(0, entry._part).reduce((a, b) => a + b, 0);++ const overall = (before + entry._weights[entry._part] * (pct / 100)) * 100;++ // 100 is reserved for "the file is ready"; until then the bar stops just short.++ entry.progress = Math.max(entry.progress || 0, Math.min(overall, 99.5));++ entry.speed = cleanField(speed);++ entry.eta = cleanField(eta);++ if (entry.phase !== 'processing') entry.phase = 'downloading';++ } else if (line.startsWith('OVM|')) {++ const first = line.indexOf('|', 4);++ const second = first < 0 ? -1 : line.indexOf('|', first + 1);++ if (second < 0) return;++ const parse = (t) => { try { return JSON.parse(t); } catch { return null; } };++ const sizes = parse(line.slice(4, first));++ const approx = parse(line.slice(first + 1, second));++ const title = parse(line.slice(second + 1));++ if (typeof title === 'string' && title && !entry.title) entry.title = title;++ const pick = [sizes, approx].find(a => Array.isArray(a) && a.length > 1 && a.every(n => Number.isFinite(n) && n > 0));++ if (pick) {++ const total = pick.reduce((a, b) => a + b, 0);++ entry._weights = pick.map(n => n / total);++ entry.totalBytes = total;++ } else if (Array.isArray(sizes) && sizes.length <= 1) {++ entry._weights = [1];++ }++ } else if (line.startsWith('OVX|')) {++ entry.phase = 'processing';++ entry.speed = '';++ entry.eta = '';++ }++}++// ── Download Video ───────────────────────────────────────────++function releaseSlot(entry) {++ if (entry._slotReleased) return;++ entry._slotReleased = true;++ currentConcurrent = Math.max(0, currentConcurrent - 1);++}++++function removePartials(id) {++ const dir = path.resolve(config.downloadsDir);++ let names = [];++ try { names = fs.readdirSync(dir); } catch { return; }++ for (const f of names) {++ if (f.startsWith(id)) { try { fs.unlinkSync(path.join(dir, f)); } catch { /* ok */ } }++ }++}++/*** Start a download and return a tracking ID.* @param {string} url - YouTube URL* @param {string} quality - Quality preset key++ * @param {{ title?: string }} [opts] - title the client already shows (yt-dlp's own wins when it reports one)* @returns {Promise<{ id: string }>}*/--function startDownload(url, quality = 'best') {++function startDownload(url, quality = 'best', opts = {}) {return new Promise((resolve, reject) => {if (!isValidUrl(url)) return reject(new Error('Only YouTube URLs are supported'));if (currentConcurrent >= config.download.maxConcurrent) {return reject(new Error('Server busy — too many concurrent downloads. Try again shortly.'));}-- const preset = QUALITY_PRESETS[quality] || QUALITY_PRESETS['best'];++ if (!QUALITY_PRESETS[quality]) quality = 'best';++ const preset = QUALITY_PRESETS[quality];const cleanUrl = sanitizeUrl(url);const id = crypto.randomBytes(12).toString('hex');@@@ -190,28 +297,43 @@ function startDownload(url, quality = 'best') {// dropped by filter(Boolean) before the cleanup loop ran, so yt-dlp received// `--merge-output-format -o` and every audio download died with// "invalid merge output format "-o" given".++ //++ // --print makes yt-dlp quiet, so everything on stdout is one of our own tagged lines;++ // --no-simulate keeps it downloading and --progress keeps the progress lines coming.const cleanArgs = ['-f', preset.video,...(preset.audio ? [] : ['--merge-output-format', preset.ext || 'mp4']),'-o', outputTemplate,'--no-playlist','--no-warnings',++ '--no-simulate',++ '--progress','--newline', // progress on new lines for parsing-- '--progress-template', '%(progress._percent_str)s %(progress._speed_str)s %(progress._eta_str)s',++ '--print', 'before_dl:OVM|%(requested_formats.:.filesize)j|%(requested_formats.:.filesize_approx)j|%(title)j',++ '--print', 'post_process:OVX|pp',++ '--progress-template', 'download:OVP|%(progress._percent_str)s|%(progress._speed_str)s|%(progress._eta_str)s',...(preset.postprocess || []),cleanUrl,];++ const clientTitle = sanitizeTitle(opts.title).slice(0, 200);const entry = {id,status: 'downloading',-- progress: 0,++ phase: 'starting', // starting → downloading → processing++ progress: null, // null until yt-dlp reports a figure (indeterminate)speed: '',eta: '',error: null,++ cancelled: false,filePath: null,quality,++ title: titleCache.get(cleanUrl) || clientTitle || '',startedAt: Date.now(),++ finishedAt: null,++ _weights: preset.audio ? [1] : [0.85, 0.15],++ _part: 0,++ _lastPct: null,};activeDownloads.set(id, entry);currentConcurrent++;@@@ -219,41 +341,38 @@ function startDownload(url, quality = 'best') {const proc = spawn(config.ytdlpPath, cleanArgs, { timeout: config.download.timeout });entry.process = proc;++ let stdoutRest = '';proc.stdout.on('data', (data) => {-- const lines = data.toString().split('\n');-- for (const line of lines) {-- // Parse progress: " 45.2% 5.23MiB/s 00:12"-- const match = line.match(/([\d.]+)%\s+([\S]+)\s+([\S]+)/);-- if (match) {-- entry.progress = parseFloat(match[1]);-- entry.speed = match[2];-- entry.eta = match[3];-- }-- }++ const lines = (stdoutRest + data.toString()).split('\n');++ stdoutRest = lines.pop();++ for (const line of lines) applyLine(entry, line.trim());});let stderrTail = '';proc.stderr.on('data', (data) => {-- const text = data.toString();-- stderrTail = (stderrTail + text).slice(-2000);-- // Some progress info goes to stderr too-- const match = text.match(/([\d.]+)%/);-- if (match) entry.progress = parseFloat(match[1]);++ stderrTail = (stderrTail + data.toString()).slice(-2000);});-- proc.on('close', (code) => {-- currentConcurrent--;++ proc.on('close', (code, signal) => {++ releaseSlot(entry);++ entry.process = null;++ entry.finishedAt = Date.now();++ if (entry.status !== 'downloading') { removePartials(id); return; } // cancelled or timed out++ if (stdoutRest) applyLine(entry, stdoutRest.trim());++if (code !== 0) {-- const last = stderrTail.split('\n').map(l => l.trim()).filter(l => l && !/^\[download\]/.test(l)).pop() || `yt-dlp exited with code ${code}`;++ const last = stderrTail.split('\n').map(l => l.trim()).filter(l => l && !/^\[download\]/.test(l)).pop()++ || (signal ? 'The download took too long and was stopped' : `yt-dlp exited with code ${code}`);console.error(`[Download] ${id} (${quality}) failed: ${last}`);entry.status = 'error';entry.error = friendlyDownloadError(last);++ removePartials(id);return;}// Find the output file (yt-dlp may change extension)const dir = path.resolve(config.downloadsDir);-- const files = fs.readdirSync(dir).filter(f => f.startsWith(id));++ const files = fs.readdirSync(dir).filter(f => f.startsWith(id) && !/\.(part|ytdl|temp)$/i.test(f));if (files.length === 0) {entry.status = 'error';entry.error = 'Output file not found';@@@ -266,10 +385,14 @@ function startDownload(url, quality = 'best') {const stat = fs.statSync(filePath);entry.status = 'done';++ entry.phase = 'done';entry.progress = 100;++ entry.speed = '';++ entry.eta = '';entry.filePath = filePath;entry.fileSize = stat.size;entry.ext = ext;++ entry.filename = downloadFilename(entry.title, ext);// Register in file index for download servingconst mimeMap = {@@@ -283,33 +406,75 @@ function startDownload(url, quality = 'best') {mime: mimeMap[ext] || 'application/octet-stream',ext,size: stat.size,++ filename: entry.filename,expiresAt: Date.now() + config.retention.fileTTL,});});proc.on('error', (err) => {-- currentConcurrent--;++ releaseSlot(entry);++ entry.finishedAt = Date.now();++ if (entry.status !== 'downloading') return;entry.status = 'error';-- entry.error = `yt-dlp error: ${err.message}`;++ entry.error = err.code === 'ENOENT' ? 'The downloader is not installed on this server' : `yt-dlp error: ${err.message}`;});resolve({ id });});}++// ── Cancel ───────────────────────────────────────────────────++/**++ * Stop a running download (kills yt-dlp and removes what it wrote) or discard a finished one.++ * @returns {null | { id, cancelled: boolean, removed: boolean }} null when the id is unknown++ */++function cancelDownload(id) {++ const dl = activeDownloads.get(id);++ if (!dl) return null;++++ if (dl.status === 'downloading') {++ dl.status = 'error';++ dl.cancelled = true;++ dl.error = 'Download cancelled';++ dl.finishedAt = Date.now();++ const proc = dl.process;++ if (proc) {++ try { proc.kill('SIGTERM'); } catch { /* already gone */ }++ // ffmpeg children can ignore a polite stop; make sure nothing keeps writing.++ const hard = setTimeout(() => { try { if (dl.process) proc.kill('SIGKILL'); } catch { /* ok */ } }, 3000);++ hard.unref();++ } else {++ releaseSlot(dl);++ removePartials(id);++ }++ return { id, cancelled: true, removed: false };++ }++++ if (dl.status === 'done') {++ removeFile(id);++ return { id, cancelled: false, removed: true };++ }++ return { id, cancelled: false, removed: false };++}++// ── Download Status ──────────────────────────────────────────function getStatus(id) {const dl = activeDownloads.get(id);if (!dl) return null;return {id: dl.id,-- status: dl.status,-- progress: Math.round(dl.progress * 10) / 10,++ status: dl.status, // downloading | done | error++ phase: dl.status === 'downloading' ? dl.phase : dl.status,++ progress: dl.progress == null ? null : Math.round(dl.progress * 10) / 10, // 0–100, null = not known yetspeed: dl.speed,eta: dl.eta,error: dl.error,++ cancelled: dl.cancelled || undefined,quality: dl.quality,-- download: dl.status === 'done' ? { url: `/api/download/${id}`, size: dl.fileSize, ext: dl.ext } : null,++ title: dl.title || null,++ download: dl.status === 'done'++ ? { url: `/api/download/${id}`, size: dl.fileSize, ext: dl.ext, filename: dl.filename }++ : null,};}@@@ -349,13 +514,19 @@ function cleanup() {}}-- // Clean stale active downloads (stuck for > 15 min with no file)for (const [id, dl] of activeDownloads) {++ // Stuck for > 15 min with no fileif (dl.status === 'downloading' && now - dl.startedAt > 15 * 60 * 1000) {-- try { dl.process?.kill('SIGTERM'); } catch { /* ok */ }dl.status = 'error';dl.error = 'Download timed out';-- currentConcurrent = Math.max(0, currentConcurrent - 1);++ dl.finishedAt = now;++ try { dl.process?.kill('SIGKILL'); } catch { /* ok */ }++ if (!dl.process) releaseSlot(dl);++ }++ // Failed / cancelled entries have no file to expire with; drop them after a while.++ if (dl.status === 'error' && dl.finishedAt && now - dl.finishedAt > 15 * 60 * 1000) {++ removePartials(id);++ activeDownloads.delete(id);}}@@@ -387,6 +558,7 @@ function getStats() {}module.exports = {-- getInfo, startDownload, getStatus, getFile, removeFile,++ getInfo, startDownload, cancelDownload, getStatus, getFile, removeFile,++ sanitizeTitle, downloadFilename, contentDisposition, applyLine,cleanup, startCleanup, stopCleanup, getStats, isValidUrl,};@@@ -16,6 +16,8 @@ const fs = require('fs');const config = require('./config');const { optionalAuth } = require('./auth');const downloader = require('./downloader');++const seo = require('./seo');++const { hostGuard } = require('../../_shared/host-role');// ── Analytics ────────────────────────────────────────────────const Database = require('better-sqlite3');@@@ -31,6 +33,8 @@ const app = express();// ── Security ─────────────────────────────────────────────────app.set('trust proxy', 2);++// API answers are live state, never revalidated: a 304 on /api/status froze the progress poll.++app.set('etag', false);app.use(helmet({contentSecurityPolicy: {directives: {@@@ -60,7 +64,12 @@ app.use(cors({}));// ── Rate Limiting ────────────────────────────────────────────--app.use('/api/', rateLimit({ windowMs: 60_000, max: 60 }));++// Progress is one SSE connection, or a poll every 1–3 s when SSE is unavailable; the poll must++// not eat the general budget (60/min would cut a download off after a minute).++const isStatusRoute = (req) => req.method === 'GET' && /^\/status\/[a-f0-9]+(\/stream)?$/.test(req.path);++app.use('/api/', rateLimit({ windowMs: 60_000, max: 60, skip: isStatusRoute }));++app.use('/api/status/', rateLimit({ windowMs: 60_000, max: 240 }));++app.use('/api/', (_req, res, next) => { res.setHeader('Cache-Control', 'no-store'); next(); });// ── Analytics Middleware ─────────────────────────────────────app.use(analytics.middleware());@@@ -75,6 +84,11 @@ const downloadLimiter = rateLimit({// ── Auth ─────────────────────────────────────────────────────app.use(optionalAuth);++// ── Hosts ────────────────────────────────────────────────────++// Pages are only rendered for hosts this tool serves (or that the gateway vouches for with++// X-OV-Tool); aliases go to the short host, anything else to the tools index.++app.use(hostGuard({ knows: seo.knowsHost, aliasOf: seo.aliasOf }));++// ── API Routes ───────────────────────────────────────────────// Health check@@@ -100,13 +114,13 @@ app.post('/api/info', async (req, res) => {// Start downloadapp.post('/api/download', downloadLimiter, async (req, res) => {-- const { url, quality } = req.body;++ const { url, quality, title } = req.body;if (!url) return res.status(400).json({ error: 'URL is required' });if (!downloader.isValidUrl(url)) return res.status(400).json({ error: 'Only YouTube URLs are supported' });try {-- const { id } = await downloader.startDownload(url, quality || 'best');-- res.json({ success: true, id, statusUrl: `/api/status/${id}` });++ const { id } = await downloader.startDownload(url, quality || 'best', { title: typeof title === 'string' ? title : '' });++ res.json({ success: true, id, statusUrl: `/api/status/${id}`, streamUrl: `/api/status/${id}/stream` });} catch (err) {console.error('[Download] Error:', err.message);res.status(422).json({ error: err.message });@@@ -120,35 +134,70 @@ app.get('/api/status/:id', (req, res) => {res.json(status);});--// Download status via SSE (real-time progress)++// Download status via SSE (real-time progress).++//++// Protocol (the poll endpoint above answers the same JSON):++// event: progress status "downloading" — phase starting|downloading|processing, progress 0–100|null++// event: complete status "done" — download { url, size, ext, filename }++// event: failed status "error" — error text (cancelled: true after DELETE)++// Every frame is also sent once as an unnamed message (`data:` only) for clients that predate++// the named events. The stream closes itself after a terminal event.++const SSE_EVENT = { downloading: 'progress', done: 'complete', error: 'failed' };++app.get('/api/status/:id/stream', (req, res) => {const id = req.params.id;res.writeHead(200, {-- 'Content-Type': 'text/event-stream',-- 'Cache-Control': 'no-cache',++ 'Content-Type': 'text/event-stream; charset=utf-8',++ 'Cache-Control': 'no-store, no-transform','Connection': 'keep-alive','X-Accel-Buffering': 'no',});++ res.write('retry: 3000\n\n');++++ let timer = null;++ let lastFrame = '';++ let lastWrite = 0;++ const stop = () => { if (timer) { clearInterval(timer); timer = null; } };++ const send = (event, payload) => {++ const json = JSON.stringify(payload);++ res.write(`data: ${json}\n\n`);++ res.write(`event: ${event}\ndata: ${json}\n\n`);++ lastWrite = Date.now();++ };-- const interval = setInterval(() => {++ const tick = () => {const status = downloader.getStatus(id);if (!status) {-- res.write(`data: ${JSON.stringify({ error: 'Not found' })}\n\n`);-- clearInterval(interval);-- res.end();-- return;++ send('failed', { id, status: 'error', error: 'Download not found or expired' });++ stop();++ return res.end();}---- res.write(`data: ${JSON.stringify(status)}\n\n`);---- if (status.status === 'done' || status.status === 'error') {-- clearInterval(interval);-- setTimeout(() => res.end(), 500);++ const json = JSON.stringify(status);++ const terminal = status.status === 'done' || status.status === 'error';++ if (json !== lastFrame || terminal) {++ lastFrame = json;++ send(SSE_EVENT[status.status] || 'progress', status);++ } else if (Date.now() - lastWrite > 15000) {++ res.write(': keep-alive\n\n');++ lastWrite = Date.now();}-- }, 1000);++ if (terminal) {++ stop();++ setTimeout(() => res.end(), 250);++ }++ };++++ timer = setInterval(tick, 500);++ tick(); // first frame immediately, not after a tick of silence++ req.on('close', stop);++});-- req.on('close', () => clearInterval(interval));++// Cancel a running download (kills yt-dlp) or discard a finished file++app.delete('/api/download/:id', (req, res) => {++ const result = downloader.cancelDownload(req.params.id);++ if (!result) return res.status(404).json({ error: 'Download not found' });++ res.json({ success: true, ...result });});// Serve downloaded file@@@ -158,8 +207,9 @@ app.get('/api/download/:id', (req, res) => {res.set({'Content-Type': entry.mime,-- 'Content-Disposition': `attachment; filename="openvibeyt-download.${entry.ext}"`,++ 'Content-Disposition': downloader.contentDisposition(entry.filename || `youtube-video.${entry.ext}`),'Content-Length': entry.size,++ 'Cache-Control': 'private, no-store',});res.sendFile(entry.filePath);});@@@ -179,6 +229,7 @@ app.get('/api/internal/analytics/bots', (req, res) => {// ── Static Files ─────────────────────────────────────────────// (shared client-side libs are loaded absolutely from https://openvibe.network/shared/)app.use(express.static(path.join(__dirname, '..', 'public'), {++ index: false, // '/' is rendered below with the head for this host (server/seo.js)setHeaders(res, filePath) {if (filePath.endsWith('.js') || filePath.endsWith('.css')) {res.setHeader('Cache-Control', 'no-cache');@@@ -186,10 +237,16 @@ app.use(express.static(path.join(__dirname, '..', 'public'), {},}));--// SPA fallback--app.get('*', (req, res) => {++// The page: one document, head stamped per host. Anything else that is not a file is a 404++// (no soft-404 copies of the home page under made-up paths).++app.get(['/', '/index.html'], (req, res) => {++ if (req.path !== '/') return res.redirect(301, '/');++ return seo.sendIndex(req, res);++});++app.use((req, res) => {if (req.path.startsWith('/api/')) return res.status(404).json({ error: 'Not found' });-- res.sendFile(path.join(__dirname, '..', 'public', 'index.html'));++ res.status(404).set('Cache-Control', 'no-cache').type('html')++ .send('<!doctype html><meta charset="utf-8"><meta name="robots" content="noindex"><title>Not found</title><p>Nothing here. <a href="/">YouTube Downloader</a></p>');});// ── Start ────────────────────────────────────────────────────nnew file mode 100644@@@ -0,0 +1,150 @@++'use strict';++++// ═══════════════════════════════════════════════════════════════++// YT.OpenVibe — hosts and the server-rendered <head>++//++// public/index.html carries the tool and its crawlable copy; the head is stamped here per++// request so canonical / Open Graph / Twitter / JSON-LD name the right host:++// - through the gateway (X-OV-* headers): the canonical host it names, so the short host++// (yt.…) points at youtube-downloader.… or at a custom domain;++// - reached directly: the host itself, as before.++// ═══════════════════════════════════════════════════════════════++++const fs = require('fs');++const path = require('path');++const { canonicalHostFor, requestHost, isLocalHost } = require('../../_shared/host-role');++++const PUBLIC = path.join(__dirname, '..', 'public');++++// Code defaults; the gateway's catalog (and the Network's domain overrides) win when present.++const HOSTS = {++ canonical: 'youtube-downloader.openvibe.tools',++ short: 'yt.openvibe.tools',++ aliases: ['youtube.openvibe.tools', 'ytdl.openvibe.tools'],++};++const DEFAULT_HOST = HOSTS.short;++++const knowsHost = (host) => host === HOSTS.canonical || host === HOSTS.short;++const aliasOf = (host) => (HOSTS.aliases.includes(host) ? HOSTS.short : '');++++const TITLE = 'YouTube Downloader — Save Video & Audio | OpenVibe';++const DESCRIPTION = 'Download YouTube videos as MP4 or WebM up to 1080p, or keep just the audio as MP3, M4A, Opus or FLAC. Paste a link, pick a format, save the file.';++const KEYWORDS = ['youtube downloader', 'youtube to mp3', 'youtube to mp4', 'download youtube video', 'youtube audio downloader', 'youtube to flac'];++++// The same questions are printed on the page (public/index.html) — keep the two in step.++const FAQ = [++ { q: 'How do I download a YouTube video?',++ a: 'Copy the video link from YouTube, paste it into the box at the top of this page and press Fetch. Pick a video quality or an audio format, press Download, and when the file is ready press Save file.' },++ { q: 'Which formats and qualities can I choose?',++ a: 'Video is saved as MP4 at the best available quality, 1080p, 720p, 480p or 360p, or as WebM (VP9). Audio only can be saved as MP3, M4A (AAC), Opus or FLAC.' },++ { q: 'How do I convert a YouTube video to MP3?',++ a: 'Paste the link, choose MP3 under Audio Only and press Download. The audio track is extracted and converted on the server, then offered as a single MP3 file named after the video.' },++ { q: 'Is it legal to download YouTube videos?',++ a: 'That depends on the video and on where you live. Downloading your own uploads, public-domain works and videos whose licence allows copies (for example Creative Commons) is generally fine. Other videos are protected by copyright and YouTube\'s terms of service restrict downloading them without permission. You are responsible for having the right to save what you download.' },++ { q: 'How long is my file kept?',++ a: 'Files are temporary. A finished download stays on the server for about an hour so you can save it, then it is deleted automatically.' },++ { q: 'Why did a download fail?',++ a: 'Private, members-only and age-restricted videos cannot be fetched, and very long videos (over three hours) are refused. If a quality is not offered for a video, pick another one. Occasionally YouTube rate-limits the server; trying again a few minutes later usually works.' },++];++++const HOWTO_STEPS = [++ { name: 'Copy the video link', text: 'Open the video on YouTube and copy its address (youtube.com/watch… or youtu.be/…).' },++ { name: 'Paste it here', text: 'Paste the link into the box on this page and press Fetch to load the title, thumbnail and length.' },++ { name: 'Choose a format', text: 'Pick a video quality (up to 1080p MP4, or WebM) or an audio format (MP3, M4A, Opus, FLAC).' },++ { name: 'Download and save', text: 'Press Download, watch the progress, then press Save file. The file is named after the video.' },++];++++const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));++const ldScript = (obj) => `<script type="application/ld+json">${JSON.stringify(obj).replace(/</g, '\\u003c')}</script>`;++++function headTags(canonicalHost) {++ const url = `https://${canonicalHost}/`;++ const image = `https://${canonicalHost}/og.png`;++ const ld = [++ {++ '@context': 'https://schema.org', '@type': 'SoftwareApplication',++ name: 'OpenVibe YouTube Downloader', url, description: DESCRIPTION, image,++ applicationCategory: 'MultimediaApplication', operatingSystem: 'Any (web browser)',++ keywords: KEYWORDS.join(', '),++ featureList: ['MP4 up to 1080p', 'WebM (VP9)', 'MP3', 'M4A (AAC)', 'Opus', 'FLAC', 'Live progress with speed and time remaining'],++ publisher: { '@type': 'Organization', name: 'OpenVibe', url: 'https://openvibe.network/' },++ },++ {++ '@context': 'https://schema.org', '@type': 'HowTo',++ name: 'How to download a YouTube video', description: DESCRIPTION, url, image,++ totalTime: 'PT1M',++ step: HOWTO_STEPS.map((s, i) => ({ '@type': 'HowToStep', position: i + 1, name: s.name, text: s.text, url: `${url}#how-to` })),++ },++ {++ '@context': 'https://schema.org', '@type': 'FAQPage', url,++ mainEntity: FAQ.map(f => ({ '@type': 'Question', name: f.q, acceptedAnswer: { '@type': 'Answer', text: f.a } })),++ },++ {++ '@context': 'https://schema.org', '@type': 'BreadcrumbList',++ itemListElement: [++ { '@type': 'ListItem', position: 1, name: 'OpenVibe Tools', item: 'https://openvibe.tools/' },++ { '@type': 'ListItem', position: 2, name: 'YouTube Downloader', item: url },++ ],++ },++ ];++++ return [++ `<title>${esc(TITLE)}</title>`,++ `<meta name="description" content="${esc(DESCRIPTION)}">`,++ `<meta name="keywords" content="${esc(KEYWORDS.join(', '))}">`,++ `<link rel="canonical" href="${url}">`,++ '<meta name="robots" content="index, follow, max-image-preview:large, max-snippet:-1">',++ `<meta property="og:title" content="${esc(TITLE)}">`,++ `<meta property="og:description" content="${esc(DESCRIPTION)}">`,++ '<meta property="og:type" content="website">',++ `<meta property="og:url" content="${url}">`,++ `<meta property="og:image" content="${image}">`,++ '<meta property="og:image:width" content="1200">',++ '<meta property="og:image:height" content="630">',++ '<meta property="og:site_name" content="OpenVibe">',++ '<meta property="og:locale" content="en_US">',++ '<meta name="twitter:card" content="summary_large_image">',++ `<meta name="twitter:title" content="${esc(TITLE)}">`,++ `<meta name="twitter:description" content="${esc(DESCRIPTION)}">`,++ `<meta name="twitter:image" content="${image}">`,++ ...ld.map(ldScript),++ ].join('\n ');++}++++/** The how-to and the questions, visible on the page (and readable without JavaScript). */++function contentBlock() {++ return `<section class="yt-info" id="how-to" aria-labelledby="yt-howto-h"><h2 id="yt-howto-h">How to download a YouTube video</h2><ol>${HOWTO_STEPS.map(s => `<li><b>${esc(s.name)}.</b> ${esc(s.text)}</li>`).join('')}</ol></section>++<section class="yt-info" id="faq" aria-labelledby="yt-faq-h"><h2 id="yt-faq-h">Questions</h2>${FAQ.map(f => `<details><summary>${esc(f.q)}</summary><p>${esc(f.a)}</p></details>`).join('')}++<p class="yt-more">More tools: <a href="https://openvibe.tools/">every OpenVibe tool</a> · <a href="https://openvibe.tools/audio-tools">audio tools</a> · <a href="https://openvibe.tools/image-tools">image tools</a></p></section>++<style>.yt-info{max-width:760px;margin:36px auto 0;padding:0 20px;color:var(--text-secondary,#a8b3c4);line-height:1.6}.yt-info h2{color:var(--text-primary,#e6edf7);font-size:20px;margin:0 0 10px}.yt-info li{margin:6px 0}.yt-info details{border-bottom:1px solid var(--border,rgba(255,255,255,.08));padding:10px 0}.yt-info summary{cursor:pointer;color:var(--text-primary,#e6edf7);font-weight:600}.yt-more{margin-top:18px;font-size:14px}</style>`;++}++++let _base = null;++const _cache = new Map();++++function baseHtml() {++ if (_base === null || process.env.NODE_ENV === 'development') _base = fs.readFileSync(path.join(PUBLIC, 'index.html'), 'utf8');++ return _base;++}++++function renderIndex(canonicalHost) {++ if (process.env.NODE_ENV !== 'development' && _cache.has(canonicalHost)) return _cache.get(canonicalHost);++ const out = baseHtml().replace('<!--OV:HEAD-->', headTags(canonicalHost)).replace('<!--OV:CONTENT-->', contentBlock());++ if (_cache.size > 50) _cache.clear();++ _cache.set(canonicalHost, out);++ return out;++}++++/** Express handler: the page with this request's canonical host stamped into the head. */++function sendIndex(req, res) {++ const host = requestHost(req);++ const own = !host || isLocalHost(host) ? DEFAULT_HOST : host;++ // Reached directly (no gateway headers) on the short host: the canonical is still the descriptive host.++ const canonicalHost = canonicalHostFor(req, own === HOSTS.short ? HOSTS.canonical : own);++ res.setHeader('Content-Type', 'text/html; charset=utf-8');++ res.setHeader('Cache-Control', 'no-cache');++ res.setHeader('Vary', 'X-OV-Canonical-Host');++ return res.send(renderIndex(canonicalHost));++}++++module.exports = { HOSTS, TITLE, DESCRIPTION, FAQ, HOWTO_STEPS, knowsHost, aliasOf, headTags, renderIndex, sendIndex };nnew file mode 100644@@@ -0,0 +1,11 @@++'use strict';++const assert = require('assert');++const DS = require('../public/js/download-state.js');++let s = DS.initial(); s = DS.reduce(s, { status: 'downloading', progress: null }); assert.equal(s.phase, 'starting');++s = DS.reduce(s, { status: 'downloading', progress: 40, speed: '2MiB/s', eta: '00:05' }); assert.equal(s.progress, 0.4);++s = DS.reduce(s, { status: 'downloading', progress: 10 }); assert.equal(s.percent, 40, 'never runs backwards');++const done = DS.reduce(s, { status: 'done', id: 'ab', download: { filename: 'a.mp3', size: 5 } }); assert.ok(done.terminal && done.file.url === '/api/download/ab');++assert.ok(DS.reduce(s, { status: 'complete', id: 'ab' }).terminal, 'the legacy word is accepted');++assert.strictEqual(DS.reduce(done, { status: 'downloading', progress: 1 }), done, 'a terminal state is final');++assert.equal(DS.pollDelay(11 * 60 * 1000), null);++console.log('download-state: all checks passed');nnew file mode 100755@@@ -0,0 +1,55 @@++#!/usr/bin/env bash++# Put a custom domain (e.g. youtubedownloadonline.com) in front of the Tools gateway.++#++# sudo deploy/scripts/add-custom-domain.sh youtubedownloadonline.com [--dns-cloudflare]++#++# 1. Point the domain's A/AAAA records at this server first.++# 2. Run this script: it issues a certificate (HTTP-01 by default, or DNS-01 through the++# Cloudflare credentials already on the host) and writes an nginx vhost that forwards++# the domain, and www., to the gateway with the original Host header.++# 3. In openvibe.network/admin → Domains, attach the host to a tool (owner only). The gateway++# picks it up within a minute; until then it answers 404 for the unknown host.++set -euo pipefail++DOMAIN="${1:?usage: add-custom-domain.sh <domain> [--dns-cloudflare]}"; MODE="${2:-}"++[[ "$DOMAIN" =~ ^([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$ ]] || { echo "Not a valid hostname: $DOMAIN" >&2; exit 1; }++[[ "$DOMAIN" == *openvibe.* ]] && { echo "OpenVibe zones are already covered by their wildcard vhosts." >&2; exit 1; }++PORT="${GATEWAY_PORT:-4001}"; CONF="/etc/nginx/sites-available/custom-$DOMAIN.conf"; WEBROOT=/var/www/letsencrypt++mkdir -p "$WEBROOT"++++if [[ ! -d "/etc/letsencrypt/live/$DOMAIN" ]]; then++ if [[ "$MODE" == "--dns-cloudflare" ]]; then++ certbot certonly --dns-cloudflare --dns-cloudflare-credentials /root/.secrets/cloudflare-openvibe.ini -d "$DOMAIN" -d "www.$DOMAIN" --non-interactive --agree-tos++ else++ cat > "$CONF" <<NGINX++server { listen 80; listen [::]:80; server_name $DOMAIN www.$DOMAIN; location /.well-known/acme-challenge/ { root $WEBROOT; } location / { return 404; } }++NGINX++ ln -sf "$CONF" "/etc/nginx/sites-enabled/custom-$DOMAIN.conf"; nginx -t && systemctl reload nginx++ certbot certonly --webroot -w "$WEBROOT" -d "$DOMAIN" -d "www.$DOMAIN" --non-interactive --agree-tos++ fi++fi++++cat > "$CONF" <<NGINX++# Custom domain for an OpenVibe tool. Generated by add-custom-domain.sh; the gateway decides++# which tool this host is (openvibe.network/admin → Domains).++server { listen 80; listen [::]:80; server_name $DOMAIN www.$DOMAIN;++ location /.well-known/acme-challenge/ { root $WEBROOT; }++ location / { return 301 https://$DOMAIN\$request_uri; } }++server { listen 443 ssl http2; listen [::]:443 ssl http2; server_name www.$DOMAIN;++ ssl_certificate /etc/letsencrypt/live/$DOMAIN/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/$DOMAIN/privkey.pem;++ return 301 https://$DOMAIN\$request_uri; }++server { listen 443 ssl http2; listen [::]:443 ssl http2; server_name $DOMAIN;++ ssl_certificate /etc/letsencrypt/live/$DOMAIN/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/$DOMAIN/privkey.pem;++ client_max_body_size 512m;++ add_header Strict-Transport-Security "max-age=31536000" always;++ location / {++ proxy_pass http://127.0.0.1:$PORT; proxy_http_version 1.1;++ proxy_set_header Host \$host; proxy_set_header X-Real-IP \$remote_addr;++ proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto https;++ # Host-role headers come from the gateway only, never from a client.++ proxy_set_header X-OV-Tool ""; proxy_set_header X-OV-Host-Role ""; proxy_set_header X-OV-Canonical-Host ""; proxy_set_header X-OV-Short-Host "";++ proxy_buffering off; proxy_read_timeout 600s; proxy_send_timeout 600s;++ } }++NGINX++ln -sf "$CONF" "/etc/nginx/sites-enabled/custom-$DOMAIN.conf"++nginx -t && systemctl reload nginx++echo "Done. Now attach $DOMAIN to a tool in https://openvibe.network/admin (Domains tab)."@@@ -308,7 +308,14 @@}}++ /** Drop the server-held cookies (ov_sso is httpOnly, so only the server can). Best effort. */++ function serverLogout() {++ try { fetch(`${_config.apiBase}/api/auth/logout`, { method: 'POST', credentials: 'include', keepalive: true }).catch(() => {}); } catch { /* */ }++ try { localStorage.setItem('ov_sso_hint', 'guest'); } catch { /* */ }++ }++function logout() {++ serverLogout();const activeId = getActiveId();if (activeId) removeAccount(activeId);localStorage.removeItem(TOKEN_KEY);@@@ -319,6 +326,7 @@}function logoutAll() {++ serverLogout();localStorage.removeItem(STORAGE_KEY);localStorage.removeItem(TOKEN_KEY);localStorage.removeItem(ACTIVE_KEY);@@@ -19,7 +19,7 @@ const BRAND_DEFAULTS = Object.freeze({gamesServiceName: 'OpenVibe.Games',mediaServiceName: 'OpenVibe.Media',tagline: 'One Account. All of OpenVibe.',-- campTagline: 'Free & Open Live Streaming',++ campTagline: 'Open Live Streaming, Community Run',toolsSubdomainBase: 'openvibe.tools',discord: 'https://discord.gg/M6MuRUaeJj',github: 'https://github.com/OpenVibe.Live',@@@ -99,8 +99,8 @@ function buildBrandFromRegistry(registry = {}, env = process.env) {// Build service list using registry URLs where availableconst services = [-- { id: 'live', name: streamerServiceName, url: liveUrl, description: 'Free & Open Live Streaming' },-- { id: 'tools', name: toolsServiceName, url: toolsUrl, description: 'Free Online Tools' },++ { id: 'live', name: streamerServiceName, url: liveUrl, description: 'Open live streaming, community run' },++ { id: 'tools', name: toolsServiceName, url: toolsUrl, description: 'Online tools for everyone' },{ id: 'games', name: gamesServiceName, url: gamesUrl, description: 'Games & Community Canvas' },{ id: 'media', name: mediaServiceName, url: mediaUrl, description: 'VODs, Clips, Pastes & Files' },{ id: 'network', name: BRAND_DEFAULTS.networkServiceName, url: networkUrl, description: 'Accounts, SSO & OpenCoins' },@@@ -155,8 +155,8 @@ const BRAND = Object.freeze({// Primary services in the network (for dashboard display, health checks, etc.)// Static list with OpenVibe defaults — use buildBrandFromRegistry() for live values.services: Object.freeze([-- { id: 'live', name: 'OpenVibe.Live', url: 'https://openvibe.live', description: 'Free & Open Live Streaming' },-- { id: 'tools', name: 'OpenVibe.Tools', url: 'https://openvibe.tools', description: 'Free Online Tools' },++ { id: 'live', name: 'OpenVibe.Live', url: 'https://openvibe.live', description: 'Open live streaming, community run' },++ { id: 'tools', name: 'OpenVibe.Tools', url: 'https://openvibe.tools', description: 'Online tools for everyone' },{ id: 'games', name: 'OpenVibe.Games', url: 'https://openvibe.games', description: 'Games & Community Canvas' },{ id: 'media', name: 'OpenVibe.Media', url: 'https://openvibe.media', description: 'VODs, Clips, Pastes & Files' },{ id: 'network', name: 'OpenVibe.Network', url: 'https://openvibe.network', description: 'Accounts, SSO & OpenCoins' },@@@ -34,7 +34,7 @@// the link, with no edit anywhere else.const NETWORK = [{ id: 'live', name: 'Live streaming', url: 'https://openvibe.live', desc: 'Go live, restream and clip' },-- { id: 'tools', name: 'Free online tools', url: 'https://openvibe.tools', desc: 'The whole toolbox' },++ { id: 'tools', name: 'Online tools', url: 'https://openvibe.tools', desc: 'The whole toolbox' },{ id: 'dev', name: 'Developer tools', url: 'https://dev.openvibe.tools', desc: 'Formatters, encoders, validators' },{ id: 'net', name: 'Network diagnostics', url: 'https://net.openvibe.tools', desc: 'Ping, DNS, Whois, SSL' },{ id: 'img', name: 'Image converter', url: 'https://img.openvibe.tools', desc: 'Convert, resize, compress' },@@@ -42,7 +42,11 @@{ id: 'docs', name: 'PDF & document tools', url: 'https://docs.openvibe.tools', desc: 'Merge, split, compress' },{ id: 'text', name: 'Text tools', url: 'https://text.openvibe.tools', desc: 'Fancy text, case, ASCII' },{ id: 'yt', name: 'YouTube downloader', url: 'https://yt.openvibe.tools', desc: 'Video and audio' },++ { id: 'community', name: 'Community & pastes', url: 'https://openvibe.community', desc: 'Pastes, posts and the people of OpenVibe' },++ { id: 'games', name: 'Games', url: 'https://openvibe.games', desc: 'Scraplandia and browser games' },++ { id: 'media', name: 'Media', url: 'https://openvibe.media', desc: 'VODs, clips and files for every site' },{ id: 'network', name: 'Account & themes', url: NETWORK_URL, desc: 'One account, every site' },++ { id: 'more', name: 'The whole network →', url: NETWORK_URL + '/#network', desc: 'Every OpenVibe site, live and upcoming' },];const LEGAL = [@@@ -59,7 +63,7 @@const DEFAULTS = {service: null, // 'live' | 'tools' | 'dev' | … (auto-detected when omitted)brandName: null,-- tagline: 'Free, open source and community run. Built in the open by the people using it.',++ tagline: 'Open source and community run. Built in the open by the people using it.',variant: 'full',mount: '#ov-footer',links: [], // [{ heading, items: [{ name, url, onclick? }] }]@@@ -84,9 +88,20 @@return sub === h ? 'tools' : (sub || 'tools');}++ const TLD_LABELS = { live: 'Live', tools: 'Tools', network: 'Network', media: 'Media', games: 'Games', community: 'Community', chat: 'Chat', codes: 'Codes', blog: 'Blog', wiki: 'Wiki', news: 'News', reviews: 'Reviews', tips: 'Tips', vip: 'VIP', trade: 'Trade', host: 'Host', deals: 'Deals', coupons: 'Coupons' };++ /** The full site name from the hostname — the same spelling the navbar uses (Pastes.OpenVibe.Tools). */function brandFor(service) {++ // The navbar already resolved the spelling (MergePDF, JSON, Pastes…): reuse it so the two++ // never disagree on the same page.++ try { const nb = typeof window !== 'undefined' && window.OpenVibeNavbar && window.OpenVibeNavbar.brand && window.OpenVibeNavbar.brand(); if (nb && nb.name) return nb.name; } catch { /* */ }++ const h = typeof location !== 'undefined' ? location.hostname.toLowerCase() : '';++ const cap = (w) => w ? w.charAt(0).toUpperCase() + w.slice(1) : '';++ let m = h.match(/^(?:(.+)\.)?openvibe\.([a-z]+)$/);++ if (m) { const sub = m[1] && m[1] !== 'www' ? m[1] : null; return [sub ? (sub.length <= 4 ? sub.toUpperCase() : cap(sub)) : null, 'OpenVibe', TLD_LABELS[m[2]] || cap(m[2])].filter(Boolean).join('.'); }++ if ((m = h.match(/^(?:(.+)\.)?openre\.stream$/))) return 'OpenRe.Stream';const found = NETWORK.find(n => n.id === service);-- if (found) return found.name === 'Account & themes' ? 'OpenVibe' : found.name;++ if (service && TLD_LABELS[service]) return `OpenVibe.${TLD_LABELS[service]}`;++ if (found && found.name !== 'Account & themes' && found.name !== 'The whole network →') return `${cap(service)}.OpenVibe.Tools`;return 'OpenVibe';}nnew file mode 100644@@@ -0,0 +1,111 @@++/**++ * openvibe-shared/history.js — cross-site history for a signed-in account.++ *++ * OpenVibeHistory.record({ type, title, url, icon, service, meta }, { token, apiBase })++ * OpenVibeHistory.recent({ limit, service, type, token, apiBase }) → [{...}]++ * OpenVibeHistory.clear({ token }) → wipes it++ *++ * Every property records what the account touched — a tool used, a stream watched, a paste++ * opened, a game played — so "Recently used" in the shared navbar and the History tab on the++ * Network show one timeline across the whole network instead of one per site. Writes are++ * deduplicated server-side (same URL within ten minutes bumps the entry instead of adding one)++ * and the browser side throttles too, so a page cannot flood it. Nothing is recorded for++ * anonymous sessions, and the user can pause or clear the timeline from their account page.++ *++ * Loaded lazily by navbar.js from https://openvibe.network/shared/history.js; safe to include++ * directly. Token resolution matches the navbar: ov_token cookie → localStorage.ov_token.++ */++(function (root) {++ 'use strict';++ if (root.OpenVibeHistory) return;++++ const DEFAULT_API = 'https://openvibe.network';++ const SENT_KEY = 'ov_history_sent'; // sessionStorage: url → ts, to avoid re-posting on every re-render++ const THROTTLE_MS = 5 * 60 * 1000;++++ function token(opts) {++ if (opts && opts.token) return opts.token;++ const m = (typeof document !== 'undefined' ? document.cookie : '').match(/(?:^|;\s*)ov_token=([^;]*)/);++ if (m && m[1]) { try { return decodeURIComponent(m[1]); } catch { return m[1]; } }++ try { return localStorage.getItem('ov_token'); } catch { return null; }++ }++++ function api(opts) { return (opts && opts.apiBase) || DEFAULT_API; }++++ function sent() { try { return JSON.parse(sessionStorage.getItem(SENT_KEY) || '{}'); } catch { return {}; } }++ function markSent(url) {++ try {++ const m = sent(); m[url] = Date.now();++ const keys = Object.keys(m); if (keys.length > 60) for (const k of keys.slice(0, keys.length - 60)) delete m[k];++ sessionStorage.setItem(SENT_KEY, JSON.stringify(m));++ } catch { /* storage unavailable */ }++ }++++ /** Guess an icon and service label from the URL when the caller gives none. */++ function inferService(url) {++ let host = '';++ try { host = new URL(url, location.href).hostname; } catch { return { service: null }; }++ const m = host.match(/^(?:(.+)\.)?openvibe\.([a-z]+)$/) || host.match(/^(?:(.+)\.)?(openre)\.stream$/);++ if (!m) return { service: null };++ return { service: m[2], sub: m[1] || null };++ }++++ const ICONS = { tool: 'fa-screwdriver-wrench', stream: 'fa-tower-broadcast', vod: 'fa-clapperboard', clip: 'fa-scissors', paste: 'fa-paste', game: 'fa-gamepad', page: 'fa-file-lines', post: 'fa-comments', account: 'fa-user', theme: 'fa-palette', download: 'fa-download', chat: 'fa-comment' };++++ async function record(entry, opts) {++ const t = token(opts);++ if (!t || !entry) return false;++ const url = String(entry.url || (typeof location !== 'undefined' ? location.href : '')).slice(0, 2000);++ if (!url) return false;++ const last = sent()[url] || 0;++ if (!entry.force && Date.now() - last < THROTTLE_MS) return false;++ markSent(url);++ const inf = inferService(url);++ const body = {++ type: String(entry.type || 'page').slice(0, 32),++ title: String(entry.title || (typeof document !== 'undefined' ? document.title : '') || url).slice(0, 200),++ url,++ icon: entry.icon || ICONS[entry.type] || null,++ service: entry.service || inf.service || null,++ sub: entry.sub || inf.sub || null,++ meta: entry.meta && typeof entry.meta === 'object' ? entry.meta : null,++ };++ try {++ const res = await fetch(`${api(opts)}/api/history`, {++ method: 'POST', keepalive: true,++ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${t}` },++ body: JSON.stringify(body),++ });++ return res.ok;++ } catch { return false; }++ }++++ async function recent(opts) {++ const t = token(opts);++ if (!t) return [];++ const q = new URLSearchParams();++ if (opts && opts.limit) q.set('limit', String(opts.limit));++ if (opts && opts.service) q.set('service', opts.service);++ if (opts && opts.type) q.set('type', opts.type);++ try {++ const res = await fetch(`${api(opts)}/api/history?${q}`, { headers: { Authorization: `Bearer ${t}` } });++ if (!res.ok) return [];++ const data = await res.json();++ return (data && data.items) || [];++ } catch { return []; }++ }++++ async function clear(opts) {++ const t = token(opts);++ if (!t) return false;++ try { const res = await fetch(`${api(opts)}/api/history`, { method: 'DELETE', headers: { Authorization: `Bearer ${t}` } }); return res.ok; } catch { return false; }++ }++++ async function remove(id, opts) {++ const t = token(opts);++ if (!t) return false;++ try { const res = await fetch(`${api(opts)}/api/history/${encodeURIComponent(id)}`, { method: 'DELETE', headers: { Authorization: `Bearer ${t}` } }); return res.ok; } catch { return false; }++ }++++ root.OpenVibeHistory = { record, recent, clear, remove, ICONS };++})(typeof window !== 'undefined' ? window : globalThis);nnew file mode 100644@@@ -0,0 +1,161 @@++/**++ * openvibe-shared/island.js — the activity island.++ *++ * A page tells the navbar what it is doing. The brand mark becomes the indicator (progress ring,++ * state colour) and a pill grows out of the brand; hover, focus or tap expands it into a rich card++ * (image, title, subtitle, progress bar, detail line, actions).++ *++ * OpenVibeIsland.start({ id, title, subtitle, icon, image, progress, state, detail, actions })++ * OpenVibeIsland.update(id, patch) progress: 0–1 | null (indeterminate)++ * OpenVibeIsland.finish(id, { state: 'ok', title, actions, ttl })++ * OpenVibeIsland.fail(id, { title, detail })++ * OpenVibeIsland.remove(id) · .list()++ *++ * Works without the navbar (fixed pill, top centre). All text goes through textContent; links are++ * http(s) or same-origin paths only. Motion respects prefers-reduced-motion.++ */++(function (root) {++ 'use strict';++ if (root.OpenVibeIsland) return;++ if (typeof document === 'undefined') { const n = () => {}; const stub = { start: () => ({ update: n, finish: n, fail: n, remove: n }), update: n, finish: n, fail: n, remove: n, list: () => [] }; if (typeof module !== 'undefined') module.exports = stub; return; }++++ const CSS = `++.ov-island{position:relative;display:inline-flex;align-items:center;margin-left:10px;z-index:60;font:500 12.5px/1.2 system-ui,-apple-system,Segoe UI,Roboto,sans-serif;color:var(--text-primary,#e6edf7)}++.ov-island.is-floating{position:fixed;top:10px;left:50%;transform:translateX(-50%);margin:0;z-index:2147482000}++.ov-island[hidden]{display:none}++.ovi-pill{appearance:none;border:1px solid color-mix(in srgb,var(--ovis-c) 45%,transparent);background:color-mix(in srgb,var(--ovis-c) 12%,var(--bg-elevated,var(--bg-secondary,#0d131d)));color:inherit;font:inherit;display:inline-flex;align-items:center;gap:8px;height:30px;padding:0 12px 0 5px;border-radius:999px;cursor:pointer;max-width:min(300px,46vw);animation:ovisGrow .4s cubic-bezier(.2,1.3,.3,1);box-shadow:0 4px 18px rgba(0,0,0,.28)}++.ov-island{--ovis-c:var(--accent,#3b82f6)}++.ov-island[data-state=ok]{--ovis-c:var(--success,#22c55e)}++.ov-island[data-state=error]{--ovis-c:var(--danger,#ef4444)}++.ovi-pill .ov-icon{--ovi-size:22px;--ovi-accent:var(--ovis-c)}++.ovi-pill-text{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:650}++.ovi-pill-pct{font-variant-numeric:tabular-nums;color:var(--text-secondary,#a8b3c4)}++.ovi-count{background:var(--ovis-c);color:var(--on-accent,#fff);border-radius:999px;font-size:10.5px;font-weight:800;padding:1px 6px}++.ovi-card{position:absolute;top:calc(100% + 8px);left:0;width:min(340px,calc(100vw - 24px));background:var(--bg-elevated,var(--bg-secondary,#111826));border:1px solid var(--border,rgba(255,255,255,.12));border-radius:18px;box-shadow:0 24px 60px rgba(0,0,0,.5);padding:8px;opacity:0;transform:translateY(-6px) scale(.97);transform-origin:top left;pointer-events:none;transition:opacity .18s,transform .22s cubic-bezier(.2,1.2,.3,1)}++.ov-island.is-floating .ovi-card{left:50%;translate:-50% 0;transform-origin:top center}++.ov-island.is-open .ovi-card{opacity:1;transform:none;pointer-events:auto}++.ovi-item{display:grid;grid-template-columns:auto 1fr;gap:10px;padding:8px;border-radius:12px;--ovis-c:var(--accent,#3b82f6)}++.ovi-item[data-state=ok]{--ovis-c:var(--success,#22c55e)}++.ovi-item[data-state=error]{--ovis-c:var(--danger,#ef4444)}++.ovi-item+.ovi-item{border-top:1px solid var(--border,rgba(255,255,255,.08));border-radius:0}++.ovi-thumb{width:64px;height:44px;border-radius:8px;object-fit:cover;background:#0006}++.ovi-item .ov-icon{--ovi-size:40px;--ovi-accent:var(--ovis-c)}++.ovi-t{font-weight:700;font-size:13.5px}++.ovi-s{color:var(--text-secondary,#a8b3c4);margin-top:2px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}++.ovi-body{min-width:0}++.ovi-bar{height:4px;border-radius:4px;background:color-mix(in srgb,var(--ovis-c) 18%,transparent);margin-top:8px;overflow:hidden}++.ovi-bar i{display:block;height:100%;width:0;background:var(--ovis-c);border-radius:4px;transition:width .35s cubic-bezier(.2,.8,.2,1)}++.ovi-bar.is-ind i{width:35%;animation:ovisInd 1.2s ease-in-out infinite}++.ovi-d{color:var(--text-muted,#7d8aa0);font-size:11.5px;margin-top:6px;font-variant-numeric:tabular-nums;overflow-wrap:anywhere}++.ovi-acts{display:flex;gap:6px;flex-wrap:wrap;margin-top:8px}++.ovi-act{appearance:none;border:0;cursor:pointer;text-decoration:none;font:700 12px/1 system-ui,sans-serif;padding:7px 11px;border-radius:8px;color:var(--ovis-c);background:color-mix(in srgb,var(--ovis-c) 14%,transparent)}++.ovi-act:first-child{background:var(--ovis-c);color:var(--on-accent,#fff)}++.ovi-act:focus-visible,.ovi-pill:focus-visible{outline:2px solid var(--ovis-c);outline-offset:2px}++@keyframes ovisGrow{from{max-width:30px;opacity:0}to{opacity:1}}++@keyframes ovisInd{0%{transform:translateX(-110%)}100%{transform:translateX(300%)}}++@media (max-width:640px){.ov-island{margin-left:6px}.ovi-pill{max-width:40vw;padding-right:9px}.ovi-pill-pct{display:none}.ovi-card{position:fixed;top:56px;left:12px;right:12px;width:auto}}++@media (prefers-reduced-motion:reduce){.ovi-pill,.ovi-bar.is-ind i{animation:none}.ovi-card,.ovi-bar i{transition:none}}`;++++ const items = new Map(); // id → activity (insertion order = age)++ const timers = new Map();++ let host = null, pill = null, card = null, seq = 0;++++ const el = (tag, cls, text) => { const e = document.createElement(tag); if (cls) e.className = cls; if (text != null) e.textContent = String(text); return e; };++ const safeUrl = (u) => { try { const x = new URL(u, location.href); return /^(https?|blob):$/.test(x.protocol) ? x.href : null; } catch { return null; } };++ const clamp = (p) => (typeof p === 'number' && isFinite(p) ? Math.max(0, Math.min(1, p)) : null);++++ function ensure() {++ if (host && host.isConnected) return;++ if (!document.getElementById('ov-island-css')) { const s = el('style'); s.id = 'ov-island-css'; s.textContent = CSS; document.head.appendChild(s); }++ if (!root.OpenVibeIcons && !document.getElementById('ov-icons-loader')) { const sc = el('script'); sc.id = 'ov-icons-loader'; sc.src = 'https://openvibe.network/shared/ov-icons.js'; sc.async = true; document.head.appendChild(sc); }++ host = el('div', 'ov-island'); host.hidden = true;++ pill = el('button', 'ovi-pill'); pill.type = 'button'; pill.setAttribute('aria-haspopup', 'true'); pill.setAttribute('aria-expanded', 'false');++ card = el('div', 'ovi-card'); card.setAttribute('role', 'region'); card.setAttribute('aria-label', 'Activity');++ host.append(pill, card);++ const brand = document.querySelector('.openvibe-navbar .ovnav-brand, .openvibe-navbar .openvibe-brand, .navbar .nav-brand, .navbar .logo');++ if (brand && brand.parentNode) brand.parentNode.insertBefore(host, brand.nextSibling);++ else { host.classList.add('is-floating'); document.body.appendChild(host); }++ const open = (v) => { host.classList.toggle('is-open', v); pill.setAttribute('aria-expanded', String(v)); };++ pill.addEventListener('click', () => open(!host.classList.contains('is-open')));++ if (matchMedia('(hover:hover)').matches) { host.addEventListener('mouseenter', () => open(true)); host.addEventListener('mouseleave', () => open(false)); }++ document.addEventListener('click', (e) => { if (!host.contains(e.target)) open(false); });++ document.addEventListener('keydown', (e) => { if (e.key === 'Escape') open(false); });++ }++++ function top() { let t = null; for (const a of items.values()) t = a; return t; }++++ function brandMark(a) {++ // The navbar's own mark mirrors the top activity: progress ring + state colour.++ document.querySelectorAll('.openvibe-navbar .ov-mark, .navbar .ov-mark').forEach((m) => {++ if (m.closest('.openvibe-network-badge')) return;++ if (!a) { m.removeAttribute('data-state'); m.removeAttribute('data-progress'); m.style.removeProperty('--ovm-p'); return; }++ m.setAttribute('data-state', a.state === 'info' ? 'idle' : a.state);++ if (a.progress == null || a.state !== 'busy') m.removeAttribute('data-progress'); else { m.setAttribute('data-progress', String(a.progress)); m.style.setProperty('--ovm-p', String(a.progress)); }++ });++ try { root.dispatchEvent(new CustomEvent('ov:activity', { detail: a ? { id: a.id, state: a.state, progress: a.progress, title: a.title } : null })); } catch { /* */ }++ }++++ function iconEl(a, size) { const i = el('span', 'ov-icon'); i.dataset.icon = a.icon || 'ov'; i.dataset.size = String(size); i.dataset.state = a.state === 'info' ? 'idle' : a.state; if (a.progress != null && a.state === 'busy') i.dataset.progress = String(a.progress); i.dataset.fx = a.state === 'busy' ? 'orbit' : 'none'; return i; }++++ function render() {++ ensure();++ const a = top();++ brandMark(a);++ if (!a) { host.hidden = true; host.classList.remove('is-open'); return; }++ host.hidden = false; host.dataset.state = a.state;++ pill.replaceChildren(iconEl(a, 22), el('span', 'ovi-pill-text', a.title || 'Working'));++ if (a.state === 'busy' && a.progress != null) pill.appendChild(el('span', 'ovi-pill-pct', Math.round(a.progress * 100) + '%'));++ if (items.size > 1) pill.appendChild(el('span', 'ovi-count', items.size));++ pill.setAttribute('aria-label', `${a.title || 'Activity'}${a.progress != null ? ', ' + Math.round(a.progress * 100) + ' percent' : ''}`);++++ card.replaceChildren(...[...items.values()].reverse().map((it) => {++ const row = el('div', 'ovi-item'); row.dataset.state = it.state;++ const img = it.image && safeUrl(it.image);++ if (img) { const im = el('img', 'ovi-thumb'); im.src = img; im.alt = ''; im.loading = 'lazy'; im.referrerPolicy = 'no-referrer'; row.appendChild(im); } else row.appendChild(iconEl(it, 40));++ const body = el('div', 'ovi-body'); body.appendChild(el('div', 'ovi-t', it.title || ''));++ if (it.subtitle) body.appendChild(el('div', 'ovi-s', it.subtitle));++ if (it.state === 'busy') { const bar = el('div', 'ovi-bar' + (it.progress == null ? ' is-ind' : '')); const fill = el('i'); if (it.progress != null) fill.style.width = (it.progress * 100).toFixed(1) + '%'; bar.appendChild(fill); bar.setAttribute('role', 'progressbar'); if (it.progress != null) bar.setAttribute('aria-valuenow', String(Math.round(it.progress * 100))); body.appendChild(bar); }++ if (it.detail) body.appendChild(el('div', 'ovi-d', it.detail));++ if (it.actions && it.actions.length) {++ const acts = el('div', 'ovi-acts');++ it.actions.slice(0, 3).forEach((ac) => {++ if (!ac || !ac.label) return;++ const href = ac.href && safeUrl(ac.href);++ const b = el(href ? 'a' : 'button', 'ovi-act', ac.label);++ if (href) { b.href = href; if (ac.download) b.setAttribute('download', typeof ac.download === 'string' ? ac.download : ''); } else b.type = 'button';++ if (ac.onClick) b.addEventListener('click', (ev) => { try { ac.onClick(ev); } catch { /* */ } });++ acts.appendChild(b);++ });++ body.appendChild(acts);++ }++ row.appendChild(body);++ return row;++ }));++ }++++ function put(id, patch, defaults) {++ const prev = items.get(id) || Object.assign({ id, title: '', subtitle: '', icon: 'ov', image: '', progress: null, state: 'busy', detail: '', actions: [] }, defaults);++ const next = Object.assign({}, prev, patch || {});++ if (patch && 'progress' in patch) next.progress = clamp(patch.progress);++ if (!['busy', 'ok', 'error', 'info'].includes(next.state)) next.state = 'busy';++ items.set(id, next);++ clearTimeout(timers.get(id));++ if (next.ttl) timers.set(id, setTimeout(() => remove(id), next.ttl));++ render();++ return next;++ }++++ function handle(id) { return { id, update: (p) => update(id, p), finish: (p) => finish(id, p), fail: (p) => fail(id, p), remove: () => remove(id) }; }++ function start(o) { const id = (o && o.id) || 'act-' + (++seq); items.delete(id); put(id, Object.assign({ state: 'busy' }, o, { id })); return handle(id); }++ function update(id, patch) { if (!items.has(id)) return; const p = Object.assign({}, patch); delete p.id; put(id, p); }++ function finish(id, patch) { if (!items.has(id)) return; put(id, Object.assign({ state: 'ok', progress: 1, actions: [], ttl: 8000 }, patch)); if (host && top() && top().id === id && (patch && patch.actions && patch.actions.length)) { host.classList.add('is-open'); pill.setAttribute('aria-expanded', 'true'); setTimeout(() => { if (host && !host.matches(':hover')) host.classList.remove('is-open'); }, 5000); } }++ function fail(id, patch) { if (!items.has(id)) return; put(id, Object.assign({ state: 'error', progress: null, actions: [], ttl: 12000 }, patch)); }++ function remove(id) { clearTimeout(timers.get(id)); timers.delete(id); if (items.delete(id)) render(); }++++ const api = { start, update, finish, fail, remove, list: () => [...items.values()] };++ root.OpenVibeIsland = api;++ // The navbar exposes the same thing as OpenVibeNavbar.activity, whichever loads first.++ const bind = () => { if (root.OpenVibeNavbar && !root.OpenVibeNavbar.activity) { try { root.OpenVibeNavbar.activity = api; } catch { /* */ } } };++ bind(); root.addEventListener('load', bind);++ if (typeof module !== 'undefined' && module.exports) module.exports = api;++})(typeof window !== 'undefined' ? window : globalThis);@@@ -8,12 +8,43 @@// onLogin/onLogout callbacks. When no `user` is passed the navbar resolves// it itself: ov_token cookie → localStorage → token opt → sessionUrl, then// GET {apiBase}/api/auth/me with `Authorization: Bearer <token>`.++//++// Brand: derived from the hostname — `pastes.openvibe.tools` renders as++// Pastes · OpenVibe · Tools (three segments, the subdomain first so the++// context reads left-to-right), `openvibe.live` as OpenVibe · Live. Pass++// brand: { sub, tld, name, icon, variant } to override any part, or the++// legacy brandName/brandIcon. compact: 'auto' (default — the brand shortens++// to the subdomain on narrow viewports), 'always', 'never'.++// Menus are modular: every site keeps the shared account menu and adds its own++// pieces — links: [{label, href, icon?, active?}] replaces the service's top++// links; menu: { before: [item], after: [item] } adds dropdown rows++// ({label, href, icon, onClick, danger, external}); OpenVibeNavbar.addMenuItem()++// / setLinks() do the same at runtime. Signed-in users also get a++// "Recently used" row fed by the shared history module when it is loaded.// ═══════════════════════════════════════════════════════════════(function (root) {'use strict';-- let _config = { service: 'network', token: null, user: null, apiBase: 'https://openvibe.network', onLogin: null, onLogout: null, loginUrl: null, sessionUrl: null };++ let _config = {++ service: 'network', token: null, user: null, apiBase: 'https://openvibe.network',++ onLogin: null, onLogout: null, loginUrl: null, sessionUrl: null,++ brand: null, brandName: null, brandIcon: null, compact: 'auto',++ links: null, menu: null, recent: true,++ // history: { type: 'tool'|'stream'|'paste'|'page'|…, title, url, icon } — recorded for the++ // signed-in user once auth resolves (cross-site "Recently used" / History on the Network).++ history: null,++ // silentLogin: 'https://site/auth/login?silent=1&next={url}' — when nobody is signed in here++ // but this browser has signed in to the network before (ov_sso_hint), try one silent++ // prompt=none round trip per tab so a session on one site becomes a session on all.++ silentLogin: null,++ // fedcm: false to opt out; 'optional' (default) shows the browser's native chip the first++ // time and re-authenticates silently afterwards; 'silent' only re-authenticates.++ fedcm: 'optional',++ fedcmLogin: null, // POST target for the assertion (default: this site's /auth/fedcm)++ };++ const _runtimeMenu = { before: [], after: [] };++ let _runtimeLinks = null;let _navEl = null;function injectStyles() {@@@ -29,10 +60,65 @@font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;color: var(--text-primary, #e0e0e0);}-- .openvibe-navbar-brand { display: flex; align-items: center; gap: 8px; text-decoration: none; color: inherit; margin-right: 8px; }-- .openvibe-navbar-brand .flame { font-size: 18px; color: var(--accent, #8b5cf6); }-- .openvibe-navbar-brand .name { font-size: 15px; font-weight: 700; letter-spacing: -.3px; }-- .openvibe-navbar-brand .service-name { font-size: 11px; color: var(--accent-light, #a78bfa); font-weight: 500; letter-spacing: .5px; text-transform: uppercase; }++ .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)); }++ .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; }++ .ovnav-launcher .ovl-q:focus { border-color: var(--accent, #3b82f6); }++ .ovnav-launcher .ovl-h { display: flex; justify-content: space-between; font-size: 11px; font-weight: 700; letter-spacing: .7px; text-transform: uppercase; color: var(--text-muted, #7d8aa0); margin: 12px 4px 6px; }++ .ovnav-launcher .ovl-h a { color: var(--accent-light, var(--accent, #60a5fa)); text-decoration: none; text-transform: none; letter-spacing: 0; }++ .ovnav-launcher .ovl-sites { display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; }++ .ovnav-launcher .ovl-fams { display: grid; grid-template-columns: repeat(2, 1fr); gap: 2px; }++ .ovnav-launcher .ovl-site, .ovnav-launcher .ovl-fam { display: flex; align-items: center; gap: 9px; padding: 8px; border-radius: 10px; color: var(--text-primary, #e6edf7); text-decoration: none; min-width: 0; }++ .ovnav-launcher .ovl-site { flex-direction: column; text-align: center; gap: 6px; padding: 12px 6px; }++ .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-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; } }++ .openvibe-navbar-brand .flame { font-size: 18px; text-decoration: none; color: var(--accent, #8b5cf6); display: inline-grid; place-items: center; width: 28px; height: 28px; flex: none; }++ .openvibe-navbar-brand .name { display: inline-flex; align-items: baseline; font-size: 15px; font-weight: 700; letter-spacing: -.3px; white-space: nowrap; line-height: 1; }++ /* Segments: subdomain · OpenVibe · TLD. The part that names *this* site is the loud one. */++ .openvibe-navbar-brand .b-sub { color: var(--text-primary, #e0e0e0); }++ .openvibe-navbar-brand .b-core { color: var(--text-primary, #e0e0e0); }++ .openvibe-navbar-brand .b-tld { color: var(--accent-light, var(--accent, #a78bfa)); }++ .openvibe-navbar-brand.has-sub .b-core { color: var(--text-secondary, #b0b0b8); font-weight: 600; }++ .openvibe-navbar-brand.has-sub .b-tld { color: var(--text-secondary, #b0b0b8); font-weight: 600; }++ .openvibe-navbar-brand .b-dot { color: var(--accent, #8b5cf6); opacity: .75; margin: 0 1px; font-weight: 800; }++ .openvibe-navbar-brand .b-sub, .openvibe-navbar-brand .b-core, .openvibe-navbar-brand .b-tld { transition: color .2s, opacity .2s; }++ .openvibe-navbar-brand:hover .b-tld, .openvibe-navbar-brand:hover .b-sub { color: var(--accent-light, #a78bfa); }++ .openvibe-navbar-brand:hover .b-dot { opacity: 1; }++ .openvibe-navbar-brand .b-tag { font-size: 9px; font-weight: 700; letter-spacing: .6px; text-transform: uppercase; color: var(--accent-light, #a78bfa); background: var(--accent-glow, rgba(139,92,246,.14)); border-radius: 4px; padding: 2px 5px; margin-left: 6px; align-self: center; }++ /* Compact: drop the trailing segments on narrow viewports, keep what identifies the page. */++ .openvibe-navbar[data-compact="always"] .openvibe-navbar-brand.has-sub .b-core,++ .openvibe-navbar[data-compact="always"] .openvibe-navbar-brand.has-sub .b-tld,++ .openvibe-navbar[data-compact="always"] .openvibe-navbar-brand.has-sub .b-dot { display: none; }++ @media (max-width: 860px) {++ .openvibe-navbar[data-compact="auto"] .openvibe-navbar-brand.has-sub .b-core,++ .openvibe-navbar[data-compact="auto"] .openvibe-navbar-brand.has-sub .b-tld,++ .openvibe-navbar[data-compact="auto"] .openvibe-navbar-brand.has-sub .b-dot { display: none; }++ }++ @media (max-width: 420px) {++ .openvibe-navbar[data-compact="auto"] .openvibe-navbar-brand:not(.has-sub) .b-core,++ .openvibe-navbar[data-compact="auto"] .openvibe-navbar-brand:not(.has-sub) .b-dot { display: none; }++ .openvibe-navbar[data-compact="auto"] .openvibe-navbar-brand .b-tag { display: none; }++ }++ .openvibe-navbar-links a .icon { margin-right: 5px; opacity: .8; }++ .openvibe-navbar-dropdown-recent { padding: 6px 8px 2px; border-bottom: 1px solid var(--border, #333340); }++ .openvibe-navbar-dropdown-recent .label { font-size: 10px; font-weight: 700; letter-spacing: .6px; text-transform: uppercase; color: var(--text-muted, #707080); padding: 2px 8px 4px; display: flex; justify-content: space-between; align-items: center; }++ .openvibe-navbar-dropdown-recent .label a { color: var(--accent-light, #a78bfa); text-decoration: none; font-weight: 600; letter-spacing: 0; text-transform: none; }++ .openvibe-navbar-dropdown-recent .item { display: flex; align-items: center; gap: 8px; padding: 6px 8px; border-radius: 6px; font-size: 12px; color: var(--text-secondary, #b0b0b8); text-decoration: none; transition: background .12s; min-width: 0; }++ .openvibe-navbar-dropdown-recent .item:hover { background: var(--bg-hover, #2f2f3d); color: var(--text-primary, #e0e0e0); }++ .openvibe-navbar-dropdown-recent .item .icon { width: 18px; text-align: center; color: var(--accent-light, #a78bfa); flex: none; }++ .openvibe-navbar-dropdown-recent .item .t { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }++ .openvibe-navbar-dropdown-recent .item .s { margin-left: auto; font-size: 10px; color: var(--text-muted, #707080); flex: none; }++ .openvibe-navbar-dropdown-menu .sep { height: 1px; background: var(--border, #333340); margin: 4px -8px; }.openvibe-navbar-links { display: flex; align-items: center; gap: 4px; margin-left: 8px; }.openvibe-navbar-links a {@@@ -350,6 +436,211 @@'colors.openvibe.tools': { name: 'OpenVibeColor', icon: 'fa-palette' },};++ // ─── Brand from hostname ───────────────────────────────────++ // Every property is <sub?>.openvibe.<tld> (plus openre.stream). The navbar spells the++ // whole name — Pastes.OpenVibe.Tools, not "Paste.OpenVibe" — because the subdomain and++ // the TLD are what tell a visitor where they are in the network.++ const TLD_LABELS = {++ live: 'Live', tools: 'Tools', network: 'Network', media: 'Media', games: 'Games',++ community: 'Community', chat: 'Chat', codes: 'Codes', blog: 'Blog', wiki: 'Wiki',++ news: 'News', reviews: 'Reviews', tips: 'Tips', vip: 'VIP', trade: 'Trade', host: 'Host',++ deals: 'Deals', coupons: 'Coupons',++ };++ const SUB_LABELS = {++ json: 'JSON', yaml: 'YAML', xml: 'XML', csv: 'CSV', sql: 'SQL', html: 'HTML', jwt: 'JWT',++ uuid: 'UUID', guid: 'GUID', url: 'URL', b64: 'B64', sha256: 'SHA256', og: 'OG', md: 'MD',++ yt: 'YT', ip: 'IP', myip: 'MyIP', ipv4: 'IPv4', ipv6: 'IPv6', geoip: 'GeoIP', asn: 'ASN',++ rdns: 'rDNS', dns: 'DNS', mx: 'MX', txt: 'TXT', ns: 'NS', spf: 'SPF', dkim: 'DKIM',++ dmarc: 'DMARC', mtr: 'MTR', ssl: 'SSL', tls: 'TLS', ptr: 'PTR', smtp: 'SMTP', http: 'HTTP',++ httpstatus: 'HTTPStatus', rdap: 'RDAP', isp: 'ISP', pdf: 'PDF', mergepdf: 'MergePDF',++ splitpdf: 'SplitPDF', compresspdf: 'CompressPDF', rotatepdf: 'RotatePDF',++ reorderpdf: 'ReorderPDF', watermarkpdf: 'WatermarkPDF', protectpdf: 'ProtectPDF',++ unlockpdf: 'UnlockPDF', image2pdf: 'Image2PDF', jpg2pdf: 'JPG2PDF', png2pdf: 'PNG2PDF',++ pdf2jpg: 'PDF2JPG', pdf2png: 'PDF2PNG', png: 'PNG', jpg: 'JPG', jpeg: 'JPEG', webp: 'WebP',++ avif: 'AVIF', heic: 'HEIC', heif: 'HEIF', svg: 'SVG', gif: 'GIF', ico: 'ICO', tiff: 'TIFF',++ bmp: 'BMP', mp3: 'MP3', wav: 'WAV', flac: 'FLAC', ogg: 'OGG', m4a: 'M4A', aac: 'AAC',++ opus: 'Opus', wma: 'WMA', aiff: 'AIFF', ac3: 'AC3', eq: 'EQ', equalizer: 'EQ', mxn: 'MXN',++ ascii: 'ASCII', smallcaps: 'SmallCaps', titlecase: 'TitleCase', textlogo: 'TextLogo',++ textart: 'TextArt', channelart: 'ChannelArt', lowerthird: 'LowerThird', copypaste: 'CopyPaste',++ dnspropagation: 'DNSPropagation', opengraph: 'OpenGraph', whip: 'WHIP', ingest: 'Ingest',++ play: 'Play', my: 'My', auth: 'Auth', api: 'API', admin: 'Admin', docs: 'Docs', dev: 'Dev',++ net: 'Net', img: 'Img', pastes: 'Pastes', paste: 'Pastes', maps: 'Maps', food: 'Food',++ text: 'Text', logo: 'Logo', audio: 'Audio', ai: 'AI', cdn: 'CDN', status: 'Status',++ };++ const SERVICE_TLD = { live: 'live', tools: 'tools', games: 'games', media: 'media', network: 'network', community: 'community' };++ const SERVICE_SUB = { net: 'net', dev: 'dev', paste: 'pastes', maps: 'maps', food: 'food', img: 'img', yt: 'yt', audio: 'audio', text: 'text', logo: 'logo', docs: 'docs' };++++ function titleCase(w) { return w ? w.charAt(0).toUpperCase() + w.slice(1) : ''; }++ function subLabel(sub) { return SUB_LABELS[sub] || titleCase(sub); }++++ /**++ * { sub, core, tld, name, short, icon, variant } for the current page.++ * sub 'Pastes' | null tld 'Tools' core 'OpenVibe'++ * name 'Pastes.OpenVibe.Tools' short 'Pastes' (what compact mode keeps)++ * variant the ov-mark flavour: the TLD id ('live', 'tools', …) — every site gets its own twist++ */++ function resolveBrand() {++ const b = Object.assign({}, _config.brand || {});++ const host = currentHost().toLowerCase();++ let sub = null, tld = null, core = 'OpenVibe';++ let m = host.match(/^(?:(.+)\.)?openvibe\.([a-z]+)$/);++ if (m) { sub = m[1] && m[1] !== 'www' ? m[1] : null; tld = m[2]; }++ else if ((m = host.match(/^(?:(.+)\.)?openre\.stream$/))) { sub = m[1] && m[1] !== 'www' ? m[1] : null; core = 'OpenRe'; tld = 'stream'; }++ // Off-network hosts (localhost, previews): fall back to the service id.++ if (!tld) { tld = SERVICE_TLD[_config.service] || (SERVICE_SUB[_config.service] ? 'tools' : 'network'); sub = SERVICE_SUB[_config.service] || null; }++ // Legacy brandName ("Paste.OpenVibe", "OpenVibe.Live") still steers the segments.++ if (_config.brandName && !b.sub && !b.tld) {++ const parts = String(_config.brandName).split('.');++ if (parts.length >= 2 && /^openvibe$/i.test(parts[0])) tld = parts[1].toLowerCase();++ else if (parts.length >= 2 && /^openvibe$/i.test(parts[1])) sub = parts[0].toLowerCase();++ else if (parts.length === 1) sub = parts[0].toLowerCase();++ }++ if (b.sub !== undefined) sub = b.sub ? String(b.sub).toLowerCase() : null;++ if (b.tld) tld = String(b.tld).toLowerCase();++ const subText = b.subLabel || (sub ? subLabel(sub) : null);++ const tldText = b.tldLabel || TLD_LABELS[tld] || titleCase(tld);++ const name = b.name || [subText, core, tldText].filter(Boolean).join('.');++ const icon = b.icon || _config.brandIcon || null;++ const variant = b.variant || (core === 'OpenRe' ? 'stream' : tld);++ return { sub, subText, core, tld, tldText, name, short: subText || `${core}.${tldText}`, icon, variant, tag: b.tag || null, href: b.href || '/' };++ }++++ /** Where each brand segment goes: sub → this tool, OpenVibe → the network, TLD → the site's apex. */++ function brandLinks(brand) {++ const apex = brand.core === 'OpenRe' ? 'https://openre.stream/' : `https://openvibe.${brand.tld}/`;++ if (!brand.subText) return { sub: brand.href, core: brand.href, tld: brand.href, apex };++ return { sub: brand.href, core: 'https://openvibe.network/', tld: apex, apex };++ }++++ function brandHTML(brand) {++ const to = brandLinks(brand);++ const seg = (cls, text, href, title) => `<a href="${escapeAttr(href)}"${title ? ` title="${escapeAttr(title)}"` : ''} class="${cls}">${escapeAttr(text)}</a>`;++ const dot = '<span class="b-dot">.</span>';++ const text = brand.subText++ ? seg('b-sub', brand.subText, to.sub, brand.name) + dot + seg('b-core', brand.core, to.core, 'OpenVibe Network') + dot + seg('b-tld', brand.tldText, to.tld, `All of ${brand.core}.${brand.tldText}`)++ : seg('b-core', brand.core, to.core, brand.name) + dot + seg('b-tld', brand.tldText, to.tld, brand.name);++ const mark = brand.icon++ ? `<i class="fa-solid ${escapeAttr(brand.icon)}"></i>`++ : `<span class="ov-mark" data-size="28" data-variant="${escapeAttr(brand.variant)}"></span>`;++ return `<div class="openvibe-navbar-brand${brand.subText ? ' has-sub' : ''}">++ <a class="flame" href="${escapeAttr(brand.href)}" aria-label="${escapeAttr(brand.name)} home">${mark}</a>++ <span class="name">${text}${brand.tag ? `<span class="b-tag">${escapeAttr(brand.tag)}</span>` : ''}</span>++ </div>++ ${_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>'}`;++ }++++ // ── Network launcher ─────────────────────────────────────++ const LAUNCHER_SITES = [++ { name: 'Live', desc: 'Streams, clips and chat', icon: 'live', url: 'https://openvibe.live/' },++ { name: 'Tools', desc: 'Every online tool', icon: 'tools', url: 'https://openvibe.tools/' },++ { name: 'Community', desc: 'Pastes and posts', icon: 'community', url: 'https://openvibe.community/' },++ { name: 'Games', desc: 'Browser games', icon: 'games', url: 'https://openvibe.games/' },++ { name: 'Media', desc: 'VODs, clips, files', icon: 'media', url: 'https://openvibe.media/' },++ { name: 'Network', desc: 'Account and themes', icon: 'network', url: 'https://openvibe.network/' },++ ];++ const LAUNCHER_FAMILIES = [++ { name: 'Media Tools', icon: 'youtube', url: 'https://yt.openvibe.tools/' },++ { name: 'Image Tools', icon: 'image', url: 'https://img.openvibe.tools/' },++ { name: 'Audio Tools', icon: 'audio', url: 'https://audio.openvibe.tools/' },++ { name: 'PDF & Documents', icon: 'pdf', url: 'https://docs.openvibe.tools/' },++ { name: 'Text Tools', icon: 'text', url: 'https://text.openvibe.tools/' },++ { name: 'Developer Tools', icon: 'code', url: 'https://dev.openvibe.tools/' },++ { name: 'Network Tools', icon: 'dns', url: 'https://net.openvibe.tools/' },++ ];++ const CATALOG_URL = 'https://openvibe.tools/api/catalog.json';++ const okUrl = (u) => { try { const x = new URL(u); return x.protocol === 'https:' ? x.href : null; } catch { return null; } };++++ async function launcherCatalog() {++ try { const c = JSON.parse(sessionStorage.getItem('ov_catalog') || 'null'); if (c && Date.now() - c.at < 30 * 60000) return c.data; } catch { /* */ }++ try {++ const r = await fetch(CATALOG_URL, { credentials: 'omit' }); if (!r.ok) return null;++ const j = await r.json();++ const data = { families: (j.families || []).slice(0, 12).map(f => ({ name: String(f.name || ''), icon: String(f.icon || 'tools'), url: okUrl(f.url) })).filter(f => f.name && f.url),++ tools: (j.tools || []).slice(0, 400).map(t => ({ name: String(t.name || ''), icon: String(t.icon || 'tools'), url: okUrl(t.url), k: [t.name, t.tagline].concat(t.keywords || []).join(' ').toLowerCase().slice(0, 400) })).filter(t => t.name && t.url) };++ try { sessionStorage.setItem('ov_catalog', JSON.stringify({ at: Date.now(), data })); } catch { /* */ }++ return data;++ } catch { return null; }++ }++++ function bindLauncher(nav) {++ const btn = nav.querySelector('#openvibe-launcher-btn'); if (!btn) return;++ let panel = null;++ const tile = (it, cls) => `<a class="${cls}" href="${escapeAttr(it.url)}"><span class="ov-icon" data-icon="${escapeAttr(it.icon)}" data-size="${cls === 'ovl-site' ? 34 : 24}" data-fx="none"></span><span><b>${escapeAttr(it.name)}</b>${it.desc ? `<small>${escapeAttr(it.desc)}</small>` : ''}</span></a>`;++ const close = () => { if (panel) panel.classList.remove('open'); btn.setAttribute('aria-expanded', 'false'); };++ function paint(cat, q) {++ const fams = (cat && cat.families.length ? cat.families : LAUNCHER_FAMILIES);++ const body = panel.querySelector('.ovl-body');++ if (q && cat) {++ const hits = cat.tools.filter(t => t.k.includes(q)).slice(0, 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>`;++ }++ btn.addEventListener('click', async (e) => {++ e.stopPropagation();++ if (panel && panel.classList.contains('open')) return close();++ if (!panel) {++ panel = document.createElement('div'); panel.className = 'ovnav-launcher'; panel.setAttribute('role', 'dialog'); panel.setAttribute('aria-label', 'OpenVibe sites and tools');++ panel.innerHTML = '<input type="search" class="ovl-q" placeholder="Find a tool or site" aria-label="Find a tool or site"><div class="ovl-body"></div>';++ nav.appendChild(panel);++ if (!root.OpenVibeIcons && !document.getElementById('ov-icons-loader')) { const sc = document.createElement('script'); sc.id = 'ov-icons-loader'; sc.src = 'https://openvibe.network/shared/ov-icons.js'; sc.async = true; document.head.appendChild(sc); }++ let cat = null; paint(null, '');++ const input = panel.querySelector('.ovl-q');++ input.addEventListener('input', () => paint(cat, input.value.trim().toLowerCase()));++ input.addEventListener('keydown', (ev) => { if (ev.key === 'Enter') { const first = panel.querySelector('.ovl-body a'); if (first) location.href = first.href; } if (ev.key === 'ArrowDown') { const first = panel.querySelector('.ovl-body a'); if (first) { ev.preventDefault(); first.focus(); } } });++ panel.addEventListener('keydown', (ev) => {++ if (ev.key === 'Escape') { close(); btn.focus(); return; }++ if (ev.key !== 'ArrowDown' && ev.key !== 'ArrowUp') return;++ const links = [...panel.querySelectorAll('.ovl-body a')]; const i = links.indexOf(document.activeElement); if (i < 0) return;++ ev.preventDefault(); const n = i + (ev.key === 'ArrowDown' ? 1 : -1); (links[n] || (n < 0 ? input : links[0])).focus();++ });++ panel.addEventListener('click', (ev) => ev.stopPropagation());++ document.addEventListener('click', close);++ launcherCatalog().then((c) => { if (c) { cat = c; paint(cat, input.value.trim().toLowerCase()); } });++ }++ panel.classList.add('open'); btn.setAttribute('aria-expanded', 'true');++ try { panel.querySelector('.ovl-q').focus({ preventScroll: true }); } catch { /* */ }++ });++ }++++ // ── Notification bell (mounted by the navbar itself) ─────++ function mountBell(nav, u) {++ if (_config.notifications === false || !u || u.is_anon || !_config.token) return;++ const mount = nav.querySelector('#openvibe-bell-mount'); if (!mount || mount.childElementCount) return;++ const go = () => {++ const N = root.OpenVibeNotifications; if (!N || mount.childElementCount) return;++ try { if (!N.__ovNavInit) { N.init({ token: _config.token, apiBase: 'https://openvibe.network' }); N.__ovNavInit = true; } N.createBell(mount); } catch { /* */ }++ };++ if (root.OpenVibeNotifications) return go();++ if (document.getElementById('ov-notify-loader')) return;++ const sc = document.createElement('script'); sc.id = 'ov-notify-loader'; sc.src = 'https://openvibe.network/shared/notification-ui.js'; sc.async = true; sc.onload = () => setTimeout(go, 0); document.head.appendChild(sc);++ }++++ function menuItemHTML(item) {++ if (!item) return '';++ if (item.sep) return '<div class="sep"></div>';++ const icon = item.icon ? `<span class="icon"><i class="fa-solid ${escapeAttr(item.icon)}"></i></span>` : '<span class="icon"></span>';++ const cls = item.danger ? ' class="danger"' : '';++ const id = item.id ? ` data-menu-id="${escapeAttr(item.id)}"` : '';++ if (item.href) return `<a href="${escapeAttr(item.href)}"${cls}${id}${item.external ? ' target="_blank" rel="noopener"' : ''}>${icon} ${escapeAttr(item.label)}</a>`;++ return `<button type="button"${cls}${id}>${icon} ${escapeAttr(item.label)}</button>`;++ }++++ function bindMenuItems(container, items) {++ for (const item of items) {++ if (!item || !item.id || typeof item.onClick !== 'function') continue;++ container.querySelector(`[data-menu-id="${item.id}"]`)?.addEventListener('click', (e) => { if (!item.href) e.preventDefault(); item.onClick(e); });++ }++ }++++ function currentLinks() {++ const list = _runtimeLinks || _config.links || SERVICE_LINKS[_config.service] || [];++ const path = (typeof location !== 'undefined' && location.pathname) || '/';++ return list.map((l) => Object.assign({}, l, { active: l.active !== undefined ? l.active : (l.href === path && path !== '/') }));++ }++const SERVICE_LINKS = {live: [{ label: 'Watch', href: '/' },@@@ -610,6 +901,114 @@let _authInFlight = null;++ function ssoHint() {++ const m = (typeof document !== 'undefined' ? document.cookie : '').match(/(?:^|;\s*)ov_sso_hint=([^;]*)/);++ if (m) return m[1];++ try { return localStorage.getItem('ov_sso_hint'); } catch { return null; }++ }++++ /**++ * One silent sign-in attempt per tab: only when this browser has signed in to the network++ * before (the hint survives token expiry), never after an explicit sign-out ('guest'), and++ * never for bots. The site's login route turns silent=1 into prompt=none and comes straight++ * back on error=login_required, so a signed-out visitor sees one quick redirect at most.++ */++ let _ssoClientLoading = null;++ function loadSsoClient() {++ if (root.OpenVibeSSO) return Promise.resolve(root.OpenVibeSSO);++ if (_ssoClientLoading) return _ssoClientLoading;++ _ssoClientLoading = new Promise((resolve) => {++ const sc = document.createElement('script'); sc.async = true; sc.src = `${_config.apiBase}/shared/sso-client.js`;++ sc.onload = () => resolve(root.OpenVibeSSO || null); sc.onerror = () => resolve(null);++ document.head.appendChild(sc);++ });++ return _ssoClientLoading;++ }++++ /** Signed in here: cross-site links carry the session along (see sso-client.js). */++ function enableHandoff() {++ loadSsoClient().then((sso) => { try { sso && sso.handoffLinks({ signedIn: !!_config.user && !_config.user.is_anon }); } catch { /* */ } });++ }++++ function silentLoginNow() {++ const url = String(_config.silentLogin).replace('{url}', encodeURIComponent(location.href));++ location.replace(url);++ return true;++ }++++ /**++ * Two ways to find out that this browser is signed in to the network without a session here:++ * 1. the hint cookie this site set on an earlier sign-in ('account') — go straight to the++ * silent sign-in (one quick redirect, back where you were);++ * 2. otherwise ask the network in a hidden iframe (GET /sso/check) — invisible, no++ * redirect unless the answer is yes. Browsers that partition third-party cookies answer++ * no and nothing happens, which is the same as before.++ * At most once per tab per 10 minutes; never after an explicit sign-out ('guest'); never for bots.++ */++ function maybeSilentLogin() {++ if (!_config.silentLogin || typeof location === 'undefined') return false;++ const hint = ssoHint();++ if (hint === 'guest') return false;++ if (/bot|crawl|spider|slurp|headless/i.test(navigator.userAgent || '')) return false;++ if (/[?&]sso=none\b/.test(location.search)) return false;++ try {++ const last = +sessionStorage.getItem('ov_silent_sso_at') || 0;++ if (Date.now() - last < 10 * 60 * 1000) return false;++ sessionStorage.setItem('ov_silent_sso_at', String(Date.now()));++ } catch { return false; }++ if (hint === 'account') return silentLoginNow();++ checkNetworkSession().then((state) => {++ if (state && state.signedIn) return silentLoginNow();++ // No answer or "not signed in" — either a guest, or a browser that keeps the network's++ // cookie away from iframes. FedCM asks the browser itself; the network's login status++ // makes it a no-op for guests, a native chip (then silent re-auth) for signed-in users.++ if (_config.fedcm === false) return;++ loadSsoClient().then(async (sso) => {++ if (!sso || !sso.fedcmAvailable()) return;++ const r = await sso.fedcm({ apiBase: _config.apiBase, fedcmLogin: _config.fedcmLogin || undefined, mediation: _config.fedcm === 'silent' ? 'silent' : 'optional' });++ if (r && r.ok) { try { sessionStorage.removeItem('ov_silent_sso_at'); } catch { /* */ } location.reload(); }++ });++ });++ return false;++ }++++ /** Ask the network (hidden iframe + postMessage) whether this browser is signed in there. */++ function checkNetworkSession(timeoutMs = 4000) {++ return new Promise((resolve) => {++ let done = false, frame = null, timer = null;++ const finish = (v) => { if (done) return; done = true; clearTimeout(timer); window.removeEventListener('message', onMsg); try { frame?.remove(); } catch { /* */ } resolve(v); };++ const onMsg = (e) => {++ if (e.origin !== _config.apiBase || !e.data || e.data.type !== 'ov-sso') return;++ finish({ signedIn: !!e.data.signedIn, username: e.data.username || null });++ };++ try {++ window.addEventListener('message', onMsg);++ frame = document.createElement('iframe');++ frame.setAttribute('aria-hidden', 'true'); frame.setAttribute('tabindex', '-1');++ frame.style.cssText = 'position:absolute;width:0;height:0;border:0;opacity:0;pointer-events:none';++ frame.src = `${_config.apiBase}/sso/check?origin=${encodeURIComponent(location.origin)}`;++ (document.body || document.documentElement).appendChild(frame);++ timer = setTimeout(() => finish(null), timeoutMs);++ } catch { finish(null); }++ });++ }++++ function recordHistory() {++ const h = _config.history;++ if (!h || !_config.user || _config.user.is_anon) return;++ const rec = Object.assign({ url: location.href, title: document.title, service: resolveBrand().tld }, h);++ const H = root.OpenVibeHistory;++ if (H && typeof H.record === 'function') { H.record(rec, { token: _config.token, apiBase: _config.apiBase }); return; }++ if (!document.getElementById('ov-history-loader')) {++ const sc = document.createElement('script'); sc.id = 'ov-history-loader'; sc.async = true;++ sc.src = `${_config.apiBase}/shared/history.js`;++ sc.onload = () => { try { root.OpenVibeHistory.record(rec, { token: _config.token, apiBase: _config.apiBase }); } catch { /* */ } };++ document.head.appendChild(sc);++ } else {++ document.getElementById('ov-history-loader').addEventListener('load', () => { try { root.OpenVibeHistory.record(rec, { token: _config.token, apiBase: _config.apiBase }); } catch { /* */ } });++ }++ }++function refreshAuthState() {if (_authInFlight) return _authInFlight;_authInFlight = resolveSessionUser()@@@ -619,6 +1018,10 @@_config.user = session.user;if (session.token) _config.token = session.token;render();++ recordHistory();++ enableHandoff();++ } else {++ maybeSilentLogin();}try {document.dispatchEvent(new CustomEvent('openvibe-navbar-auth', {@@@ -673,28 +1076,37 @@});}++ /**++ * "Recently used" rows in the dropdown: the last few things this account touched anywhere++ * on the network, from the shared history module (loaded lazily from the Network).++ */++ function renderRecent(el) {++ if (!el || !_config.user || _config.user.is_anon) return;++ const draw = (items) => {++ if (!items || !items.length) { el.hidden = true; return; }++ el.hidden = false;++ el.innerHTML = `<div class="label"><span>Recently used</span><a href="https://openvibe.network/my#history">All history</a></div>` +++ items.slice(0, 4).map(h => `<a class="item" href="${escapeAttr(h.url)}"><span class="icon"><i class="fa-solid ${escapeAttr(h.icon || 'fa-clock-rotate-left')}"></i></span><span class="t">${escapeAttr(h.title || h.url)}</span><span class="s">${escapeAttr(h.service_label || h.service || '')}</span></a>`).join('');++ };++ const H = root.OpenVibeHistory;++ if (H && typeof H.recent === 'function') { H.recent({ limit: 4, token: _config.token, apiBase: _config.apiBase }).then(draw).catch(() => draw(null)); return; }++ if (document.getElementById('ov-history-loader')) return;++ const sc = document.createElement('script'); sc.id = 'ov-history-loader'; sc.async = true;++ sc.src = `${_config.apiBase}/shared/history.js`;++ sc.onload = () => { try { root.OpenVibeHistory.recent({ limit: 4, token: _config.token, apiBase: _config.apiBase }).then(draw).catch(() => draw(null)); } catch { /* */ } };++ document.head.appendChild(sc);++ }++function render() {if (_navEl) _navEl.remove();const nav = document.createElement('nav');nav.className = 'openvibe-navbar';const svc = _config.service;-- const links = SERVICE_LINKS[svc] || [];---- // Resolve brand name + icon: config override > subdomain lookup > service defaults-- const host = (typeof location !== 'undefined' && location.hostname) || '';-- // Exact match first (e.g., hostname.openvibe.tools), then fallback to service name-- let subBrand = SUBDOMAIN_BRANDS[host];-- if (!subBrand && host && host.includes('.openvibe.tools')) {-- // Try matching against service names-- const parts = host.split('.');-- const potential = parts[0]; // e.g., 'json' from 'json.openvibe.tools'-- subBrand = SUBDOMAIN_BRANDS[`${potential}.openvibe.tools`];-- }-- const svcName = _config.brandName || (subBrand && subBrand.name) || SERVICE_NAMES[svc] || 'OpenVibe';-- const svcIcon = _config.brandIcon || (subBrand && subBrand.icon) || SERVICE_ICONS[svc] || 'fa-circle-nodes';-- const brandHref = '/';++ const brand = resolveBrand();++ if (typeof nav.setAttribute === 'function') { nav.setAttribute('data-compact', _config.compact || 'auto'); nav.setAttribute('data-service', svc); }++ const links = currentLinks();const u = _config.user;const accounts = getAccounts();@@@ -702,25 +1114,27 @@const loginHref = resolveLoginHref(currentHost(), window.location.href);const addAccountHref = `${_config.apiBase}/login?add_account=1&return=${encodeURIComponent(window.location.href)}`;++ // The OV brand mark is a self-contained drop-in (mounts every .ov-mark it finds).++ if (!window.__ovMark && !document.getElementById('ov-mark-loader')) {++ try { const sc = document.createElement('script'); sc.id = 'ov-mark-loader'; sc.src = 'https://openvibe.network/shared/ov-mark.js'; sc.async = true; document.head.appendChild(sc); } catch { /* */ }++ }nav.innerHTML = `-- <a class="openvibe-navbar-brand" href="${brandHref}">-- <span class="flame"><i class="fa-solid ${svcIcon}"></i></span>-- <div>-- <div class="name">${svcName}</div>-- </div>-- </a>++ ${brandHTML(brand)}<div class="openvibe-navbar-links">-- ${links.map(l => `<a href="${l.href}">${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('')}${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><div class="openvibe-navbar-right">-- <a class="openvibe-network-badge" href="https://openvibe.network" title="Connected to OpenVibe"><i class="fa-solid fa-circle-nodes"></i> OpenVibe</a>++ <a class="openvibe-network-badge" href="https://openvibe.network" title="Connected to OpenVibe"><span class="ov-mark" data-size="16" data-static="1"></span> OpenVibe</a><div id="openvibe-bell-mount"></div>${u ? avatarImg(u, 64, 'openvibe-navbar-avatar', 'openvibe-avatar-btn') :`<a class="openvibe-navbar-login" id="openvibe-login-btn" href="${escapeAttr(loginHref)}">Sign In</a>`}</div>`;++ bindLauncher(nav);++ // Pages that wire the bell themselves run right after init(); give them the first go.++ setTimeout(() => mountBell(nav, u), 0);// Dropdownif (u) {@@@ -729,6 +1143,8 @@dropdown.id = 'openvibe-user-dropdown';const otherAccounts = accounts.filter(a => isAnon ? !a.is_anon : String(a.id) !== String(u.id));++ const before = ((_config.menu && _config.menu.before) || []).concat(_runtimeMenu.before);++ const after = ((_config.menu && _config.menu.after) || []).concat(_runtimeMenu.after);dropdown.innerHTML = `<div class="openvibe-navbar-dropdown-header">@@@ -755,17 +1171,23 @@<span>Add another account</span></a></div>++ <div class="openvibe-navbar-dropdown-recent" id="openvibe-recent" hidden></div><div class="openvibe-navbar-dropdown-menu">++ ${before.map(menuItemHTML).join('')}${before.length ? '<div class="sep"></div>' : ''}<a href="https://openvibe.network/my"><span class="icon"><i class="fa-solid fa-user"></i></span> My Account</a>-- <a href="https://openvibe.network/my#notifications"><span class="icon"><i class="fa-solid fa-bell"></i></span> Notification Settings</a>++ <a href="https://openvibe.network/my#history"><span class="icon"><i class="fa-solid fa-clock-rotate-left"></i></span> History</a>++ <a href="https://openvibe.network/my#notifications"><span class="icon"><i class="fa-solid fa-bell"></i></span> Notifications</a><a href="https://openvibe.network/themes"><span class="icon"><i class="fa-solid fa-palette"></i></span> Themes</a><a href="https://openvibe.network/my#linked"><span class="icon"><i class="fa-solid fa-link"></i></span> Linked Services</a>${u.role === 'admin' ? `<a href="https://openvibe.network/admin"><span class="icon"><i class="fa-solid fa-screwdriver-wrench"></i></span> Admin Panel</a>` : ''}-- <div style="height:1px;background:var(--border,#333340);margin:4px -8px"></div>++ ${after.length ? '<div class="sep"></div>' : ''}${after.map(menuItemHTML).join('')}++ <div class="sep"></div><button id="openvibe-logout-btn" class="danger"><span class="icon"><i class="fa-solid fa-right-from-bracket"></i></span> Sign Out</button></div>`;nav.appendChild(dropdown);++ bindMenuItems(dropdown, before.concat(after));++ if (_config.recent !== false) renderRecent(dropdown.querySelector('#openvibe-recent'));// Avatar click toggles dropdownnav.querySelector('#openvibe-avatar-btn').addEventListener('click', () => {@@@ -793,6 +1215,8 @@clearAuthState();_config.user = null;_config.token = null;++ try { root.OpenVibeSSO && root.OpenVibeSSO.preventSilent(); } catch { /* */ }++ try { localStorage.setItem('ov_sso_hint', 'guest'); } catch { /* */ }if (onToolsDomain()) {// The gateway also clears the Domain=.openvibe.tools cookie// and the httpOnly refresh cookie, then sends us back here.@@@ -834,9 +1258,13 @@// the shared SSO state (ov_token cookie / localStorage / optional// sessionUrl) and the navbar re-renders when it arrives.if (!_config.user) refreshAuthState();++ else { recordHistory(); enableHandoff(); }return el;},++ /** Record something the signed-in user did here ({type, title, url, icon}) in their network history. */++ record(entry) { const h = _config.history; _config.history = Object.assign({}, h || {}, entry || {}); recordHistory(); _config.history = h; },++/** Re-resolve the signed-in user from the shared SSO state. */refreshAuth() { return refreshAuthState(); },@@@ -846,6 +1274,33 @@render();},++ /** Replace this site's top links at runtime ([{label, href, icon?, active?, external?}]). */++ setLinks(links) { _runtimeLinks = Array.isArray(links) ? links : null; if (_navEl) render(); },++++ /** Add a row to the account dropdown: {label, href|onClick, icon, danger, external, position:'before'|'after'}. */++ addMenuItem(item) {++ if (!item || !item.label) return;++ const it = Object.assign({ id: item.id || `mi-${Math.random().toString(36).slice(2, 8)}` }, item);++ (it.position === 'before' ? _runtimeMenu.before : _runtimeMenu.after).push(it);++ if (_navEl && _config.user) render();++ return it.id;++ },++++ removeMenuItem(id) {++ for (const k of ['before', 'after']) _runtimeMenu[k] = _runtimeMenu[k].filter(i => i.id !== id);++ if (_navEl && _config.user) render();++ },++++ /** Is this browser signed in to the network? ({ signedIn, username } or null when unknown). */++ checkNetworkSession,++++ /** The resolved brand for this page ({ sub, core, tld, name, short, variant }). */++ brand() { return resolveBrand(); },++++ /** The activity island (island.js), loaded on first use: OpenVibeNavbar.activity.start({...}). */++ get activity() { return root.OpenVibeIsland || null; },++ set activity(_v) { /* island.js binds itself; nothing to store */ },++setToken(token) { _config.token = token; },/** Get the bell mount point for OpenVibeNotifications. */nnew file mode 100644@@@ -0,0 +1,184 @@++/**++ * openvibe-shared/ov-icons.js — the OpenVibe animated icon family.++ *++ * <span class="ov-icon" data-icon="youtube" data-size="40"></span>++ * <span class="ov-icon" data-icon="pdf" data-state="busy" data-progress="0.4" data-fx="orbit"></span>++ *++ * One construction for every icon so the network reads as one brand: the OV ring (with its comet)++ * around a glyph drawn in the theme accent. The ring is also a progress meter (data-progress 0–1)++ * and a status light (data-state idle|busy|ok|error), so the same icon that names a tool can show++ * what the tool is doing. Glyphs are 24×24 stroke paths centred in the ring.++ *++ * OpenVibeIcons.names() every registered icon++ * OpenVibeIcons.register(name, { glyph, fx?, accent? })++ * OpenVibeIcons.mount(root?) (re)scan; new nodes are picked up automatically++ * OpenVibeIcons.set(el, { progress, state, icon })++ * OpenVibeIcons.svg(name, size) markup string, for server-side / templates++ *++ * Effects (data-fx): orbit (default) · pulse · draw · bounce · none. Motion is transform/opacity/++ * stroke only and stops under prefers-reduced-motion. Unknown names fall back to the OV mark "V".++ */++(function (root) {++ 'use strict';++ if (root.OpenVibeIcons) return;++++ const P = (d) => `<path d="${d}"/>`;++ // Glyphs: stroke-only, 24×24 box, designed to sit inside the ring.++ const GLYPHS = {++ ov: P('M6.5 7.5 12 17.5l5.5-10'),++ network: '<circle cx="12" cy="6" r="2"/><circle cx="5.5" cy="17" r="2"/><circle cx="18.5" cy="17" r="2"/>' + P('M12 8v3.5M12 11.5 6.8 15.4M12 11.5l5.2 3.9'),++ live: '<circle cx="12" cy="12" r="2.2"/>' + P('M7.5 7.5a6.4 6.4 0 0 0 0 9M16.5 7.5a6.4 6.4 0 0 1 0 9M4.8 5a10 10 0 0 0 0 14M19.2 5a10 10 0 0 1 0 14'),++ tools: P('M14.5 5.5a4 4 0 0 0-5.2 5.2L4.5 15.5a1.8 1.8 0 0 0 2.5 2.5l4.8-4.8a4 4 0 0 0 5.2-5.2l-2.6 2.6-2.1-.5-.5-2.1z'),++ media: '<rect x="4" y="6" width="16" height="12" rx="2"/>' + P('M10 9.5v5l4.5-2.5z'),++ games: P('M7 9h10a4 4 0 0 1 3.9 4.9l-.6 2.6a2 2 0 0 1-3.5.8L15.5 16h-7l-1.3 1.3a2 2 0 0 1-3.5-.8l-.6-2.6A4 4 0 0 1 7 9zM8 11.5v3M6.5 13h3') + '<circle cx="15.5" cy="12" r=".6"/><circle cx="17.5" cy="14" r=".6"/>',++ community: '<circle cx="9" cy="9" r="2.6"/><circle cx="16.5" cy="10" r="2"/>' + P('M3.8 18.5a5.2 5.2 0 0 1 10.4 0M14.6 14.2a4 4 0 0 1 5.6 3.8'),++ chat: P('M5 6.5h14a1.5 1.5 0 0 1 1.5 1.5v7A1.5 1.5 0 0 1 19 16.5h-7l-4 3v-3H5A1.5 1.5 0 0 1 3.5 15V8A1.5 1.5 0 0 1 5 6.5zM8 10.5h8M8 13h5'),++ codes: P('M9 8 4.5 12 9 16M15 8l4.5 4-4.5 4M13.2 6l-2.4 12'),++ code: P('M9 8 4.5 12 9 16M15 8l4.5 4-4.5 4M13.2 6l-2.4 12'),++ blog: P('M5 19l1.2-4.2L16 5l3 3-9.8 9.8zM14 7l3 3'),++ wiki: P('M5 5.5h6a2 2 0 0 1 2 2V19a2 2 0 0 0-2-2H5zM19 5.5h-4a2 2 0 0 0-2 2V19a2 2 0 0 1 2-2h4z'),++ news: '<rect x="4" y="5.5" width="13" height="13" rx="1.5"/>' + P('M17 9h3v8a1.5 1.5 0 0 1-3 0M7 9h7M7 12h7M7 15h4'),++ reviews: P('M12 4.5l2.2 4.6 5 .7-3.6 3.5.9 5-4.5-2.4-4.5 2.4.9-5L4.8 9.8l5-.7z'),++ tips: P('M12 19s-6.5-3.8-6.5-8.6A3.6 3.6 0 0 1 12 8a3.6 3.6 0 0 1 6.5 2.4C18.5 15.2 12 19 12 19z'),++ vip: P('M4.5 16.5 6 8l3.8 3.5L12 6.5l2.2 5L18 8l1.5 8.5zM5 19h14'),++ trade: P('M4.5 18.5h15M6.5 15l3.5-4 3 2.5 4.5-6M17.5 7.5h-3M17.5 7.5v3'),++ host: '<rect x="4.5" y="5" width="15" height="5.5" rx="1.2"/><rect x="4.5" y="13.5" width="15" height="5.5" rx="1.2"/><circle cx="7.5" cy="7.75" r=".6"/><circle cx="7.5" cy="16.25" r=".6"/>',++ deals: P('M4.5 12.5V5.5h7l8 8-7 7zM8.2 9.2h.01'),++ coupons: P('M4 8.5h16v2.5a1.5 1.5 0 0 0 0 3v2.5H4V14a1.5 1.5 0 0 0 0-3zM10 8.5v8') ,++ stream: P('M5 17.5 12 6l7 11.5M8 13h8') + '<circle cx="12" cy="6" r="1.4"/>',++ youtube: '<rect x="3.5" y="6.5" width="17" height="11" rx="3"/>' + P('M10.5 9.8v4.4l3.8-2.2z'),++ video: '<rect x="3.5" y="6.5" width="17" height="11" rx="3"/>' + P('M10.5 9.8v4.4l3.8-2.2z'),++ download: P('M12 4.5v10M8 11l4 4 4-4M5 19h14'),++ upload: P('M12 15.5v-10M8 9l4-4 4 4M5 19h14'),++ image: '<rect x="4" y="5" width="16" height="14" rx="2"/><circle cx="9" cy="10" r="1.5"/>' + P('M4.5 17l4.5-4.5 3.5 3.5 2.5-2.5 4.5 4.5'),++ audio: P('M9 17V7.5l9-2V15') + '<circle cx="6.8" cy="17" r="2.2"/><circle cx="15.8" cy="15" r="2.2"/>',++ pdf: P('M7 4.5h7l4 4V19a.5.5 0 0 1-.5.5h-10.5A.5.5 0 0 1 6.5 19V5a.5.5 0 0 1 .5-.5zM14 4.5v4h4M9 13h6M9 16h4'),++ docs: P('M7 4.5h7l4 4V19a.5.5 0 0 1-.5.5h-10.5A.5.5 0 0 1 6.5 19V5a.5.5 0 0 1 .5-.5zM14 4.5v4h4M9 13h6M9 16h4'),++ text: P('M5.5 7V5.5h13V7M12 5.5v13M9.5 18.5h5'),++ logo: P('M12 4.5l1.8 4.2 4.2 1.8-4.2 1.8L12 16.5l-1.8-4.2L6 10.5l4.2-1.8zM18 15.5l.8 1.7 1.7.8-1.7.8-.8 1.7-.8-1.7-1.7-.8 1.7-.8z'),++ json: P('M9 5.5c-2 0-2 1.5-2 3s0 3.5-2 3.5c2 0 2 2 2 3.5s0 3 2 3M15 5.5c2 0 2 1.5 2 3s0 3.5 2 3.5c-2 0-2 2-2 3.5s0 3-2 3'),++ dns: '<circle cx="12" cy="12" r="7.5"/>' + P('M4.5 12h15M12 4.5c2.5 2 2.5 13 0 15M12 4.5c-2.5 2-2.5 13 0 15'),++ ip: P('M12 20s-6-5.2-6-10a6 6 0 0 1 12 0c0 4.8-6 10-6 10z') + '<circle cx="12" cy="10" r="2.2"/>',++ ssl: '<rect x="6" y="11" width="12" height="8.5" rx="1.8"/>' + P('M8.5 11V8.5a3.5 3.5 0 0 1 7 0V11M12 14.5v2'),++ ping: P('M3.5 12h4l2-5 3.5 10 2.5-7 1.5 2h3.5'),++ whois: '<circle cx="11" cy="11" r="5.5"/>' + P('M15.2 15.2 19.5 19.5M9 10a2 2 0 0 1 4 0c0 1.5-2 1.6-2 3M11 15h.01'),++ search: '<circle cx="11" cy="11" r="5.5"/>' + P('M15.2 15.2 19.5 19.5'),++ map: P('M4.5 7l5-2 5 2 5-2v12l-5 2-5-2-5 2zM9.5 5v12M14.5 7v12'),++ food: P('M7 4.5v6a2 2 0 0 0 4 0v-6M9 4.5v15M16.5 4.5c-2 1.5-2.5 4-2.5 6.5h2.5zM16.5 11v8.5'),++ paste: '<rect x="6" y="6" width="12" height="14" rx="1.8"/>' + P('M9.5 6V5a1 1 0 0 1 1-1h3a1 1 0 0 1 1 1v1M9 11h6M9 14h6M9 17h3'),++ account: '<circle cx="12" cy="9" r="3.2"/>' + P('M5.5 19a6.5 6.5 0 0 1 13 0'),++ bell: P('M6.5 16.5h11l-1.2-2V11a4.3 4.3 0 0 0-8.6 0v3.5zM10.5 19a1.6 1.6 0 0 0 3 0'),++ history: '<circle cx="12" cy="12" r="7.5"/>' + P('M12 8v4.2l2.8 1.8'),++ theme: P('M12 4.5a7.5 7.5 0 1 0 0 15c1 0 1.6-.8 1.2-1.7-.5-1.1.3-2.3 1.5-2.3h1.6a3.2 3.2 0 0 0 3.2-3.2C19.5 8 16.2 4.5 12 4.5z') + '<circle cx="8.5" cy="11" r=".7"/><circle cx="11.5" cy="8" r=".7"/><circle cx="15" cy="9" r=".7"/>',++ check: P('M6 12.5l4 4 8-9'),++ error: P('M12 7v6M12 16.5h.01'),++ clip: P('M6 6l12 12M6 18L18 6') ,++ };++ const ALIASES = { yt: 'youtube', net: 'dns', dev: 'code', img: 'image', sound: 'audio', document: 'pdf', pastes: 'paste', openre: 'stream', maps: 'map', user: 'account', notifications: 'bell' };++ const registry = Object.create(null);++ for (const [k, g] of Object.entries(GLYPHS)) registry[k] = { glyph: g };++++ const CSS = `++.ov-icon{--ovi-size:32px;display:inline-grid;place-items:center;width:var(--ovi-size);height:var(--ovi-size);flex:none;vertical-align:middle;line-height:0;color:var(--ovi-accent,var(--accent,#3b82f6));position:relative}++.ov-icon svg{width:100%;height:100%;overflow:visible;display:block}++.ov-icon .ovi-bg{fill:currentColor;opacity:.1;transition:opacity .25s}++.ov-icon .ovi-ring{fill:none;stroke:currentColor;stroke-width:2.4;opacity:.26}++.ov-icon .ovi-comet{fill:none;stroke:currentColor;stroke-width:2.4;stroke-linecap:round;stroke-dasharray:26 100;transform-origin:24px 24px;animation:oviSpin 3.6s linear infinite}++.ov-icon .ovi-prog{fill:none;stroke:currentColor;stroke-width:3;stroke-linecap:round;transform:rotate(-90deg);transform-origin:24px 24px;stroke-dasharray:125.7;stroke-dashoffset:125.7;transition:stroke-dashoffset .35s cubic-bezier(.2,.8,.2,1),opacity .2s;opacity:0}++.ov-icon .ovi-glyph{fill:none;stroke:var(--ovi-glyph,var(--text-primary,#e6edf7));stroke-width:1.7;stroke-linecap:round;stroke-linejoin:round;transform-origin:24px 24px;transition:transform .25s cubic-bezier(.2,1.4,.3,1),stroke .2s}++.ov-icon .ovi-glyph circle[r=".6"],.ov-icon .ovi-glyph circle[r=".7"]{fill:currentColor;stroke:none}++.ov-icon:hover .ovi-glyph,a:hover>.ov-icon .ovi-glyph,button:hover>.ov-icon .ovi-glyph{transform:scale(1.1)}++.ov-icon:hover .ovi-bg,a:hover>.ov-icon .ovi-bg{opacity:.18}++.ov-icon[data-fx=none] .ovi-comet{display:none}++.ov-icon[data-fx=pulse] .ovi-comet{display:none}++.ov-icon[data-fx=pulse] .ovi-bg{animation:oviPulse 2s ease-in-out infinite;transform-origin:24px 24px}++.ov-icon[data-fx=draw] .ovi-glyph path{stroke-dasharray:80;animation:oviDraw 1.2s cubic-bezier(.2,.8,.2,1) both}++.ov-icon[data-fx=bounce] svg{animation:oviBounce 1.8s cubic-bezier(.3,1.4,.4,1) infinite}++.ov-icon[data-progress] .ovi-prog{opacity:1}++.ov-icon[data-progress] .ovi-comet{opacity:0}++.ov-icon[data-state=busy] .ovi-comet{animation-duration:1.1s;opacity:1;stroke-dasharray:40 86}++.ov-icon[data-state=busy][data-progress] .ovi-comet{opacity:.35}++.ov-icon[data-state=ok]{--ovi-accent:var(--success,#22c55e)}++.ov-icon[data-state=error]{--ovi-accent:var(--danger,#ef4444)}++.ov-icon[data-state=ok] .ovi-comet,.ov-icon[data-state=error] .ovi-comet{display:none}++.ov-icon[data-state=ok] .ovi-ring,.ov-icon[data-state=error] .ovi-ring{opacity:.9}++.ov-icon[data-state=ok] svg{animation:oviPop .5s cubic-bezier(.2,1.6,.3,1)}++.ov-icon[data-state=error] svg{animation:oviShake .4s ease}++@keyframes oviSpin{to{transform:rotate(360deg)}}++@keyframes oviPulse{0%,100%{transform:scale(.9);opacity:.08}50%{transform:scale(1.06);opacity:.22}}++@keyframes oviDraw{from{stroke-dashoffset:80}to{stroke-dashoffset:0}}++@keyframes oviBounce{0%,100%{transform:translateY(0)}50%{transform:translateY(-6%)}}++@keyframes oviPop{0%{transform:scale(.8)}60%{transform:scale(1.12)}100%{transform:scale(1)}}++@keyframes oviShake{0%,100%{transform:translateX(0)}25%{transform:translateX(-6%)}75%{transform:translateX(6%)}}++@media (prefers-reduced-motion:reduce){.ov-icon *, .ov-icon svg{animation:none!important;transition:none!important}.ov-icon .ovi-comet{display:none}}`;++++ function resolve(name) {++ const n = String(name || 'ov').toLowerCase();++ return registry[n] ? n : (registry[ALIASES[n]] ? ALIASES[n] : 'ov');++ }++++ /** Markup for one icon (usable server-side: no DOM needed). Glyph is scaled 24→22 and centred in the 48 box. */++ function svg(name, size) {++ const def = registry[resolve(name)];++ const s = size ? ` width="${size}" height="${size}"` : '';++ return `<svg viewBox="0 0 48 48"${s} aria-hidden="true" focusable="false"><circle class="ovi-bg" cx="24" cy="24" r="21"/><circle class="ovi-ring" cx="24" cy="24" r="20"/><circle class="ovi-comet" cx="24" cy="24" r="20"/><circle class="ovi-prog" cx="24" cy="24" r="20"/><g class="ovi-glyph" transform="translate(13 13) scale(.9167)">${def.glyph}</g></svg>`;++ }++++ function applyProgress(el) {++ const raw = el.getAttribute('data-progress');++ const prog = el.querySelector('.ovi-prog');++ if (!prog) return;++ if (raw == null || raw === '') { prog.style.strokeDashoffset = ''; return; }++ const p = Math.max(0, Math.min(1, parseFloat(raw) || 0));++ prog.style.strokeDashoffset = String(125.7 * (1 - p));++ }++++ function mountOne(el) {++ const name = resolve(el.getAttribute('data-icon'));++ if (el.getAttribute('data-ovi') === name) { applyProgress(el); return; }++ el.setAttribute('data-ovi', name);++ const size = parseInt(el.getAttribute('data-size'), 10);++ if (size) el.style.setProperty('--ovi-size', size + 'px');++ const def = registry[name];++ if (def.accent) el.style.setProperty('--ovi-accent', def.accent);++ if (def.fx && !el.hasAttribute('data-fx')) el.setAttribute('data-fx', def.fx);++ el.innerHTML = svg(name);++ if (!el.hasAttribute('role')) el.setAttribute('aria-hidden', 'true');++ applyProgress(el);++ }++++ function mount(rootEl) { (rootEl || document).querySelectorAll('.ov-icon').forEach(mountOne); }++++ function set(el, patch) {++ if (!el || !patch) return;++ if (patch.icon) el.setAttribute('data-icon', patch.icon);++ if ('state' in patch) { if (patch.state) el.setAttribute('data-state', patch.state); else el.removeAttribute('data-state'); }++ if ('progress' in patch) { if (patch.progress == null) el.removeAttribute('data-progress'); else el.setAttribute('data-progress', String(patch.progress)); }++ mountOne(el);++ }++++ function register(name, def) {++ if (!name || !def || !def.glyph) return;++ registry[String(name).toLowerCase()] = { glyph: def.glyph, fx: def.fx || null, accent: def.accent || null };++ if (typeof document !== 'undefined') document.querySelectorAll(`.ov-icon[data-icon="${name}"]`).forEach((el) => { el.removeAttribute('data-ovi'); mountOne(el); });++ }++++ const api = { names: () => Object.keys(registry), register, mount, set, svg, resolve, CSS };++ root.OpenVibeIcons = api;++ if (typeof module !== 'undefined' && module.exports) module.exports = api;++ if (typeof document === 'undefined') return;++++ function init() {++ if (!document.getElementById('ov-icons-css')) { const st = document.createElement('style'); st.id = 'ov-icons-css'; st.textContent = CSS; document.head.appendChild(st); }++ mount();++ try {++ new MutationObserver((muts) => {++ for (const m of muts) {++ if (m.type === 'attributes') { if (m.target.classList && m.target.classList.contains('ov-icon')) mountOne(m.target); continue; }++ m.addedNodes.forEach((n) => { if (n.nodeType !== 1) return; if (n.classList.contains('ov-icon')) mountOne(n); else if (n.querySelector) n.querySelectorAll('.ov-icon').forEach(mountOne); });++ }++ }).observe(document.documentElement, { childList: true, subtree: true, attributes: true, attributeFilter: ['data-icon', 'data-progress'] });++ } catch { /* */ }++ }++ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init); else init();++})(typeof window !== 'undefined' ? window : globalThis);nnew file mode 100644@@@ -0,0 +1,91 @@++/**++ * ov-mark.js — the OpenVibe "OV" brand mark as a drop-in.++ * <span class="ov-mark"></span> → 32px animated mark++ * <span class="ov-mark" data-size="56"> → any size; add data-static="1" for no motion++ * <span class="ov-mark" data-variant="live"> → the site's own twist on the motion (see VARIANTS)++ * Self-contained: injects its own CSS once, works on any page (Live, Network, static pages).++ * The O is a ring with a comet arc running around it, the V is drawn inside with a light++ * sweep, and a dot orbits the ring. Transform/opacity/stroke motion only.++ */++(function () {++ 'use strict';++ if (window.__ovMark) return; window.__ovMark = true;++ let n = 0;++ const CSS = `++.ov-mark{display:inline-grid;place-items:center;width:32px;height:32px;color:var(--accent,#8b5cf6);flex:none;vertical-align:middle;line-height:0}++.ov-mark svg{width:100%;height:100%;overflow:visible;transform-origin:50% 50%;animation:ovmFloat 5s ease-in-out infinite}++.ov-mark .g{fill:currentColor;opacity:.1;transform-box:fill-box;transform-origin:center;animation:ovmGlow 3.4s ease-in-out infinite}++.ov-mark .r{fill:none;stroke:currentColor;stroke-width:4;opacity:.28}++.ov-mark .c{fill:none;stroke-width:4;stroke-linecap:round;stroke-dasharray:34 79;transform-box:fill-box;transform-origin:center;animation:ovmComet 3.6s linear infinite}++.ov-mark .v{fill:none;stroke-width:4.6;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:46;animation:ovmDraw 1.1s cubic-bezier(.2,.8,.2,1) both}++.ov-mark .d{fill:var(--ovm-dot,#fff);transform-box:fill-box;transform-origin:center;animation:ovmDot 2.4s ease-in-out infinite}++/* Per-site variants: one family, each site with its own motion so the network reads as one brand++ with many rooms. Colours stay with the theme (currentColor); only the dot and the rhythm change. */++.ov-mark[data-variant=live]{--ovm-dot:#ef4444}++.ov-mark[data-variant=live] .d{animation:ovmOnAir 1.4s ease-in-out infinite}++.ov-mark[data-variant=tools] .c{animation-duration:2.2s;stroke-dasharray:22 91}++.ov-mark[data-variant=tools] .o2{display:none}++.ov-mark[data-variant=media] .c{stroke-dasharray:14 14 14 71;animation-duration:4.4s}++.ov-mark[data-variant=games] svg{animation:ovmBounce 1.6s cubic-bezier(.3,1.4,.4,1) infinite}++.ov-mark[data-variant=games] .d{animation-duration:1.6s}++.ov-mark[data-variant=community] .o2{opacity:.9;r:1.8}++.ov-mark[data-variant=community] .c{animation-direction:reverse}++.ov-mark[data-variant=network] .g{animation-duration:2.4s}++.ov-mark[data-variant=chat] .d{animation:ovmDot 1s ease-in-out infinite}++.ov-mark[data-variant=stream] .c{stroke-dasharray:60 53;animation-duration:2.8s}++.ov-mark[data-variant=codes] .v{animation:ovmDraw 1.1s cubic-bezier(.2,.8,.2,1) infinite alternate}++.ov-mark[data-variant=blog] .c,.ov-mark[data-variant=wiki] .c,.ov-mark[data-variant=news] .c{animation-duration:6s}++.ov-mark[data-variant=vip]{--ovm-dot:#facc15}++.ov-mark[data-variant=tips]{--ovm-dot:#22c55e}++.ov-mark[data-variant=deals],.ov-mark[data-variant=coupons]{--ovm-dot:#f59e0b}++.ov-mark[data-variant=trade] .c{animation-timing-function:cubic-bezier(.4,0,.2,1)}++.ov-mark[data-variant=host] svg{animation:none}++@keyframes ovmOnAir{0%,100%{transform:scale(1);opacity:.7}50%{transform:scale(1.7);opacity:1}}++@keyframes ovmBounce{0%,100%{transform:translateY(0)}50%{transform:translateY(-2.5px)}}++.ov-mark .o{fill:#fff;filter:drop-shadow(0 0 3px currentColor)}++.ov-mark .o2{opacity:.5}++.ov-mark:hover svg,a:hover>.ov-mark svg,button:hover>.ov-mark svg{animation:ovmSpin .9s cubic-bezier(.2,1.5,.3,1) 1}++.ov-mark:hover .c{animation-duration:.9s}++.ov-mark[data-static] svg,.ov-mark[data-static] .g,.ov-mark[data-static] .c,.ov-mark[data-static] .v,.ov-mark[data-static] .d{animation:none!important}++.ov-mark[data-static] .o{display:none}++.ov-mark .p{fill:none;stroke:currentColor;stroke-width:4.4;stroke-linecap:round;transform:rotate(-90deg);transform-box:fill-box;transform-origin:center;stroke-dasharray:113.1;stroke-dashoffset:calc(113.1px * (1 - var(--ovm-p,0)));opacity:0;transition:stroke-dashoffset .35s cubic-bezier(.2,.8,.2,1),opacity .2s}++.ov-mark[data-progress] .p{opacity:1}++.ov-mark[data-progress] .c,.ov-mark[data-progress] .o{opacity:.25}++.ov-mark[data-state=busy] .c{animation-duration:1.1s}++.ov-mark[data-state=ok]{color:var(--success,#22c55e)!important}++.ov-mark[data-state=error]{color:var(--danger,#ef4444)!important}++.ov-mark[data-state=ok] svg{animation:ovmSpin .9s cubic-bezier(.2,1.5,.3,1) 1}++@keyframes ovmFloat{0%,100%{transform:translateY(0) rotate(0deg)}50%{transform:translateY(-1px) rotate(2deg)}}++@keyframes ovmGlow{0%,100%{opacity:.08;transform:scale(.92)}50%{opacity:.2;transform:scale(1.06)}}++@keyframes ovmComet{to{transform:rotate(360deg)}}++@keyframes ovmDraw{from{stroke-dashoffset:46}to{stroke-dashoffset:0}}++@keyframes ovmDot{0%,100%{transform:scale(1);opacity:.85}50%{transform:scale(1.5);opacity:1}}++@keyframes ovmSpin{from{transform:rotate(0deg) scale(1)}40%{transform:rotate(200deg) scale(1.18)}to{transform:rotate(360deg) scale(1)}}++@media (prefers-reduced-motion:reduce){.ov-mark svg,.ov-mark .g,.ov-mark .c,.ov-mark .v,.ov-mark .d{animation:none!important}.ov-mark .o{display:none}.ov-mark .c{stroke-dasharray:none;opacity:.9}}`;++ function svg(id) {++ return `<svg viewBox="0 0 48 48" aria-hidden="true"><defs>++<linearGradient id="${id}g" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="currentColor"/><stop offset="1" stop-color="currentColor" stop-opacity="0.45"/></linearGradient>++<linearGradient id="${id}v" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#fff"/><stop offset="0.5" stop-color="currentColor"/><stop offset="1" stop-color="#fff" stop-opacity="0.9"/><animateTransform attributeName="gradientTransform" type="translate" from="-1 0" to="1 0" dur="3.2s" repeatCount="indefinite"/></linearGradient>++<path id="${id}p" d="M24,6 A18,18 0 1,1 23.99,6 Z"/></defs>++<circle class="g" cx="24" cy="24" r="21"/><circle class="r" cx="24" cy="24" r="18"/><circle class="c" cx="24" cy="24" r="18" stroke="url(#${id}g)"/>++<circle class="p" cx="24" cy="24" r="18"/>++<path class="v" d="M14.5,17 L24,34 L33.5,17" stroke="url(#${id}v)"/><circle class="d" cx="24" cy="34" r="2.6"/>++<circle class="o" r="2"><animateMotion dur="3.6s" repeatCount="indefinite"><mpath href="#${id}p"/></animateMotion></circle>++<circle class="o o2" r="1.4"><animateMotion dur="3.6s" begin="0.14s" repeatCount="indefinite"><mpath href="#${id}p"/></animateMotion></circle></svg>`;++ }++ function mount(root) {++ (root || document).querySelectorAll('.ov-mark:not([data-ov])').forEach(el => {++ el.setAttribute('data-ov', '1');++ const size = parseInt(el.getAttribute('data-size'), 10);++ if (size) { el.style.width = size + 'px'; el.style.height = size + 'px'; }++ el.innerHTML = svg('ovm' + (++n) + '_');++ });++ }++ function init() {++ if (!document.getElementById('ov-mark-css')) { const st = document.createElement('style'); st.id = 'ov-mark-css'; st.textContent = CSS; document.head.appendChild(st); }++ mount();++ try { new MutationObserver(() => mount()).observe(document.body, { childList: true, subtree: true }); } catch { /* */ }++ }++ window.ovMarkMount = mount;++ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init); else init();++})();@@@ -11,7 +11,9 @@"./brand": "./brand.js","./middleware": "./middleware.js","./notifications": "./notifications.js",-- "./url-resolver": "./url-resolver.js"++ "./url-resolver": "./url-resolver.js",++ "./seo": "./seo.js",++ "./icons": "./ov-icons.js"},"license": "MIT","dependencies": {nnew file mode 100644@@@ -0,0 +1,51 @@++#!/usr/bin/env node++'use strict';++/**++ * Regenerates the built-in theme map inside theme-loader.js from builtin-themes.js.++ *++ * theme-loader.js runs in the browser before the first paint and cannot require() the catalog, so++ * it carries a copy. That copy used to be maintained by hand and drifted. Now it is generated:++ *++ * node packages/openvibe-shared/scripts/build-theme-loader.js # rewrite++ * node packages/openvibe-shared/scripts/build-theme-loader.js --check # exit 1 if stale++ *++ * Every entry carries the FULL variable set (the theme's own values over the default theme's), so++ * switching themes replaces every token — a theme that does not name --success no longer keeps the++ * previous theme's --success on <html>.++ */++const fs = require('fs');++const path = require('path');++const { BUILTIN_THEMES, DEFAULT_VARS } = require('../builtin-themes');++++const LOADER = path.join(__dirname, '..', 'theme-loader.js');++const BEGIN = ' // THEMES:BEGIN — generated by scripts/build-theme-loader.js from builtin-themes.js; do not edit by hand';++const END = ' // THEMES:END';++++function render() {++ const lines = BUILTIN_THEMES.map((t) => {++ const vars = { ...DEFAULT_VARS, ...t.variables };++ const body = Object.entries(vars).map(([k, v]) => `'${k}':'${String(v).replace(/'/g, "\\'")}'`).join(',');++ return ` '${t.id}': { ${body} },`;++ });++ return `${BEGIN}\n const THEMES = {\n${lines.join('\n')}\n };\n${END}`;++}++++function currentBlock(src) {++ const a = src.indexOf(BEGIN);++ const b = src.indexOf(END);++ if (a >= 0 && b > a) return { a, b: b + END.length };++ // First run: the hand-written block.++ const start = src.indexOf(' const THEMES = {');++ if (start < 0) throw new Error('theme-loader.js: THEMES block not found');++ const close = src.indexOf('\n };', start);++ return { a: start, b: close + '\n };'.length };++}++++const src = fs.readFileSync(LOADER, 'utf8');++const { a, b } = currentBlock(src);++const next = src.slice(0, a) + render() + src.slice(b);++const check = process.argv.includes('--check');++if (next === src) { if (!check) console.log('theme-loader.js is up to date'); process.exit(0); }++if (check) { console.error('theme-loader.js is stale: run node packages/openvibe-shared/scripts/build-theme-loader.js'); process.exit(1); }++fs.writeFileSync(LOADER, next);++console.log(`theme-loader.js regenerated (${BUILTIN_THEMES.length} themes)`);nnew file mode 100644@@@ -0,0 +1,174 @@++'use strict';++/**++ * openvibe-shared/seo.js — one SEO toolkit for every OpenVibe site (Node, no dependencies).++ *++ * const seo = require('openvibe-shared/seo');++ * seo.headTags({ title, description, canonical, image, jsonLd: [seo.jsonLd.website({...})] })++ * seo.sitemapXml([{ loc, lastmod }]) seo.robotsTxt({ sitemaps }) seo.llmsTxt({...})++ *++ * Rules it encodes so pages do not have to remember them: one canonical per page, titles cut at++ * 60 characters on a word, descriptions at 160, og/twitter mirrored from the same values, JSON-LD++ * serialised safely (no `</script>` break-outs), sitemaps with absolute URLs only, robots.txt that++ * welcomes search and AI crawlers by default and always names the sitemap, and an /llms.txt so++ * language-model crawlers get a clean map of the site.++ */++++const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));++const xml = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));++++/** Cut on a word boundary with an ellipsis; never mid-word, never longer than `max`. */++function clip(text, max) {++ const t = String(text == null ? '' : text).replace(/\s+/g, ' ').trim();++ if (t.length <= max) return t;++ const cut = t.slice(0, max - 1);++ const at = cut.lastIndexOf(' ');++ return (at > max * 0.6 ? cut.slice(0, at) : cut).replace(/[\s,;:.\-–—]+$/, '') + '…';++}++++function absolute(url, base) {++ if (!url) return '';++ try { return new URL(url, base || undefined).toString(); } catch { return String(url); }++}++++/** JSON-LD script tag; `<` is escaped so content can never close the script element. */++function jsonLdTag(obj) {++ return `<script type="application/ld+json">${JSON.stringify(obj).replace(/</g, '\\u003c')}</script>`;++}++++/**++ * The whole <head> SEO block as an HTML string.++ * @param {object} o title, description, canonical (absolute), image, imageAlt, type ('website'),++ * siteName, robots ('index, follow'), locale ('en_US'), keywords (array|string), author,++ * themeColor, twitterSite, alternates [{ hreflang, href }], prev, next, jsonLd (array|object),++ * titleSuffix (appended when it fits), noTitle (skip <title> when the page has its own)++ */++function headTags(o = {}) {++ let title = String(o.title || o.siteName || 'OpenVibe');++ if (o.titleSuffix && (title + o.titleSuffix).length <= 60) title += o.titleSuffix;++ title = clip(title, 60);++ const description = clip(o.description || '', 160);++ const canonical = absolute(o.canonical);++ const image = o.image ? absolute(o.image, canonical) : '';++ const keywords = Array.isArray(o.keywords) ? o.keywords.join(', ') : (o.keywords || '');++ const robots = o.robots || 'index, follow, max-image-preview:large, max-snippet:-1';++ const out = [];++ if (!o.noTitle) out.push(`<title>${esc(title)}</title>`);++ if (description) out.push(`<meta name="description" content="${esc(description)}">`);++ if (keywords) out.push(`<meta name="keywords" content="${esc(keywords)}">`);++ out.push(`<meta name="robots" content="${esc(robots)}">`);++ if (o.author) out.push(`<meta name="author" content="${esc(o.author)}">`);++ if (o.themeColor) out.push(`<meta name="theme-color" content="${esc(o.themeColor)}">`);++ if (canonical) out.push(`<link rel="canonical" href="${esc(canonical)}">`);++ for (const a of o.alternates || []) if (a && a.href) out.push(`<link rel="alternate" hreflang="${esc(a.hreflang || 'x-default')}" href="${esc(absolute(a.href))}">`);++ if (o.prev) out.push(`<link rel="prev" href="${esc(absolute(o.prev))}">`);++ if (o.next) out.push(`<link rel="next" href="${esc(absolute(o.next))}">`);++ out.push(`<meta property="og:type" content="${esc(o.type || 'website')}">`);++ if (o.siteName) out.push(`<meta property="og:site_name" content="${esc(o.siteName)}">`);++ out.push(`<meta property="og:title" content="${esc(title)}">`);++ if (description) out.push(`<meta property="og:description" content="${esc(description)}">`);++ if (canonical) out.push(`<meta property="og:url" content="${esc(canonical)}">`);++ out.push(`<meta property="og:locale" content="${esc(o.locale || 'en_US')}">`);++ if (image) {++ out.push(`<meta property="og:image" content="${esc(image)}">`);++ if (o.imageAlt) out.push(`<meta property="og:image:alt" content="${esc(o.imageAlt)}">`);++ }++ out.push(`<meta name="twitter:card" content="${image && o.largeImage !== false ? 'summary_large_image' : 'summary'}">`);++ if (o.twitterSite) out.push(`<meta name="twitter:site" content="${esc(o.twitterSite)}">`);++ out.push(`<meta name="twitter:title" content="${esc(title)}">`);++ if (description) out.push(`<meta name="twitter:description" content="${esc(description)}">`);++ if (image) out.push(`<meta name="twitter:image" content="${esc(image)}">`);++ const ld = o.jsonLd ? (Array.isArray(o.jsonLd) ? o.jsonLd : [o.jsonLd]) : [];++ for (const item of ld) if (item) out.push(jsonLdTag(item));++ return out.join('\n');++}++++const ORG = { '@type': 'Organization', '@id': 'https://openvibe.network/#org', name: 'OpenVibe', url: 'https://openvibe.network/', logo: 'https://openvibe.network/assets/logo-512.png', sameAs: ['https://github.com/OpenVibers', 'https://discord.gg/M6MuRUaeJj'] };++++const jsonLd = {++ organization(extra = {}) { return { '@context': 'https://schema.org', ...ORG, ...extra }; },++ /** WebSite; `searchUrl` with {q} adds the sitelinks SearchAction. */++ website({ name, url, description, searchUrl, inLanguage = 'en' } = {}) {++ const o = { '@context': 'https://schema.org', '@type': 'WebSite', '@id': `${url}#site`, name, url, description, inLanguage, publisher: { '@id': ORG['@id'] } };++ if (searchUrl) o.potentialAction = { '@type': 'SearchAction', target: { '@type': 'EntryPoint', urlTemplate: searchUrl.replace('{q}', '{search_term_string}') }, 'query-input': 'required name=search_term_string' };++ return o;++ },++ webPage({ name, url, description, type = 'WebPage', siteUrl, dateModified } = {}) {++ return { '@context': 'https://schema.org', '@type': type, '@id': `${url}#page`, name, url, description, ...(siteUrl ? { isPartOf: { '@id': `${siteUrl}#site` } } : {}), ...(dateModified ? { dateModified } : {}) };++ },++ /** A web tool. No price/offer fields: the platform makes no cost claims. */++ softwareApp({ name, url, description, category = 'UtilitiesApplication', keywords, image, os = 'Any (web browser)' } = {}) {++ return { '@context': 'https://schema.org', '@type': 'WebApplication', name, url, description, applicationCategory: category, operatingSystem: os, browserRequirements: 'Requires a modern web browser', ...(keywords ? { keywords: Array.isArray(keywords) ? keywords.join(', ') : keywords } : {}), ...(image ? { image } : {}), publisher: { '@id': ORG['@id'] } };++ },++ breadcrumbs(items = []) {++ return { '@context': 'https://schema.org', '@type': 'BreadcrumbList', itemListElement: items.map((it, i) => ({ '@type': 'ListItem', position: i + 1, name: it.name, item: it.url })) };++ },++ itemList(name, items = [], { url } = {}) {++ return { '@context': 'https://schema.org', '@type': 'ItemList', name, ...(url ? { url } : {}), numberOfItems: items.length, itemListElement: items.map((it, i) => ({ '@type': 'ListItem', position: i + 1, name: it.name, url: it.url, ...(it.description ? { description: it.description } : {}) })) };++ },++ faq(pairs = []) {++ return { '@context': 'https://schema.org', '@type': 'FAQPage', mainEntity: pairs.map((p) => ({ '@type': 'Question', name: p.q, acceptedAnswer: { '@type': 'Answer', text: p.a } })) };++ },++ howTo({ name, description, steps = [] } = {}) {++ return { '@context': 'https://schema.org', '@type': 'HowTo', name, description, step: steps.map((s, i) => ({ '@type': 'HowToStep', position: i + 1, name: s.name || `Step ${i + 1}`, text: s.text })) };++ },++ article({ headline, url, description, author, datePublished, dateModified, image } = {}) {++ return { '@context': 'https://schema.org', '@type': 'Article', headline: clip(headline, 110), url, description, ...(author ? { author: { '@type': 'Person', name: author } } : {}), ...(datePublished ? { datePublished } : {}), ...(dateModified ? { dateModified } : {}), ...(image ? { image } : {}), publisher: { '@id': ORG['@id'] } };++ },++ video({ name, url, description, thumbnailUrl, uploadDate, duration, contentUrl, embedUrl } = {}) {++ return { '@context': 'https://schema.org', '@type': 'VideoObject', name, url, description: description || name, thumbnailUrl, uploadDate, ...(duration ? { duration } : {}), ...(contentUrl ? { contentUrl } : {}), ...(embedUrl ? { embedUrl } : {}) };++ },++};++++/** urls: [{ loc, lastmod?, changefreq?, priority?, alternates?: [{hreflang, href}], images?: [url] }] — absolute http(s) only. */++function sitemapXml(urls = []) {++ const rows = urls.filter((u) => u && /^https?:\/\//i.test(u.loc)).map((u) => {++ const parts = [`<loc>${xml(u.loc)}</loc>`];++ if (u.lastmod) parts.push(`<lastmod>${xml(String(u.lastmod).slice(0, 10))}</lastmod>`);++ if (u.changefreq) parts.push(`<changefreq>${xml(u.changefreq)}</changefreq>`);++ if (u.priority != null) parts.push(`<priority>${Number(u.priority).toFixed(1)}</priority>`);++ for (const a of u.alternates || []) parts.push(`<xhtml:link rel="alternate" hreflang="${xml(a.hreflang)}" href="${xml(a.href)}"/>`);++ for (const img of u.images || []) parts.push(`<image:image><image:loc>${xml(img)}</image:loc></image:image>`);++ return ` <url>${parts.join('')}</url>`;++ });++ return `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml" xmlns:image="http://www.google.com/schemas/sitemap-image/1.1">\n${rows.join('\n')}\n</urlset>\n`;++}++++function sitemapIndexXml(maps = []) {++ const rows = maps.filter((m) => m && m.loc).map((m) => ` <sitemap><loc>${xml(m.loc)}</loc>${m.lastmod ? `<lastmod>${xml(String(m.lastmod).slice(0, 10))}</lastmod>` : ''}</sitemap>`);++ return `<?xml version="1.0" encoding="UTF-8"?>\n<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${rows.join('\n')}\n</sitemapindex>\n`;++}++++// Crawlers that build search and AI indexes — welcomed by name so a generic Disallow never hits them.++const AI_AND_SEARCH_BOTS = ['Googlebot', 'Bingbot', 'DuckDuckBot', 'Applebot', 'GPTBot', 'OAI-SearchBot', 'ChatGPT-User', 'ClaudeBot', 'Claude-Web', 'anthropic-ai', 'PerplexityBot', 'CCBot', 'Google-Extended', 'Amazonbot', 'meta-externalagent', 'YouBot', 'Bytespider'];++++function robotsTxt({ sitemaps = [], disallow = [], allow = ['/'], allowAI = true, block = [], crawlDelay } = {}) {++ const lines = ['User-agent: *'];++ for (const a of allow) lines.push(`Allow: ${a}`);++ for (const d of disallow) lines.push(`Disallow: ${d}`);++ if (crawlDelay) lines.push(`Crawl-delay: ${crawlDelay}`);++ if (allowAI) {++ lines.push('');++ for (const bot of AI_AND_SEARCH_BOTS) lines.push(`User-agent: ${bot}`);++ lines.push('Allow: /');++ for (const d of disallow) lines.push(`Disallow: ${d}`);++ }++ for (const b of block) { lines.push('', `User-agent: ${b}`, 'Disallow: /'); }++ lines.push('');++ for (const s of sitemaps) lines.push(`Sitemap: ${s}`);++ return lines.join('\n') + '\n';++}++++/** /llms.txt (llmstxt.org): a markdown map of the site for language-model crawlers. */++function llmsTxt({ name, summary, details, sections = [] } = {}) {++ const out = [`# ${name}`, '', `> ${String(summary || '').replace(/\s+/g, ' ').trim()}`, ''];++ if (details) out.push(String(details).trim(), '');++ for (const sec of sections) {++ if (!sec || !sec.links || !sec.links.length) continue;++ out.push(`## ${sec.title}`, '');++ for (const l of sec.links) out.push(`- [${l.title}](${l.url})${l.note ? `: ${String(l.note).replace(/\s+/g, ' ').trim()}` : ''}`);++ out.push('');++ }++ return out.join('\n');++}++++module.exports = { headTags, jsonLd, jsonLdTag, sitemapXml, sitemapIndexXml, robotsTxt, llmsTxt, clip, esc, absolute, AI_AND_SEARCH_BOTS };nnew file mode 100644@@@ -0,0 +1,117 @@++/**++ * openvibe-shared/sso-client.js — the pieces that make one sign-in feel like one sign-in++ * across separate domains. Loaded lazily by navbar.js; Live loads it directly.++ *++ * OpenVibeSSO.handoffLinks({ signedIn }) cross-site links carry the session along++ * OpenVibeSSO.fedcm({ apiBase, fedcmLogin, mediation }) browser-native sign-in (Chrome/Edge)++ * OpenVibeSSO.loginUrlFor(href) the silent-login URL that lands on `href`++ *++ * Link hand-off: when this page has a session and a link points at another OpenVibe site, the++ * navigation goes through that site's silent sign-in (`…/auth/login?silent=1&next=<link>`).++ * It is a top-level navigation, so it works in every browser with no third-party cookies, and++ * the target answers straight from its own session when it already has one. Sites that share a++ * cookie domain (openvibe.tools and its subdomains) are one site here. Links can opt out with++ * data-ov-nohandoff.++ *++ * FedCM: `navigator.credentials.get({ identity })` — the browser itself asks openvibe.network++ * for an assertion (a native chip the first time per site, silent auto re-auth after that) and++ * the page posts it to the site's /auth/fedcm, which exchanges it server-side for a session.++ * Never called for browsers the network has told "logged-out", so guests see nothing.++ */++(function (root) {++ 'use strict';++ if (root.OpenVibeSSO) return;++++ const NETWORK = 'https://openvibe.network';++ // Site → where its silent sign-in lives. Hosts sharing a session cookie share an entry.++ const SITES = [++ { test: (h) => h === 'openvibe.live' || h.endsWith('.openvibe.live'), key: 'live', login: 'https://openvibe.live/api/auth/sso/login?silent=1&next={url}', fedcm: 'https://openvibe.live/api/auth/fedcm' },++ { test: (h) => h === 'openvibe.tools' || h.endsWith('.openvibe.tools'), key: 'tools', login: 'https://openvibe.tools/auth/login?silent=1&next={url}', fedcm: 'https://openvibe.tools/auth/fedcm' },++ { test: (h) => h === 'openvibe.games' || h.endsWith('.openvibe.games'), key: 'games', login: 'https://openvibe.games/auth/login?silent=1&next={url}', fedcm: '/auth/fedcm' },++ { test: (h) => h === 'openvibe.community' || h.endsWith('.openvibe.community'), key: 'community', login: 'https://openvibe.community/auth/login?silent=1&next={url}', fedcm: '/auth/fedcm' },++ ];++ const siteFor = (host) => SITES.find(s => s.test(String(host || '').toLowerCase())) || null;++ const here = () => (typeof location !== 'undefined' ? siteFor(location.hostname) : null);++++ /** The URL that signs the browser in on the link's site and lands on the link, or null. */++ function loginUrlFor(href) {++ let u; try { u = new URL(href, location.href); } catch { return null; }++ if (u.protocol !== 'https:') return null;++ const target = siteFor(u.hostname);++ if (!target) return null;++ const mine = here();++ if (mine && mine.key === target.key) return null; // same session cookie already++ return target.login.replace('{url}', encodeURIComponent(u.toString()));++ }++++ let _handoffOn = false, _signedIn = false;++ function rewrite(a) {++ if (!a || a.dataset.ovHandoff || a.hasAttribute('data-ov-nohandoff') || !a.getAttribute('href')) return;++ const url = loginUrlFor(a.href);++ if (!url) return;++ a.dataset.ovHandoff = a.href;++ a.href = url;++ }++ function onIntent(e) {++ if (!_signedIn) return;++ const a = e.target && e.target.closest ? e.target.closest('a[href]') : null;++ if (a) rewrite(a);++ }++ /** Turn cross-site links into session hand-offs while signed in (idempotent; call again to update). */++ function handoffLinks(opts) {++ _signedIn = !!(opts && opts.signedIn);++ if (_handoffOn) return;++ _handoffOn = true;++ // Rewrite just before the navigation can start: pointer/keyboard intent, and touch.++ for (const ev of ['pointerdown', 'touchstart', 'focusin', 'auxclick']) document.addEventListener(ev, onIntent, { capture: true, passive: true });++ document.addEventListener('click', onIntent, true);++ document.addEventListener('contextmenu', (e) => {++ // A copied link should stay a plain link.++ const a = e.target && e.target.closest ? e.target.closest('a[data-ov-handoff]') : null;++ if (a) { a.href = a.dataset.ovHandoff; delete a.dataset.ovHandoff; }++ }, true);++ }++++ /** FedCM availability: the API exists and this is a top-level, secure document. */++ function fedcmAvailable() {++ try { return typeof navigator !== 'undefined' && 'IdentityCredential' in root && root.isSecureContext && root.top === root; } catch { return false; }++ }++++ /**++ * Ask the browser for a FedCM assertion from openvibe.network and turn it into a session here.++ * Resolves { ok, user } or { ok: false, reason }. mediation 'silent' = only auto re-auth++ * (previously approved on this site), 'optional' = show the chip the first time.++ */++ async function fedcm(opts) {++ const o = Object.assign({ apiBase: NETWORK, mediation: 'optional', timeoutMs: 15000 }, opts || {});++ if (!fedcmAvailable()) return { ok: false, reason: 'unsupported' };++ const site = here();++ const fedcmLogin = o.fedcmLogin || (site && site.fedcm) || '/auth/fedcm';++ const nonce = Array.from(crypto.getRandomValues(new Uint8Array(16)), b => b.toString(16).padStart(2, '0')).join('');++ const ctrl = new AbortController(); const timer = setTimeout(() => ctrl.abort(), o.timeoutMs);++ let cred;++ try {++ cred = await navigator.credentials.get({++ identity: { context: 'signin', providers: [{ configURL: `${o.apiBase}/fedcm/config.json`, clientId: location.origin, nonce, params: { nonce } }] },++ mediation: o.mediation, signal: ctrl.signal,++ });++ } catch (err) {++ clearTimeout(timer);++ return { ok: false, reason: (err && (err.name || err.message)) || 'declined' };++ }++ clearTimeout(timer);++ if (!cred || !cred.token) return { ok: false, reason: 'no-token' };++ try {++ const res = await fetch(fedcmLogin, { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token: cred.token, nonce }) });++ const data = await res.json().catch(() => ({}));++ if (!res.ok) return { ok: false, reason: data.error || `http-${res.status}`, auto: !!cred.isAutoSelected };++ try { sessionStorage.setItem('ov_sso_hint', 'account'); localStorage.setItem('ov_sso_hint', 'account'); } catch { /* */ }++ return { ok: true, user: data.user || null, auto: !!cred.isAutoSelected };++ } catch (err) { return { ok: false, reason: 'network' }; }++ }++++ /** After an explicit sign-out: the browser must not auto re-auth us back in. */++ function preventSilent() { try { if (navigator.credentials && navigator.credentials.preventSilentAccess) navigator.credentials.preventSilentAccess(); } catch { /* */ } }++++ root.OpenVibeSSO = { handoffLinks, loginUrlFor, fedcm, fedcmAvailable, preventSilent, siteFor, SITES };++})(typeof window !== 'undefined' ? window : globalThis);nnew file mode 100644@@@ -0,0 +1,124 @@++/**++ * openvibe-shared/ui.js — one toast / confirm / alert implementation for every OpenVibe site.++ *++ * OpenVibeUI.toast('Saved', { type: 'success' })++ * OpenVibeUI.toast('Upload failed', { type: 'error', title: 'Images', action: { label: 'Retry', onClick } })++ * if (await OpenVibeUI.confirm({ title: 'Delete paste?', message: 'This cannot be undone.', danger: true })) …++ * await OpenVibeUI.alert({ title: 'Heads up', message: '…' })++ *++ * Themed from the site's CSS variables, stacked bottom-right (bottom-centre on phones), announced++ * to assistive tech (role=status, role=alert for errors), pause-on-hover, Escape closes dialogs,++ * focus is trapped in dialogs and restored afterwards. Text is always set with textContent.++ */++(function (root) {++ 'use strict';++ if (root.OpenVibeUI) return;++ if (typeof document === 'undefined') { const noop = { toast() { return { close() {} }; }, confirm: async () => false, alert: async () => {} }; if (typeof module !== 'undefined') module.exports = noop; return; }++++ const CSS = `++.ovui-toasts{position:fixed;right:16px;bottom:16px;z-index:2147483000;display:flex;flex-direction:column;gap:8px;max-width:min(380px,calc(100vw - 24px));pointer-events:none}++.ovui-toast{pointer-events:auto;display:grid;grid-template-columns:auto 1fr auto;gap:10px;align-items:start;padding:11px 12px;border-radius:12px;background:var(--bg-elevated,var(--bg-secondary,#131a26));color:var(--text-primary,#e6edf7);border:1px solid var(--border,rgba(255,255,255,.1));box-shadow:0 10px 30px rgba(0,0,0,.35);font:500 13.5px/1.4 system-ui,-apple-system,Segoe UI,Roboto,sans-serif;animation:ovuiIn .28s cubic-bezier(.2,1.2,.3,1);border-left:3px solid var(--ovui-c,var(--accent,#3b82f6))}++.ovui-toast[data-type=success]{--ovui-c:var(--success,#22c55e)}++.ovui-toast[data-type=error]{--ovui-c:var(--danger,#ef4444)}++.ovui-toast[data-type=warning]{--ovui-c:var(--warning,#f59e0b)}++.ovui-toast.is-out{animation:ovuiOut .2s ease forwards}++.ovui-dot{width:8px;height:8px;border-radius:50%;background:var(--ovui-c,var(--accent,#3b82f6));margin-top:6px}++.ovui-title{font-weight:700;margin-bottom:1px}++.ovui-msg{color:var(--text-secondary,#a8b3c4);overflow-wrap:anywhere}++.ovui-title+.ovui-msg{font-weight:400}++.ovui-toast .ovui-msg:first-child{color:inherit}++.ovui-x,.ovui-act{appearance:none;border:0;background:none;color:inherit;font:inherit;cursor:pointer;border-radius:8px}++.ovui-x{opacity:.55;padding:2px 6px;line-height:1;font-size:16px}++.ovui-x:hover{opacity:1}++.ovui-act{margin-top:6px;padding:4px 10px;font-weight:700;color:var(--accent,#3b82f6);background:color-mix(in srgb,var(--accent,#3b82f6) 14%,transparent);text-decoration:none;display:inline-block}++.ovui-back{position:fixed;inset:0;z-index:2147483100;background:rgba(3,6,12,.6);backdrop-filter:blur(3px);display:grid;place-items:center;padding:16px;animation:ovuiFade .15s ease}++.ovui-dialog{width:min(420px,100%);background:var(--bg-elevated,var(--bg-secondary,#131a26));color:var(--text-primary,#e6edf7);border:1px solid var(--border,rgba(255,255,255,.1));border-radius:16px;padding:20px;box-shadow:0 24px 60px rgba(0,0,0,.5);font:400 14.5px/1.5 system-ui,-apple-system,Segoe UI,Roboto,sans-serif;animation:ovuiIn .22s cubic-bezier(.2,1.2,.3,1)}++.ovui-dialog h2{margin:0 0 6px;font-size:17px;font-weight:700}++.ovui-dialog p{margin:0;color:var(--text-secondary,#a8b3c4);white-space:pre-wrap;overflow-wrap:anywhere}++.ovui-btns{display:flex;justify-content:flex-end;gap:8px;margin-top:18px}++.ovui-btn{appearance:none;border:1px solid var(--border,rgba(255,255,255,.14));background:transparent;color:inherit;font:600 14px/1 system-ui,sans-serif;padding:10px 16px;border-radius:10px;cursor:pointer}++.ovui-btn:hover{background:rgba(127,127,127,.12)}++.ovui-btn.is-primary{background:var(--accent,#3b82f6);border-color:transparent;color:var(--on-accent,#fff)}++.ovui-btn.is-danger{background:var(--danger,#ef4444);border-color:transparent;color:#fff}++.ovui-btn:focus-visible,.ovui-x:focus-visible,.ovui-act:focus-visible{outline:2px solid var(--accent,#3b82f6);outline-offset:2px}++@keyframes ovuiIn{from{opacity:0;transform:translateY(10px) scale(.97)}to{opacity:1;transform:none}}++@keyframes ovuiOut{to{opacity:0;transform:translateX(16px)}}++@keyframes ovuiFade{from{opacity:0}to{opacity:1}}++@media (max-width:560px){.ovui-toasts{left:12px;right:12px;bottom:calc(12px + env(safe-area-inset-bottom,0px));max-width:none}}++@media (prefers-reduced-motion:reduce){.ovui-toast,.ovui-dialog,.ovui-back{animation:none!important}}`;++++ function css() { if (document.getElementById('ovui-css')) return; const s = document.createElement('style'); s.id = 'ovui-css'; s.textContent = CSS; document.head.appendChild(s); }++ function el(tag, cls, text) { const e = document.createElement(tag); if (cls) e.className = cls; if (text != null) e.textContent = String(text); return e; }++ function safeHref(h) { try { const u = new URL(h, location.href); return /^https?:$/.test(u.protocol) ? u.href : null; } catch { return null; } }++++ let stack = null;++ const MAX = 4;++++ function toast(message, opts) {++ const o = opts || {};++ css();++ if (!stack || !stack.isConnected) { stack = el('div', 'ovui-toasts'); stack.setAttribute('aria-live', 'polite'); document.body.appendChild(stack); }++ const type = ['info', 'success', 'error', 'warning'].includes(o.type) ? o.type : 'info';++ const t = el('div', 'ovui-toast'); t.dataset.type = type; t.setAttribute('role', type === 'error' ? 'alert' : 'status');++ t.appendChild(el('span', 'ovui-dot'));++ const body = el('div');++ if (o.title) body.appendChild(el('div', 'ovui-title', o.title));++ body.appendChild(el('div', 'ovui-msg', message));++ if (o.action && o.action.label) {++ const href = o.action.href && safeHref(o.action.href);++ const a = el(href ? 'a' : 'button', 'ovui-act', o.action.label);++ if (href) a.href = href; else a.type = 'button';++ a.addEventListener('click', (ev) => { try { o.action.onClick && o.action.onClick(ev); } finally { close(); } });++ body.appendChild(a);++ }++ t.appendChild(body);++ const x = el('button', 'ovui-x', '×'); x.type = 'button'; x.setAttribute('aria-label', 'Dismiss'); x.addEventListener('click', () => close()); t.appendChild(x);++ stack.appendChild(t);++ while (stack.children.length > MAX) stack.firstChild.remove();++++ const ttl = o.ttl === 0 ? 0 : (o.ttl || (type === 'error' ? 8000 : 4500));++ let timer = null, closed = false;++ const arm = () => { if (ttl) timer = setTimeout(close, ttl); };++ function close() { if (closed) return; closed = true; clearTimeout(timer); t.classList.add('is-out'); setTimeout(() => t.remove(), 220); }++ t.addEventListener('mouseenter', () => clearTimeout(timer)); t.addEventListener('mouseleave', arm);++ arm();++ return { close, el: t };++ }++++ function dialog(o, withCancel) {++ css();++ return new Promise((resolve) => {++ const prev = document.activeElement;++ const back = el('div', 'ovui-back');++ const d = el('div', 'ovui-dialog'); d.setAttribute('role', withCancel ? 'alertdialog' : 'dialog'); d.setAttribute('aria-modal', 'true');++ const h = el('h2', null, o.title || (withCancel ? 'Are you sure?' : 'Notice')); h.id = 'ovui-h-' + Date.now(); d.setAttribute('aria-labelledby', h.id); d.appendChild(h);++ if (o.message) d.appendChild(el('p', null, o.message));++ const btns = el('div', 'ovui-btns');++ let cancel = null;++ if (withCancel) { cancel = el('button', 'ovui-btn', o.cancelLabel || 'Cancel'); cancel.type = 'button'; btns.appendChild(cancel); }++ const ok = el('button', 'ovui-btn ' + (o.danger ? 'is-danger' : 'is-primary'), o.confirmLabel || (withCancel ? 'Confirm' : 'OK')); ok.type = 'button'; btns.appendChild(ok);++ d.appendChild(btns); back.appendChild(d); document.body.appendChild(back);++ function done(v) { document.removeEventListener('keydown', onKey, true); back.remove(); try { prev && prev.focus && prev.focus(); } catch { /* */ } resolve(v); }++ function onKey(e) {++ if (e.key === 'Escape') { e.preventDefault(); done(false); }++ else if (e.key === 'Tab') { const f = [cancel, ok].filter(Boolean); const i = f.indexOf(document.activeElement); e.preventDefault(); f[(i + (e.shiftKey ? f.length - 1 : 1)) % f.length].focus(); }++ }++ document.addEventListener('keydown', onKey, true);++ back.addEventListener('mousedown', (e) => { if (e.target === back) done(false); });++ ok.addEventListener('click', () => done(true));++ if (cancel) cancel.addEventListener('click', () => done(false));++ (o.danger && cancel ? cancel : ok).focus();++ });++ }++++ const api = {++ toast,++ success: (m, o) => toast(m, Object.assign({}, o, { type: 'success' })),++ error: (m, o) => toast(m, Object.assign({}, o, { type: 'error' })),++ warning: (m, o) => toast(m, Object.assign({}, o, { type: 'warning' })),++ confirm: (o) => dialog(typeof o === 'string' ? { message: o } : (o || {}), true),++ alert: (o) => dialog(typeof o === 'string' ? { message: o } : (o || {}), false).then(() => undefined),++ };++ root.OpenVibeUI = api;++ if (typeof module !== 'undefined' && module.exports) module.exports = api;++})(typeof window !== 'undefined' ? window : globalThis);
new file mode 100644
new file mode 100644
@@ -0,0 +1,120 @@
@@ -0,0 +1,120 @@
+'use strict';
+
+// ═══════════════════════════════════════════════════════════════
+// Host roles for the satellite apps (shared-contracts §1).
+//
+// The Tools gateway owns the catalog of hosts. When it forwards a request it says which tool
+// the host belongs to and what role the host plays:
+//
+// X-OV-Tool catalog id of the tool ("yt", "png", …)
+// X-OV-Host-Role canonical | short | alias
+// X-OV-Canonical-Host the host every canonical / og:url / JSON-LD url uses
+// X-OV-Short-Host the host people type
+//
+// A satellite keeps its own domain map for branding, so without the headers it behaves as it
+// always has (each host is its own canonical). With them, the canonical host comes from the
+// gateway — which is how a custom domain becomes canonical without the satellite knowing it.
+//
+// No dependencies: every satellite requires this file by relative path.
+// ═══════════════════════════════════════════════════════════════
+
+const TOOLS_HOME = 'https://openvibe.tools/';
+const HOST_RE = /^(?=.{1,253}$)([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/;
+const TOOL_RE = /^[a-z0-9][a-z0-9._-]{0,63}$/;
+
+function cleanHost(value) {
+ const h = String(value || '').trim().toLowerCase().replace(/:\d+$/, '').replace(/\.$/, '');
+ return HOST_RE.test(h) ? h : '';
+}
+
+/** Request hostname, lower-cased, without the port ('' when absent). */
+function requestHost(req) {
+ return String((req.headers && req.headers.host) || '').trim().toLowerCase().replace(/:\d+$/, '');
+}
+
+/** Loopback / LAN / bare names: development, health checks and service-to-service calls. */
+function isLocalHost(host) {
+ if (!host) return true;
+ if (host === 'localhost' || host.endsWith('.localhost') || host.endsWith('.local')) return true;
+ if (/^\[?[0-9a-f:]+\]?$/i.test(host)) return true; // IPv6 literal
+ if (/^\d{1,3}(\.\d{1,3}){3}$/.test(host)) return true; // IPv4 literal
+ return !host.includes('.');
+}
+
+/**
+ * What the gateway said about this request.
+ * @returns {{ viaGateway: boolean, tool: string, role: string, canonicalHost: string, shortHost: string }}
+ */
+function hostRole(req) {
+ const h = req.headers || {};
+ const toolRaw = String(h['x-ov-tool'] || '').trim().toLowerCase();
+ const tool = TOOL_RE.test(toolRaw) ? toolRaw : '';
+ const roleRaw = String(h['x-ov-host-role'] || '').trim().toLowerCase();
+ const role = ['canonical', 'short', 'alias'].includes(roleRaw) ? roleRaw : '';
+ return {
+ viaGateway: !!tool,
+ tool,
+ role,
+ canonicalHost: cleanHost(h['x-ov-canonical-host']),
+ shortHost: cleanHost(h['x-ov-short-host']),
+ };
+}
+
+/** The host canonical URLs are built on: the gateway's when it named one, else `fallbackHost`. */
+function canonicalHostFor(req, fallbackHost) {
+ const info = req.ovHost || hostRole(req);
+ return info.canonicalHost || cleanHost(fallbackHost) || requestHost(req);
+}
+
+/** `https://<canonical host><pathname>` — query strings never belong in a canonical. */
+function canonicalUrlFor(req, fallbackHost, pathname) {
+ let p = pathname == null ? String(req.path || '/') : String(pathname);
+ if (!p.startsWith('/')) p = `/${p}`;
+ return `https://${canonicalHostFor(req, fallbackHost)}${p}`;
+}
+
+/**
+ * Express middleware. Sets `req.ovHost` and turns away hosts this satellite does not serve.
+ *
+ * @param {object} opts
+ * @param {(host: string) => boolean} opts.knows is this hostname in the satellite's own map?
+ * @param {(host: string) => string} [opts.aliasOf] where an alias in the satellite's own map points ('' = not an alias)
+ * @param {string[]} [opts.skip] path prefixes that are never redirected (APIs, health checks)
+ */
+function hostGuard(opts) {
+ const knows = opts.knows;
+ const aliasOf = opts.aliasOf || (() => '');
+ const skip = opts.skip || ['/api/'];
+ const extra = new Set(String(process.env.OV_EXTRA_HOSTS || '').split(',').map(s => s.trim().toLowerCase()).filter(Boolean));
+
+ return function ovHostGuard(req, res, next) {
+ const info = hostRole(req);
+ const host = requestHost(req);
+ info.host = host;
+ req.ovHost = info;
+
+ if (skip.some(p => req.path.startsWith(p))) return next();
+ if (req.method !== 'GET' && req.method !== 'HEAD') return next();
+
+ if (info.viaGateway) {
+ // The gateway redirects aliases itself; this only covers a request that slipped past it.
+ if (info.role === 'alias') {
+ const target = info.shortHost || info.canonicalHost;
+ if (target && target !== host) return res.redirect(301, `https://${target}${req.originalUrl || '/'}`);
+ }
+ return next();
+ }
+
+ if (isLocalHost(host) || extra.has(host)) return next();
+
+ const target = cleanHost(aliasOf(host));
+ if (target && target !== host) return res.redirect(301, `https://${target}${req.originalUrl || '/'}`);
+ if (knows(host)) return next();
+
+ // Not ours: never render a default page under a made-up brand.
+ res.setHeader('Cache-Control', 'public, max-age=300');
+ return res.redirect(301, TOOLS_HOME);
+ };
+}
+
+module.exports = { hostRole, hostGuard, canonicalHostFor, canonicalUrlFor, requestHost, isLocalHost, cleanHost, TOOLS_HOME };
deleted file mode 100644
deleted file mode 100644
@@ -1,744 +0,0 @@
@@ -1,744 +0,0 @@
-<!DOCTYPE html>
-<html lang="en">
-<head>
- <meta charset="UTF-8">
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
- <title>OpenVibe.Tools — Online Tools Directory</title>
- <meta name="description" content="OpenVibe.Tools: 200+ online tools. Network diagnostics, developer utilities, pastes, image & audio converters, text generators, logo makers, PDF tools, survival maps, and more. One OpenVibe account, everywhere.">
- <meta name="keywords" content="openvibe tools, online tools, network diagnostics, developer tools, image converter, audio converter, text generator, logo maker, pdf tools, pastebin">
- <meta name="author" content="OpenVibe">
- <meta name="theme-color" content="#8b5cf6">
-
- <!-- Open Graph -->
- <meta property="og:type" content="website">
- <meta property="og:url" content="https://openvibe.tools/">
- <meta property="og:title" content="OpenVibe.Tools — Online Tools Directory">
- <meta property="og:description" content="200+ online tools: network diagnostics, developer utilities, image & audio converters, text generators, PDF tools, and more.">
- <meta property="og:site_name" content="OpenVibe.Tools">
- <meta property="og:locale" content="en_US">
-
- <!-- Twitter -->
- <meta name="twitter:card" content="summary">
- <meta name="twitter:url" content="https://openvibe.tools/">
- <meta name="twitter:title" content="OpenVibe.Tools — Online Tools Directory">
- <meta name="twitter:description" content="200+ online tools under one roof. Part of the OpenVibe network.">
-
- <link rel="canonical" href="https://openvibe.tools/">
- <link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect fill='%238b5cf6' width='32' height='32' rx='8'/%3E%3Ccircle cx='16' cy='10' r='3' fill='white'/%3E%3Ccircle cx='9' cy='22' r='3' fill='white'/%3E%3Ccircle cx='23' cy='22' r='3' fill='white'/%3E%3Cpath d='M16 10L9 22M16 10l7 12M9 22h14' stroke='white' stroke-width='1.6'/%3E%3C/svg%3E">
-
- <link rel="preconnect" href="https://fonts.googleapis.com">
- <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
-<!-- Critical theme bootstrap (ov-theme-boot) — MUST stay inline and above the stylesheets.
- Paints the cached theme synchronously so the default palette never flashes. Reads
- localStorage only: no network, nothing to block on. theme-loader.js still runs after
- and reconciles with the account. -->
-<script>
-(function(){try{var raw=localStorage.getItem('ov_theme');if(!raw)return;var t=JSON.parse(raw),v=t&&t.variables;if(!v)return;var el=document.documentElement;for(var k in v)if(k.charAt(0)==='-')el.style.setProperty(k,v[k]);if(t.id)el.setAttribute('data-theme',t.id);}catch(_){}})();
-</script>
- <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&display=swap" rel="stylesheet">
- <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.5.1/css/all.min.css">
-
- <!-- Shared OpenVibe assets (served by the Network) -->
- <script src="https://openvibe.network/shared/theme-loader.js"></script>
-
- <script type="application/ld+json">
- {
- "@context": "https://schema.org",
- "@type": "WebSite",
- "name": "OpenVibe.Tools",
- "url": "https://openvibe.tools/",
- "description": "200+ online tools: network diagnostics, developer utilities, converters, generators, and more.",
- "publisher": { "@type": "Organization", "name": "OpenVibe", "url": "https://openvibe.network" }
- }
- </script>
-
- <style>
- :root {
- --bg-primary: #0e0e13;
- --bg-secondary: #131318;
- --bg-card: rgba(24, 24, 34, 0.85);
- --bg-hover: #232333;
- --text-primary: #f1f2fb;
- --text-secondary: #a5a8c4;
- --text-muted: #62657f;
- --accent: #8b5cf6;
- --accent-light: #a78bfa;
- --accent-dark: #6d28d9;
- --secondary: #22d3ee;
- --border: rgba(255,255,255,0.07);
- --border-light: rgba(255,255,255,0.13);
- --shadow: 0 24px 64px rgba(0,0,0,0.4);
- --radius: 20px;
- --radius-sm: 14px;
- }
-
- *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
- html { background: var(--bg-primary); scroll-behavior: smooth; }
- body {
- font-family: 'Inter', system-ui, -apple-system, sans-serif;
- background:
- radial-gradient(1100px 500px at 85% -10%, rgba(139,92,246,0.14), transparent 60%),
- radial-gradient(900px 420px at 0% 10%, rgba(34,211,238,0.08), transparent 55%),
- var(--bg-primary);
- color: var(--text-primary);
- line-height: 1.6;
- min-height: 100vh;
- -webkit-font-smoothing: antialiased;
- }
- a { color: inherit; text-decoration: none; }
- .shell { max-width: 1180px; margin: 0 auto; padding: 0 24px; }
-
- .btn {
- display: inline-flex; align-items: center; gap: 8px; cursor: pointer;
- padding: 10px 18px; border-radius: 12px; font-weight: 700; font-size: 0.9rem;
- border: 1px solid var(--border-light); background: transparent; color: var(--text-primary);
- transition: all 0.18s ease; font-family: inherit;
- }
- .btn:hover { background: var(--bg-hover); transform: translateY(-1px); }
- .btn-primary {
- background: linear-gradient(135deg, var(--accent), var(--accent-dark));
- border-color: transparent; color: #fff; box-shadow: 0 8px 22px rgba(139,92,246,0.35);
- }
- .btn-primary:hover { filter: brightness(1.1); background: linear-gradient(135deg, var(--accent), var(--accent-dark)); }
-
- /* ─── Hero ─── */
- .hero { padding: 64px 0 40px; text-align: center; }
- .hero h1 {
- font-size: clamp(2.2rem, 5.5vw, 3.6rem); font-weight: 900; letter-spacing: -0.03em; line-height: 1.12;
- }
- .hero h1 .grad {
- background: linear-gradient(120deg, var(--accent-light), var(--secondary));
- -webkit-background-clip: text; background-clip: text; color: transparent;
- }
- .hero p.tag { margin: 18px auto 0; max-width: 640px; color: var(--text-secondary); font-size: 1.08rem; }
- .hero-search {
- margin: 30px auto 0; max-width: 560px; position: relative;
- }
- .hero-search input {
- width: 100%; padding: 16px 20px 16px 50px; font: inherit; font-size: 1rem;
- color: var(--text-primary); background: var(--bg-card); cursor: pointer;
- border: 1px solid var(--border-light); border-radius: 16px; outline: none;
- transition: border-color 0.18s ease;
- }
- .hero-search input:hover { border-color: rgba(139,92,246,0.5); }
- .hero-search i { position: absolute; left: 20px; top: 50%; transform: translateY(-50%); color: var(--text-muted); }
- .hero-search kbd {
- position: absolute; right: 14px; top: 50%; transform: translateY(-50%);
- font-family: inherit; font-size: 0.72rem; font-weight: 700; color: var(--text-muted);
- border: 1px solid var(--border-light); border-radius: 6px; padding: 3px 7px;
- }
- .hero-stats { display: flex; gap: 26px; justify-content: center; margin-top: 28px; flex-wrap: wrap; }
- .hero-stat { text-align: center; }
- .hero-stat b { display: block; font-size: 1.35rem; font-weight: 900; color: var(--accent-light); }
- .hero-stat span { font-size: 0.74rem; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.06em; }
-
- /* ─── Section titles ─── */
- .section { padding: 44px 0; }
- .section-title { margin-bottom: 28px; }
- .section-title h2 { font-size: 1.55rem; font-weight: 800; letter-spacing: -0.02em; }
- .section-title p { color: var(--text-secondary); font-size: 0.95rem; margin-top: 4px; }
-
- /* ─── Tool cards ─── */
- .tools-grid {
- display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 18px;
- }
- .tool-card {
- position: relative; overflow: hidden; display: flex; flex-direction: column; gap: 10px;
- padding: 24px; border-radius: var(--radius); background: var(--bg-card);
- border: 1px solid var(--border); transition: all 0.2s ease;
- }
- .tool-card:hover { transform: translateY(-3px); border-color: color-mix(in srgb, var(--card-accent, var(--accent)) 45%, transparent); box-shadow: var(--shadow); }
- .tool-card::before {
- content: ''; position: absolute; inset: 0 auto 0 0; width: 3px;
- background: var(--card-accent, var(--accent)); opacity: 0; transition: opacity 0.2s ease;
- }
- .tool-card:hover::before { opacity: 1; }
- .tool-card .icon {
- width: 46px; height: 46px; border-radius: 13px; display: grid; place-items: center; font-size: 1.2rem;
- background: color-mix(in srgb, var(--card-accent, var(--accent)) 13%, transparent);
- color: var(--card-accent, var(--accent));
- }
- .tool-card h3 { font-size: 1.08rem; font-weight: 800; }
- .tool-card .domain { font-size: 0.78rem; font-weight: 600; color: var(--card-accent, var(--accent-light)); }
- .tool-card p { color: var(--text-secondary); font-size: 0.88rem; }
- .tool-card .aliases { margin-top: auto; padding-top: 8px; font-size: 0.74rem; color: var(--text-muted); }
-
- /* ─── Network strip ─── */
- .net-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); gap: 18px; }
- .net-card {
- display: flex; align-items: center; gap: 16px; padding: 20px 22px;
- border-radius: var(--radius-sm); background: var(--bg-card); border: 1px solid var(--border);
- transition: all 0.2s ease;
- }
- .net-card:hover { transform: translateY(-2px); border-color: color-mix(in srgb, var(--card-accent, var(--accent)) 45%, transparent); }
- .net-card .icon {
- width: 44px; height: 44px; border-radius: 12px; display: grid; place-items: center; font-size: 1.15rem;
- background: color-mix(in srgb, var(--card-accent, var(--accent)) 13%, transparent);
- color: var(--card-accent, var(--accent)); flex-shrink: 0;
- }
- .net-card h3 { font-size: 0.98rem; font-weight: 800; }
- .net-card p { color: var(--text-secondary); font-size: 0.8rem; }
-
- /* ─── Directory modal ─── */
- .dir-overlay {
- position: fixed; inset: 0; z-index: 60; display: none;
- background: rgba(8,8,14,0.78); backdrop-filter: blur(8px); padding: 4vh 16px;
- }
- .dir-overlay.open { display: flex; justify-content: center; }
- .dir-panel {
- width: 100%; max-width: 940px; max-height: 92vh; display: flex; flex-direction: column;
- background: var(--bg-secondary); border: 1px solid var(--border-light);
- border-radius: var(--radius); box-shadow: var(--shadow); overflow: hidden;
- }
- .dir-head { display: flex; align-items: center; gap: 14px; padding: 18px 22px; border-bottom: 1px solid var(--border); }
- .dir-head h2 { font-size: 1.05rem; font-weight: 800; flex: 1; }
- .dir-head .dir-count { font-size: 0.78rem; color: var(--text-muted); font-weight: 600; }
- .dir-close {
- width: 34px; height: 34px; border-radius: 10px; display: grid; place-items: center; cursor: pointer;
- background: transparent; border: 1px solid var(--border-light); color: var(--text-secondary); font-size: 0.95rem;
- }
- .dir-close:hover { background: var(--bg-hover); color: var(--text-primary); }
- .dir-search-row { padding: 14px 22px 6px; }
- .dir-search-row input {
- width: 100%; padding: 12px 16px; font: inherit; font-size: 0.95rem; color: var(--text-primary);
- background: var(--bg-card); border: 1px solid var(--border-light); border-radius: 12px; outline: none;
- }
- .dir-search-row input:focus { border-color: var(--accent); }
- .dir-filters { display: flex; gap: 8px; padding: 10px 22px; flex-wrap: wrap; }
- .dir-filter-btn {
- padding: 6px 13px; border-radius: 999px; font-size: 0.78rem; font-weight: 700; cursor: pointer;
- background: transparent; border: 1px solid var(--border-light); color: var(--text-secondary); font-family: inherit;
- text-transform: capitalize;
- }
- .dir-filter-btn.active { background: var(--accent); border-color: var(--accent); color: #fff; }
- .dir-body { overflow-y: auto; padding: 8px 22px 24px; }
- .dir-category { margin-top: 18px; }
- .dir-cat-head { display: flex; align-items: center; gap: 10px; margin-bottom: 10px; }
- .dir-cat-head i { color: var(--accent-light); }
- .dir-cat-head h3 { font-size: 0.92rem; font-weight: 800; }
- .dir-cat-count { font-size: 0.7rem; font-weight: 700; color: var(--text-muted); border: 1px solid var(--border); padding: 1px 8px; border-radius: 999px; }
- .dir-tools { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 8px; }
- .dir-tool {
- display: flex; align-items: center; gap: 11px; padding: 10px 12px; border-radius: 11px;
- border: 1px solid transparent; transition: all 0.14s ease;
- }
- .dir-tool:hover { background: var(--bg-hover); border-color: var(--border-light); }
- .dir-tool-icon {
- width: 32px; height: 32px; border-radius: 9px; display: grid; place-items: center; font-size: 0.82rem;
- background: color-mix(in srgb, var(--tool-color, var(--accent)) 13%, transparent);
- color: var(--tool-color, var(--accent)); flex-shrink: 0;
- }
- .dir-tool-name { font-size: 0.83rem; font-weight: 700; line-height: 1.3; }
- .dir-tool-domain { font-size: 0.7rem; color: var(--text-muted); }
- .dir-empty { padding: 40px; text-align: center; color: var(--text-muted); }
- .dir-empty i { display: block; font-size: 1.6rem; margin-bottom: 10px; }
-
- /* ─── Footer ─── */
-
- @media (max-width: 640px) {
- .hero { padding: 40px 0 28px; }
- .hero-stats { gap: 18px; }
- }
- </style>
-</head>
-<body>
- <div class="shell">
- <!-- ─── Hero ─── -->
- <section class="hero">
- <h1>Every tool you need,<br><span class="grad">one vibe apart.</span></h1>
- <p class="tag">200+ online tools — network diagnostics, developer utilities, converters, generators, maps, and more. One OpenVibe account works everywhere. Open source, built in the open.</p>
- <div class="hero-search" onclick="openDirectory()">
- <i class="fa-solid fa-magnifying-glass"></i>
- <input type="text" placeholder="Search tools — try 'mp3', 'resize', 'zalgo', 'whois'…" readonly>
- <kbd>Ctrl K</kbd>
- </div>
- <div class="hero-stats">
- <div class="hero-stat"><b id="stat-tools">200+</b><span>Tools</span></div>
- <div class="hero-stat"><b>10</b><span>Suites</span></div>
- <div class="hero-stat"><b>1</b><span>Account</span></div>
- <div class="hero-stat"><b>Open</b><span>source</span></div>
- </div>
- </section>
-
- <!-- ─── Tool suites ─── -->
- <section class="section" id="tools">
- <div class="section-title">
- <h2>The tool suites</h2>
- <p>Each suite lives on its own subdomain — and most individual tools have a memorable alias of their own.</p>
- </div>
- <div class="tools-grid">
- <a class="tool-card" href="https://net.openvibe.tools" style="--card-accent:#22d3ee">
- <div class="icon"><i class="fa-solid fa-network-wired"></i></div>
- <h3>Net.OpenVibe</h3>
- <div class="domain">net.openvibe.tools</div>
- <p>38 network & internet diagnostics — DNS, GeoIP, ping, traceroute, SSL, Whois/RDAP, headers, port checks.</p>
- <div class="aliases">Also: dns. · ping. · ssl. · whois. · myip. · traceroute. & 30 more</div>
- </a>
- <a class="tool-card" href="https://dev.openvibe.tools" style="--card-accent:#a78bfa">
- <div class="icon"><i class="fa-solid fa-code"></i></div>
- <h3>Dev.OpenVibe</h3>
- <div class="domain">dev.openvibe.tools</div>
- <p>26 developer & SEO tools — JSON, YAML, Base64, JWT, regex, diff, hashes, timestamps, OpenGraph.</p>
- <div class="aliases">Also: json. · base64. · jwt. · regex. · uuid. · cron. & 20 more</div>
- </a>
- <a class="tool-card" href="https://pastes.openvibe.tools" style="--card-accent:#f59e0b">
- <div class="icon"><i class="fa-solid fa-clipboard"></i></div>
- <h3>Paste.OpenVibe</h3>
- <div class="domain">pastes.openvibe.tools</div>
- <p>Share code snippets and screenshots. Syntax highlighting, comments, likes, forks, burn-after-read.</p>
- <div class="aliases">Backed by OpenVibe.Media</div>
- </a>
- <a class="tool-card" href="https://maps.openvibe.tools" style="--card-accent:#22c55e">
- <div class="icon"><i class="fa-solid fa-map-location-dot"></i></div>
- <h3>Maps.OpenVibe</h3>
- <div class="domain">maps.openvibe.tools</div>
- <p>Survival map for North America — free campsites, shelter, water, food resources, weather, and terrain.</p>
- <div class="aliases">18+ live data sources</div>
- </a>
- <a class="tool-card" href="https://food.openvibe.tools" style="--card-accent:#ef4444">
- <div class="icon"><i class="fa-solid fa-utensils"></i></div>
- <h3>Food.OpenVibe</h3>
- <div class="domain">food.openvibe.tools</div>
- <p>Budget grocery finder, meal planning, and food bank locator for North America.</p>
- <div class="aliases">Pairs with Maps.OpenVibe</div>
- </a>
- <a class="tool-card" href="https://img.openvibe.tools" style="--card-accent:#3b82f6">
- <div class="icon"><i class="fa-solid fa-images"></i></div>
- <h3>Img.OpenVibe</h3>
- <div class="domain">img.openvibe.tools</div>
- <p>Convert, compress, resize, and crop images. PNG, JPG, WebP, AVIF, HEIC, SVG, GIF, ICO, and more.</p>
- <div class="aliases">Also: png. · jpg. · webp. · avif. · convert. · favicon. & 10 more</div>
- </a>
- <a class="tool-card" href="https://yt.openvibe.tools" style="--card-accent:#f43f5e">
- <div class="icon"><i class="fa-solid fa-circle-play"></i></div>
- <h3>YT.OpenVibe</h3>
- <div class="domain">yt.openvibe.tools</div>
- <p>Download YouTube videos and audio in the format and quality you want.</p>
- <div class="aliases">Video & audio extraction</div>
- </a>
- <a class="tool-card" href="https://audio.openvibe.tools" style="--card-accent:#06b6d4">
- <div class="icon"><i class="fa-solid fa-headphones"></i></div>
- <h3>Audio.OpenVibe</h3>
- <div class="domain">audio.openvibe.tools</div>
- <p>Convert between 10+ audio formats and apply 25+ effects — trim, pitch, reverb, EQ, vocal removal.</p>
- <div class="aliases">Also: mp3. · wav. · flac. · trim. · pitch. · ringtone. & 30 more</div>
- </a>
- <a class="tool-card" href="https://text.openvibe.tools" style="--card-accent:#f472b6">
- <div class="icon"><i class="fa-solid fa-pen-fancy"></i></div>
- <h3>Text.OpenVibe + Logo.OpenVibe</h3>
- <div class="domain">text.openvibe.tools · logo.openvibe.tools</div>
- <p>Fancy text, zalgo, unicode, kaomoji, counters, sorters — plus title cards, wordmarks, badges, and watermarks.</p>
- <div class="aliases">Also: fancy. · zalgo. · case. · symbols. · title. · badge. & 30 more</div>
- </a>
- <a class="tool-card" href="https://docs.openvibe.tools" style="--card-accent:#10b981">
- <div class="icon"><i class="fa-solid fa-file-pdf"></i></div>
- <h3>Docs.OpenVibe</h3>
- <div class="domain">docs.openvibe.tools</div>
- <p>PDF & document tools — merge, split, compress, rotate, watermark, protect, unlock, images to PDF and back.</p>
- <div class="aliases">Also: pdf.openvibe.tools</div>
- </a>
- </div>
- </section>
-
- <!-- ─── Rest of the network ─── -->
- <section class="section" id="network">
- <div class="section-title">
- <h2>The rest of the OpenVibe network</h2>
- <p>One account. All of OpenVibe.</p>
- </div>
- <div class="net-grid">
- <a class="net-card" href="https://openvibe.live" style="--card-accent:#e74c3c">
- <div class="icon"><i class="fa-solid fa-tower-broadcast"></i></div>
- <div><h3>OpenVibe.Live</h3><p>Open live streaming — browser or OBS, clips, VODs, chat.</p></div>
- </a>
- <a class="net-card" href="https://openvibe.games" style="--card-accent:#8b5cf6">
- <div class="icon"><i class="fa-solid fa-gamepad"></i></div>
- <div><h3>OpenVibe.Games</h3><p>Scraplandia — browser RPG, crafting, collaborative canvas.</p></div>
- </a>
- <a class="net-card" href="https://openvibe.network" style="--card-accent:#22d3ee">
- <div class="icon"><i class="fa-solid fa-circle-nodes"></i></div>
- <div><h3>OpenVibe.Network</h3><p>Your account, themes, notifications — the home of your identity.</p></div>
- </a>
- </div>
- </section>
- </div>
-
- <!-- ─── All Tools Directory modal ─── -->
- <div class="dir-overlay" id="dir-overlay" onclick="if(event.target===this)closeDirectory()">
- <div class="dir-panel">
- <div class="dir-head">
- <h2><i class="fa-solid fa-grip" style="color:var(--accent-light);margin-right:8px"></i>All tools</h2>
- <span class="dir-count" id="dir-count"></span>
- <button class="dir-close" onclick="closeDirectory()" aria-label="Close"><i class="fa-solid fa-xmark"></i></button>
- </div>
- <div class="dir-search-row">
- <input type="text" id="dir-search" placeholder="Search 200+ tools and subdomains…" autocomplete="off">
- </div>
- <div class="dir-filters" id="dir-filters"></div>
- <div class="dir-body" id="dir-body"></div>
- </div>
- </div>
-
- <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>
- 'use strict';
-
- /* ─── All Tools Directory data (subdomain aliases per suite) ─── */
- const DIR_DATA = [
- { cat: 'Hubs', icon: 'fa-circle-nodes', color: '#8b5cf6', tools: [
- { name: 'OpenVibe.Tools', domain: 'openvibe.tools', icon: 'fa-circle-nodes' },
- { name: 'Net.OpenVibe Hub', domain: 'net.openvibe.tools', icon: 'fa-network-wired' },
- { name: 'Dev.OpenVibe Hub', domain: 'dev.openvibe.tools', icon: 'fa-code' },
- { name: 'Paste.OpenVibe', domain: 'pastes.openvibe.tools', icon: 'fa-clipboard' },
- { name: 'Maps.OpenVibe', domain: 'maps.openvibe.tools', icon: 'fa-map-location-dot' },
- { name: 'Food.OpenVibe', domain: 'food.openvibe.tools', icon: 'fa-utensils' },
- { name: 'Img.OpenVibe Hub', domain: 'img.openvibe.tools', icon: 'fa-images' },
- { name: 'YT.OpenVibe', domain: 'yt.openvibe.tools', icon: 'fa-circle-play' },
- { name: 'Audio.OpenVibe Hub', domain: 'audio.openvibe.tools', icon: 'fa-headphones' },
- { name: 'Text.OpenVibe Hub', domain: 'text.openvibe.tools', icon: 'fa-pen-fancy' },
- { name: 'Logo.OpenVibe Hub', domain: 'logo.openvibe.tools', icon: 'fa-wand-magic-sparkles' },
- { name: 'Docs.OpenVibe Hub', domain: 'docs.openvibe.tools', icon: 'fa-file-pdf' },
- ]},
- { cat: 'Image', icon: 'fa-images', color: '#3b82f6', tools: [
- { name: 'OpenVibePNG', domain: 'png.openvibe.tools', icon: 'fa-file-image' },
- { name: 'OpenVibeJPG', domain: 'jpg.openvibe.tools', icon: 'fa-file-image' },
- { name: 'OpenVibeWebP', domain: 'webp.openvibe.tools', icon: 'fa-file-image' },
- { name: 'OpenVibeAVIF', domain: 'avif.openvibe.tools', icon: 'fa-file-image' },
- { name: 'OpenVibeHEIC', domain: 'heic.openvibe.tools', icon: 'fa-file-image' },
- { name: 'OpenVibeSVG', domain: 'svg.openvibe.tools', icon: 'fa-bezier-curve' },
- { name: 'OpenVibeGIF', domain: 'gif.openvibe.tools', icon: 'fa-film' },
- { name: 'OpenVibeICO', domain: 'ico.openvibe.tools', icon: 'fa-icons' },
- { name: 'OpenVibeTIFF', domain: 'tiff.openvibe.tools', icon: 'fa-file-image' },
- { name: 'OpenVibeBMP', domain: 'bmp.openvibe.tools', icon: 'fa-file-image' },
- { name: 'OpenVibeConvert', domain: 'convert.openvibe.tools', icon: 'fa-arrows-rotate' },
- { name: 'OpenVibeCompress', domain: 'compress.openvibe.tools', icon: 'fa-compress' },
- { name: 'OpenVibeResize', domain: 'resize.openvibe.tools', icon: 'fa-up-right-and-down-left-from-center' },
- { name: 'OpenVibeCrop', domain: 'crop.openvibe.tools', icon: 'fa-crop-simple' },
- { name: 'OpenVibeFavicon', domain: 'favicon.openvibe.tools', icon: 'fa-icons' },
- ]},
- { cat: 'Audio — Formats', icon: 'fa-headphones', color: '#06b6d4', tools: [
- { name: 'OpenVibeMP3', domain: 'mp3.openvibe.tools', icon: 'fa-file-audio' },
- { name: 'OpenVibeWAV', domain: 'wav.openvibe.tools', icon: 'fa-file-audio' },
- { name: 'OpenVibeFLAC', domain: 'flac.openvibe.tools', icon: 'fa-file-audio' },
- { name: 'OpenVibeOGG', domain: 'ogg.openvibe.tools', icon: 'fa-file-audio' },
- { name: 'OpenVibeM4A', domain: 'm4a.openvibe.tools', icon: 'fa-file-audio' },
- { name: 'OpenVibeOpus', domain: 'opus.openvibe.tools', icon: 'fa-file-audio' },
- { name: 'OpenVibeAAC', domain: 'aac.openvibe.tools', icon: 'fa-file-audio' },
- { name: 'OpenVibeWMA', domain: 'wma.openvibe.tools', icon: 'fa-file-audio' },
- { name: 'OpenVibeAIFF', domain: 'aiff.openvibe.tools', icon: 'fa-file-audio' },
- { name: 'OpenVibeAC3', domain: 'ac3.openvibe.tools', icon: 'fa-file-audio' },
- ]},
- { cat: 'Audio — Effects', icon: 'fa-sliders', color: '#06b6d4', tools: [
- { name: 'OpenVibeTrim', domain: 'trim.openvibe.tools', icon: 'fa-scissors' },
- { name: 'OpenVibeMerge', domain: 'merge.openvibe.tools', icon: 'fa-code-merge' },
- { name: 'OpenVibePitch', domain: 'pitch.openvibe.tools', icon: 'fa-wave-square' },
- { name: 'OpenVibeSpeed', domain: 'speed.openvibe.tools', icon: 'fa-gauge-high' },
- { name: 'OpenVibeReverse Text', domain: 'reversetext.openvibe.tools', icon: 'fa-backward' },
- { name: 'OpenVibeNormalize', domain: 'normalize.openvibe.tools', icon: 'fa-chart-line' },
- { name: 'OpenVibeFade', domain: 'fade.openvibe.tools', icon: 'fa-volume-low' },
- { name: 'OpenVibeLoop', domain: 'loop.openvibe.tools', icon: 'fa-repeat' },
- { name: 'OpenVibeBass', domain: 'bass.openvibe.tools', icon: 'fa-volume-high' },
- { name: 'OpenVibeEQ', domain: 'equalizer.openvibe.tools', icon: 'fa-sliders' },
- { name: 'OpenVibeEcho', domain: 'echo.openvibe.tools', icon: 'fa-circle-radiation' },
- { name: 'OpenVibeReverb', domain: 'reverb.openvibe.tools', icon: 'fa-church' },
- { name: 'OpenVibeChorus', domain: 'chorus.openvibe.tools', icon: 'fa-users' },
- { name: 'OpenVibeDistortion', domain: 'distortion.openvibe.tools', icon: 'fa-bolt' },
- { name: 'OpenVibeCompressor', domain: 'compressor.openvibe.tools', icon: 'fa-down-left-and-up-right-to-center' },
- { name: 'OpenVibeStereo', domain: 'stereo.openvibe.tools', icon: 'fa-headphones-simple' },
- { name: 'OpenVibeSilence', domain: 'silence.openvibe.tools', icon: 'fa-volume-xmark' },
- { name: 'OpenVibeBitcrusher', domain: 'bitcrusher.openvibe.tools', icon: 'fa-microchip' },
- { name: 'OpenVibeNoise', domain: 'noise.openvibe.tools', icon: 'fa-broom' },
- { name: 'OpenVibeVoiceFX', domain: 'voice.openvibe.tools', icon: 'fa-microphone' },
- ]},
- { cat: 'Audio — Specialized', icon: 'fa-wand-magic-sparkles', color: '#06b6d4', tools: [
- { name: 'OpenVibeVocal', domain: 'vocal.openvibe.tools', icon: 'fa-microphone-lines' },
- { name: 'OpenVibeKaraoke', domain: 'karaoke.openvibe.tools', icon: 'fa-music' },
- { name: 'OpenVibeExtract', domain: 'extract.openvibe.tools', icon: 'fa-file-export' },
- { name: 'OpenVibeWaveform', domain: 'waveform.openvibe.tools', icon: 'fa-wave-square' },
- { name: 'OpenVibeRingtone', domain: 'ringtone.openvibe.tools', icon: 'fa-bell' },
- { name: 'OpenVibePodcast', domain: 'podcast.openvibe.tools', icon: 'fa-podcast' },
- { name: 'OpenVibeMeta', domain: 'metadata.openvibe.tools', icon: 'fa-tags' },
- ]},
- { cat: 'Text — Generators', icon: 'fa-pen-fancy', color: '#f472b6', tools: [
- { name: 'OpenVibeFancy', domain: 'fancy.openvibe.tools', icon: 'fa-wand-sparkles' },
- { name: 'OpenVibeZalgo', domain: 'zalgo.openvibe.tools', icon: 'fa-skull' },
- { name: 'OpenVibeSmallCaps', domain: 'smallcaps.openvibe.tools', icon: 'fa-font' },
- { name: 'OpenVibeCursive', domain: 'cursive.openvibe.tools', icon: 'fa-italic' },
- { name: 'OpenVibeGothic', domain: 'gothic.openvibe.tools', icon: 'fa-book-skull' },
- { name: 'OpenVibeWide', domain: 'wide.openvibe.tools', icon: 'fa-text-width' },
- { name: 'OpenVibeMono', domain: 'monospaced.openvibe.tools', icon: 'fa-code' },
- { name: 'OpenVibeBubble', domain: 'bubble.openvibe.tools', icon: 'fa-circle' },
- ]},
- { cat: 'Text — Encoding', icon: 'fa-lock', color: '#f472b6', tools: [
- { name: 'OpenVibeBraille', domain: 'braille.openvibe.tools', icon: 'fa-braille' },
- { name: 'OpenVibeMorse', domain: 'morse.openvibe.tools', icon: 'fa-tower-cell' },
- { name: 'OpenVibeBinary', domain: 'binary.openvibe.tools', icon: 'fa-hashtag' },
- ]},
- { cat: 'Text — Utilities', icon: 'fa-screwdriver-wrench', color: '#f472b6', tools: [
- { name: 'OpenVibeCase', domain: 'case.openvibe.tools', icon: 'fa-font' },
- { name: 'OpenVibeClean', domain: 'clean.openvibe.tools', icon: 'fa-broom' },
- { name: 'OpenVibeCount', domain: 'count.openvibe.tools', icon: 'fa-calculator' },
- { name: 'OpenVibeSort', domain: 'sort.openvibe.tools', icon: 'fa-arrow-down-a-z' },
- { name: 'OpenVibeDedupe', domain: 'dedupe.openvibe.tools', icon: 'fa-filter' },
- { name: 'OpenVibeSlug', domain: 'slug.openvibe.tools', icon: 'fa-link' },
- { name: 'OpenVibeCompare', domain: 'compare.openvibe.tools', icon: 'fa-code-compare' },
- { name: 'OpenVibeMarkdown', domain: 'markdown.openvibe.tools', icon: 'fa-file-lines' },
- { name: 'OpenVibeJSON', domain: 'json.openvibe.tools', icon: 'fa-code' },
- { name: 'OpenVibeEscape', domain: 'escape.openvibe.tools', icon: 'fa-shield' },
- ]},
- { cat: 'Text — Symbols & Identity', icon: 'fa-star', color: '#f472b6', tools: [
- { name: 'OpenVibeSymbols', domain: 'symbols.openvibe.tools', icon: 'fa-icons' },
- { name: 'OpenVibeUnicode', domain: 'unicode.openvibe.tools', icon: 'fa-globe' },
- { name: 'OpenVibeKaomoji', domain: 'kaomoji.openvibe.tools', icon: 'fa-face-smile' },
- { name: 'OpenVibeEmojis', domain: 'emojis.openvibe.tools', icon: 'fa-face-grin' },
- { name: 'OpenVibeBio', domain: 'bio.openvibe.tools', icon: 'fa-address-card' },
- { name: 'OpenVibeNickname', domain: 'nickname.openvibe.tools', icon: 'fa-id-badge' },
- { name: 'OpenVibeASCII Art', domain: 'ascii.openvibe.tools', icon: 'fa-terminal' },
- { name: 'OpenVibeBanner', domain: 'banner.openvibe.tools', icon: 'fa-heading' },
- ]},
- { cat: 'Logo & Design', icon: 'fa-wand-magic-sparkles', color: '#f472b6', tools: [
- { name: 'OpenVibeTitle', domain: 'title.openvibe.tools', icon: 'fa-heading' },
- { name: 'OpenVibeWordmark', domain: 'wordmark.openvibe.tools', icon: 'fa-signature' },
- { name: 'OpenVibeTransparent', domain: 'transparent.openvibe.tools', icon: 'fa-chess-board' },
- { name: 'OpenVibeBadge', domain: 'badge.openvibe.tools', icon: 'fa-certificate' },
- { name: 'OpenVibeThumbnail', domain: 'thumbnail.openvibe.tools', icon: 'fa-photo-film' },
- { name: 'OpenVibeWatermark', domain: 'watermark.openvibe.tools', icon: 'fa-droplet' },
- { name: 'OpenVibeNeon', domain: 'neon.openvibe.tools', icon: 'fa-lightbulb' },
- { name: 'OpenVibeOverlay', domain: 'overlay.openvibe.tools', icon: 'fa-layer-group' },
- { name: 'OpenVibeLowerThird', domain: 'lowerthird.openvibe.tools', icon: 'fa-closed-captioning' },
- ]},
- { cat: 'PDF & Documents', icon: 'fa-file-pdf', color: '#10b981', tools: [
- { name: 'OpenVibePDF', domain: 'pdf.openvibe.tools', icon: 'fa-file-pdf' },
- { name: 'Merge PDFs', domain: 'docs.openvibe.tools/merge', icon: 'fa-object-group' },
- { name: 'Split PDF', domain: 'docs.openvibe.tools/split', icon: 'fa-scissors' },
- { name: 'Compress PDF', domain: 'docs.openvibe.tools/compress', icon: 'fa-compress' },
- { name: 'Rotate PDF', domain: 'docs.openvibe.tools/rotate', icon: 'fa-rotate' },
- { name: 'Reorder Pages', domain: 'docs.openvibe.tools/reorder', icon: 'fa-arrow-down-1-9' },
- { name: 'Watermark PDF', domain: 'docs.openvibe.tools/watermark', icon: 'fa-droplet' },
- { name: 'Protect PDF', domain: 'docs.openvibe.tools/protect', icon: 'fa-lock' },
- { name: 'Unlock PDF', domain: 'docs.openvibe.tools/unlock', icon: 'fa-unlock' },
- { name: 'Images to PDF', domain: 'docs.openvibe.tools/img2pdf', icon: 'fa-images' },
- { name: 'PDF to Images', domain: 'docs.openvibe.tools/pdf2img', icon: 'fa-photo-film' },
- { name: 'PDF Metadata', domain: 'docs.openvibe.tools/metadata', icon: 'fa-tags' },
- ]},
- { cat: 'Mexican / Latino', icon: 'fa-pepper-hot', color: '#ef4444', tools: [
- { name: 'ICE Alerts', domain: 'ice.openvibe.tools', icon: 'fa-shield-halved' },
- { name: 'OpenVibePeso', domain: 'peso.openvibe.tools', icon: 'fa-coins' },
- { name: 'OpenVibeSpanish', domain: 'spanish.openvibe.tools', icon: 'fa-language' },
- { name: 'OpenVibeSlang', domain: 'slang.openvibe.tools', icon: 'fa-pepper-hot' },
- ]},
- { cat: 'Network — Diagnostics', icon: 'fa-network-wired', color: '#22d3ee', tools: [
- { name: 'OpenVibeLookup', domain: 'lookup.openvibe.tools', icon: 'fa-magnifying-glass' },
- { name: 'OpenVibeMyIP', domain: 'myip.openvibe.tools', icon: 'fa-location-crosshairs' },
- { name: 'OpenVibeGeoIP', domain: 'geoip.openvibe.tools', icon: 'fa-earth-americas' },
- { name: 'OpenVibeIP', domain: 'ip.openvibe.tools', icon: 'fa-at' },
- { name: 'OpenVibeIPv4', domain: 'ipv4.openvibe.tools', icon: 'fa-hashtag' },
- { name: 'OpenVibeIPv6', domain: 'ipv6.openvibe.tools', icon: 'fa-code' },
- { name: 'OpenVibeHostname', domain: 'hostname.openvibe.tools', icon: 'fa-server' },
- { name: 'OpenVibeISP', domain: 'isp.openvibe.tools', icon: 'fa-building' },
- { name: 'OpenVibeASN', domain: 'asn.openvibe.tools', icon: 'fa-diagram-project' },
- { name: 'OpenVibeReverseDNS', domain: 'rdns.openvibe.tools', icon: 'fa-rotate-left' },
- { name: 'OpenVibeWhois', domain: 'whois.openvibe.tools', icon: 'fa-address-book' },
- { name: 'OpenVibeRDAP', domain: 'rdap.openvibe.tools', icon: 'fa-id-card' },
- ]},
- { cat: 'Network — DNS', icon: 'fa-sitemap', color: '#22d3ee', tools: [
- { name: 'OpenVibeDNS', domain: 'dns.openvibe.tools', icon: 'fa-sitemap' },
- { name: 'OpenVibeDig', domain: 'dig.openvibe.tools', icon: 'fa-terminal' },
- { name: 'OpenVibeNSLookup', domain: 'nslookup.openvibe.tools', icon: 'fa-magnifying-glass-arrow-right' },
- { name: 'OpenVibeDNSPropagation', domain: 'dnspropagation.openvibe.tools', icon: 'fa-globe' },
- { name: 'OpenVibeMX', domain: 'mx.openvibe.tools', icon: 'fa-envelope' },
- { name: 'OpenVibeTXT', domain: 'txt.openvibe.tools', icon: 'fa-file-lines' },
- { name: 'OpenVibeNS', domain: 'ns.openvibe.tools', icon: 'fa-server' },
- { name: 'OpenVibeSPF', domain: 'spf.openvibe.tools', icon: 'fa-shield-halved' },
- { name: 'OpenVibeDKIM', domain: 'dkim.openvibe.tools', icon: 'fa-key' },
- { name: 'OpenVibeDMARC', domain: 'dmarc.openvibe.tools', icon: 'fa-user-shield' },
- ]},
- { cat: 'Network — Active', icon: 'fa-satellite-dish', color: '#22d3ee', tools: [
- { name: 'OpenVibePing', domain: 'ping.openvibe.tools', icon: 'fa-satellite-dish' },
- { name: 'OpenVibeTraceroute', domain: 'traceroute.openvibe.tools', icon: 'fa-route' },
- { name: 'OpenVibeMTR', domain: 'mtr.openvibe.tools', icon: 'fa-chart-line' },
- { name: 'OpenVibePortCheck', domain: 'port.openvibe.tools', icon: 'fa-door-open' },
- { name: 'OpenVibeHeaders', domain: 'headers.openvibe.tools', icon: 'fa-list' },
- { name: 'OpenVibeRedirects', domain: 'redirects.openvibe.tools', icon: 'fa-share' },
- { name: 'OpenVibeSSL', domain: 'ssl.openvibe.tools', icon: 'fa-lock' },
- { name: 'OpenVibeCurl', domain: 'curl.openvibe.tools', icon: 'fa-download' },
- { name: 'OpenVibeHTTPStatus', domain: 'httpstatus.openvibe.tools', icon: 'fa-circle-check' },
- { name: 'OpenVibeLatency', domain: 'latency.openvibe.tools', icon: 'fa-gauge-high' },
- { name: 'OpenVibeBlacklist', domain: 'blacklist.openvibe.tools', icon: 'fa-ban' },
- { name: 'OpenVibeReputation', domain: 'reputation.openvibe.tools', icon: 'fa-star' },
- { name: 'OpenVibeSMTP', domain: 'smtp.openvibe.tools', icon: 'fa-paper-plane' },
- { name: 'OpenVibeUptime', domain: 'uptime.openvibe.tools', icon: 'fa-heart-pulse' },
- { name: 'OpenVibeRobots', domain: 'robots.openvibe.tools', icon: 'fa-robot' },
- { name: 'OpenVibeSitemap', domain: 'sitemap.openvibe.tools', icon: 'fa-sitemap' },
- ]},
- { cat: 'Developer — Data & Formats', icon: 'fa-database', color: '#a78bfa', tools: [
- { name: 'OpenVibeJSON', domain: 'jsonfmt.openvibe.tools', icon: 'fa-code' },
- { name: 'OpenVibeYAML', domain: 'yaml.openvibe.tools', icon: 'fa-file-code' },
- { name: 'OpenVibeXML', domain: 'xml.openvibe.tools', icon: 'fa-file-code' },
- { name: 'OpenVibeCSV', domain: 'csv.openvibe.tools', icon: 'fa-table' },
- { name: 'OpenVibeSQL', domain: 'sql.openvibe.tools', icon: 'fa-database' },
- { name: 'OpenVibeHTML', domain: 'html.openvibe.tools', icon: 'fa-file-code' },
- { name: 'OpenVibeMarkdown', domain: 'md.openvibe.tools', icon: 'fa-file-lines' },
- ]},
- { cat: 'Developer — Encoding & Crypto', icon: 'fa-lock', color: '#a78bfa', tools: [
- { name: 'OpenVibeBase64', domain: 'base64.openvibe.tools', icon: 'fa-lock' },
- { name: 'OpenVibeURL Encoder', domain: 'url.openvibe.tools', icon: 'fa-link' },
- { name: 'OpenVibeJWT', domain: 'jwt.openvibe.tools', icon: 'fa-key' },
- { name: 'OpenVibeUUID', domain: 'uuid.openvibe.tools', icon: 'fa-fingerprint' },
- { name: 'OpenVibeHash', domain: 'hash.openvibe.tools', icon: 'fa-hashtag' },
- { name: 'OpenVibeHex', domain: 'hex.openvibe.tools', icon: 'fa-barcode' },
- { name: 'OpenVibeEscape', domain: 'entities.openvibe.tools', icon: 'fa-shield-halved' },
- ]},
- { cat: 'Developer — Quality & Time', icon: 'fa-wand-magic-sparkles', color: '#a78bfa', tools: [
- { name: 'OpenVibeBeautify', domain: 'beautify.openvibe.tools', icon: 'fa-wand-magic-sparkles' },
- { name: 'OpenVibeMinify', domain: 'minify.openvibe.tools', icon: 'fa-compress' },
- { name: 'OpenVibeDiff', domain: 'codediff.openvibe.tools', icon: 'fa-code-compare' },
- { name: 'OpenVibeRegex', domain: 'regex.openvibe.tools', icon: 'fa-magnifying-glass' },
- { name: 'OpenVibeSlug', domain: 'slugify.openvibe.tools', icon: 'fa-link' },
- { name: 'OpenVibeLorem', domain: 'lorem.openvibe.tools', icon: 'fa-paragraph' },
- { name: 'OpenVibeTimestamp', domain: 'timestamp.openvibe.tools', icon: 'fa-clock' },
- { name: 'OpenVibeCron', domain: 'cron.openvibe.tools', icon: 'fa-calendar-check' },
- ]},
- { cat: 'Developer — HTTP & SEO', icon: 'fa-terminal', color: '#a78bfa', tools: [
- { name: 'OpenVibeCurl Converter', domain: 'curlconvert.openvibe.tools', icon: 'fa-terminal' },
- { name: 'OpenVibeWebhook', domain: 'webhook.openvibe.tools', icon: 'fa-satellite-dish' },
- { name: 'OpenVibeColor', domain: 'color.openvibe.tools', icon: 'fa-palette' },
- { name: 'OpenVibeOpenGraph', domain: 'opengraph.openvibe.tools', icon: 'fa-share-nodes' },
- ]},
- { cat: 'Network Services', icon: 'fa-circle-nodes', color: '#8b5cf6', tools: [
- { name: 'OpenVibe.Live', domain: 'openvibe.live', icon: 'fa-tower-broadcast' },
- { name: 'OpenVibe.Games', domain: 'openvibe.games', icon: 'fa-gamepad' },
- { name: 'OpenVibe.Network', domain: 'openvibe.network', icon: 'fa-circle-nodes' },
- ]},
- ];
-
- /* ─── Directory modal ─── */
- let dirFilter = 'all';
- function openDirectory() {
- const overlay = document.getElementById('dir-overlay');
- overlay.classList.add('open');
- document.body.style.overflow = 'hidden';
- renderDirectory();
- document.getElementById('dir-search').value = '';
- document.getElementById('dir-search').focus();
- }
- function closeDirectory() {
- document.getElementById('dir-overlay').classList.remove('open');
- document.body.style.overflow = '';
- }
- document.addEventListener('keydown', e => {
- if (e.key === 'Escape' && document.getElementById('dir-overlay').classList.contains('open')) closeDirectory();
- if ((e.ctrlKey || e.metaKey) && e.key === 'k') { e.preventDefault(); openDirectory(); }
- });
-
- function escHtml(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
- function highlightMatch(text, q) {
- if (!q) return escHtml(text);
- const idx = text.toLowerCase().indexOf(q);
- if (idx === -1) return escHtml(text);
- return escHtml(text.slice(0, idx)) + '<mark style="background:rgba(139,92,246,0.3);color:inherit;border-radius:2px;padding:0 1px">' + escHtml(text.slice(idx, idx + q.length)) + '</mark>' + escHtml(text.slice(idx + q.length));
- }
-
- function renderDirectory(query) {
- const q = (query || '').toLowerCase().trim();
- const filtersEl = document.getElementById('dir-filters');
- const bodyEl = document.getElementById('dir-body');
-
- const catNames = ['all', ...new Set(DIR_DATA.map(c => c.cat.split(' — ')[0]))];
- filtersEl.innerHTML = catNames.map(c =>
- `<button class="dir-filter-btn${(dirFilter === c) ? ' active' : ''}" onclick="setDirFilter('${c.replace(/'/g, "\\'")}')">${c}</button>`
- ).join('');
-
- let totalShown = 0;
- let html = '';
- for (const cat of DIR_DATA) {
- const baseCat = cat.cat.split(' — ')[0];
- if (dirFilter !== 'all' && baseCat !== dirFilter) continue;
- const filtered = cat.tools.filter(t =>
- !q || t.name.toLowerCase().includes(q) || t.domain.toLowerCase().includes(q) || cat.cat.toLowerCase().includes(q)
- );
- if (!filtered.length) continue;
- totalShown += filtered.length;
- html += `<div class="dir-category">
- <div class="dir-cat-head">
- <i class="fa-solid ${cat.icon}"></i>
- <h3>${cat.cat}</h3>
- <span class="dir-cat-count">${filtered.length}</span>
- </div>
- <div class="dir-tools">
- ${filtered.map(t => `<a class="dir-tool" href="https://${t.domain}" target="_blank" rel="noopener" style="--tool-color:${cat.color}">
- <div class="dir-tool-icon"><i class="fa-solid ${t.icon}"></i></div>
- <div>
- <div class="dir-tool-name">${highlightMatch(t.name, q)}</div>
- <div class="dir-tool-domain">${highlightMatch(t.domain, q)}</div>
- </div>
- </a>`).join('')}
- </div>
- </div>`;
- }
- if (!totalShown) {
- html = `<div class="dir-empty"><i class="fa-solid fa-magnifying-glass"></i>No tools match "${escHtml(q)}"</div>`;
- }
- bodyEl.innerHTML = html;
- document.getElementById('dir-count').textContent = totalShown + ' tools';
- }
- function setDirFilter(f) { dirFilter = f; renderDirectory(document.getElementById('dir-search').value); }
- document.getElementById('dir-search').addEventListener('input', e => renderDirectory(e.target.value));
-
- // Live tool count in the hero
- document.getElementById('stat-tools').textContent =
- DIR_DATA.reduce((sum, c) => sum + c.tools.length, 0) + '+';
-
- /* ─── Shared chrome (navbar + footer from openvibe.network) ─── */
- function getCookie(n) {
- const m = document.cookie.match(new RegExp('(?:^|; )' + n + '=([^;]*)'));
- return m ? decodeURIComponent(m[1]) : null;
- }
-
- // The navbar owns the account UI (sign-in, avatar, menu) and boots from the shared
- // ov_token cookie straight away — no /auth/me round trip before it can render.
- function initChrome() {
- if (window.OpenVibeNavbar) {
- try {
- OpenVibeNavbar.init({
- service: 'tools',
- apiBase: 'https://openvibe.network',
- token: getCookie('ov_token') || localStorage.getItem('ov_token'),
- history: { type: 'tool', title: 'OpenVibe.Tools' },
- silentLogin: 'https://openvibe.tools/auth/login?silent=1&next={url}',
- });
- } catch { /* non-critical */ }
- }
- if (window.OpenVibeFooter) {
- try {
- OpenVibeFooter.init({
- service: 'tools', variant: 'full', mount: '#ov-footer', apiBase: 'https://openvibe.network',
- links: [
- { heading: 'Tool suites', items: [
- { name: 'Net.OpenVibe', url: 'https://net.openvibe.tools' },
- { name: 'Dev.OpenVibe', url: 'https://dev.openvibe.tools' },
- { name: 'Paste.OpenVibe', url: 'https://pastes.openvibe.tools' },
- { name: 'Img.OpenVibe', url: 'https://img.openvibe.tools' },
- { name: 'Audio.OpenVibe', url: 'https://audio.openvibe.tools' },
- { name: 'Text.OpenVibe', url: 'https://text.openvibe.tools' },
- ] },
- { heading: 'More suites', items: [
- { name: 'Logo.OpenVibe', url: 'https://logo.openvibe.tools' },
- { name: 'Docs.OpenVibe', url: 'https://docs.openvibe.tools' },
- { name: 'YT.OpenVibe', url: 'https://yt.openvibe.tools' },
- { name: 'Maps.OpenVibe', url: 'https://maps.openvibe.tools' },
- { name: 'Food.OpenVibe', url: 'https://food.openvibe.tools' },
- { name: 'All tools', url: '#', onclick: 'openDirectory(); return false;' },
- ] },
- ],
- });
- } catch { /* non-critical */ }
- }
- }
- window.addEventListener('DOMContentLoaded', initChrome);
- </script>
-</body>
-</html>
new file mode 100644
new file mode 100644
@@ -0,0 +1,60 @@
@@ -0,0 +1,60 @@
+/* openvibe.tools — progressive enhancement for the server-rendered pages.
+ * Without this file every page still works: search is a plain GET form. With it: shared navbar and
+ * footer, instant search from the cached catalog, and the query kept in the URL (?q=) so results
+ * can be shared, reloaded and reached with Back. */
+(function () {
+ 'use strict';
+ var cookie = function (n) { var m = document.cookie.match(new RegExp('(?:^|; )' + n + '=([^;]*)')); return m ? decodeURIComponent(m[1]) : null; };
+ var token = null; try { token = cookie('ov_token') || localStorage.getItem('ov_token'); } catch (e) { token = cookie('ov_token'); }
+ try { if (window.OpenVibeNavbar) OpenVibeNavbar.init({ service: 'tools', apiBase: 'https://openvibe.network', token: token, history: { type: 'tool', title: document.title }, silentLogin: 'https://openvibe.tools/auth/login?silent=1&next={url}' }); } catch (e) { /* non-critical */ }
+ try { if (window.OpenVibeFooter) OpenVibeFooter.init({ service: 'tools', variant: 'full', mount: '#ov-footer', apiBase: 'https://openvibe.network' }); } catch (e) { /* */ }
+
+ var input = document.getElementById('q'), results = document.getElementById('results');
+ if (!input || !results) return;
+ var grid = document.getElementById('results-grid'), empty = document.getElementById('results-empty');
+ var catalog = null, loading = null, others = [].slice.call(document.querySelectorAll('main > section:not(#results)'));
+
+ function load() {
+ if (catalog) return Promise.resolve(catalog);
+ if (loading) return loading;
+ loading = fetch('/api/catalog.json').then(function (r) { return r.json(); }).then(function (j) {
+ catalog = j.tools.map(function (t) { return { t: t, name: t.name.toLowerCase(), kw: (t.keywords || []).join(' ').toLowerCase(), hay: [t.tagline, t.description, t.id].join(' ').toLowerCase() }; });
+ return catalog;
+ }).catch(function () { loading = null; return []; });
+ return loading;
+ }
+ function find(q) {
+ var terms = q.toLowerCase().split(/[^a-z0-9.+#]+/).filter(Boolean).slice(0, 8);
+ return catalog.map(function (c) {
+ var s = 0;
+ for (var i = 0; i < terms.length; i++) { var w = terms[i]; var hit = 0; if (c.name.indexOf(w) >= 0) hit += 5; if (c.t.id === w) hit += 4; if (c.kw.indexOf(w) >= 0) hit += 3; else if (c.hay.indexOf(w) >= 0) hit += 1; if (!hit) return null; s += hit; }
+ return { s: s, t: c.t };
+ }).filter(Boolean).sort(function (a, b) { return b.s - a.s || a.t.name.localeCompare(b.t.name); }).slice(0, 24).map(function (x) { return x.t; });
+ }
+ function card(t) {
+ var a = document.createElement('a'); a.className = 'tool'; a.href = t.url;
+ var ic = document.createElement('span'); ic.className = 'ov-icon'; ic.dataset.icon = t.icon; ic.dataset.fx = 'none'; ic.style.setProperty('--ovi-size', '36px');
+ var box = document.createElement('span'); box.className = 'tool-t';
+ var b = document.createElement('b'); b.textContent = t.name; var s = document.createElement('small'); s.textContent = t.tagline; var i = document.createElement('i'); i.textContent = t.hosts.short || t.hosts.canonical;
+ box.appendChild(b); box.appendChild(s); box.appendChild(i); a.appendChild(ic); a.appendChild(box); return a;
+ }
+ function show(q, push) {
+ q = q.trim();
+ var url = new URL(location.href); if (q) url.searchParams.set('q', q); else url.searchParams.delete('q');
+ if (url.href !== location.href) history[push ? 'pushState' : 'replaceState']({ q: q }, '', url);
+ if (!q) { results.hidden = true; others.forEach(function (s) { s.hidden = false; }); return; }
+ load().then(function () {
+ if (input.value.trim() !== q) return;
+ var hits = find(q); grid.replaceChildren.apply(grid, hits.map(card)); empty.hidden = hits.length > 0;
+ results.hidden = false; others.forEach(function (s) { s.hidden = true; });
+ if (window.OpenVibeIcons) OpenVibeIcons.mount(grid); else if (!document.getElementById('ov-icons-loader')) { var sc = document.createElement('script'); sc.id = 'ov-icons-loader'; sc.src = 'https://openvibe.network/shared/ov-icons.js'; document.head.appendChild(sc); }
+ });
+ }
+ var timer = null;
+ input.addEventListener('focus', load, { once: true });
+ input.addEventListener('input', function () { clearTimeout(timer); timer = setTimeout(function () { show(input.value, false); }, 90); });
+ input.form.addEventListener('submit', function (e) { e.preventDefault(); show(input.value, true); var first = grid.querySelector('a'); if (first && input.value.trim()) first.focus(); });
+ window.addEventListener('popstate', function () { var q = new URL(location.href).searchParams.get('q') || ''; input.value = q; show(q, false); });
+ document.addEventListener('keydown', function (e) { if (e.key === '/' && document.activeElement !== input && !/input|textarea|select/i.test(document.activeElement.tagName)) { e.preventDefault(); input.focus(); } });
+ var q0 = new URL(location.href).searchParams.get('q'); if (q0) { input.value = q0; show(q0, false); }
+})();
deleted file mode 100644
deleted file mode 100644
@@ -1,45 +0,0 @@
@@ -1,45 +0,0 @@
-# openvibe.tools — General Index Rules
-User-agent: *
-Allow: /
-Allow: /api/brand
-Allow: /api/health
-
-# Disallow API and transient paths
-Disallow: /api/
-Disallow: /auth/
-Disallow: /.well-known/
-Disallow: /node_modules/
-Disallow: /tmp/
-Disallow: /temp/
-
-# Aggressive Bot Rate Limiting
-User-agent: AhrefsBot
-Disallow: /
-Crawl-delay: 10
-
-User-agent: SemrushBot
-Disallow: /
-Crawl-delay: 10
-
-User-agent: DotBot
-Disallow: /
-Crawl-delay: 10
-
-User-agent: MJ12bot
-Disallow: /
-Crawl-delay: 30
-
-# Friendly Bots — Normal Rate
-User-agent: Googlebot
-Crawl-delay: 0
-
-User-agent: Bingbot
-Crawl-delay: 1
-
-User-agent: DuckDuckBot
-Crawl-delay: 1
-
-# Sitemaps
-Sitemap: https://openvibe.tools/sitemap.xml
-
-Host: https://openvibe.tools
deleted file mode 100644
deleted file mode 100644
@@ -1,459 +0,0 @@
@@ -1,459 +0,0 @@
-<?xml version="1.0" encoding="UTF-8"?>
-<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
- <url>
- <loc>https://openvibe.tools/</loc>
- <lastmod>2026-08-17</lastmod>
- <changefreq>weekly</changefreq>
- <priority>1.0</priority>
- </url>
- <url>
- <loc>https://net.openvibe.tools/</loc>
- <lastmod>2026-08-17</lastmod>
- <changefreq>weekly</changefreq>
- <priority>0.9</priority>
- </url>
- <url>
- <loc>https://dev.openvibe.tools/</loc>
- <lastmod>2026-08-17</lastmod>
- <changefreq>weekly</changefreq>
- <priority>0.9</priority>
- </url>
- <url>
- <loc>https://pastes.openvibe.tools/</loc>
- <lastmod>2026-08-17</lastmod>
- <changefreq>weekly</changefreq>
- <priority>0.9</priority>
- </url>
- <url>
- <loc>https://maps.openvibe.tools/</loc>
- <lastmod>2026-08-17</lastmod>
- <changefreq>weekly</changefreq>
- <priority>0.9</priority>
- </url>
- <url>
- <loc>https://food.openvibe.tools/</loc>
- <lastmod>2026-08-17</lastmod>
- <changefreq>weekly</changefreq>
- <priority>0.9</priority>
- </url>
- <url>
- <loc>https://img.openvibe.tools/</loc>
- <lastmod>2026-08-17</lastmod>
- <changefreq>weekly</changefreq>
- <priority>0.9</priority>
- </url>
- <url>
- <loc>https://yt.openvibe.tools/</loc>
- <lastmod>2026-08-17</lastmod>
- <changefreq>weekly</changefreq>
- <priority>0.9</priority>
- </url>
- <url>
- <loc>https://audio.openvibe.tools/</loc>
- <lastmod>2026-08-17</lastmod>
- <changefreq>weekly</changefreq>
- <priority>0.9</priority>
- </url>
- <url>
- <loc>https://text.openvibe.tools/</loc>
- <lastmod>2026-08-17</lastmod>
- <changefreq>weekly</changefreq>
- <priority>0.9</priority>
- </url>
- <url>
- <loc>https://logo.openvibe.tools/</loc>
- <lastmod>2026-08-17</lastmod>
- <changefreq>weekly</changefreq>
- <priority>0.9</priority>
- </url>
- <url>
- <loc>https://docs.openvibe.tools/</loc>
- <lastmod>2026-08-17</lastmod>
- <changefreq>weekly</changefreq>
- <priority>0.9</priority>
- </url>
- <url>
- <loc>https://lookup.openvibe.tools/</loc>
- <lastmod>2026-08-17</lastmod>
- <changefreq>weekly</changefreq>
- <priority>0.7</priority>
- </url>
- <url>
- <loc>https://myip.openvibe.tools/</loc>
- <lastmod>2026-08-17</lastmod>
- <changefreq>weekly</changefreq>
- <priority>0.7</priority>
- </url>
- <url>
- <loc>https://ip.openvibe.tools/</loc>
- <lastmod>2026-08-17</lastmod>
- <changefreq>weekly</changefreq>
- <priority>0.7</priority>
- </url>
- <url>
- <loc>https://ipv4.openvibe.tools/</loc>
- <lastmod>2026-08-17</lastmod>
- <changefreq>weekly</changefreq>
- <priority>0.7</priority>
- </url>
- <url>
- <loc>https://ipv6.openvibe.tools/</loc>
- <lastmod>2026-08-17</lastmod>
- <changefreq>weekly</changefreq>
- <priority>0.7</priority>
- </url>
- <url>
- <loc>https://geoip.openvibe.tools/</loc>
- <lastmod>2026-08-17</lastmod>
- <changefreq>weekly</changefreq>
- <priority>0.7</priority>
- </url>
- <url>
- <loc>https://hostname.openvibe.tools/</loc>
- <lastmod>2026-08-17</lastmod>
- <changefreq>weekly</changefreq>
- <priority>0.7</priority>
- </url>
- <url>
- <loc>https://isp.openvibe.tools/</loc>
- <lastmod>2026-08-17</lastmod>
- <changefreq>weekly</changefreq>
- <priority>0.7</priority>
- </url>
- <url>
- <loc>https://asn.openvibe.tools/</loc>
- <lastmod>2026-08-17</lastmod>
- <changefreq>weekly</changefreq>
- <priority>0.7</priority>
- </url>
- <url>
- <loc>https://rdns.openvibe.tools/</loc>
- <lastmod>2026-08-17</lastmod>
- <changefreq>weekly</changefreq>
- <priority>0.7</priority>
- </url>
- <url>
- <loc>https://whois.openvibe.tools/</loc>
- <lastmod>2026-08-17</lastmod>
- <changefreq>weekly</changefreq>
- <priority>0.7</priority>
- </url>
- <url>
- <loc>https://rdap.openvibe.tools/</loc>
- <lastmod>2026-08-17</lastmod>
- <changefreq>weekly</changefreq>
- <priority>0.7</priority>
- </url>
- <url>
- <loc>https://dns.openvibe.tools/</loc>
- <lastmod>2026-08-17</lastmod>
- <changefreq>weekly</changefreq>
- <priority>0.7</priority>
- </url>
- <url>
- <loc>https://dig.openvibe.tools/</loc>
- <lastmod>2026-08-17</lastmod>
- <changefreq>weekly</changefreq>
- <priority>0.7</priority>
- </url>
- <url>
- <loc>https://nslookup.openvibe.tools/</loc>
- <lastmod>2026-08-17</lastmod>
- <changefreq>weekly</changefreq>
- <priority>0.7</priority>
- </url>
- <url>
- <loc>https://dnspropagation.openvibe.tools/</loc>
- <lastmod>2026-08-17</lastmod>
- <changefreq>weekly</changefreq>
- <priority>0.7</priority>
- </url>
- <url>
- <loc>https://mx.openvibe.tools/</loc>
- <lastmod>2026-08-17</lastmod>
- <changefreq>weekly</changefreq>
- <priority>0.7</priority>
- </url>
- <url>
- <loc>https://txt.openvibe.tools/</loc>
- <lastmod>2026-08-17</lastmod>
- <changefreq>weekly</changefreq>
- <priority>0.7</priority>
- </url>
- <url>
- <loc>https://ns.openvibe.tools/</loc>
- <lastmod>2026-08-17</lastmod>
- <changefreq>weekly</changefreq>
- <priority>0.7</priority>
- </url>
- <url>
- <loc>https://spf.openvibe.tools/</loc>
- <lastmod>2026-08-17</lastmod>
- <changefreq>weekly</changefreq>
- <priority>0.7</priority>
- </url>
- <url>
- <loc>https://dkim.openvibe.tools/</loc>
- <lastmod>2026-08-17</lastmod>
- <changefreq>weekly</changefreq>
- <priority>0.7</priority>
- </url>
- <url>
- <loc>https://dmarc.openvibe.tools/</loc>
- <lastmod>2026-08-17</lastmod>
- <changefreq>weekly</changefreq>
- <priority>0.7</priority>
- </url>
- <url>
- <loc>https://ping.openvibe.tools/</loc>
- <lastmod>2026-08-17</lastmod>
- <changefreq>weekly</changefreq>
- <priority>0.7</priority>
- </url>
- <url>
- <loc>https://traceroute.openvibe.tools/</loc>
- <lastmod>2026-08-17</lastmod>
- <changefreq>weekly</changefreq>
- <priority>0.7</priority>
- </url>
- <url>
- <loc>https://mtr.openvibe.tools/</loc>
- <lastmod>2026-08-17</lastmod>
- <changefreq>weekly</changefreq>
- <priority>0.7</priority>
- </url>
- <url>
- <loc>https://port.openvibe.tools/</loc>
- <lastmod>2026-08-17</lastmod>
- <changefreq>weekly</changefreq>
- <priority>0.7</priority>
- </url>
- <url>
- <loc>https://headers.openvibe.tools/</loc>
- <lastmod>2026-08-17</lastmod>
- <changefreq>weekly</changefreq>
- <priority>0.7</priority>
- </url>
- <url>
- <loc>https://redirects.openvibe.tools/</loc>
- <lastmod>2026-08-17</lastmod>
- <changefreq>weekly</changefreq>
- <priority>0.7</priority>
- </url>
- <url>
- <loc>https://ssl.openvibe.tools/</loc>
- <lastmod>2026-08-17</lastmod>
- <changefreq>weekly</changefreq>
- <priority>0.7</priority>
- </url>
- <url>
- <loc>https://curl.openvibe.tools/</loc>
- <lastmod>2026-08-17</lastmod>
- <changefreq>weekly</changefreq>
- <priority>0.7</priority>
- </url>
- <url>
- <loc>https://httpstatus.openvibe.tools/</loc>
- <lastmod>2026-08-17</lastmod>
- <changefreq>weekly</changefreq>
- <priority>0.7</priority>
- </url>
- <url>
- <loc>https://latency.openvibe.tools/</loc>
- <lastmod>2026-08-17</lastmod>
- <changefreq>weekly</changefreq>
- <priority>0.7</priority>
- </url>
- <url>
- <loc>https://blacklist.openvibe.tools/</loc>
- <lastmod>2026-08-17</lastmod>
- <changefreq>weekly</changefreq>
- <priority>0.7</priority>
- </url>
- <url>
- <loc>https://reputation.openvibe.tools/</loc>
- <lastmod>2026-08-17</lastmod>
- <changefreq>weekly</changefreq>
- <priority>0.7</priority>
- </url>
- <url>
- <loc>https://smtp.openvibe.tools/</loc>
- <lastmod>2026-08-17</lastmod>
- <changefreq>weekly</changefreq>
- <priority>0.7</priority>
- </url>
- <url>
- <loc>https://uptime.openvibe.tools/</loc>
- <lastmod>2026-08-17</lastmod>
- <changefreq>weekly</changefreq>
- <priority>0.7</priority>
- </url>
- <url>
- <loc>https://robots.openvibe.tools/</loc>
- <lastmod>2026-08-17</lastmod>
- <changefreq>weekly</changefreq>
- <priority>0.7</priority>
- </url>
- <url>
- <loc>https://sitemap.openvibe.tools/</loc>
- <lastmod>2026-08-17</lastmod>
- <changefreq>weekly</changefreq>
- <priority>0.7</priority>
- </url>
- <url>
- <loc>https://json.openvibe.tools/</loc>
- <lastmod>2026-08-17</lastmod>
- <changefreq>weekly</changefreq>
- <priority>0.7</priority>
- </url>
- <url>
- <loc>https://yaml.openvibe.tools/</loc>
- <lastmod>2026-08-17</lastmod>
- <changefreq>weekly</changefreq>
- <priority>0.7</priority>
- </url>
- <url>
- <loc>https://xml.openvibe.tools/</loc>
- <lastmod>2026-08-17</lastmod>
- <changefreq>weekly</changefreq>
- <priority>0.7</priority>
- </url>
- <url>
- <loc>https://csv.openvibe.tools/</loc>
- <lastmod>2026-08-17</lastmod>
- <changefreq>weekly</changefreq>
- <priority>0.7</priority>
- </url>
- <url>
- <loc>https://sql.openvibe.tools/</loc>
- <lastmod>2026-08-17</lastmod>
- <changefreq>weekly</changefreq>
- <priority>0.7</priority>
- </url>
- <url>
- <loc>https://markdown.openvibe.tools/</loc>
- <lastmod>2026-08-17</lastmod>
- <changefreq>weekly</changefreq>
- <priority>0.7</priority>
- </url>
- <url>
- <loc>https://html.openvibe.tools/</loc>
- <lastmod>2026-08-17</lastmod>
- <changefreq>weekly</changefreq>
- <priority>0.7</priority>
- </url>
- <url>
- <loc>https://base64.openvibe.tools/</loc>
- <lastmod>2026-08-17</lastmod>
- <changefreq>weekly</changefreq>
- <priority>0.7</priority>
- </url>
- <url>
- <loc>https://url.openvibe.tools/</loc>
- <lastmod>2026-08-17</lastmod>
- <changefreq>weekly</changefreq>
- <priority>0.7</priority>
- </url>
- <url>
- <loc>https://jwt.openvibe.tools/</loc>
- <lastmod>2026-08-17</lastmod>
- <changefreq>weekly</changefreq>
- <priority>0.7</priority>
- </url>
- <url>
- <loc>https://uuid.openvibe.tools/</loc>
- <lastmod>2026-08-17</lastmod>
- <changefreq>weekly</changefreq>
- <priority>0.7</priority>
- </url>
- <url>
- <loc>https://hash.openvibe.tools/</loc>
- <lastmod>2026-08-17</lastmod>
- <changefreq>weekly</changefreq>
- <priority>0.7</priority>
- </url>
- <url>
- <loc>https://hex.openvibe.tools/</loc>
- <lastmod>2026-08-17</lastmod>
- <changefreq>weekly</changefreq>
- <priority>0.7</priority>
- </url>
- <url>
- <loc>https://escape.openvibe.tools/</loc>
- <lastmod>2026-08-17</lastmod>
- <changefreq>weekly</changefreq>
- <priority>0.7</priority>
- </url>
- <url>
- <loc>https://timestamp.openvibe.tools/</loc>
- <lastmod>2026-08-17</lastmod>
- <changefreq>weekly</changefreq>
- <priority>0.7</priority>
- </url>
- <url>
- <loc>https://cron.openvibe.tools/</loc>
- <lastmod>2026-08-17</lastmod>
- <changefreq>weekly</changefreq>
- <priority>0.7</priority>
- </url>
- <url>
- <loc>https://beautify.openvibe.tools/</loc>
- <lastmod>2026-08-17</lastmod>
- <changefreq>weekly</changefreq>
- <priority>0.7</priority>
- </url>
- <url>
- <loc>https://minify.openvibe.tools/</loc>
- <lastmod>2026-08-17</lastmod>
- <changefreq>weekly</changefreq>
- <priority>0.7</priority>
- </url>
- <url>
- <loc>https://diff.openvibe.tools/</loc>
- <lastmod>2026-08-17</lastmod>
- <changefreq>weekly</changefreq>
- <priority>0.7</priority>
- </url>
- <url>
- <loc>https://regex.openvibe.tools/</loc>
- <lastmod>2026-08-17</lastmod>
- <changefreq>weekly</changefreq>
- <priority>0.7</priority>
- </url>
- <url>
- <loc>https://slug.openvibe.tools/</loc>
- <lastmod>2026-08-17</lastmod>
- <changefreq>weekly</changefreq>
- <priority>0.7</priority>
- </url>
- <url>
- <loc>https://lorem.openvibe.tools/</loc>
- <lastmod>2026-08-17</lastmod>
- <changefreq>weekly</changefreq>
- <priority>0.7</priority>
- </url>
- <url>
- <loc>https://curl.openvibe.tools/</loc>
- <lastmod>2026-08-17</lastmod>
- <changefreq>weekly</changefreq>
- <priority>0.7</priority>
- </url>
- <url>
- <loc>https://webhook.openvibe.tools/</loc>
- <lastmod>2026-08-17</lastmod>
- <changefreq>weekly</changefreq>
- <priority>0.7</priority>
- </url>
- <url>
- <loc>https://color.openvibe.tools/</loc>
- <lastmod>2026-08-17</lastmod>
- <changefreq>weekly</changefreq>
- <priority>0.7</priority>
- </url>
- <url>
- <loc>https://opengraph.openvibe.tools/</loc>
- <lastmod>2026-08-17</lastmod>
- <changefreq>weekly</changefreq>
- <priority>0.7</priority>
- </url>
-</urlset>
@@ -52,6 +52,17 @@ app.use(helmet({
@@ -52,6 +52,17 @@ app.use(helmet({
},
},
crossOriginEmbedderPolicy: false,
crossOriginEmbedderPolicy: false,
}));
}));
+// ── Host roles: aliases redirect, unknown hosts leave, satellite hosts are proxied ──
+const registry = require('./registry');
+const { hostRoles } = require('./registry/host-middleware');
+registry.start();
+app.use(hostRoles({
+ hostOf: getRequestHost,
+ enforce: config.isProduction || process.env.OV_ENFORCE_HOSTS === '1',
+ // Gateway-served subdomains that predate the catalog (tool aliases, the pastes hand-off).
+ isLegacyHost: (sub) => NET_TOOL_MAP.has(sub) || !!NET_ALIASES[sub] || DEV_TOOL_MAP.has(sub) || !!DEV_ALIASES[sub] || ['pastes', 'paste', 'my', 'login'].includes(sub),
+}));
+
app.use(cookieParser());
app.use(cookieParser());
app.use(express.json({ limit: '1mb' }));
app.use(express.json({ limit: '1mb' }));
app.use(express.urlencoded({ extended: true }));
app.use(express.urlencoded({ extended: true }));
@@ -166,6 +177,8 @@ app.use('/api/dev', rateLimit({ windowMs: 60_000, max: 60 }), createDevRoutes(nu
@@ -166,6 +177,8 @@ app.use('/api/dev', rateLimit({ windowMs: 60_000, max: 60 }), createDevRoutes(nu
// ── Host-header subdomain routing ────────────────────────────
// ── Host-header subdomain routing ────────────────────────────
function subdomainOf(req) {
function subdomainOf(req) {
+ // A mirror or custom domain is the tool it points at.
+ if (req.ovHost && req.ovHost.tool && !getRequestHost(req).endsWith('.openvibe.tools')) return req.ovHost.tool;
return getRequestHost(req).replace(/\.openvibe\.tools$/, '');
return getRequestHost(req).replace(/\.openvibe\.tools$/, '');
}
}
@@ -186,26 +199,22 @@ function isStaticPath(reqPath) {
@@ -186,26 +199,22 @@ function isStaticPath(reqPath) {
// Every tool subdomain gets its OWN title, description, canonical, structured data and a
// Every tool subdomain gets its OWN title, description, canonical, structured data and a
// crawlable content block (server/seo). They used to share the hub's <head>, which told search
// crawlable content block (server/seo). They used to share the hub's <head>, which told search
// engines that ~65 distinct tools were one page.
// engines that ~65 distinct tools were one page.
-const { renderTool, buildSitemap, SATELLITES } = require('./seo/render');
+const { renderTool } = require('./seo/render');
const NET_HTML = path.join(__dirname, '..', 'public', 'net.html');
const NET_HTML = path.join(__dirname, '..', 'public', 'net.html');
const DEV_HTML = path.join(__dirname, '..', 'public', 'dev.html');
const DEV_HTML = path.join(__dirname, '..', 'public', 'dev.html');
function sendTool(req, res, file) {
function sendTool(req, res, file) {
try {
try {
res.set('Content-Type', 'text/html; charset=utf-8');
res.set('Content-Type', 'text/html; charset=utf-8');
- return res.send(renderTool(file, subdomainOf(req)));
+ return res.send(renderTool(file, subdomainOf(req), req.ovHost && req.ovHost.canonicalHost));
} catch (err) {
} catch (err) {
console.error('[SEO] render failed:', err.message);
console.error('[SEO] render failed:', err.message);
return res.sendFile(file);
return res.sendFile(file);
}
}
}
}
-// Generated sitemap — covers both hubs, every tool subdomain and the satellite apps, so it can
-// never drift from the tool catalogs the way a hand-maintained file does.
-app.get('/sitemap.xml', (_req, res) => {
- res.set('Content-Type', 'application/xml; charset=utf-8');
- res.set('Cache-Control', 'public, max-age=3600');
- res.send(buildSitemap(SATELLITES));
-});
+// 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());
// Net tool subdomains → net.html SPA
// Net tool subdomains → net.html SPA
app.use((req, res, next) => {
app.use((req, res, next) => {
@@ -253,7 +262,8 @@ app.get('*', (req, res) => {
@@ -253,7 +262,8 @@ app.get('*', (req, res) => {
if (req.path.startsWith('/api/')) {
if (req.path.startsWith('/api/')) {
return res.status(404).json({ error: 'Not found' });
return res.status(404).json({ error: 'Not found' });
}
}
- res.sendFile(path.join(__dirname, '..', 'public', 'index.html'));
+ // Unknown page: a real 404 that still helps (search + every family), never a soft-200 copy of the index.
+ res.status(404).set('Cache-Control', 'no-store').type('html').send(require('./pages/site').renderNotFound());
});
});
// ── Start ────────────────────────────────────────────────────
// ── Start ────────────────────────────────────────────────────
new file mode 100644
new file mode 100644
@@ -0,0 +1,235 @@
@@ -0,0 +1,235 @@
+'use strict';
+// ═══════════════════════════════════════════════════════════════
+// openvibe.tools, server-rendered from the registry.
+// / index: search, families, popular tools
+// /<family-path> one family: every tool, intro, FAQ
+// /tool/<id> one tool: what it does, where it lives (primary host + mirrors), related
+// /all-tools A–Z
+// /search?q= results (works as a plain form; JS only makes it instant)
+// /sitemap.xml /robots.txt /llms.txt /api/catalog.json
+// Every page is complete HTML without JavaScript. Pages are rendered once per registry version
+// and served from memory with an ETag.
+// ═══════════════════════════════════════════════════════════════
+const crypto = require('crypto');
+const express = require('express');
+const seo = require('openvibe-shared/seo');
+const icons = require('openvibe-shared/icons');
+const registry = require('../registry');
+
+const SITE = 'https://openvibe.tools';
+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 hostLabel = (h) => h.replace(/\.openvibe\.tools$/, '') === h ? h : h;
+
+function toolCard(t) {
+ return `<a class="tool" href="${esc(t.url)}" data-k="${esc([t.name, t.tagline, ...(t.keywords || [])].join(' ').toLowerCase())}">
+ ${icon(t.icon, 36)}<span class="tool-t"><b>${esc(t.name)}</b><small>${esc(t.tagline)}</small><i>${esc(hostLabel(t.hosts.short || t.hosts.canonical))}</i></span></a>`;
+}
+
+const CSS = `
+*{box-sizing:border-box}html{-webkit-text-size-adjust:100%}
+body{margin:0;background:var(--bg-primary,#0a0f18);color:var(--text-primary,#e6edf7);font:400 16px/1.55 Inter,system-ui,-apple-system,Segoe UI,Roboto,sans-serif}
+a{color:var(--accent-light,var(--accent,#60a5fa))}
+.wrap{max-width:1180px;margin:0 auto;padding:0 20px}
+.topbar{display:flex;align-items:center;gap:14px;padding:12px 20px;border-bottom:1px solid var(--border,rgba(255,255,255,.08))}
+.topbar a{color:inherit;text-decoration:none;font-weight:700}.topbar nav{display:flex;gap:14px;margin-left:auto;font-size:14px;flex-wrap:wrap}.topbar nav a{font-weight:500;color:var(--text-secondary,#a8b3c4)}
+.hero{padding:clamp(28px,6vw,64px) 0 8px;text-align:center}
+.hero h1{font-size:clamp(30px,5.2vw,52px);line-height:1.08;letter-spacing:-.03em;margin:0 0 12px;font-weight:800}
+.hero h1 span{color:var(--accent-light,var(--accent,#60a5fa))}
+.hero p{max-width:680px;margin:0 auto 22px;color:var(--text-secondary,#a8b3c4);font-size:clamp(15px,1.6vw,18px)}
+.search{display:flex;max-width:640px;margin:0 auto;background:var(--bg-secondary,#111826);border:1px solid var(--border,rgba(255,255,255,.12));border-radius:14px;padding:5px;gap:4px}
+.search:focus-within{border-color:var(--accent,#3b82f6);box-shadow:0 0 0 4px var(--accent-glow,rgba(59,130,246,.18))}
+.search input{flex:1;min-width:0;background:none;border:0;outline:0;color:inherit;font:inherit;padding:10px 12px}
+.search button{border:0;border-radius:10px;background:var(--accent,#3b82f6);color:var(--on-accent,#fff);font:700 14px/1 inherit;padding:0 18px;cursor:pointer}
+.chips{display:flex;flex-wrap:wrap;justify-content:center;gap:8px;margin:18px auto 0;max-width:820px;padding:0;list-style:none}
+.chips a{display:inline-flex;align-items:center;gap:7px;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}
+.chips a:hover{border-color:var(--accent,#3b82f6)}
+h2{font-size:clamp(20px,2.4vw,26px);letter-spacing:-.02em;margin:0}
+section{padding:clamp(24px,4vw,44px) 0 0}
+.sec-h{display:flex;align-items:center;gap:12px;margin-bottom:6px}.sec-h a.more{margin-left:auto;font-size:14px;font-weight:600;text-decoration:none;white-space:nowrap}
+.sec-p{color:var(--text-secondary,#a8b3c4);margin:0 0 16px;max-width:820px}
+.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(250px,1fr));gap:10px}
+.tool{display:flex;gap:12px;align-items:flex-start;padding:12px;border-radius:14px;border:1px solid var(--border,rgba(255,255,255,.08));background:var(--bg-secondary,#111826);text-decoration:none;color:inherit;transition:border-color .15s,transform .15s}
+.tool:hover,.tool:focus-visible{border-color:var(--accent,#3b82f6);transform:translateY(-1px);outline:0}
+.tool-t{min-width:0;display:block}.tool b{display:block;font-size:15px}.tool small{display:block;color:var(--text-secondary,#a8b3c4);font-size:13px;line-height:1.35;margin-top:2px}
+.tool i{display:block;font-style:normal;font-size:11.5px;color:var(--text-muted,#7d8aa0);margin-top:5px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
+.tool[hidden]{display:none}
+.crumbs{font-size:13.5px;color:var(--text-muted,#7d8aa0);padding-top:18px}.crumbs a{color:inherit}
+.page-h{display:flex;gap:16px;align-items:center;padding:14px 0 4px}.page-h h1{font-size:clamp(26px,4vw,40px);letter-spacing:-.03em;margin:0;line-height:1.1}
+.lead{font-size:clamp(15px,1.5vw,18px);color:var(--text-secondary,#a8b3c4);max-width:820px}
+.cta{display:inline-flex;align-items:center;gap:8px;background:var(--accent,#3b82f6);color:var(--on-accent,#fff);font-weight:700;text-decoration:none;padding:12px 20px;border-radius:12px;margin:6px 0}
+.hosts{list-style:none;padding:0;margin:8px 0;display:flex;flex-direction:column;gap:6px;max-width:640px}
+.hosts li{display:flex;gap:10px;align-items:center;padding:9px 12px;border:1px solid var(--border,rgba(255,255,255,.08));border-radius:10px;flex-wrap:wrap}
+.hosts li.primary{border-color:var(--accent,#3b82f6);background:var(--accent-glow,rgba(59,130,246,.1))}
+.hosts .tag{font-size:11px;font-weight:700;letter-spacing:.5px;text-transform:uppercase;color:var(--text-muted,#7d8aa0);margin-left:auto}
+.kw{display:flex;flex-wrap:wrap;gap:6px;padding:0;list-style:none}.kw li{font-size:12.5px;padding:3px 9px;border-radius:999px;background:var(--bg-secondary,#111826);color:var(--text-secondary,#a8b3c4)}
+details{border-bottom:1px solid var(--border,rgba(255,255,255,.08));padding:12px 0;max-width:820px}summary{cursor:pointer;font-weight:650}details p{color:var(--text-secondary,#a8b3c4);margin:8px 0 0}
+.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}
+#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}}
+${icons.CSS}`;
+
+const NETWORK = [
+ ['live', 'OpenVibe.Live', 'Live streams, clips and chat', 'https://openvibe.live/'],
+ ['community', 'OpenVibe.Community', 'Pastes, posts and people', 'https://openvibe.community/'],
+ ['games', 'OpenVibe.Games', 'Browser games', 'https://openvibe.games/'],
+ ['media', 'OpenVibe.Media', 'VODs, clips and files', 'https://openvibe.media/'],
+ ['network', 'OpenVibe.Network', 'One account for every site', 'https://openvibe.network/'],
+];
+
+function shell({ head, body, families }) {
+ return `<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="theme-color" content="#3b82f6">
+${head}
+<link rel="icon" type="image/svg+xml" href="https://openvibe.network/assets/favicon.svg">
+<link rel="alternate" type="application/json" href="${SITE}/api/catalog.json" title="Tool catalog">
+<link rel="preconnect" href="https://openvibe.network">
+<script src="https://openvibe.network/shared/theme-loader.js"></script>
+<style>${CSS}</style></head><body>
+<noscript><header class="topbar"><a href="${SITE}/">${NAME}</a><nav>${families.filter(f => f.path).map(f => `<a href="${esc(f.path)}">${esc(f.name)}</a>`).join('')}<a href="/all-tools">All tools</a></nav></header></noscript>
+<main class="wrap">${body}
+<section aria-labelledby="net-h"><div class="sec-h"><h2 id="net-h">The rest of OpenVibe</h2></div><p class="sec-p">One account works on every site. Open source and community-run.</p>
+<div class="net">${NETWORK.map(([ic, n, d, u]) => `<a class="tool" href="${u}">${icon(ic, 36)}<span class="tool-t"><b>${n}</b><small>${d}</small></span></a>`).join('')}</div></section>
+</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 src="/js/site.js" defer></script>
+</body></html>`;
+}
+
+function head(o) {
+ return seo.headTags(Object.assign({ siteName: NAME, image: 'https://openvibe.network/assets/og-tools.png', type: 'website', locale: 'en_US' }, o));
+}
+
+const searchForm = (q) => `<form class="search" action="/search" method="get" role="search"><input type="search" name="q" id="q" value="${esc(q || '')}" placeholder="What do you need to do? Try “merge pdf” or “dns lookup”" aria-label="Search tools" autocomplete="off"><button type="submit">Search</button></form>`;
+
+function score(t, terms) {
+ let s = 0; const name = t.name.toLowerCase(); const hay = [t.tagline, t.description, ...(t.keywords || []), t.id].join(' ').toLowerCase();
+ for (const w of terms) { if (name.includes(w)) s += 5; if (t.id === w) s += 4; if ((t.keywords || []).some(k => k.includes(w))) s += 3; else if (hay.includes(w)) s += 1; else if (!name.includes(w)) return 0; }
+ return s;
+}
+function search(q) {
+ const terms = String(q || '').toLowerCase().split(/[^a-z0-9.+#]+/).filter(Boolean).slice(0, 8);
+ if (!terms.length) return [];
+ return registry.get().tools.map(t => [score(t, terms), t]).filter(x => x[0] > 0).sort((a, b) => b[0] - a[0] || a[1].name.localeCompare(b[1].name)).slice(0, 60).map(x => x[1]);
+}
+
+// ── Pages ────────────────────────────────────────────────────
+function pageIndex() {
+ const { tools, families } = 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>
+${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>
+<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('')}`;
+ 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',
+ jsonLd: [seo.jsonLd.organization(), seo.jsonLd.website({ name: NAME, url: SITE + '/', description: 'Online tools for files, text, code and networks.', searchUrl: SITE + '/search?q={search_term_string}' }),
+ seo.jsonLd.itemList('Tool families', fams.map(f => ({ name: f.name, url: SITE + f.path, description: f.tagline })))] }) });
+}
+
+function pageFamily(f) {
+ const { tools, families } = registry.get();
+ const list = tools.filter(t => t.family === f.id);
+ const url = SITE + f.path;
+ const body = `<nav class="crumbs" aria-label="Breadcrumb"><a href="/">Tools</a> › ${esc(f.name)}</nav>
+<div class="page-h">${icon(f.icon, 56)}<h1>${esc(f.name)}</h1></div><p class="lead">${esc(f.description)}</p>
+${f.hosts ? `<a class="cta" href="${esc(f.url)}">Open ${esc(f.name)}</a>` : ''}
+<section><div class="sec-h"><h2>${list.length} tools</h2></div><p class="sec-p">${esc(f.intro || '')}</p><div class="grid">${list.map(toolCard).join('')}</div></section>
+${f.faq && f.faq.length ? `<section><div class="sec-h"><h2>Questions</h2></div>${f.faq.map(([q, a]) => `<details><summary>${esc(q)}</summary><p>${esc(a)}</p></details>`).join('')}</section>` : ''}`;
+ return shell({ families, body, head: head({ title: `${f.name}: ${f.tagline}`, description: f.description, canonical: url, keywords: list.slice(0, 12).map(t => t.name.toLowerCase()).join(', '),
+ jsonLd: [seo.jsonLd.breadcrumbs([{ name: 'Tools', url: SITE + '/' }, { name: f.name, url }]), seo.jsonLd.itemList(f.name, list.map(t => ({ name: t.name, url: t.url, description: t.tagline }))),
+ ...(f.faq && f.faq.length ? [seo.jsonLd.faq(f.faq.map(([q, a]) => ({ q, a })))] : [])] }) });
+}
+
+function pageTool(t) {
+ const { tools, families } = registry.get();
+ const f = families.find(x => x.id === t.family);
+ const url = `${SITE}/tool/${t.id}`;
+ const related = tools.filter(x => x.family === t.family && x.id !== t.id).slice(0, 8);
+ const hosts = [[t.hosts.canonical, 'Primary'], ...(t.hosts.short ? [[t.hosts.short, 'Short link']] : []), ...t.hosts.aliases.map(a => [a, 'Mirror'])];
+ const body = `<nav class="crumbs" aria-label="Breadcrumb"><a href="/">Tools</a> › ${f && f.path ? `<a href="${esc(f.path)}">${esc(f.name)}</a> › ` : ''}${esc(t.name)}</nav>
+<div class="page-h">${icon(t.icon, 56)}<h1>${esc(t.name)}</h1></div><p class="lead">${esc(t.description)}</p>
+<a class="cta" href="${esc(t.url)}">Open ${esc(t.name)}</a>
+<section><div class="sec-h"><h2>Where to find it</h2></div><p class="sec-p">Every address below opens the same tool. The primary address is the one to bookmark and share.</p>
+<ul class="hosts">${hosts.map(([h, tag], i) => `<li${i === 0 ? ' class="primary"' : ''}><a href="https://${esc(h)}/"${i === 0 ? '' : ' rel="nofollow"'}>${esc(h)}</a><span class="tag">${tag}</span></li>`).join('')}</ul></section>
+${t.keywords && t.keywords.length ? `<section><div class="sec-h"><h2>People also call this</h2></div><ul class="kw">${t.keywords.map(k => `<li>${esc(k)}</li>`).join('')}</ul></section>` : ''}
+${related.length ? `<section><div class="sec-h"><h2>Related tools</h2>${f && f.path ? `<a class="more" href="${esc(f.path)}">All ${esc(f.name)}</a>` : ''}</div><div class="grid">${related.map(toolCard).join('')}</div></section>` : ''}`;
+ return shell({ families, body, head: head({ title: `${t.name}: ${t.tagline}`, description: t.description, canonical: url, keywords: (t.keywords || []).join(', '),
+ jsonLd: [seo.jsonLd.breadcrumbs([{ name: 'Tools', url: SITE + '/' }, ...(f && f.path ? [{ name: f.name, url: SITE + f.path }] : []), { name: t.name, url }]),
+ seo.jsonLd.softwareApp({ name: t.name, url: t.url, description: t.description, category: 'UtilitiesApplication', keywords: t.keywords })] }) });
+}
+
+function pageAll() {
+ const { tools, families } = registry.get();
+ const sorted = [...tools].sort((a, b) => a.name.localeCompare(b.name));
+ const body = `<nav class="crumbs" aria-label="Breadcrumb"><a href="/">Tools</a> › All tools</nav><div class="page-h">${icon('tools', 56)}<h1>All ${tools.length} tools, A to Z</h1></div>
+${searchForm('')}<section><ul class="az">${sorted.map(t => `<li><a href="${esc(t.url)}">${esc(t.name)}</a> <small><a href="/tool/${esc(t.id)}" aria-label="About ${esc(t.name)}">about</a></small></li>`).join('')}</ul></section>`;
+ return shell({ families, body, head: head({ title: `All ${tools.length} Online Tools, A to Z`, description: 'The complete list of tools on OpenVibe.Tools: converters, downloaders, PDF and image tools, text utilities, developer tools and network diagnostics.', canonical: SITE + '/all-tools',
+ jsonLd: [seo.jsonLd.breadcrumbs([{ name: 'Tools', url: SITE + '/' }, { name: 'All tools', url: SITE + '/all-tools' }])] }) });
+}
+
+function pageSearch(q) {
+ const { families } = registry.get();
+ const hits = search(q);
+ const body = `<nav class="crumbs" aria-label="Breadcrumb"><a href="/">Tools</a> › Search</nav><div class="page-h"><h1>${q ? `Tools for “${esc(q)}”` : 'Search tools'}</h1></div>${searchForm(q)}
+<section>${hits.length ? `<div class="grid">${hits.map(toolCard).join('')}</div>` : `<p class="empty">${q ? 'No tool matches that yet.' : 'Type what you need to do.'} <a href="/all-tools">Browse every tool</a>.</p>`}</section>`;
+ return shell({ families, body, head: head({ title: q ? `Tools for “${seo.clip(q, 30)}”` : 'Search tools', description: 'Search every tool on OpenVibe.Tools.', canonical: SITE + '/search', robots: 'noindex,follow' }) });
+}
+
+// ── Cache + router ───────────────────────────────────────────
+const cache = new Map();
+function cached(key, render) {
+ const ver = registry.get().updated;
+ let e = cache.get(key);
+ if (!e || e.ver !== ver) { const html = render(); e = { ver, html, etag: '"' + crypto.createHash('sha1').update(html).digest('base64url').slice(0, 20) + '"' }; cache.set(key, e); }
+ return e;
+}
+function send(req, res, key, render, type) {
+ const e = cached(key, render);
+ res.set('Content-Type', type || 'text/html; charset=utf-8');
+ res.set('Cache-Control', 'public, max-age=300, stale-while-revalidate=86400');
+ res.set('ETag', e.etag);
+ if (req.headers['if-none-match'] === e.etag) return res.status(304).end();
+ res.send(e.html);
+}
+
+function sitemapEntries() {
+ const { tools, families } = registry.get();
+ const today = new Date().toISOString().slice(0, 10);
+ return [{ loc: SITE + '/', changefreq: 'daily', priority: 1 }, { loc: SITE + '/all-tools', changefreq: 'weekly', priority: 0.8 },
+ ...families.filter(f => f.path).map(f => ({ loc: SITE + f.path, changefreq: 'weekly', priority: 0.9 })),
+ ...families.filter(f => f.hosts).map(f => ({ loc: f.url, changefreq: 'weekly', priority: 0.9 })),
+ ...tools.filter(t => !t.external).flatMap(t => [{ loc: t.url, changefreq: 'weekly', priority: 0.8 }, { loc: `${SITE}/tool/${t.id}`, changefreq: 'monthly', priority: 0.5 }]),
+ ].map(u => Object.assign({ lastmod: today }, u));
+}
+
+function createSiteRouter() {
+ const router = express.Router();
+ const apexOnly = (req, res, next) => (!req.ovHost || req.ovHost.kind === 'apex' || (req.ovHost.kind === 'unknown' && !req.ovHost.inZone) ? next() : next('router'));
+ router.use(apexOnly);
+ router.get('/api/catalog.json', (req, res) => { res.set('Access-Control-Allow-Origin', '*'); send(req, res, 'catalog', () => JSON.stringify(registry.catalog()), 'application/json; charset=utf-8'); });
+ router.get('/', (req, res) => send(req, res, '/', pageIndex));
+ router.get('/all-tools', (req, res) => send(req, res, '/all-tools', pageAll));
+ router.get('/search', (req, res) => { res.set('Cache-Control', 'no-store'); res.type('html').send(pageSearch(String(req.query.q || '').slice(0, 80))); });
+ router.get('/tool/:id', (req, res, next) => { const t = registry.get().tools.find(x => x.id === req.params.id); return t ? send(req, res, '/tool/' + t.id, () => pageTool(t)) : next(); });
+ router.get('/sitemap.xml', (req, res) => send(req, res, 'sitemap', () => seo.sitemapXml(sitemapEntries()), 'application/xml; charset=utf-8'));
+ router.get('/robots.txt', (req, res) => send(req, res, 'robots', () => seo.robotsTxt({ sitemaps: [SITE + '/sitemap.xml'], disallow: ['/api/', '/auth/'], allow: ['/api/catalog.json'], allowAI: true }), 'text/plain; charset=utf-8'));
+ router.get('/llms.txt', (req, res) => send(req, res, 'llms', () => { const { tools, families } = registry.get(); return seo.llmsTxt({ name: NAME, summary: `${tools.length} online tools for files, text, code and networks. Open source and community-run. A machine-readable catalog is at ${SITE}/api/catalog.json.`,
+ sections: families.filter(f => f.path).map(f => ({ title: f.name, links: tools.filter(t => t.family === f.id).map(t => ({ title: t.name, url: t.url, note: t.tagline })) })) }); }, 'text/plain; charset=utf-8'));
+ router.get('/:slug', (req, res, next) => { const f = registry.get().families.find(x => x.path === '/' + req.params.slug); return f ? send(req, res, f.path, () => pageFamily(f)) : next(); });
+ return router;
+}
+
+const renderNotFound = () => pageSearch('').replace('<h1>Search tools</h1>', '<h1>That page is not here</h1>').replace(/<title>[^<]*<\/title>/, '<title>Page not found | OpenVibe.Tools</title>');
+
+module.exports = { createSiteRouter, search, sitemapEntries, renderNotFound };
new file mode 100644
new file mode 100644
@@ -0,0 +1,146 @@
@@ -0,0 +1,146 @@
+'use strict';
+
+// ═══════════════════════════════════════════════════════════════
+// Plain-language copy for the tools served by the satellite apps.
+//
+// Row: [id, name, tagline (≤ 60 chars), description, keywords, icon]
+// id the tool's existing subdomain label (png → png.openvibe.tools); unique site-wide
+// keywords the searches people actually type, comma separated
+// icon an ov-icons name (contract §4); unknown names fall back to the OV mark
+//
+// The satellites' own host maps decide WHICH hosts exist and which are mirrors of each other
+// (registry/sources.js); this file only says what each tool is called and what it does.
+// ═══════════════════════════════════════════════════════════════
+
+const IMG = [
+ ['convert', 'Image Converter', 'Change a picture from one format to another', 'Convert pictures between PNG, JPG, WebP, AVIF, HEIC, SVG, GIF, ICO, TIFF and BMP. Drop in one file or a batch, pick the format you need and download the converted images.', 'image converter, convert image, png to jpg, jpg to png, webp to jpg, heic to jpg, convert picture format', 'image'],
+ ['compress', 'Image Compressor', 'Make image files smaller without visible loss', 'Shrink JPG, PNG, WebP and AVIF files so pages load faster and attachments fit in an email. Choose a quality level, compare the size before and after, and download the smaller file.', 'image compressor, compress image, reduce image size, compress jpg, compress png, make photo smaller, optimize images', 'image'],
+ ['resize', 'Image Resizer', 'Resize a picture to exact pixels or a percentage', 'Set a width, a height or a percentage and get a resized copy of your picture. Keeps the aspect ratio unless you ask it not to. Handy for profile photos, product shots and forum size limits.', 'image resizer, resize image, resize photo, change image size, resize picture online, resize to pixels', 'image'],
+ ['crop', 'Image Cropper', 'Crop a picture to a ratio or a custom size', 'Cut a picture down to the part you want. Use presets such as 16:9, 4:3 and 1:1 for thumbnails and avatars, or type exact dimensions.', 'crop image, image cropper, crop photo, crop picture online, crop to square, 16:9 crop', 'image'],
+ ['png', 'PNG Converter', 'Convert any picture to PNG', 'Turn JPG, WebP, AVIF, HEIC, GIF, BMP or TIFF pictures into PNG files. PNG keeps sharp edges and transparency, which makes it the right choice for logos, screenshots and graphics.', 'convert to png, jpg to png, webp to png, heic to png, png converter, image to png', 'image'],
+ ['jpg', 'JPG Converter', 'Convert any picture to JPG', 'Turn PNG, WebP, AVIF, HEIC, GIF, BMP or TIFF pictures into JPG files that open everywhere. Pick the quality to balance sharpness against file size.', 'convert to jpg, png to jpg, webp to jpg, heic to jpg, jpg converter, jpeg converter, image to jpeg', 'image'],
+ ['webp', 'WebP Converter', 'Convert pictures to WebP for faster websites', 'Convert PNG, JPG, AVIF, HEIC, GIF and TIFF pictures to WebP, the format browsers load fastest. Typical files come out 25–35% smaller than the same JPG.', 'convert to webp, png to webp, jpg to webp, webp converter, image to webp', 'image'],
+ ['avif', 'AVIF Converter', 'Convert pictures to AVIF, the smallest modern format', 'Convert PNG, JPG, WebP, HEIC and TIFF pictures to AVIF. AVIF gives the smallest files at the same visual quality and is supported by every current browser.', 'convert to avif, jpg to avif, png to avif, avif converter, image to avif', 'image'],
+ ['heic', 'HEIC Converter', 'Open iPhone HEIC photos as JPG or PNG', 'iPhones save photos as HEIC, which many apps and websites refuse. Convert HEIC and HEIF photos to JPG, PNG or WebP so you can share, upload or print them.', 'heic to jpg, heic converter, heic to png, iphone photo converter, heif to jpg, open heic file', 'image'],
+ ['svg', 'SVG Converter', 'Turn SVG graphics into PNG or JPG, and back', 'Render an SVG drawing as a PNG, JPG or WebP at the size you choose, or wrap a bitmap in an SVG. Useful when a site or an app does not accept vector files.', 'svg to png, svg converter, svg to jpg, convert svg, png to svg', 'image'],
+ ['gif', 'GIF Converter', 'Convert pictures to and from GIF', 'Save a picture as a GIF or turn a GIF into PNG, JPG or WebP. Animated GIFs can be converted to animated WebP for a much smaller file.', 'gif converter, convert to gif, gif to png, gif to webp, png to gif, jpg to gif', 'image'],
+ ['ico', 'ICO Converter', 'Make Windows .ico icon files from any picture', 'Convert a PNG, JPG or SVG into an ICO file containing the standard icon sizes, ready for a Windows shortcut, an application or a website.', 'ico converter, png to ico, convert to ico, make ico file, icon converter', 'image'],
+ ['favicon', 'Favicon Generator', 'Create website favicons in every size', 'Upload a logo and get the complete favicon set a modern website needs: favicon.ico, PNG icons from 16 to 512 pixels and an SVG version.', 'favicon generator, make favicon, favicon maker, png to favicon, website icon generator', 'image'],
+ ['tiff', 'TIFF Converter', 'Convert pictures to and from TIFF', 'Convert scans and print files between TIFF and PNG, JPG or WebP. Useful when a printer asks for TIFF or when a TIFF scan is too large to email.', 'tiff converter, tiff to jpg, tiff to png, convert to tiff, jpg to tiff', 'image'],
+ ['bmp', 'BMP Converter', 'Convert pictures to and from BMP', 'Convert old Windows bitmap files to PNG or JPG, or save a picture as BMP for software that only reads bitmaps.', 'bmp converter, bmp to jpg, bmp to png, convert to bmp, png to bmp', 'image'],
+];
+
+const AUDIO = [
+ ['mp3', 'MP3 Converter', 'Convert any audio or video file to MP3', 'Turn WAV, FLAC, M4A, OGG, WMA and other recordings, or the sound of a video, into an MP3 that plays on every device. Choose the bitrate from 64 to 320 kbps.', 'mp3 converter, convert to mp3, wav to mp3, m4a to mp3, flac to mp3, mp4 to mp3, audio to mp3', 'audio'],
+ ['wav', 'WAV Converter', 'Convert audio to uncompressed WAV', 'Convert MP3, FLAC, M4A, OGG and other audio to WAV for editing, sampling or burning to CD. Pick the sample rate and bit depth your software expects.', 'wav converter, mp3 to wav, convert to wav, flac to wav, m4a to wav', 'audio'],
+ ['flac', 'FLAC Converter', 'Convert audio to lossless FLAC', 'Convert WAV, AIFF and other recordings to FLAC, which keeps every detail at roughly half the size of WAV. Also converts FLAC to other formats.', 'flac converter, wav to flac, convert to flac, flac to mp3, lossless audio converter', 'audio'],
+ ['ogg', 'OGG Converter', 'Convert audio to OGG Vorbis', 'Convert MP3, WAV, FLAC and M4A files to OGG Vorbis, the open format used by games, Linux apps and many web players.', 'ogg converter, mp3 to ogg, convert to ogg, wav to ogg, ogg vorbis converter', 'audio'],
+ ['m4a', 'M4A Converter', 'Convert audio to M4A for Apple devices', 'Convert MP3, WAV, FLAC and OGG files to M4A (AAC), the format iPhones, iTunes and Apple Music handle best.', 'm4a converter, mp3 to m4a, convert to m4a, wav to m4a, m4a to mp3', 'audio'],
+ ['opus', 'Opus Converter', 'Convert audio to Opus for small, clear files', 'Convert recordings to Opus, the codec used by Discord, WhatsApp and WebRTC. It sounds clean at bitrates where MP3 falls apart, which makes it ideal for voice.', 'opus converter, convert to opus, mp3 to opus, wav to opus, opus audio', 'audio'],
+ ['aac', 'AAC Converter', 'Convert audio to AAC', 'Convert MP3, WAV, FLAC and OGG files to raw AAC for video editors, streaming encoders and devices that expect .aac files.', 'aac converter, convert to aac, mp3 to aac, wav to aac', 'audio'],
+ ['wma', 'WMA Converter', 'Convert audio to and from WMA', 'Convert old Windows Media Audio files to MP3 or WAV, or make a WMA for a car stereo or player that needs one.', 'wma converter, wma to mp3, convert wma, wma to wav', 'audio'],
+ ['aiff', 'AIFF Converter', 'Convert audio to AIFF for Mac audio software', 'Convert WAV, FLAC and MP3 files to AIFF, the uncompressed format used by Logic, GarageBand and other Mac audio tools.', 'aiff converter, convert to aiff, wav to aiff, aiff to mp3, aiff to wav', 'audio'],
+ ['ac3', 'AC3 Converter', 'Convert audio to AC3 (Dolby Digital)', 'Convert stereo or surround audio to AC3 for DVDs, Blu-ray authoring and home cinema receivers.', 'ac3 converter, convert to ac3, dolby digital converter, wav to ac3', 'audio'],
+ ['extract', 'Audio Extractor', 'Pull the soundtrack out of a video file', 'Upload an MP4, MKV, MOV or WebM video and download just its audio as MP3, WAV or M4A. Good for saving a talk, a song or an interview from a recording.', 'extract audio from video, mp4 to mp3, video to audio, video to mp3 converter, rip audio from video', 'audio'],
+ ['trim', 'Audio Trimmer', 'Cut a clip out of a song or recording', 'Mark a start and an end on the waveform and download only that part. Use it to cut silence off a recording, make a sample or shorten a voice memo.', 'audio trimmer, cut audio, trim mp3, mp3 cutter, audio cutter, cut song', 'audio'],
+ ['merge', 'Audio Joiner', 'Join several audio files into one', 'Upload two or more recordings, put them in order and download a single file. Works across formats, so an MP3 and a WAV can be joined.', 'audio joiner, merge audio, combine mp3, join songs, merge mp3 files', 'audio'],
+ ['ringtone', 'Ringtone Maker', 'Turn part of a song into a phone ringtone', 'Pick up to 30 seconds of any track, add a fade, and download an M4R ringtone for iPhone or an MP3 for Android.', 'ringtone maker, make ringtone, mp3 to ringtone, iphone ringtone maker, m4r converter', 'audio'],
+ ['speed', 'Audio Speed Changer', 'Speed up or slow down audio without changing pitch', 'Play a recording faster or slower while voices stay natural. Useful for practising music, transcribing interviews and getting through long lectures.', 'change audio speed, speed up audio, slow down audio, slow down song, tempo changer', 'audio'],
+ ['pitch', 'Pitch Changer', 'Raise or lower the pitch of a track', 'Shift a song up or down by semitones without changing its speed. Musicians use it to move a backing track into a comfortable key.', 'pitch changer, change pitch, pitch shifter, transpose song, change key of song', 'audio'],
+ ['reverse', 'Reverse Audio', 'Play a sound file backwards', 'Upload a recording and download it reversed. Popular for sound design, hidden messages in songs and just hearing what a word sounds like backwards.', 'reverse audio, play audio backwards, reverse mp3, reverse song, backwards audio', 'audio'],
+ ['normalize', 'Volume Normalizer', 'Bring a recording up to a standard loudness', 'Make quiet recordings louder and even out tracks that differ in volume. Targets the loudness used by streaming services and podcast apps.', 'normalize audio, volume normalizer, make audio louder, increase mp3 volume, loudness normalization', 'audio'],
+ ['noise', 'Noise Reducer', 'Remove hiss, hum and background noise', 'Clean up a voice recording made in a noisy room. Reduces steady background sounds such as fans, air conditioning, hiss and mains hum while keeping speech clear.', 'noise reduction, remove background noise, denoise audio, clean up audio, remove hiss', 'audio'],
+ ['silence', 'Silence Remover', 'Cut the quiet gaps out of a recording', 'Automatically removes long pauses from lectures, podcasts and voice memos so the recording is shorter and easier to listen to.', 'remove silence, silence remover, cut silence from audio, trim silence, remove pauses', 'audio'],
+ ['vocal', 'Vocal Remover', 'Remove or isolate the vocals in a song', 'Make a karaoke backing track by taking the singer out of a song, or keep only the vocal for a remix. Works best on stereo studio recordings.', 'vocal remover, remove vocals, karaoke maker, isolate vocals, instrumental maker, acapella extractor', 'audio'],
+ ['fade', 'Audio Fader', 'Add a fade in or fade out', 'Give a clip a smooth start and finish instead of an abrupt cut. Set the fade length in seconds for each end.', 'fade in audio, fade out audio, audio fade, add fade to mp3', 'audio'],
+ ['loop', 'Audio Looper', 'Repeat a clip any number of times', 'Turn a short sample into a longer track by repeating it, with an optional crossfade so the joins cannot be heard.', 'loop audio, audio looper, repeat audio, loop mp3, make audio loop', 'audio'],
+ ['bass', 'Bass Booster', 'Add more low end to a track', 'Boost the bass of a song for small speakers, headphones or a car stereo. Choose how much and at which frequency.', 'bass booster, boost bass, bass boost mp3, add bass to song', 'audio'],
+ ['equalizer', 'Audio Equalizer', 'Adjust bass, mids and treble', 'A multi-band equalizer with presets for voice, music and podcasts. Fix a muffled recording or tame harsh highs, then download the result.', 'audio equalizer, online equalizer, eq audio, adjust treble, adjust bass', 'audio'],
+ ['compressor', 'Audio Compressor', 'Even out loud and quiet parts', 'Dynamic range compression makes whispers audible and shouts less painful. Useful for podcasts, streams and recordings of meetings.', 'audio compressor, dynamic range compression, even out volume, compress audio dynamics', 'audio'],
+ ['podcast', 'Podcast Optimizer', 'Make speech recordings sound broadcast-ready', 'One click applies noise reduction, levelling and loudness normalization tuned for spoken word, then exports at the settings podcast hosts recommend.', 'podcast audio, improve podcast sound, podcast editor, voice enhancer, podcast loudness', 'audio'],
+ ['voice', 'Voice Changer', 'Change how a recorded voice sounds', 'Apply effects to a voice recording: deeper, higher, robot, radio, telephone and more. Upload a clip and download the changed version.', 'voice changer, change voice, robot voice, deep voice effect, voice effects', 'audio'],
+ ['echo', 'Echo Effect', 'Add echo or delay to audio', 'Add a repeating echo to a voice or instrument. Set the delay time and how quickly the repeats die away.', 'add echo to audio, echo effect, delay effect, audio echo', 'audio'],
+ ['reverb', 'Reverb Effect', 'Make audio sound like it is in a room or a hall', 'Add space to a dry recording with room, hall and cathedral reverbs. Control how much of the effect is mixed in.', 'add reverb, reverb effect, reverb audio, hall reverb, slowed and reverb', 'audio'],
+ ['chorus', 'Chorus Effect', 'Thicken a voice or instrument', 'The chorus effect layers slightly detuned copies of a sound to make it wider and richer. A classic on guitars, synths and backing vocals.', 'chorus effect, add chorus to audio, audio chorus', 'audio'],
+ ['distortion', 'Distortion Effect', 'Add grit, overdrive or fuzz', 'Run audio through overdrive and distortion, from warm saturation to heavy fuzz. Useful for guitars, drums and sound design.', 'distortion effect, distort audio, overdrive effect, fuzz effect', 'audio'],
+ ['bitcrusher', 'Bitcrusher', 'Give audio a lo-fi, retro game sound', 'Reduce the bit depth and sample rate of a recording for the crunchy sound of old consoles and samplers.', 'bitcrusher, lo-fi effect, 8-bit audio, bitcrush audio, retro sound effect', 'audio'],
+ ['stereo', 'Stereo & Mono Converter', 'Convert stereo to mono, swap or widen channels', 'Fold a stereo file down to mono, turn a mono recording into two channels, swap left and right, or widen the stereo image.', 'stereo to mono, mono to stereo, convert to mono, swap audio channels', 'audio'],
+ ['waveform', 'Waveform Image Generator', 'Turn a sound file into a waveform picture', 'Generate a PNG of the waveform of any recording, in your colours and size. Used for podcast covers, video overlays and audiograms.', 'waveform generator, audio waveform image, sound wave picture, waveform png', 'audio'],
+ ['metadata', 'Audio Tag Editor', 'Edit the title, artist, album and cover of a file', 'Read and change the ID3 tags of MP3 files and the metadata of FLAC, M4A and OGG files, including the cover art, without re-encoding the audio.', 'mp3 tag editor, edit id3 tags, audio metadata editor, change album art, edit mp3 info', 'audio'],
+];
+
+const DOCS = [
+ ['mergepdf', 'Merge PDF', 'Combine several PDF files into one', 'Upload two or more PDFs, drag them into the order you want and download a single combined document. Bookmarks and page sizes are kept.', 'merge pdf, combine pdf, join pdf files, pdf merger, put pdfs together', 'pdf'],
+ ['splitpdf', 'Split PDF', 'Pull pages out of a PDF or cut it into parts', 'Extract a range of pages, split a document every few pages or save each page as its own file.', 'split pdf, extract pages from pdf, separate pdf pages, pdf splitter, cut pdf', 'pdf'],
+ ['compresspdf', 'Compress PDF', 'Make a PDF small enough to email or upload', 'Reduce the size of a PDF by recompressing its images and removing unused data. Choose between lighter compression that keeps print quality and stronger compression for the smallest file.', 'compress pdf, reduce pdf size, make pdf smaller, pdf compressor, shrink pdf', 'pdf'],
+ ['rotatepdf', 'Rotate PDF', 'Turn sideways or upside-down pages the right way up', 'Rotate every page or only the ones you select by 90, 180 or 270 degrees, and save the PDF so it stays that way.', 'rotate pdf, turn pdf pages, rotate pdf pages permanently, fix upside down pdf', 'pdf'],
+ ['reorderpdf', 'Reorder PDF Pages', 'Rearrange or delete the pages of a PDF', 'See thumbnails of every page, drag them into a new order, remove the ones you do not need and download the new document.', 'reorder pdf pages, rearrange pdf, delete pdf pages, organize pdf, move pdf pages', 'pdf'],
+ ['watermarkpdf', 'Watermark PDF', 'Stamp text such as DRAFT or your name on every page', 'Add a text watermark to a PDF. Choose the wording, size, colour, angle and transparency, and apply it to all pages or a range.', 'watermark pdf, add watermark to pdf, stamp pdf, pdf watermark', 'pdf'],
+ ['protectpdf', 'Protect PDF', 'Add a password to a PDF', 'Encrypt a PDF so it can only be opened with the password you set. You can also restrict printing and copying.', 'password protect pdf, encrypt pdf, lock pdf, add password to pdf, secure pdf', 'pdf'],
+ ['unlockpdf', 'Unlock PDF', 'Remove the password from a PDF you can open', 'Enter the password once and download a copy of the PDF that no longer asks for it. For documents you own or have permission to unlock.', 'unlock pdf, remove pdf password, pdf password remover, decrypt pdf', 'pdf'],
+ ['image2pdf', 'Image to PDF', 'Turn JPG and PNG pictures into a PDF', 'Combine photos, scans or screenshots into one PDF. Set the page size, orientation and margins, and order the pictures before converting.', 'jpg to pdf, image to pdf, png to pdf, convert photo to pdf, pictures to pdf, scan to pdf', 'pdf'],
+ ['pdf2jpg', 'PDF to Image', 'Save the pages of a PDF as JPG or PNG pictures', 'Convert every page of a PDF, or just the pages you choose, into JPG or PNG images at the resolution you need.', 'pdf to jpg, pdf to png, pdf to image, convert pdf to picture, pdf page to jpg', 'pdf'],
+];
+
+const TEXT = [
+ ['fancy', 'Fancy Text Generator', 'Stylish Unicode fonts to copy and paste', 'Type a word and copy it in dozens of styles: cursive, gothic, bold, small caps, wide, monospace and more. The result is ordinary text, so it works in Instagram bios, Discord names, TikTok captions and chat.', 'fancy text generator, fancy fonts, cool text, font copy and paste, instagram fonts, cursive text generator, small caps generator', 'text'],
+ ['zalgo', 'Glitch Text Generator', 'Make creepy, corrupted-looking Zalgo text', 'Adds stacks of combining marks above and below your letters for the glitchy, cursed look. Control how wild it gets, then copy and paste.', 'zalgo text, glitch text generator, cursed text, creepy text, corrupted text generator', 'text'],
+ ['bubble', 'Bubble Text Generator', 'Write in circled and squared letters', 'Convert text to ⓑⓤⓑⓑⓛⓔ letters, filled circles or squares that can be pasted anywhere Unicode is accepted.', 'bubble text, bubble letters generator, circle text, circled letters, square text', 'text'],
+ ['ascii', 'ASCII Art Text Generator', 'Big banner text made from characters', 'Turn a word into large ASCII art lettering using classic FIGlet fonts. Made for READMEs, terminal banners, code comments and forum signatures.', 'ascii art generator, text to ascii, figlet generator, ascii text banner, ascii letters', 'text'],
+ ['symbols', 'Symbols to Copy and Paste', 'Hearts, stars, arrows and special characters', 'A searchable sheet of Unicode symbols grouped by kind: arrows, stars, hearts, currency, maths, music and box drawing. Click one to copy it.', 'symbols copy and paste, special characters, heart symbol, star symbol, arrow symbols, text symbols', 'text'],
+ ['kaomoji', 'Kaomoji & Emoticons', 'Japanese text faces to copy (╯°□°)╯', 'Hundreds of kaomoji sorted by mood: happy, sad, angry, shrug, table flip, animals and more. Click to copy.', 'kaomoji, japanese emoticons, text faces, lenny face, shrug emoticon, copy paste emoticons', 'text'],
+ ['unicode', 'Unicode Inspector', 'See exactly which characters are in a piece of text', 'Paste text to list every character with its code point, name, category and UTF-8 bytes. Finds invisible characters, look-alike letters and stray control codes.', 'unicode lookup, unicode character inspector, what character is this, code point lookup, find invisible characters', 'text'],
+ ['braille', 'Braille Translator', 'Convert text to Braille and back', 'Translate English text into Unicode Braille cells, or read Braille back into letters. For learning, labels and design work.', 'braille translator, text to braille, braille converter, braille alphabet', 'text'],
+ ['morse', 'Morse Code Translator', 'Translate text to Morse code and play it', 'Convert text to dots and dashes or decode Morse back to text. Plays the message as audio beeps at the speed you choose.', 'morse code translator, text to morse, morse code decoder, morse code generator', 'text'],
+ ['binary', 'Binary & Hex Translator', 'Convert text to binary, hex, octal and back', 'See any text as binary, hexadecimal, octal or decimal byte values, and turn a string of ones and zeros back into words.', 'binary translator, text to binary, binary to text, binary code converter, hex to text', 'text'],
+ ['case', 'Case Converter', 'UPPERCASE, lowercase, Title Case, camelCase…', 'Fix text typed with caps lock on, format a headline in title case, or convert names to camelCase, snake_case and kebab-case for code.', 'case converter, uppercase to lowercase, title case converter, capitalize text, camelcase converter, change text case', 'text'],
+ ['count', 'Word Counter', 'Count words, characters, sentences and lines', 'Paste text to see words, characters with and without spaces, sentences, paragraphs, lines and reading time. Shows how much room is left for a tweet, a meta description or an SMS.', 'word counter, character counter, count words, letter count, character count online, line counter', 'text'],
+ ['reversetext', 'Reverse Text Generator', 'Flip text backwards, upside down or mirrored', 'Reverse the letters, reverse the word order, turn text upside down or mirror it using look-alike Unicode characters.', 'reverse text, backwards text generator, upside down text, mirror text, flip text', 'text'],
+ ['clean', 'Text Cleaner', 'Strip invisible characters, odd spacing and smart quotes', 'Fix text pasted from Word, PDFs and web pages: remove zero-width and control characters, collapse extra spaces and line breaks, straighten curly quotes and strip Zalgo marks.', 'text cleaner, remove invisible characters, remove extra spaces, remove line breaks, strip formatting, clean text', 'text'],
+ ['sort', 'Sort & Dedupe Lines', 'Sort a list and remove duplicate lines', 'Sort lines alphabetically, numerically or by length, reverse or shuffle them, and remove duplicates and blank lines.', 'sort lines, remove duplicate lines, alphabetize list, dedupe list, sort text alphabetically', 'text'],
+ ['compare', 'Text Compare', 'Find the differences between two texts', 'Paste two versions side by side and see what was added, removed and changed, highlighted down to the word.', 'text compare, diff checker, compare two texts, find difference between two files, text diff online', 'text'],
+ ['json', 'JSON Formatter', 'Format, validate and minify JSON', 'Paste messy JSON to pretty-print it with clean indentation, find syntax errors with their line number, collapse and expand nodes, or minify it for production. Runs in your browser.', 'json formatter, json validator, json beautifier, json viewer, pretty print json, json minify, json lint', 'json'],
+ ['markdown', 'Markdown Editor', 'Write Markdown with a live preview', 'Type Markdown on the left and see the formatted result on the right. Supports tables, task lists and code blocks, and exports clean HTML.', 'markdown editor, markdown preview, markdown to html, online markdown viewer, readme editor', 'code'],
+ ['escape', 'Escape & Unescape Text', 'Encode text for HTML, URLs, JSON and JavaScript', 'Escape or unescape HTML entities, URL percent-encoding, JSON strings, JavaScript strings and Base64 in one place.', 'html escape, html entities encoder, unescape html, url encode, json escape string, escape characters', 'code'],
+ ['slug', 'URL Slug Generator', 'Turn a title into a clean URL slug', 'Convert a headline into a lowercase, hyphenated slug with accents and punctuation removed, ready for a blog post or product URL.', 'slug generator, url slug, slugify, seo friendly url generator, title to slug', 'code'],
+ ['bio', 'Bio Generator', 'Write a short bio for a social profile', 'Answer a few prompts and get ready-to-paste bios for Instagram, TikTok, X, Twitch and Discord, with optional fancy fonts and symbols.', 'bio generator, instagram bio ideas, tiktok bio, social media bio maker, twitch bio', 'text'],
+ ['nickname', 'Nickname Generator', 'Gamer tags and usernames that are not taken yet', 'Generate usernames and gamer tags from a word, a style or pure randomness. Add decorations for games that allow special characters.', 'nickname generator, username generator, gamertag generator, cool usernames, name ideas for games', 'text'],
+ ['logo', 'Logo Maker', 'Make a simple logo, title card or badge', 'The starting point for the graphics tools: text logos, stream title cards, thumbnails, badges, watermarks and transparent text PNGs, all made in the browser and exported as images.', 'logo maker, text logo maker, make a logo online, channel logo maker, simple logo generator', 'logo'],
+ ['wordmark', 'Wordmark Generator', 'Design a text-only logo, including neon styles', 'Set your name in a typeface, adjust spacing, colour, outline and glow, and export a transparent PNG or SVG wordmark.', 'wordmark generator, text logo, neon text generator, name logo maker, typography logo', 'logo'],
+ ['title', 'Title Card Maker', 'Title screens for videos and streams', 'Make a “Starting soon”, “Be right back” or episode title card at 1080p or 4K with your own text, colours and background.', 'title card maker, starting soon screen, stream title screen, video title maker, intro card', 'logo'],
+ ['thumbnail', 'Thumbnail Maker', 'YouTube thumbnails, covers and channel art', 'Lay out a 1280×720 YouTube thumbnail, a stream cover or channel banner with big readable text over your picture, and export it as PNG or JPG.', 'thumbnail maker, youtube thumbnail maker, channel art maker, cover image maker, stream thumbnail', 'logo'],
+ ['badge', 'Badge & Sticker Maker', 'Round badges, labels and stickers', 'Design circular and shield badges or die-cut style stickers with text on a curve, icons and borders. Export with a transparent background.', 'badge maker, sticker maker, make a badge online, emblem maker, sub badge maker', 'logo'],
+ ['watermark', 'Watermark Maker', 'Watermarks, overlays and lower thirds for video', 'Create a transparent watermark, a stream overlay label or a lower-third name strap and export it as a PNG ready to drop into OBS or a video editor.', 'watermark maker, lower third maker, stream overlay maker, make a watermark, transparent logo overlay', 'logo'],
+ ['transparent', 'Transparent Text PNG', 'Text as a PNG with no background', 'Type text, choose a font, colour and outline, and download it as a PNG with a transparent background for thumbnails, overlays and memes.', 'transparent text png, text to png, text png generator, transparent text maker', 'logo'],
+ ['peso', 'Peso to Dollar Converter', 'Convert Mexican pesos and US dollars', 'Convert between MXN and USD at the current rate, with a quick table of common amounts for shopping and travel.', 'peso to dollar, mxn to usd, dollar to peso, pesos a dolares, currency converter mexico', 'text'],
+ ['spanish', 'Spanish–English Phrasebook', 'Everyday phrases in Spanish and English', 'Common phrases for greetings, directions, food, work, emergencies and paperwork, side by side in Spanish and English with pronunciation hints.', 'spanish phrases, english to spanish phrases, spanish phrasebook, frases en ingles, basic spanish', 'text'],
+ ['slang', 'Mexican Slang Dictionary', 'What Mexican Spanish slang words mean', 'Look up words and expressions you will hear in Mexico but not find in a textbook, with meanings, examples and a note on how polite each one is.', 'mexican slang, mexican spanish slang words, what does wey mean, slang mexicano, mexican expressions', 'text'],
+ ['ice', 'ICE Alerts & Know Your Rights', 'Rights information for immigration encounters', 'Plain-language know-your-rights cards in English and Spanish for encounters with immigration enforcement, plus hotline numbers and what to do if someone is detained.', 'know your rights ice, immigration rights card, ice encounter rights, conoce tus derechos, red card', 'text'],
+];
+
+const MEDIA = [
+ ['yt', 'YouTube Downloader', 'Save videos and audio from YouTube', 'Paste a YouTube link and download the video as MP4 in the resolution you choose, or just the audio as MP3 or M4A. A progress bar shows the download being prepared. For your own uploads and videos you are allowed to keep.', 'youtube downloader, youtube to mp3, youtube to mp4, download youtube video, youtube video downloader, yt to mp3, save youtube video', 'youtube'],
+];
+
+const PLACES = [
+ ['maps', 'Survival Map', 'Campsites, shelters, water and help across North America', 'A map of the things you need when money is short or you are living on the road: dispersed and no-fee campsites, shelters, food banks, water, showers and public resources across the United States, Canada and Mexico.', 'survival map, dispersed camping map, boondocking map, homeless shelters near me, food banks near me, camping on public land', 'map'],
+ ['food', 'Budget Food Finder', 'Cheap groceries, meal plans and food banks', 'Plan meals around a tight budget: compare the cost of staple foods, build a week of meals that meets your nutrition needs for the least money, and find food banks near you.', 'cheap meal plan, budget grocery list, eat on a budget, food banks near me, cheapest healthy food, meal planner budget', 'food'],
+];
+
+const PASTES = [
+ ['pastes', 'Pastes', 'Share text and code snippets with a link', 'Pastes now live on OpenVibe.Community. Post text or code, get a short link to share, and let people comment. Existing paste links keep working.', 'pastebin, paste text online, share code snippet, text sharing', 'paste'],
+];
+
+function rows(family, list) {
+ return list.map(([id, name, tagline, description, keywords, icon]) => ({
+ id, family, name, tagline, description, icon,
+ keywords: keywords.split(',').map((k) => k.trim()).filter(Boolean),
+ }));
+}
+
+const SATELLITE_COPY = [
+ ...rows('img', IMG), ...rows('audio', AUDIO), ...rows('docs', DOCS), ...rows('text', TEXT),
+ ...rows('media', MEDIA), ...rows('places', PLACES), ...rows('pastes', PASTES),
+];
+
+module.exports = { SATELLITE_COPY };
new file mode 100644
new file mode 100644
@@ -0,0 +1,110 @@
@@ -0,0 +1,110 @@
+'use strict';
+
+// ═══════════════════════════════════════════════════════════════
+// Tool families — the sections of openvibe.tools.
+//
+// id stable key used by tools (`tool.family`) and by the Network launcher
+// path the family page on the apex (openvibe.tools/<path>)
+// hub the subdomain that serves the family's own hub app, when it has one
+// port the satellite that serves the family (null = this gateway)
+// ═══════════════════════════════════════════════════════════════
+
+const FAMILIES = [
+ {
+ id: 'net', name: 'Network Tools', icon: 'network', path: '/network-tools', hub: 'net', hubAliases: ['network'], port: null,
+ tagline: 'Look up DNS, IPs, domains, certificates and mail servers',
+ description: 'Diagnostics for anyone who runs a website, a mail server or a home network. Look up DNS records, find out who owns an IP address or a domain, check an SSL certificate, trace a route, test a port and see whether a site is reachable from other parts of the world.',
+ intro: 'These tools answer the questions that come up when something on the internet does not work: where does this domain point, why is mail bouncing, is the certificate about to expire, is the site down for everyone. Each one takes a domain, an IP address or a URL and gives a readable answer in a few seconds.',
+ faq: [
+ ['Do I need to install anything?', 'No. Every lookup runs from our servers and the result appears in your browser, so the tools work on a phone, a locked-down work laptop or a Chromebook.'],
+ ['Where do the lookups run from?', 'DNS, WHOIS and certificate checks run from the OpenVibe servers. Ping, traceroute and latency tests can run from several regions so you can compare results around the world.'],
+ ['Can I check a server inside my own network?', 'No. The checks come from the public internet, so the target has to be reachable from outside your network.'],
+ ],
+ },
+ {
+ id: 'dev', name: 'Developer Tools', icon: 'code', path: '/developer-tools', hub: 'dev', hubAliases: ['code', 'build', 'debug'], port: null,
+ tagline: 'Format, convert, encode and test data while you code',
+ description: 'Small utilities for everyday programming: format and validate JSON, YAML, XML and SQL, decode Base64 and JWTs, test regular expressions, compare two files, generate UUIDs and hashes, read cron expressions and convert timestamps.',
+ intro: 'Paste something in, get the answer, move on. Most of these tools do their work inside your browser, so the data you paste is not uploaded. The few that need the network, such as the Open Graph preview and the webhook inspector, say so on the page.',
+ faq: [
+ ['Is the data I paste sent to a server?', 'Formatters, encoders, generators and the diff tool run in your browser. Tools that have to fetch a URL for you make one request to that URL and nothing else.'],
+ ['Do the tools work offline?', 'Once a page has loaded, the browser-side tools keep working without a connection.'],
+ ['Is there a size limit?', 'Browser-side tools are limited only by your device memory. Documents of a few megabytes are fine.'],
+ ],
+ },
+ {
+ id: 'img', name: 'Image Tools', icon: 'image', path: '/image-tools', hub: 'img', hubAliases: [], port: 4012,
+ tagline: 'Convert, compress, resize and crop pictures',
+ description: 'Change an image from one format to another, make it smaller, resize it to exact pixel dimensions or crop it to a ratio. Works with PNG, JPG, WebP, AVIF, HEIC photos from iPhones, SVG, GIF, ICO, TIFF and BMP.',
+ intro: 'Pick the tool named after what you want to end up with: a PNG, a smaller file, a square crop, a favicon. Drop one or more pictures on the page, adjust the quality or size if you want to, and download the result.',
+ faq: [
+ ['Which image formats can I upload?', 'PNG, JPG, WebP, AVIF, HEIC and HEIF, SVG, GIF, ICO, TIFF and BMP are accepted by every image tool.'],
+ ['What happens to my pictures?', 'Files are processed on our server, kept only long enough for you to download the result, and then deleted.'],
+ ['Can I convert several images at once?', 'Yes. Drop a batch of files and download the results one by one or together.'],
+ ],
+ },
+ {
+ id: 'audio', name: 'Audio Tools', icon: 'audio', path: '/audio-tools', hub: 'audio', hubAliases: ['convert.audio'], port: 4014,
+ tagline: 'Convert, cut, clean up and add effects to sound files',
+ description: 'Convert recordings between MP3, WAV, FLAC, OGG, M4A and other formats, trim a clip, join files, change speed or pitch, remove background noise, make a ringtone or pull the soundtrack out of a video.',
+ intro: 'Upload a sound file (or a video, for the extractor), choose what to do with it and download the result. Format converters let you pick the bitrate; the editing tools show a preview before you commit.',
+ faq: [
+ ['Which audio formats are supported?', 'MP3, WAV, FLAC, OGG, M4A, AAC, Opus, WMA, AIFF and AC3, plus the audio track of common video files such as MP4, MKV and WebM.'],
+ ['Will converting reduce the quality?', 'Converting to a lossless format such as WAV or FLAC keeps everything. Converting to MP3, AAC, OGG or Opus discards some detail; pick a higher bitrate to keep more of it.'],
+ ['How large can a file be?', 'Uploads of up to 100 MB are accepted, which covers most songs, podcasts and voice recordings.'],
+ ],
+ },
+ {
+ id: 'docs', name: 'PDF & Document Tools', icon: 'pdf', path: '/pdf-tools', hub: 'docs', hubAliases: ['pdf'], port: 4016,
+ tagline: 'Merge, split, compress, protect and convert PDFs',
+ description: 'Everyday PDF jobs without desktop software: combine several PDFs into one, pull pages out, shrink a file so it fits in an email, rotate or reorder pages, add a watermark or a password, and turn pictures into a PDF or a PDF into pictures.',
+ intro: 'Each tool does one job. Upload your PDF or images, set the options, and download the new file. Your original is never changed.',
+ faq: [
+ ['Are my documents kept?', 'No. Files are processed, offered for download and then removed from the server.'],
+ ['Can I unlock a PDF without the password?', 'No. The unlock tool removes protection from a PDF when you know its password; it does not break into files.'],
+ ['Is there a page limit?', 'There is no page limit. The upload size limit is 100 MB per file.'],
+ ],
+ },
+ {
+ id: 'text', name: 'Text & Logo Tools', icon: 'text', path: '/text-tools', hub: 'text', hubAliases: ['type', 'fonts'], port: 4015,
+ tagline: 'Fancy fonts, text clean-up, counters, logos and banners',
+ description: 'Generators and fixers for words: fancy Unicode fonts for social profiles, case converters, word counters, line sorters, text comparison, Morse, Braille and binary translators, plus simple makers for logos, stream titles, thumbnails, badges and watermarks.',
+ intro: 'Type or paste your text and copy the result. Everything here works instantly in the browser. The logo and graphics makers export a PNG you can use on a stream, a channel or a profile.',
+ faq: [
+ ['How do the fancy fonts work?', 'They are not fonts. The generators swap your letters for look-alike Unicode characters, which is why the result can be pasted into a bio, a username or a chat message.'],
+ ['Will the styled text show up everywhere?', 'Almost every modern phone and browser displays these characters. A few older devices show empty boxes for the rarer styles.'],
+ ['Can I use the logos I make commercially?', 'Yes. What you make is yours. Check the licence of any font or image you add yourself.'],
+ ],
+ },
+ {
+ id: 'media', name: 'Video Tools (YouTube)', icon: 'youtube', path: '/video-tools', hub: null, hubAliases: [], port: 4013,
+ tagline: 'Save YouTube videos and audio',
+ description: 'Download a YouTube video as an MP4 in the resolution you choose, or keep only the sound as an MP3 or M4A file. Useful for watching offline, archiving your own uploads and saving talks or music you have the right to keep.',
+ intro: 'Paste a video link, choose video or audio and the quality, and the file is prepared on our server while a progress bar shows how far along it is.',
+ faq: [
+ ['Which sites are supported?', 'YouTube videos, Shorts and music links. Live streams can be saved once they have ended.'],
+ ['Is downloading from YouTube allowed?', 'Download only videos you own, videos in the public domain or under a licence that permits it, or where your local law allows a personal copy.'],
+ ['Why does a long video take a while?', 'The server fetches the video and the audio separately and joins them. A one-hour 1080p video usually takes a minute or two.'],
+ ],
+ },
+ {
+ id: 'places', name: 'Maps & Food', icon: 'map', path: '/maps-and-food', hub: null, hubAliases: [], port: null,
+ tagline: 'Find water, shelter, supplies and somewhere to eat',
+ description: 'Two practical maps. The survival map shows drinking water, public toilets, shelters, hospitals, fuel and other essentials near any place on earth. The food finder shows restaurants, cafes, markets and food banks around you.',
+ intro: 'Both tools use open map data. Allow location access or search for a town, then filter the map to the kind of place you need.',
+ faq: [
+ ['Where does the map data come from?', 'OpenStreetMap and other open datasets, maintained by volunteers around the world. Coverage is best in towns and cities.'],
+ ['Do I have to share my location?', 'No. You can search for any place by name instead.'],
+ ],
+ },
+ {
+ id: 'pastes', name: 'Pastes', icon: 'paste', path: null, hub: null, hubAliases: [], port: null, external: 'https://openvibe.community/',
+ tagline: 'Share text and code snippets with a link',
+ description: 'Pastes now live on OpenVibe.Community. Post a snippet of text or code, get a short link, and let people comment on it.',
+ intro: '', faq: [],
+ },
+];
+
+const FAMILY_MAP = new Map(FAMILIES.map((f) => [f.id, f]));
+
+module.exports = { FAMILIES, FAMILY_MAP };
new file mode 100644
new file mode 100644
@@ -0,0 +1,65 @@
@@ -0,0 +1,65 @@
+'use strict';
+// Host roles at the door (shared-contracts §1). Runs before body parsing so proxied uploads and
+// SSE streams pass through untouched.
+// alias → 301 short host (same path)
+// unknown *.openvibe.tools → 301 https://openvibe.tools/ (no made-up brands)
+// unknown custom domain → 404
+// tool/family on a satellite → streamed to the satellite with X-OV-* headers
+// everything else → req.ovHost for the gateway's own pages
+const http = require('http');
+const registry = require('./index');
+
+const HOP = new Set(['connection', 'keep-alive', 'proxy-authenticate', 'proxy-authorization', 'te', 'trailer', 'transfer-encoding', 'upgrade']);
+
+function proxyTo(port, info, req, res) {
+ const headers = {};
+ for (const [k, v] of Object.entries(req.headers)) if (!HOP.has(k) && !k.startsWith('x-ov-')) headers[k] = v;
+ headers['x-ov-tool'] = info.tool;
+ headers['x-ov-host-role'] = info.role;
+ headers['x-ov-canonical-host'] = info.canonicalHost;
+ headers['x-ov-short-host'] = info.shortHost;
+ headers['x-forwarded-host'] = info.host;
+ headers['x-forwarded-proto'] = 'https';
+ const prior = req.headers['x-forwarded-for'];
+ headers['x-forwarded-for'] = (prior ? prior + ', ' : '') + (req.socket.remoteAddress || '');
+ const up = http.request({ host: '127.0.0.1', port, method: req.method, path: req.originalUrl || req.url, headers }, (r) => {
+ const out = {};
+ for (const [k, v] of Object.entries(r.headers)) if (!HOP.has(k)) out[k] = v;
+ res.writeHead(r.statusCode || 502, out);
+ r.pipe(res);
+ });
+ up.setTimeout(10 * 60_000, () => up.destroy(new Error('upstream timeout')));
+ up.on('error', () => { if (!res.headersSent) res.status(502).type('text/plain').send('This tool is restarting. Try again in a moment.'); else res.destroy(); });
+ res.on('close', () => up.destroy());
+ req.pipe(up);
+}
+
+/**
+ * @param {object} opts
+ * @param {(host: string) => string} opts.hostOf request → hostname
+ * @param {(sub: string) => boolean} opts.isLegacyHost gateway-served subdomains that are not catalog tools (aliases, pastes…)
+ * @param {boolean} opts.enforce false in development: unknown hosts fall through
+ */
+function hostRoles(opts) {
+ return function ovHostRoles(req, res, next) {
+ const host = opts.hostOf(req);
+ const info = registry.resolveHost(host);
+ req.ovHost = info;
+ if (info.kind === 'apex') return next();
+ if (info.kind === 'unknown') {
+ if (!opts.enforce) return next();
+ if (!info.inZone) return res.status(404).type('text/plain').send('Not an OpenVibe site.');
+ if (opts.isLegacyHost(info.host.slice(0, -('.' + registry.ZONE).length))) return next();
+ res.set('Cache-Control', 'public, max-age=300');
+ return res.redirect(301, `https://${registry.APEX}/`);
+ }
+ if (info.role === 'alias') {
+ res.set('Cache-Control', 'public, max-age=300');
+ return res.redirect(301, `https://${info.shortHost}${req.originalUrl || '/'}`);
+ }
+ if (info.port) return proxyTo(info.port, info, req, res);
+ next();
+ };
+}
+
+module.exports = { hostRoles, proxyTo };
new file mode 100644
new file mode 100644
@@ -0,0 +1,141 @@
@@ -0,0 +1,141 @@
+'use strict';
+// ═══════════════════════════════════════════════════════════════
+// The tool registry (docs: OpenVibe.Network/docs/shared-contracts.md §1).
+//
+// One list of every tool and family on openvibe.tools, each with its hosts:
+// canonical the host links, sitemaps and rel=canonical use (may be a custom domain)
+// short the host a person types — serves 200, canonical points at the canonical host
+// aliases 301 → short (or canonical when there is no short)
+// Code defaults below ← owner overrides from https://openvibe.network/api/domains (60 s refresh,
+// last good copy kept). Anything else under *.openvibe.tools is not a tool: the gateway sends it
+// to the index instead of rendering a made-up brand.
+// ═══════════════════════════════════════════════════════════════
+const { FAMILIES } = require('./families');
+const { SATELLITE_COPY } = require('./copy-satellites');
+const { CATALOG: SEO } = require('../seo/catalog');
+
+const ZONE = 'openvibe.tools';
+const APEX = ZONE;
+const DOMAINS_URL = process.env.OV_DOMAINS_URL || 'https://openvibe.network/api/domains';
+const HOST_RE = /^(?=.{1,253}$)([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/;
+
+// Human-first defaults: the descriptive host is canonical, the one people already type is short.
+const HOST_DEFAULTS = {
+ yt: { canonical: 'youtube-downloader', short: 'yt', aliases: ['youtube', 'ytdl', 'youtubedownloader', 'youtube-download'] },
+};
+const FAMILY_ICON = { net: 'dns', dev: 'code' };
+const NET_ICONS = { ip: 'ip', myip: 'ip', dns: 'dns', whois: 'whois', ssl: 'ssl', ping: 'ping', traceroute: 'ping' };
+
+/** 'yt' → 'yt.openvibe.tools'; a full hostname is kept as it is. */
+const toHost = (h) => { const s = String(h || '').trim().toLowerCase(); return s.includes('.') && HOST_RE.test(s) ? s : `${s}.${ZONE}`; };
+const firstSentence = (s, max = 60) => { const t = String(s || '').split(/(?<=[.!?])\s/)[0].replace(/\.$/, ''); return t.length <= max ? t : t.slice(0, max).replace(/\s+\S*$/, ''); };
+
+function baseTools() {
+ const out = [];
+ for (const rec of SEO.values()) {
+ if (rec.hub) continue;
+ out.push({
+ id: rec.sub, family: rec.family, name: rec.name, tagline: firstSentence(rec.desc), description: rec.about || rec.desc,
+ keywords: String(rec.kw || '').split(',').map(k => k.trim()).filter(Boolean),
+ icon: (rec.family === 'net' && NET_ICONS[rec.sub]) || (rec.sub.includes('json') ? 'json' : FAMILY_ICON[rec.family]) || 'tools',
+ });
+ }
+ for (const t of SATELLITE_COPY) out.push({ ...t });
+ return out;
+}
+
+const familyById = new Map(FAMILIES.map(f => [f.id, f]));
+let overrides = []; // [{ tool_id, host, role }]
+let overridesAt = 0;
+let built = null; // { tools, families, byHost, updated }
+
+function build() {
+ const byHost = new Map();
+ const claim = (host, entry) => { if (host && !byHost.has(host)) byHost.set(host, entry); };
+ const ov = new Map(); // tool_id → { canonical, short, aliases[] }
+ for (const o of overrides) {
+ const e = ov.get(o.tool_id) || { aliases: [] }; ov.set(o.tool_id, e);
+ if (o.role === 'canonical') e.canonical = o.host; else if (o.role === 'short') e.short = o.host; else e.aliases.push(o.host);
+ }
+ const hostsFor = (id, def) => {
+ const d = def || {}; const o = ov.get(id) || { aliases: [] };
+ const canonical = o.canonical || toHost(d.canonical || id);
+ let short = o.short || (d.short ? toHost(d.short) : null);
+ const aliases = new Set([...(d.aliases || []).map(toHost), ...o.aliases]);
+ // A host that lost its role to an override still works: it becomes an alias.
+ const defCanon = toHost(d.canonical || id);
+ if (o.canonical && defCanon !== canonical) { if (!short) short = defCanon; else aliases.add(defCanon); }
+ if (o.short && d.short && toHost(d.short) !== short) aliases.add(toHost(d.short));
+ if (short === canonical) short = null;
+ aliases.delete(canonical); if (short) aliases.delete(short);
+ return { canonical, short, aliases: [...aliases] };
+ };
+
+ const tools = baseTools().map(t => {
+ const fam = familyById.get(t.family);
+ const hosts = hostsFor(t.id, HOST_DEFAULTS[t.id]);
+ const external = t.id === 'pastes' ? 'https://openvibe.community/pastes' : null;
+ return { ...t, hosts, url: external || `https://${hosts.canonical}/`, port: fam ? fam.port : null, external };
+ });
+ const families = FAMILIES.map(f => {
+ const hosts = f.hub ? hostsFor(f.id, { canonical: f.hub, aliases: f.hubAliases }) : null;
+ return { ...f, hosts, url: f.external || (hosts ? `https://${hosts.canonical}/` : `https://${APEX}${f.path}`), page: f.path ? `https://${APEX}${f.path}` : null };
+ });
+ for (const list of [tools, families]) for (const x of list) {
+ if (!x.hosts || x.external) continue;
+ const kind = list === tools ? 'tool' : 'family';
+ claim(x.hosts.canonical, { kind, role: 'canonical', item: x });
+ 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() };
+ return built;
+}
+
+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 };
+ const hit = get().byHost.get(host);
+ const inZone = host.endsWith('.' + ZONE);
+ if (!hit) return { kind: 'unknown', host, inZone };
+ const h = hit.item.hosts;
+ return { kind: hit.kind, role: hit.role, item: hit.item, tool: hit.item.id, host, inZone, canonicalHost: h.canonical, shortHost: h.short || h.canonical, port: hit.item.port || null };
+}
+
+/** The public catalog (GET /api/catalog.json). */
+function catalog() {
+ const r = get();
+ 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 })),
+ 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}` })),
+ };
+}
+
+function setOverrides(rows) {
+ const known = new Set([...baseTools().map(t => t.id), ...FAMILIES.map(f => f.id)]);
+ const seen = new Set();
+ overrides = (Array.isArray(rows) ? rows : []).filter(o => o && known.has(o.tool_id) && HOST_RE.test(String(o.host || '')) && ['canonical', 'short', 'alias'].includes(o.role) && o.host !== APEX && !seen.has(o.host) && seen.add(o.host))
+ .map(o => ({ tool_id: o.tool_id, host: String(o.host).toLowerCase(), role: o.role }));
+ build();
+}
+
+async function refresh(fetchImpl = fetch) {
+ try {
+ const ac = new AbortController(); const t = setTimeout(() => ac.abort(), 4000);
+ const r = await fetchImpl(DOMAINS_URL, { signal: ac.signal, headers: { accept: 'application/json' } }); clearTimeout(t);
+ if (!r.ok) throw new Error('HTTP ' + r.status);
+ const j = await r.json();
+ const next = JSON.stringify(j.domains || []);
+ if (next !== JSON.stringify(overrides.map(o => ({ tool_id: o.tool_id, host: o.host, role: o.role }))) || !overridesAt) setOverrides(j.domains || []);
+ overridesAt = Date.now();
+ return true;
+ } catch { return false; /* keep the last good copy */ }
+}
+
+function start() { refresh(); const t = setInterval(refresh, 60_000); t.unref(); return t; }
+
+module.exports = { get, catalog, resolveHost, setOverrides, refresh, start, ZONE, APEX, FAMILIES };
@@ -97,13 +97,14 @@ function contentBlock(rec, related) {
@@ -97,13 +97,14 @@ function contentBlock(rec, related) {
* Render the SPA file for one subdomain with its own metadata and content.
* Render the SPA file for one subdomain with its own metadata and content.
* Returns the original HTML untouched when the subdomain is not catalogued.
* Returns the original HTML untouched when the subdomain is not catalogued.
*/
*/
-function renderTool(file, sub) {
+function renderTool(file, sub, canonicalHost) {
const rec = seoFor(sub);
const rec = seoFor(sub);
if (!rec) return baseHtml(file);
if (!rec) return baseHtml(file);
- const key = `${file}::${sub}`;
+ const key = `${file}::${sub}::${canonicalHost || ''}`;
if (_rendered.has(key)) return _rendered.get(key);
if (_rendered.has(key)) return _rendered.get(key);
- const url = `https://${sub}.openvibe.tools`;
+ // The registry decides the canonical host (a custom domain may own the tool).
+ const url = `https://${canonicalHost || sub + '.openvibe.tools'}`;
const related = relatedTo(sub, 6);
const related = relatedTo(sub, 6);
const title = `${rec.title} | ${BRAND}`;
const title = `${rec.title} | ${BRAND}`;
let html = baseHtml(file);
let html = baseHtml(file);
new file mode 100644
new file mode 100644
@@ -0,0 +1,36 @@
@@ -0,0 +1,36 @@
+'use strict';
+const assert = require('assert');
+const registry = require('../server/registry');
+const site = require('../server/pages/site');
+
+const c = registry.catalog();
+assert.ok(c.tools.length > 100 && c.families.length >= 8, 'catalog is populated');
+assert.equal(new Set(c.tools.map(t => t.id)).size, c.tools.length, 'tool ids are unique');
+for (const t of c.tools) { assert.ok(t.name && t.tagline && t.description && t.icon && t.hosts.canonical, `complete record: ${t.id}`); assert.ok(!/\b(free|\$0|no ads|ad-free)\b/i.test(t.tagline), `no cost claims: ${t.id}`); }
+
+const r = (h) => registry.resolveHost(h);
+assert.equal(r('openvibe.tools').kind, 'apex');
+assert.equal(r('www.openvibe.tools').kind, 'apex');
+assert.deepEqual([r('yt.openvibe.tools').role, r('youtube.openvibe.tools').role, r('youtube-downloader.openvibe.tools').role], ['short', 'alias', 'canonical']);
+assert.equal(r('yt.openvibe.tools').port, 4013);
+assert.equal(r('poopy.openvibe.tools').kind, 'unknown', 'made-up subdomains are not tools');
+assert.equal(r('poopy.openvibe.tools').inZone, true);
+assert.equal(r('evil.example').inZone, false);
+
+// Owner overrides: a custom domain becomes canonical, the old canonical keeps working as an alias.
+registry.setOverrides([{ tool_id: 'yt', host: 'youtubedownloadonline.com', role: 'canonical' }, { tool_id: 'nope', host: 'x.com', role: 'alias' }, { tool_id: 'yt', host: 'openvibe.tools', role: 'alias' }, { tool_id: 'yt', host: 'bad host', role: 'alias' }]);
+const yt = registry.catalog().tools.find(t => t.id === 'yt');
+assert.equal(yt.hosts.canonical, 'youtubedownloadonline.com');
+assert.ok(yt.hosts.aliases.includes('youtube-downloader.openvibe.tools'));
+assert.equal(r('youtubedownloadonline.com').role, 'canonical');
+assert.equal(r('x.com').kind, 'unknown', 'override for an unknown tool is ignored');
+assert.equal(r('openvibe.tools').kind, 'apex', 'the apex cannot be claimed');
+assert.ok(site.sitemapEntries().some(u => u.loc === 'https://youtubedownloadonline.com/'), 'sitemap uses the canonical host');
+assert.ok(!site.sitemapEntries().some(u => u.loc === 'https://yt.openvibe.tools/'), 'short hosts stay out of the sitemap');
+registry.setOverrides([]);
+assert.equal(registry.catalog().tools.find(t => t.id === 'yt').hosts.canonical, 'youtube-downloader.openvibe.tools');
+
+assert.equal(site.search('merge pdf')[0].id, 'mergepdf');
+assert.equal(site.search('youtube to mp3')[0].id, 'yt');
+assert.deepEqual(site.search(''), []);
+console.log('registry + site: all checks passed');
@@ -3,22 +3,7 @@
@@ -3,22 +3,7 @@
<head>
<head>
<meta charset="UTF-8">
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
- <title>YT.OpenVibe — YouTube Downloader</title>
- <meta name="description" content="Download YouTube videos and audio. MP4, WebM, MP3, M4A, OPUS, FLAC. No sign-up required.">
- <link rel="canonical" href="https://yt.openvibe.tools/">
- <meta name="robots" content="index, follow, max-image-preview:large, max-snippet:-1">
- <meta property="og:title" content="YT.OpenVibe — YouTube Downloader">
- <meta property="og:description" content="Download YouTube videos and audio. MP4, WebM, MP3, M4A, OPUS, FLAC. No sign-up required.">
- <meta property="og:type" content="website">
- <meta property="og:url" content="https://yt.openvibe.tools/">
- <meta property="og:image" content="https://yt.openvibe.tools/og.png">
- <meta property="og:image:width" content="1200">
- <meta property="og:image:height" content="630">
- <meta property="og:site_name" content="OpenVibe">
- <meta name="twitter:card" content="summary_large_image">
- <meta name="twitter:title" content="YT.OpenVibe — YouTube Downloader">
- <meta name="twitter:description" content="Download YouTube videos and audio. MP4, WebM, MP3, M4A, OPUS, FLAC. No sign-up required.">
- <meta name="twitter:image" content="https://yt.openvibe.tools/og.png">
+ <!--OV:HEAD-->
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<!-- Critical theme bootstrap (ov-theme-boot) — MUST stay inline and above the stylesheets.
<!-- Critical theme bootstrap (ov-theme-boot) — MUST stay inline and above the stylesheets.
@@ -116,6 +101,7 @@
@@ -116,6 +101,7 @@
</div>
</div>
<!-- Footer -->
<!-- Footer -->
+ <!--OV:CONTENT-->
<div id="ov-footer"></div>
<div id="ov-footer"></div>
<script src="https://openvibe.network/shared/theme-loader.js"></script>
<script src="https://openvibe.network/shared/theme-loader.js"></script>
@@ -123,6 +109,9 @@
@@ -123,6 +109,9 @@
<script src="https://openvibe.network/shared/footer.js"></script>
<script src="https://openvibe.network/shared/footer.js"></script>
<script src="https://openvibe.network/shared/notification-ui.js"></script>
<script src="https://openvibe.network/shared/notification-ui.js"></script>
<script src="https://openvibe.network/shared/account-switcher.js"></script>
<script src="https://openvibe.network/shared/account-switcher.js"></script>
+ <script src="https://openvibe.network/shared/ov-icons.js" defer></script>
+ <script src="https://openvibe.network/shared/island.js" defer></script>
+ <script src="/js/download-state.js"></script>
<script src="/js/app.js"></script>
<script src="/js/app.js"></script>
</body>
</body>
</html>
</html>
@@ -203,142 +203,117 @@
@@ -203,142 +203,117 @@
}
}
// --- Start Download ---
// --- Start Download ---
+ // One reducer (download-state.js) turns SSE frames and polls into view state; render() paints it
+ // on the page and mirrors it into the navbar activity island when that module is present.
+ const DS = window.YTDownloadState;
+ let dlState = null, pollTimer = null, watchdog = null, tracking = null;
+ const island = () => window.OpenVibeIsland || null;
+
async function startDownload() {
async function startDownload() {
if (!videoInfo) return;
if (!videoInfo) return;
-
- hideAll();
- show(videoCard);
- show(progressSec);
- progressBar.style.width = '0%';
- progressBar.classList.remove('indeterminate');
- progressStats.textContent = '';
- progressTitle.textContent = 'Starting download...';
+ hideAll(); show(videoCard); show(progressSec);
downloadBtn.disabled = true;
downloadBtn.disabled = true;
+ stopTracking();
+ dlState = DS.initial({ title: videoInfo.title || '' });
+ render();
try {
try {
const res = await fetch('/api/download', {
const res = await fetch('/api/download', {
- method: 'POST',
- headers: getAuthHeaders(),
- body: JSON.stringify({
- url: urlInput.value.trim(),
- quality: selectedQuality,
- }),
+ method: 'POST', headers: getAuthHeaders(),
+ body: JSON.stringify({ url: urlInput.value.trim(), quality: selectedQuality, title: videoInfo.title || '' }),
});
});
- const data = await res.json();
-
- if (!res.ok) {
- showError(data.error || 'Failed to start download');
- downloadBtn.disabled = false;
- return;
- }
-
+ const data = await res.json().catch(() => ({}));
+ if (!res.ok || !data.id) { fail(data.error || 'Failed to start download'); return; }
downloadId = data.id;
downloadId = data.id;
+ const isl = island();
+ if (isl) isl.start({ id: 'yt-' + data.id, title: 'Starting download', subtitle: videoInfo.title || '', icon: 'youtube', image: videoInfo.thumbnail || '', progress: null, state: 'busy',
+ actions: [{ label: 'Cancel', onClick: () => cancelDownload(data.id) }] });
subscribeProgress(data.id);
subscribeProgress(data.id);
} catch (err) {
} catch (err) {
- showError('Network error — please check your connection');
- downloadBtn.disabled = false;
+ fail('Network error. Please check your connection.');
}
}
}
}
- // --- SSE Progress ---
- function subscribeProgress(id) {
- if (sseSource) { sseSource.close(); sseSource = null; }
-
- sseSource = new EventSource(`/api/status/${id}/stream`);
-
- sseSource.addEventListener('progress', (e) => {
- try {
- const d = JSON.parse(e.data);
- updateProgress(d);
- } catch {}
- });
-
- sseSource.addEventListener('complete', (e) => {
- try {
- const d = JSON.parse(e.data);
- onDownloadComplete(d);
- } catch {}
- closeSse();
- });
-
- sseSource.addEventListener('error_event', (e) => {
- try {
- const d = JSON.parse(e.data);
- showError(d.error || 'Download failed');
- } catch {
- showError('Download failed unexpectedly');
- }
- closeSse();
- downloadBtn.disabled = false;
- });
+ function fail(msg) { apply({ status: 'error', error: msg }); }
- sseSource.onerror = () => {
- // SSE disconnected — fall back to polling
- closeSse();
- pollFallback(id);
- };
+ function apply(msg) {
+ if (!dlState) return;
+ const before = dlState;
+ dlState = DS.reduce(dlState, msg);
+ if (dlState !== before) render();
+ if (dlState.terminal) stopTracking();
}
}
- function closeSse() {
- if (sseSource) { sseSource.close(); sseSource = null; }
- }
-
- function updateProgress(d) {
- progressTitle.textContent = 'Downloading...';
-
- if (d.progress != null && d.progress >= 0) {
- progressBar.classList.remove('indeterminate');
- progressBar.style.width = d.progress + '%';
+ function subscribeProgress(id) {
+ tracking = id;
+ const startedAt = Date.now();
+ let gotFrame = false;
+ const onFrame = (e) => { if (tracking !== id) return; gotFrame = true; try { apply(Object.assign({ id }, JSON.parse(e.data))); } catch { /* */ } };
+ if (window.EventSource) {
+ sseSource = new EventSource(`/api/status/${id}/stream`);
+ // Named events carry the same JSON as the unnamed message; the reducer is idempotent.
+ sseSource.onmessage = onFrame;
+ ['progress', 'complete', 'failed'].forEach((n) => sseSource.addEventListener(n, onFrame));
+ sseSource.onerror = () => { if (tracking === id && !(dlState && dlState.terminal)) { closeSse(); poll(id, startedAt); } };
+ // A proxy that buffers the stream would leave us silent: start polling after 4 s without a frame.
+ watchdog = setTimeout(() => { if (!gotFrame && tracking === id) { closeSse(); poll(id, startedAt); } }, 4000);
} else {
} else {
- progressBar.classList.add('indeterminate');
+ poll(id, startedAt);
}
}
-
- const parts = [];
- if (d.progress != null && d.progress >= 0) parts.push(d.progress.toFixed(1) + '%');
- if (d.speed) parts.push(d.speed);
- if (d.eta) parts.push('ETA ' + d.eta);
- if (d.filesize) parts.push(formatSize(d.filesize));
- progressStats.textContent = parts.join(' • ');
}
}
- function onDownloadComplete(d) {
- hideAll();
- show(doneSec);
+ function poll(id, startedAt) {
+ if (tracking !== id || pollTimer) return;
+ const step = async () => {
+ pollTimer = null;
+ if (tracking !== id) return;
+ try {
+ const res = await fetch(`/api/status/${id}`, { headers: getAuthHeaders(), cache: 'no-store' });
+ if (res.status === 404) return fail('Download not found or expired');
+ if (res.ok) apply(Object.assign({ id }, await res.json()));
+ } catch { /* transient: keep trying until the deadline */ }
+ if (tracking !== id) return;
+ const delay = DS.pollDelay(Date.now() - startedAt);
+ if (delay == null) return fail('The download took too long. Please try again.');
+ pollTimer = setTimeout(step, delay);
+ };
+ step();
+ }
- const parts = [];
- if (d.filename) parts.push(d.filename);
- if (d.filesize) parts.push(formatSize(d.filesize));
- doneInfo.textContent = parts.join(' — ') || 'Your file is ready';
+ function closeSse() { if (sseSource) { sseSource.close(); sseSource = null; } }
+ function stopTracking() { tracking = null; closeSse(); clearTimeout(pollTimer); pollTimer = null; clearTimeout(watchdog); watchdog = null; }
- saveBtn.href = `/api/download/${downloadId}`;
- saveBtn.download = d.filename || '';
- downloadBtn.disabled = false;
+ async function cancelDownload(id) {
+ try { await fetch(`/api/download/${id}`, { method: 'DELETE', headers: getAuthHeaders() }); } catch { /* */ }
+ if (tracking === id) apply({ status: 'error', error: 'Cancelled', cancelled: true });
}
}
- // --- Polling Fallback (if SSE drops) ---
- function pollFallback(id) {
- const interval = setInterval(async () => {
- try {
- const res = await fetch(`/api/status/${id}`, { headers: getAuthHeaders() });
- if (!res.ok) { clearInterval(interval); showError('Download failed'); return; }
- const d = await res.json();
-
- if (d.status === 'downloading') {
- updateProgress(d);
- } else if (d.status === 'complete') {
- clearInterval(interval);
- onDownloadComplete(d);
- } else if (d.status === 'error') {
- clearInterval(interval);
- showError(d.error || 'Download failed');
- downloadBtn.disabled = false;
- }
- } catch {
- clearInterval(interval);
- showError('Lost connection');
- downloadBtn.disabled = false;
- }
- }, 1000);
+ function render() {
+ const s = dlState; if (!s) return;
+ const isl = island(); const iid = downloadId ? 'yt-' + downloadId : null;
+ if (s.phase === 'done') {
+ hideAll(); show(doneSec);
+ const parts = [];
+ if (s.file.filename) parts.push(s.file.filename);
+ if (s.file.size) parts.push(formatSize(s.file.size));
+ doneInfo.textContent = parts.join(' · ') || 'Your file is ready';
+ saveBtn.href = s.file.url || `/api/download/${downloadId}`;
+ saveBtn.download = s.file.filename || '';
+ downloadBtn.disabled = false;
+ if (isl && iid) isl.finish(iid, { state: 'ok', title: 'Ready', subtitle: s.title, detail: parts.join(' · '), actions: [{ label: 'Save file', href: saveBtn.href, download: true }], ttl: 15000 });
+ return;
+ }
+ if (s.phase === 'error') {
+ downloadBtn.disabled = false;
+ if (s.cancelled) { hideAll(); show(videoCard); } else showError(s.error);
+ if (isl && iid) { if (s.cancelled) isl.finish(iid, { state: 'info', title: 'Cancelled', ttl: 2500 }); else isl.fail(iid, { title: 'Download failed', detail: s.error }); }
+ return;
+ }
+ progressTitle.textContent = DS.label(s);
+ if (s.progress == null) { progressBar.classList.add('indeterminate'); progressBar.style.width = ''; }
+ else { progressBar.classList.remove('indeterminate'); progressBar.style.width = (s.progress * 100).toFixed(1) + '%'; }
+ progressStats.textContent = s.detail;
+ if (isl && iid) isl.update(iid, { title: DS.label(s).replace('…', ''), progress: s.progress, detail: s.detail });
}
}
// --- Reset ---
// --- Reset ---
new file mode 100644
new file mode 100644
@@ -0,0 +1,129 @@
@@ -0,0 +1,129 @@
+/* YT.OpenVibe — download state reducer.
+ *
+ * One function turns every status message into view state, whether it arrived as a named SSE
+ * event (progress / complete / failed), an unnamed SSE message, or a poll of /api/status/:id.
+ * The wire format is the same JSON everywhere:
+ * { status: 'downloading'|'done'|'error', phase, progress: 0–100|null, speed, eta, error,
+ * cancelled, title, download: { url, size, ext, filename } }
+ * Pure and DOM-free so it runs under `node --test` as well as in the page.
+ */
+(function (root, factory) {
+ if (typeof module === 'object' && module.exports) module.exports = factory();
+ else root.YTDownloadState = factory();
+})(typeof self !== 'undefined' ? self : this, function () {
+ 'use strict';
+
+ var POLL_MAX_MS = 10 * 60 * 1000;
+
+ function initial(extra) {
+ var s = {
+ phase: 'starting', // starting | downloading | processing | done | error
+ progress: null, // 0–1, or null while unknown (indeterminate bar)
+ percent: null, // 0–100 for display
+ speed: '',
+ eta: '',
+ detail: '',
+ title: '',
+ file: null, // { url, size, ext, filename } once done
+ error: null,
+ cancelled: false,
+ terminal: false,
+ };
+ if (extra) for (var k in extra) s[k] = extra[k];
+ return s;
+ }
+
+ function clean(v) {
+ var t = String(v == null ? '' : v).trim();
+ return /^(n\/?a|unknown|none)?$/i.test(t) ? '' : t;
+ }
+
+ function detailLine(s) {
+ var parts = [];
+ if (s.phase === 'processing') return 'Converting…';
+ if (s.percent != null) parts.push(s.percent.toFixed(1) + '%');
+ if (s.speed) parts.push(s.speed);
+ if (s.eta) parts.push('ETA ' + s.eta);
+ return parts.join(' · ');
+ }
+
+ /**
+ * @param {object} state previous state (from initial() or reduce())
+ * @param {object} msg a status payload, or { status: 'error', error } made up by the client
+ * @returns {object} next state — the same object when nothing may change any more
+ */
+ function reduce(state, msg) {
+ if (!state) state = initial();
+ if (state.terminal || !msg || typeof msg !== 'object') return state;
+
+ var next = {};
+ for (var k in state) next[k] = state[k];
+ if (msg.title) next.title = String(msg.title);
+
+ // 'complete' is what the first client waited for and the server never said; accept both.
+ var status = msg.status === 'complete' ? 'done' : msg.status;
+ if (!status && msg.error) status = 'error';
+
+ if (status === 'done') {
+ var d = msg.download || {};
+ next.phase = 'done';
+ next.progress = 1;
+ next.percent = 100;
+ next.speed = '';
+ next.eta = '';
+ next.file = {
+ url: d.url || (msg.id ? '/api/download/' + msg.id : ''),
+ size: d.size || 0,
+ ext: d.ext || '',
+ filename: d.filename || '',
+ };
+ next.terminal = true;
+ } else if (status === 'error') {
+ next.phase = 'error';
+ next.cancelled = !!msg.cancelled;
+ next.error = clean(msg.error) || 'Download failed';
+ next.speed = '';
+ next.eta = '';
+ next.terminal = true;
+ } else if (status === 'downloading') {
+ var p = typeof msg.progress === 'number' && isFinite(msg.progress) ? Math.max(0, Math.min(100, msg.progress)) : null;
+ // Never run backwards, and never drop back to "unknown" once a figure was shown.
+ if (p != null && (next.percent == null || p > next.percent)) next.percent = p;
+ next.progress = next.percent == null ? null : next.percent / 100;
+ next.speed = clean(msg.speed);
+ next.eta = clean(msg.eta);
+ if (msg.phase === 'processing') next.phase = 'processing';
+ else if (next.percent != null && next.percent > 0) next.phase = next.phase === 'processing' ? 'processing' : 'downloading';
+ else if (next.phase !== 'processing') next.phase = msg.phase === 'downloading' ? 'downloading' : 'starting';
+ // Converting has no percentage of its own: the bar goes indeterminate again.
+ if (next.phase === 'processing') next.progress = null;
+ } else {
+ return state;
+ }
+
+ next.detail = detailLine(next);
+ return next;
+ }
+
+ /** Headline for a state. */
+ function label(s) {
+ switch (s.phase) {
+ case 'starting': return 'Starting download…';
+ case 'downloading': return 'Downloading…';
+ case 'processing': return 'Converting…';
+ case 'done': return 'Ready';
+ case 'error': return s.cancelled ? 'Cancelled' : 'Download failed';
+ default: return '';
+ }
+ }
+
+ /** Poll delay: every second for the first ten seconds, easing out to three. null = give up. */
+ function pollDelay(elapsedMs) {
+ if (elapsedMs >= POLL_MAX_MS) return null;
+ if (elapsedMs < 10000) return 1000;
+ if (elapsedMs < 20000) return 2000;
+ return 3000;
+ }
+
+ return { initial: initial, reduce: reduce, label: label, pollDelay: pollDelay, POLL_MAX_MS: POLL_MAX_MS };
+});
@@ -105,6 +105,7 @@ function getInfo(url) {
@@ -105,6 +105,7 @@ function getInfo(url) {
}
}
try {
try {
const info = JSON.parse(stdout);
const info = JSON.parse(stdout);
+ rememberTitle(cleanUrl, info.title);
resolve({
resolve({
id: info.id,
id: info.id,
title: info.title,
title: info.title,
@@ -158,28 +159,134 @@ function friendlyDownloadError(line) {
@@ -158,28 +159,134 @@ function friendlyDownloadError(line) {
if (/Sign in to confirm|not a bot|cookies/i.test(t)) return 'YouTube is asking this server to prove it is not a bot — try again in a few minutes';
if (/Sign in to confirm|not a bot|cookies/i.test(t)) return 'YouTube is asking this server to prove it is not a bot — try again in a few minutes';
if (/Private video|members-only|login required/i.test(t)) return 'This video is private or members-only';
if (/Private video|members-only|login required/i.test(t)) return 'This video is private or members-only';
if (/Video unavailable|has been removed|not available/i.test(t)) return 'This video is unavailable';
if (/Video unavailable|has been removed|not available/i.test(t)) return 'This video is unavailable';
- if (/age|confirm your age/i.test(t)) return 'Age-restricted videos cannot be downloaded';
+ if (/\bage[- ]restrict|confirm your age/i.test(t)) return 'Age-restricted videos cannot be downloaded';
if (/ffmpeg|ffprobe|Postprocessing/i.test(t)) return `Conversion failed on the server (${t.slice(0, 120)})`;
if (/ffmpeg|ffprobe|Postprocessing/i.test(t)) return `Conversion failed on the server (${t.slice(0, 120)})`;
if (/Requested format is not available/i.test(t)) return 'That quality is not available for this video — try another';
if (/Requested format is not available/i.test(t)) return 'That quality is not available for this video — try another';
if (/HTTP Error 4\d\d/i.test(t)) return `YouTube refused the request (${t.match(/HTTP Error \d+/)[0]})`;
if (/HTTP Error 4\d\d/i.test(t)) return `YouTube refused the request (${t.match(/HTTP Error \d+/)[0]})`;
return t.replace(/^ERROR:\s*/i, '').slice(0, 160) || 'Download failed';
return t.replace(/^ERROR:\s*/i, '').slice(0, 160) || 'Download failed';
}
}
+// ── File names ───────────────────────────────────────────────
+/**
+ * A video title as a file name people recognise: no path separators, control characters or
+ * characters Windows refuses, collapsed whitespace, no trailing dots, bounded length.
+ */
+function sanitizeTitle(title) {
+ let t = String(title == null ? '' : title).normalize('NFC');
+ t = t.replace(/[\u0000-\u001f\u007f<>:"/\\|?*]+/g, ' ');
+ t = t.replace(/\s+/g, ' ').trim();
+ t = t.replace(/^[.\s]+|[.\s]+$/g, '');
+ if (t.length > 120) t = t.slice(0, 120).trim();
+ if (/^(con|prn|aux|nul|com\d|lpt\d)$/i.test(t)) t = `${t}_`;
+ return t;
+}
+
+function downloadFilename(title, ext) {
+ const base = sanitizeTitle(title) || 'youtube-video';
+ return `${base}.${ext || 'mp4'}`;
+}
+
+/** Content-Disposition with an ASCII fallback and the real name as RFC 5987 filename*. */
+function contentDisposition(filename) {
+ const ascii = String(filename).normalize('NFKD').replace(/[^\x20-\x7e]/g, '').replace(/["\\]/g, '').replace(/\s+/g, ' ').trim();
+ const ext = path.extname(String(filename));
+ const fallback = ascii && ascii !== ext ? ascii : `youtube-video${ext}`;
+ const encoded = encodeURIComponent(filename).replace(/['()*]/g, c => `%${c.charCodeAt(0).toString(16).toUpperCase()}`);
+ return `attachment; filename="${fallback}"; filename*=UTF-8''${encoded}`;
+}
+
+// Titles seen by getInfo, so a download started right after can be named without asking again.
+const titleCache = new Map(); // clean url → title
+function rememberTitle(cleanUrl, title) {
+ if (!title) return;
+ if (titleCache.size > 500) titleCache.delete(titleCache.keys().next().value);
+ titleCache.set(cleanUrl, title);
+}
+
+// ── Progress parsing ─────────────────────────────────────────
+const cleanField = (v) => {
+ const t = String(v || '').trim();
+ return /^(n\/?a|unknown|none|unknown b\/s)?$/i.test(t) ? '' : t;
+};
+
+/**
+ * Fold one yt-dlp stdout line into the entry. Lines are ours (see the --print / --progress-template
+ * arguments): OVM|sizes|approxSizes|titleJSON, OVP|percent|speed|eta, OVX|pp, OVF|filepath.
+ *
+ * A merged video is two transfers (video, then audio) and yt-dlp reports each 0 → 100 %, so the
+ * raw number runs backwards halfway through. Parts are weighted by their byte sizes when yt-dlp
+ * knows them (85/15 otherwise) and the overall figure never decreases.
+ */
+function applyLine(entry, line) {
+ if (line.startsWith('OVP|')) {
+ const [, pctRaw, speed, eta] = line.split('|');
+ const pct = parseFloat(pctRaw);
+ if (!Number.isFinite(pct)) return;
+ if (entry._lastPct != null && pct < entry._lastPct - 40 && entry._part < entry._weights.length - 1) entry._part++;
+ entry._lastPct = pct;
+ const before = entry._weights.slice(0, entry._part).reduce((a, b) => a + b, 0);
+ const overall = (before + entry._weights[entry._part] * (pct / 100)) * 100;
+ // 100 is reserved for "the file is ready"; until then the bar stops just short.
+ entry.progress = Math.max(entry.progress || 0, Math.min(overall, 99.5));
+ entry.speed = cleanField(speed);
+ entry.eta = cleanField(eta);
+ if (entry.phase !== 'processing') entry.phase = 'downloading';
+ } else if (line.startsWith('OVM|')) {
+ const first = line.indexOf('|', 4);
+ const second = first < 0 ? -1 : line.indexOf('|', first + 1);
+ if (second < 0) return;
+ const parse = (t) => { try { return JSON.parse(t); } catch { return null; } };
+ const sizes = parse(line.slice(4, first));
+ const approx = parse(line.slice(first + 1, second));
+ const title = parse(line.slice(second + 1));
+ if (typeof title === 'string' && title && !entry.title) entry.title = title;
+ const pick = [sizes, approx].find(a => Array.isArray(a) && a.length > 1 && a.every(n => Number.isFinite(n) && n > 0));
+ if (pick) {
+ const total = pick.reduce((a, b) => a + b, 0);
+ entry._weights = pick.map(n => n / total);
+ entry.totalBytes = total;
+ } else if (Array.isArray(sizes) && sizes.length <= 1) {
+ entry._weights = [1];
+ }
+ } else if (line.startsWith('OVX|')) {
+ entry.phase = 'processing';
+ entry.speed = '';
+ entry.eta = '';
+ }
+}
+
// ── Download Video ───────────────────────────────────────────
// ── Download Video ───────────────────────────────────────────
+function releaseSlot(entry) {
+ if (entry._slotReleased) return;
+ entry._slotReleased = true;
+ currentConcurrent = Math.max(0, currentConcurrent - 1);
+}
+
+function removePartials(id) {
+ const dir = path.resolve(config.downloadsDir);
+ let names = [];
+ try { names = fs.readdirSync(dir); } catch { return; }
+ for (const f of names) {
+ if (f.startsWith(id)) { try { fs.unlinkSync(path.join(dir, f)); } catch { /* ok */ } }
+ }
+}
+
/**
/**
* Start a download and return a tracking ID.
* Start a download and return a tracking ID.
* @param {string} url - YouTube URL
* @param {string} url - YouTube URL
* @param {string} quality - Quality preset key
* @param {string} quality - Quality preset key
+ * @param {{ title?: string }} [opts] - title the client already shows (yt-dlp's own wins when it reports one)
* @returns {Promise<{ id: string }>}
* @returns {Promise<{ id: string }>}
*/
*/
-function startDownload(url, quality = 'best') {
+function startDownload(url, quality = 'best', opts = {}) {
return new Promise((resolve, reject) => {
return new Promise((resolve, reject) => {
if (!isValidUrl(url)) return reject(new Error('Only YouTube URLs are supported'));
if (!isValidUrl(url)) return reject(new Error('Only YouTube URLs are supported'));
if (currentConcurrent >= config.download.maxConcurrent) {
if (currentConcurrent >= config.download.maxConcurrent) {
return reject(new Error('Server busy — too many concurrent downloads. Try again shortly.'));
return reject(new Error('Server busy — too many concurrent downloads. Try again shortly.'));
}
}
- const preset = QUALITY_PRESETS[quality] || QUALITY_PRESETS['best'];
+ if (!QUALITY_PRESETS[quality]) quality = 'best';
+ const preset = QUALITY_PRESETS[quality];
const cleanUrl = sanitizeUrl(url);
const cleanUrl = sanitizeUrl(url);
const id = crypto.randomBytes(12).toString('hex');
const id = crypto.randomBytes(12).toString('hex');
@@ -190,28 +297,43 @@ function startDownload(url, quality = 'best') {
@@ -190,28 +297,43 @@ function startDownload(url, quality = 'best') {
// dropped by filter(Boolean) before the cleanup loop ran, so yt-dlp received
// dropped by filter(Boolean) before the cleanup loop ran, so yt-dlp received
// `--merge-output-format -o` and every audio download died with
// `--merge-output-format -o` and every audio download died with
// "invalid merge output format "-o" given".
// "invalid merge output format "-o" given".
+ //
+ // --print makes yt-dlp quiet, so everything on stdout is one of our own tagged lines;
+ // --no-simulate keeps it downloading and --progress keeps the progress lines coming.
const cleanArgs = [
const cleanArgs = [
'-f', preset.video,
'-f', preset.video,
...(preset.audio ? [] : ['--merge-output-format', preset.ext || 'mp4']),
...(preset.audio ? [] : ['--merge-output-format', preset.ext || 'mp4']),
'-o', outputTemplate,
'-o', outputTemplate,
'--no-playlist',
'--no-playlist',
'--no-warnings',
'--no-warnings',
+ '--no-simulate',
+ '--progress',
'--newline', // progress on new lines for parsing
'--newline', // progress on new lines for parsing
- '--progress-template', '%(progress._percent_str)s %(progress._speed_str)s %(progress._eta_str)s',
+ '--print', 'before_dl:OVM|%(requested_formats.:.filesize)j|%(requested_formats.:.filesize_approx)j|%(title)j',
+ '--print', 'post_process:OVX|pp',
+ '--progress-template', 'download:OVP|%(progress._percent_str)s|%(progress._speed_str)s|%(progress._eta_str)s',
...(preset.postprocess || []),
...(preset.postprocess || []),
cleanUrl,
cleanUrl,
];
];
+ const clientTitle = sanitizeTitle(opts.title).slice(0, 200);
const entry = {
const entry = {
id,
id,
status: 'downloading',
status: 'downloading',
- progress: 0,
+ phase: 'starting', // starting → downloading → processing
+ progress: null, // null until yt-dlp reports a figure (indeterminate)
speed: '',
speed: '',
eta: '',
eta: '',
error: null,
error: null,
+ cancelled: false,
filePath: null,
filePath: null,
quality,
quality,
+ title: titleCache.get(cleanUrl) || clientTitle || '',
startedAt: Date.now(),
startedAt: Date.now(),
+ finishedAt: null,
+ _weights: preset.audio ? [1] : [0.85, 0.15],
+ _part: 0,
+ _lastPct: null,
};
};
activeDownloads.set(id, entry);
activeDownloads.set(id, entry);
currentConcurrent++;
currentConcurrent++;
@@ -219,41 +341,38 @@ function startDownload(url, quality = 'best') {
@@ -219,41 +341,38 @@ function startDownload(url, quality = 'best') {
const proc = spawn(config.ytdlpPath, cleanArgs, { timeout: config.download.timeout });
const proc = spawn(config.ytdlpPath, cleanArgs, { timeout: config.download.timeout });
entry.process = proc;
entry.process = proc;
+ let stdoutRest = '';
proc.stdout.on('data', (data) => {
proc.stdout.on('data', (data) => {
- const lines = data.toString().split('\n');
- for (const line of lines) {
- // Parse progress: " 45.2% 5.23MiB/s 00:12"
- const match = line.match(/([\d.]+)%\s+([\S]+)\s+([\S]+)/);
- if (match) {
- entry.progress = parseFloat(match[1]);
- entry.speed = match[2];
- entry.eta = match[3];
- }
- }
+ const lines = (stdoutRest + data.toString()).split('\n');
+ stdoutRest = lines.pop();
+ for (const line of lines) applyLine(entry, line.trim());
});
});
let stderrTail = '';
let stderrTail = '';
proc.stderr.on('data', (data) => {
proc.stderr.on('data', (data) => {
- const text = data.toString();
- stderrTail = (stderrTail + text).slice(-2000);
- // Some progress info goes to stderr too
- const match = text.match(/([\d.]+)%/);
- if (match) entry.progress = parseFloat(match[1]);
+ stderrTail = (stderrTail + data.toString()).slice(-2000);
});
});
- proc.on('close', (code) => {
- currentConcurrent--;
+ proc.on('close', (code, signal) => {
+ releaseSlot(entry);
+ entry.process = null;
+ entry.finishedAt = Date.now();
+ if (entry.status !== 'downloading') { removePartials(id); return; } // cancelled or timed out
+ if (stdoutRest) applyLine(entry, stdoutRest.trim());
+
if (code !== 0) {
if (code !== 0) {
- const last = stderrTail.split('\n').map(l => l.trim()).filter(l => l && !/^\[download\]/.test(l)).pop() || `yt-dlp exited with code ${code}`;
+ const last = stderrTail.split('\n').map(l => l.trim()).filter(l => l && !/^\[download\]/.test(l)).pop()
+ || (signal ? 'The download took too long and was stopped' : `yt-dlp exited with code ${code}`);
console.error(`[Download] ${id} (${quality}) failed: ${last}`);
console.error(`[Download] ${id} (${quality}) failed: ${last}`);
entry.status = 'error';
entry.status = 'error';
entry.error = friendlyDownloadError(last);
entry.error = friendlyDownloadError(last);
+ removePartials(id);
return;
return;
}
}
// Find the output file (yt-dlp may change extension)
// Find the output file (yt-dlp may change extension)
const dir = path.resolve(config.downloadsDir);
const dir = path.resolve(config.downloadsDir);
- const files = fs.readdirSync(dir).filter(f => f.startsWith(id));
+ const files = fs.readdirSync(dir).filter(f => f.startsWith(id) && !/\.(part|ytdl|temp)$/i.test(f));
if (files.length === 0) {
if (files.length === 0) {
entry.status = 'error';
entry.status = 'error';
entry.error = 'Output file not found';
entry.error = 'Output file not found';
@@ -266,10 +385,14 @@ function startDownload(url, quality = 'best') {
@@ -266,10 +385,14 @@ function startDownload(url, quality = 'best') {
const stat = fs.statSync(filePath);
const stat = fs.statSync(filePath);
entry.status = 'done';
entry.status = 'done';
+ entry.phase = 'done';
entry.progress = 100;
entry.progress = 100;
+ entry.speed = '';
+ entry.eta = '';
entry.filePath = filePath;
entry.filePath = filePath;
entry.fileSize = stat.size;
entry.fileSize = stat.size;
entry.ext = ext;
entry.ext = ext;
+ entry.filename = downloadFilename(entry.title, ext);
// Register in file index for download serving
// Register in file index for download serving
const mimeMap = {
const mimeMap = {
@@ -283,33 +406,75 @@ function startDownload(url, quality = 'best') {
@@ -283,33 +406,75 @@ function startDownload(url, quality = 'best') {
mime: mimeMap[ext] || 'application/octet-stream',
mime: mimeMap[ext] || 'application/octet-stream',
ext,
ext,
size: stat.size,
size: stat.size,
+ filename: entry.filename,
expiresAt: Date.now() + config.retention.fileTTL,
expiresAt: Date.now() + config.retention.fileTTL,
});
});
});
});
proc.on('error', (err) => {
proc.on('error', (err) => {
- currentConcurrent--;
+ releaseSlot(entry);
+ entry.finishedAt = Date.now();
+ if (entry.status !== 'downloading') return;
entry.status = 'error';
entry.status = 'error';
- entry.error = `yt-dlp error: ${err.message}`;
+ entry.error = err.code === 'ENOENT' ? 'The downloader is not installed on this server' : `yt-dlp error: ${err.message}`;
});
});
resolve({ id });
resolve({ id });
});
});
}
}
+// ── Cancel ───────────────────────────────────────────────────
+/**
+ * Stop a running download (kills yt-dlp and removes what it wrote) or discard a finished one.
+ * @returns {null | { id, cancelled: boolean, removed: boolean }} null when the id is unknown
+ */
+function cancelDownload(id) {
+ const dl = activeDownloads.get(id);
+ if (!dl) return null;
+
+ if (dl.status === 'downloading') {
+ dl.status = 'error';
+ dl.cancelled = true;
+ dl.error = 'Download cancelled';
+ dl.finishedAt = Date.now();
+ const proc = dl.process;
+ if (proc) {
+ try { proc.kill('SIGTERM'); } catch { /* already gone */ }
+ // ffmpeg children can ignore a polite stop; make sure nothing keeps writing.
+ const hard = setTimeout(() => { try { if (dl.process) proc.kill('SIGKILL'); } catch { /* ok */ } }, 3000);
+ hard.unref();
+ } else {
+ releaseSlot(dl);
+ removePartials(id);
+ }
+ return { id, cancelled: true, removed: false };
+ }
+
+ if (dl.status === 'done') {
+ removeFile(id);
+ return { id, cancelled: false, removed: true };
+ }
+ return { id, cancelled: false, removed: false };
+}
+
// ── Download Status ──────────────────────────────────────────
// ── Download Status ──────────────────────────────────────────
function getStatus(id) {
function getStatus(id) {
const dl = activeDownloads.get(id);
const dl = activeDownloads.get(id);
if (!dl) return null;
if (!dl) return null;
return {
return {
id: dl.id,
id: dl.id,
- status: dl.status,
- progress: Math.round(dl.progress * 10) / 10,
+ status: dl.status, // downloading | done | error
+ phase: dl.status === 'downloading' ? dl.phase : dl.status,
+ progress: dl.progress == null ? null : Math.round(dl.progress * 10) / 10, // 0–100, null = not known yet
speed: dl.speed,
speed: dl.speed,
eta: dl.eta,
eta: dl.eta,
error: dl.error,
error: dl.error,
+ cancelled: dl.cancelled || undefined,
quality: dl.quality,
quality: dl.quality,
- download: dl.status === 'done' ? { url: `/api/download/${id}`, size: dl.fileSize, ext: dl.ext } : null,
+ title: dl.title || null,
+ download: dl.status === 'done'
+ ? { url: `/api/download/${id}`, size: dl.fileSize, ext: dl.ext, filename: dl.filename }
+ : null,
};
};
}
}
@@ -349,13 +514,19 @@ function cleanup() {
@@ -349,13 +514,19 @@ function cleanup() {
}
}
}
}
- // Clean stale active downloads (stuck for > 15 min with no file)
for (const [id, dl] of activeDownloads) {
for (const [id, dl] of activeDownloads) {
+ // Stuck for > 15 min with no file
if (dl.status === 'downloading' && now - dl.startedAt > 15 * 60 * 1000) {
if (dl.status === 'downloading' && now - dl.startedAt > 15 * 60 * 1000) {
- try { dl.process?.kill('SIGTERM'); } catch { /* ok */ }
dl.status = 'error';
dl.status = 'error';
dl.error = 'Download timed out';
dl.error = 'Download timed out';
- currentConcurrent = Math.max(0, currentConcurrent - 1);
+ dl.finishedAt = now;
+ try { dl.process?.kill('SIGKILL'); } catch { /* ok */ }
+ if (!dl.process) releaseSlot(dl);
+ }
+ // Failed / cancelled entries have no file to expire with; drop them after a while.
+ if (dl.status === 'error' && dl.finishedAt && now - dl.finishedAt > 15 * 60 * 1000) {
+ removePartials(id);
+ activeDownloads.delete(id);
}
}
}
}
@@ -387,6 +558,7 @@ function getStats() {
@@ -387,6 +558,7 @@ function getStats() {
}
}
module.exports = {
module.exports = {
- getInfo, startDownload, getStatus, getFile, removeFile,
+ getInfo, startDownload, cancelDownload, getStatus, getFile, removeFile,
+ sanitizeTitle, downloadFilename, contentDisposition, applyLine,
cleanup, startCleanup, stopCleanup, getStats, isValidUrl,
cleanup, startCleanup, stopCleanup, getStats, isValidUrl,
};
};
@@ -16,6 +16,8 @@ const fs = require('fs');
@@ -16,6 +16,8 @@ const fs = require('fs');
const config = require('./config');
const config = require('./config');
const { optionalAuth } = require('./auth');
const { optionalAuth } = require('./auth');
const downloader = require('./downloader');
const downloader = require('./downloader');
+const seo = require('./seo');
+const { hostGuard } = require('../../_shared/host-role');
// ── Analytics ────────────────────────────────────────────────
// ── Analytics ────────────────────────────────────────────────
const Database = require('better-sqlite3');
const Database = require('better-sqlite3');
@@ -31,6 +33,8 @@ const app = express();
@@ -31,6 +33,8 @@ const app = express();
// ── Security ─────────────────────────────────────────────────
// ── Security ─────────────────────────────────────────────────
app.set('trust proxy', 2);
app.set('trust proxy', 2);
+// API answers are live state, never revalidated: a 304 on /api/status froze the progress poll.
+app.set('etag', false);
app.use(helmet({
app.use(helmet({
contentSecurityPolicy: {
contentSecurityPolicy: {
directives: {
directives: {
@@ -60,7 +64,12 @@ app.use(cors({
@@ -60,7 +64,12 @@ app.use(cors({
}));
}));
// ── Rate Limiting ────────────────────────────────────────────
// ── Rate Limiting ────────────────────────────────────────────
-app.use('/api/', rateLimit({ windowMs: 60_000, max: 60 }));
+// Progress is one SSE connection, or a poll every 1–3 s when SSE is unavailable; the poll must
+// not eat the general budget (60/min would cut a download off after a minute).
+const isStatusRoute = (req) => req.method === 'GET' && /^\/status\/[a-f0-9]+(\/stream)?$/.test(req.path);
+app.use('/api/', rateLimit({ windowMs: 60_000, max: 60, skip: isStatusRoute }));
+app.use('/api/status/', rateLimit({ windowMs: 60_000, max: 240 }));
+app.use('/api/', (_req, res, next) => { res.setHeader('Cache-Control', 'no-store'); next(); });
// ── Analytics Middleware ─────────────────────────────────────
// ── Analytics Middleware ─────────────────────────────────────
app.use(analytics.middleware());
app.use(analytics.middleware());
@@ -75,6 +84,11 @@ const downloadLimiter = rateLimit({
@@ -75,6 +84,11 @@ const downloadLimiter = rateLimit({
// ── Auth ─────────────────────────────────────────────────────
// ── Auth ─────────────────────────────────────────────────────
app.use(optionalAuth);
app.use(optionalAuth);
+// ── Hosts ────────────────────────────────────────────────────
+// Pages are only rendered for hosts this tool serves (or that the gateway vouches for with
+// X-OV-Tool); aliases go to the short host, anything else to the tools index.
+app.use(hostGuard({ knows: seo.knowsHost, aliasOf: seo.aliasOf }));
+
// ── API Routes ───────────────────────────────────────────────
// ── API Routes ───────────────────────────────────────────────
// Health check
// Health check
@@ -100,13 +114,13 @@ app.post('/api/info', async (req, res) => {
@@ -100,13 +114,13 @@ app.post('/api/info', async (req, res) => {
// Start download
// Start download
app.post('/api/download', downloadLimiter, async (req, res) => {
app.post('/api/download', downloadLimiter, async (req, res) => {
- const { url, quality } = req.body;
+ const { url, quality, title } = req.body;
if (!url) return res.status(400).json({ error: 'URL is required' });
if (!url) return res.status(400).json({ error: 'URL is required' });
if (!downloader.isValidUrl(url)) return res.status(400).json({ error: 'Only YouTube URLs are supported' });
if (!downloader.isValidUrl(url)) return res.status(400).json({ error: 'Only YouTube URLs are supported' });
try {
try {
- const { id } = await downloader.startDownload(url, quality || 'best');
- res.json({ success: true, id, statusUrl: `/api/status/${id}` });
+ const { id } = await downloader.startDownload(url, quality || 'best', { title: typeof title === 'string' ? title : '' });
+ res.json({ success: true, id, statusUrl: `/api/status/${id}`, streamUrl: `/api/status/${id}/stream` });
} catch (err) {
} catch (err) {
console.error('[Download] Error:', err.message);
console.error('[Download] Error:', err.message);
res.status(422).json({ error: err.message });
res.status(422).json({ error: err.message });
@@ -120,35 +134,70 @@ app.get('/api/status/:id', (req, res) => {
@@ -120,35 +134,70 @@ app.get('/api/status/:id', (req, res) => {
res.json(status);
res.json(status);
});
});
-// Download status via SSE (real-time progress)
+// Download status via SSE (real-time progress).
+//
+// Protocol (the poll endpoint above answers the same JSON):
+// event: progress status "downloading" — phase starting|downloading|processing, progress 0–100|null
+// event: complete status "done" — download { url, size, ext, filename }
+// event: failed status "error" — error text (cancelled: true after DELETE)
+// Every frame is also sent once as an unnamed message (`data:` only) for clients that predate
+// the named events. The stream closes itself after a terminal event.
+const SSE_EVENT = { downloading: 'progress', done: 'complete', error: 'failed' };
+
app.get('/api/status/:id/stream', (req, res) => {
app.get('/api/status/:id/stream', (req, res) => {
const id = req.params.id;
const id = req.params.id;
res.writeHead(200, {
res.writeHead(200, {
- 'Content-Type': 'text/event-stream',
- 'Cache-Control': 'no-cache',
+ 'Content-Type': 'text/event-stream; charset=utf-8',
+ 'Cache-Control': 'no-store, no-transform',
'Connection': 'keep-alive',
'Connection': 'keep-alive',
'X-Accel-Buffering': 'no',
'X-Accel-Buffering': 'no',
});
});
+ res.write('retry: 3000\n\n');
+
+ let timer = null;
+ let lastFrame = '';
+ let lastWrite = 0;
+ const stop = () => { if (timer) { clearInterval(timer); timer = null; } };
+ const send = (event, payload) => {
+ const json = JSON.stringify(payload);
+ res.write(`data: ${json}\n\n`);
+ res.write(`event: ${event}\ndata: ${json}\n\n`);
+ lastWrite = Date.now();
+ };
- const interval = setInterval(() => {
+ const tick = () => {
const status = downloader.getStatus(id);
const status = downloader.getStatus(id);
if (!status) {
if (!status) {
- res.write(`data: ${JSON.stringify({ error: 'Not found' })}\n\n`);
- clearInterval(interval);
- res.end();
- return;
+ send('failed', { id, status: 'error', error: 'Download not found or expired' });
+ stop();
+ return res.end();
}
}
-
- res.write(`data: ${JSON.stringify(status)}\n\n`);
-
- if (status.status === 'done' || status.status === 'error') {
- clearInterval(interval);
- setTimeout(() => res.end(), 500);
+ const json = JSON.stringify(status);
+ const terminal = status.status === 'done' || status.status === 'error';
+ if (json !== lastFrame || terminal) {
+ lastFrame = json;
+ send(SSE_EVENT[status.status] || 'progress', status);
+ } else if (Date.now() - lastWrite > 15000) {
+ res.write(': keep-alive\n\n');
+ lastWrite = Date.now();
}
}
- }, 1000);
+ if (terminal) {
+ stop();
+ setTimeout(() => res.end(), 250);
+ }
+ };
+
+ timer = setInterval(tick, 500);
+ tick(); // first frame immediately, not after a tick of silence
+ req.on('close', stop);
+});
- req.on('close', () => clearInterval(interval));
+// Cancel a running download (kills yt-dlp) or discard a finished file
+app.delete('/api/download/:id', (req, res) => {
+ const result = downloader.cancelDownload(req.params.id);
+ if (!result) return res.status(404).json({ error: 'Download not found' });
+ res.json({ success: true, ...result });
});
});
// Serve downloaded file
// Serve downloaded file
@@ -158,8 +207,9 @@ app.get('/api/download/:id', (req, res) => {
@@ -158,8 +207,9 @@ app.get('/api/download/:id', (req, res) => {
res.set({
res.set({
'Content-Type': entry.mime,
'Content-Type': entry.mime,
- 'Content-Disposition': `attachment; filename="openvibeyt-download.${entry.ext}"`,
+ 'Content-Disposition': downloader.contentDisposition(entry.filename || `youtube-video.${entry.ext}`),
'Content-Length': entry.size,
'Content-Length': entry.size,
+ 'Cache-Control': 'private, no-store',
});
});
res.sendFile(entry.filePath);
res.sendFile(entry.filePath);
});
});
@@ -179,6 +229,7 @@ app.get('/api/internal/analytics/bots', (req, res) => {
@@ -179,6 +229,7 @@ app.get('/api/internal/analytics/bots', (req, res) => {
// ── Static Files ─────────────────────────────────────────────
// ── Static Files ─────────────────────────────────────────────
// (shared client-side libs are loaded absolutely from https://openvibe.network/shared/)
// (shared client-side libs are loaded absolutely from https://openvibe.network/shared/)
app.use(express.static(path.join(__dirname, '..', 'public'), {
app.use(express.static(path.join(__dirname, '..', 'public'), {
+ index: false, // '/' is rendered below with the head for this host (server/seo.js)
setHeaders(res, filePath) {
setHeaders(res, filePath) {
if (filePath.endsWith('.js') || filePath.endsWith('.css')) {
if (filePath.endsWith('.js') || filePath.endsWith('.css')) {
res.setHeader('Cache-Control', 'no-cache');
res.setHeader('Cache-Control', 'no-cache');
@@ -186,10 +237,16 @@ app.use(express.static(path.join(__dirname, '..', 'public'), {
@@ -186,10 +237,16 @@ app.use(express.static(path.join(__dirname, '..', 'public'), {
},
},
}));
}));
-// SPA fallback
-app.get('*', (req, res) => {
+// The page: one document, head stamped per host. Anything else that is not a file is a 404
+// (no soft-404 copies of the home page under made-up paths).
+app.get(['/', '/index.html'], (req, res) => {
+ if (req.path !== '/') return res.redirect(301, '/');
+ return seo.sendIndex(req, res);
+});
+app.use((req, res) => {
if (req.path.startsWith('/api/')) return res.status(404).json({ error: 'Not found' });
if (req.path.startsWith('/api/')) return res.status(404).json({ error: 'Not found' });
- res.sendFile(path.join(__dirname, '..', 'public', 'index.html'));
+ res.status(404).set('Cache-Control', 'no-cache').type('html')
+ .send('<!doctype html><meta charset="utf-8"><meta name="robots" content="noindex"><title>Not found</title><p>Nothing here. <a href="/">YouTube Downloader</a></p>');
});
});
// ── Start ────────────────────────────────────────────────────
// ── Start ────────────────────────────────────────────────────
new file mode 100644
new file mode 100644
@@ -0,0 +1,150 @@
@@ -0,0 +1,150 @@
+'use strict';
+
+// ═══════════════════════════════════════════════════════════════
+// YT.OpenVibe — hosts and the server-rendered <head>
+//
+// public/index.html carries the tool and its crawlable copy; the head is stamped here per
+// request so canonical / Open Graph / Twitter / JSON-LD name the right host:
+// - through the gateway (X-OV-* headers): the canonical host it names, so the short host
+// (yt.…) points at youtube-downloader.… or at a custom domain;
+// - reached directly: the host itself, as before.
+// ═══════════════════════════════════════════════════════════════
+
+const fs = require('fs');
+const path = require('path');
+const { canonicalHostFor, requestHost, isLocalHost } = require('../../_shared/host-role');
+
+const PUBLIC = path.join(__dirname, '..', 'public');
+
+// Code defaults; the gateway's catalog (and the Network's domain overrides) win when present.
+const HOSTS = {
+ canonical: 'youtube-downloader.openvibe.tools',
+ short: 'yt.openvibe.tools',
+ aliases: ['youtube.openvibe.tools', 'ytdl.openvibe.tools'],
+};
+const DEFAULT_HOST = HOSTS.short;
+
+const knowsHost = (host) => host === HOSTS.canonical || host === HOSTS.short;
+const aliasOf = (host) => (HOSTS.aliases.includes(host) ? HOSTS.short : '');
+
+const TITLE = 'YouTube Downloader — Save Video & Audio | OpenVibe';
+const DESCRIPTION = 'Download YouTube videos as MP4 or WebM up to 1080p, or keep just the audio as MP3, M4A, Opus or FLAC. Paste a link, pick a format, save the file.';
+const KEYWORDS = ['youtube downloader', 'youtube to mp3', 'youtube to mp4', 'download youtube video', 'youtube audio downloader', 'youtube to flac'];
+
+// The same questions are printed on the page (public/index.html) — keep the two in step.
+const FAQ = [
+ { q: 'How do I download a YouTube video?',
+ a: 'Copy the video link from YouTube, paste it into the box at the top of this page and press Fetch. Pick a video quality or an audio format, press Download, and when the file is ready press Save file.' },
+ { q: 'Which formats and qualities can I choose?',
+ a: 'Video is saved as MP4 at the best available quality, 1080p, 720p, 480p or 360p, or as WebM (VP9). Audio only can be saved as MP3, M4A (AAC), Opus or FLAC.' },
+ { q: 'How do I convert a YouTube video to MP3?',
+ a: 'Paste the link, choose MP3 under Audio Only and press Download. The audio track is extracted and converted on the server, then offered as a single MP3 file named after the video.' },
+ { q: 'Is it legal to download YouTube videos?',
+ a: 'That depends on the video and on where you live. Downloading your own uploads, public-domain works and videos whose licence allows copies (for example Creative Commons) is generally fine. Other videos are protected by copyright and YouTube\'s terms of service restrict downloading them without permission. You are responsible for having the right to save what you download.' },
+ { q: 'How long is my file kept?',
+ a: 'Files are temporary. A finished download stays on the server for about an hour so you can save it, then it is deleted automatically.' },
+ { q: 'Why did a download fail?',
+ a: 'Private, members-only and age-restricted videos cannot be fetched, and very long videos (over three hours) are refused. If a quality is not offered for a video, pick another one. Occasionally YouTube rate-limits the server; trying again a few minutes later usually works.' },
+];
+
+const HOWTO_STEPS = [
+ { name: 'Copy the video link', text: 'Open the video on YouTube and copy its address (youtube.com/watch… or youtu.be/…).' },
+ { name: 'Paste it here', text: 'Paste the link into the box on this page and press Fetch to load the title, thumbnail and length.' },
+ { name: 'Choose a format', text: 'Pick a video quality (up to 1080p MP4, or WebM) or an audio format (MP3, M4A, Opus, FLAC).' },
+ { name: 'Download and save', text: 'Press Download, watch the progress, then press Save file. The file is named after the video.' },
+];
+
+const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
+const ldScript = (obj) => `<script type="application/ld+json">${JSON.stringify(obj).replace(/</g, '\\u003c')}</script>`;
+
+function headTags(canonicalHost) {
+ const url = `https://${canonicalHost}/`;
+ const image = `https://${canonicalHost}/og.png`;
+ const ld = [
+ {
+ '@context': 'https://schema.org', '@type': 'SoftwareApplication',
+ name: 'OpenVibe YouTube Downloader', url, description: DESCRIPTION, image,
+ applicationCategory: 'MultimediaApplication', operatingSystem: 'Any (web browser)',
+ keywords: KEYWORDS.join(', '),
+ featureList: ['MP4 up to 1080p', 'WebM (VP9)', 'MP3', 'M4A (AAC)', 'Opus', 'FLAC', 'Live progress with speed and time remaining'],
+ publisher: { '@type': 'Organization', name: 'OpenVibe', url: 'https://openvibe.network/' },
+ },
+ {
+ '@context': 'https://schema.org', '@type': 'HowTo',
+ name: 'How to download a YouTube video', description: DESCRIPTION, url, image,
+ totalTime: 'PT1M',
+ step: HOWTO_STEPS.map((s, i) => ({ '@type': 'HowToStep', position: i + 1, name: s.name, text: s.text, url: `${url}#how-to` })),
+ },
+ {
+ '@context': 'https://schema.org', '@type': 'FAQPage', url,
+ mainEntity: FAQ.map(f => ({ '@type': 'Question', name: f.q, acceptedAnswer: { '@type': 'Answer', text: f.a } })),
+ },
+ {
+ '@context': 'https://schema.org', '@type': 'BreadcrumbList',
+ itemListElement: [
+ { '@type': 'ListItem', position: 1, name: 'OpenVibe Tools', item: 'https://openvibe.tools/' },
+ { '@type': 'ListItem', position: 2, name: 'YouTube Downloader', item: url },
+ ],
+ },
+ ];
+
+ return [
+ `<title>${esc(TITLE)}</title>`,
+ `<meta name="description" content="${esc(DESCRIPTION)}">`,
+ `<meta name="keywords" content="${esc(KEYWORDS.join(', '))}">`,
+ `<link rel="canonical" href="${url}">`,
+ '<meta name="robots" content="index, follow, max-image-preview:large, max-snippet:-1">',
+ `<meta property="og:title" content="${esc(TITLE)}">`,
+ `<meta property="og:description" content="${esc(DESCRIPTION)}">`,
+ '<meta property="og:type" content="website">',
+ `<meta property="og:url" content="${url}">`,
+ `<meta property="og:image" content="${image}">`,
+ '<meta property="og:image:width" content="1200">',
+ '<meta property="og:image:height" content="630">',
+ '<meta property="og:site_name" content="OpenVibe">',
+ '<meta property="og:locale" content="en_US">',
+ '<meta name="twitter:card" content="summary_large_image">',
+ `<meta name="twitter:title" content="${esc(TITLE)}">`,
+ `<meta name="twitter:description" content="${esc(DESCRIPTION)}">`,
+ `<meta name="twitter:image" content="${image}">`,
+ ...ld.map(ldScript),
+ ].join('\n ');
+}
+
+/** The how-to and the questions, visible on the page (and readable without JavaScript). */
+function contentBlock() {
+ return `<section class="yt-info" id="how-to" aria-labelledby="yt-howto-h"><h2 id="yt-howto-h">How to download a YouTube video</h2><ol>${HOWTO_STEPS.map(s => `<li><b>${esc(s.name)}.</b> ${esc(s.text)}</li>`).join('')}</ol></section>
+<section class="yt-info" id="faq" aria-labelledby="yt-faq-h"><h2 id="yt-faq-h">Questions</h2>${FAQ.map(f => `<details><summary>${esc(f.q)}</summary><p>${esc(f.a)}</p></details>`).join('')}
+<p class="yt-more">More tools: <a href="https://openvibe.tools/">every OpenVibe tool</a> · <a href="https://openvibe.tools/audio-tools">audio tools</a> · <a href="https://openvibe.tools/image-tools">image tools</a></p></section>
+<style>.yt-info{max-width:760px;margin:36px auto 0;padding:0 20px;color:var(--text-secondary,#a8b3c4);line-height:1.6}.yt-info h2{color:var(--text-primary,#e6edf7);font-size:20px;margin:0 0 10px}.yt-info li{margin:6px 0}.yt-info details{border-bottom:1px solid var(--border,rgba(255,255,255,.08));padding:10px 0}.yt-info summary{cursor:pointer;color:var(--text-primary,#e6edf7);font-weight:600}.yt-more{margin-top:18px;font-size:14px}</style>`;
+}
+
+let _base = null;
+const _cache = new Map();
+
+function baseHtml() {
+ if (_base === null || process.env.NODE_ENV === 'development') _base = fs.readFileSync(path.join(PUBLIC, 'index.html'), 'utf8');
+ return _base;
+}
+
+function renderIndex(canonicalHost) {
+ if (process.env.NODE_ENV !== 'development' && _cache.has(canonicalHost)) return _cache.get(canonicalHost);
+ const out = baseHtml().replace('<!--OV:HEAD-->', headTags(canonicalHost)).replace('<!--OV:CONTENT-->', contentBlock());
+ if (_cache.size > 50) _cache.clear();
+ _cache.set(canonicalHost, out);
+ return out;
+}
+
+/** Express handler: the page with this request's canonical host stamped into the head. */
+function sendIndex(req, res) {
+ const host = requestHost(req);
+ const own = !host || isLocalHost(host) ? DEFAULT_HOST : host;
+ // Reached directly (no gateway headers) on the short host: the canonical is still the descriptive host.
+ const canonicalHost = canonicalHostFor(req, own === HOSTS.short ? HOSTS.canonical : own);
+ res.setHeader('Content-Type', 'text/html; charset=utf-8');
+ res.setHeader('Cache-Control', 'no-cache');
+ res.setHeader('Vary', 'X-OV-Canonical-Host');
+ return res.send(renderIndex(canonicalHost));
+}
+
+module.exports = { HOSTS, TITLE, DESCRIPTION, FAQ, HOWTO_STEPS, knowsHost, aliasOf, headTags, renderIndex, sendIndex };
new file mode 100644
new file mode 100644
@@ -0,0 +1,11 @@
@@ -0,0 +1,11 @@
+'use strict';
+const assert = require('assert');
+const DS = require('../public/js/download-state.js');
+let s = DS.initial(); s = DS.reduce(s, { status: 'downloading', progress: null }); assert.equal(s.phase, 'starting');
+s = DS.reduce(s, { status: 'downloading', progress: 40, speed: '2MiB/s', eta: '00:05' }); assert.equal(s.progress, 0.4);
+s = DS.reduce(s, { status: 'downloading', progress: 10 }); assert.equal(s.percent, 40, 'never runs backwards');
+const done = DS.reduce(s, { status: 'done', id: 'ab', download: { filename: 'a.mp3', size: 5 } }); assert.ok(done.terminal && done.file.url === '/api/download/ab');
+assert.ok(DS.reduce(s, { status: 'complete', id: 'ab' }).terminal, 'the legacy word is accepted');
+assert.strictEqual(DS.reduce(done, { status: 'downloading', progress: 1 }), done, 'a terminal state is final');
+assert.equal(DS.pollDelay(11 * 60 * 1000), null);
+console.log('download-state: all checks passed');
new file mode 100755
new file mode 100755
@@ -0,0 +1,55 @@
@@ -0,0 +1,55 @@
+#!/usr/bin/env bash
+# Put a custom domain (e.g. youtubedownloadonline.com) in front of the Tools gateway.
+#
+# sudo deploy/scripts/add-custom-domain.sh youtubedownloadonline.com [--dns-cloudflare]
+#
+# 1. Point the domain's A/AAAA records at this server first.
+# 2. Run this script: it issues a certificate (HTTP-01 by default, or DNS-01 through the
+# Cloudflare credentials already on the host) and writes an nginx vhost that forwards
+# the domain, and www., to the gateway with the original Host header.
+# 3. In openvibe.network/admin → Domains, attach the host to a tool (owner only). The gateway
+# picks it up within a minute; until then it answers 404 for the unknown host.
+set -euo pipefail
+DOMAIN="${1:?usage: add-custom-domain.sh <domain> [--dns-cloudflare]}"; MODE="${2:-}"
+[[ "$DOMAIN" =~ ^([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$ ]] || { echo "Not a valid hostname: $DOMAIN" >&2; exit 1; }
+[[ "$DOMAIN" == *openvibe.* ]] && { echo "OpenVibe zones are already covered by their wildcard vhosts." >&2; exit 1; }
+PORT="${GATEWAY_PORT:-4001}"; CONF="/etc/nginx/sites-available/custom-$DOMAIN.conf"; WEBROOT=/var/www/letsencrypt
+mkdir -p "$WEBROOT"
+
+if [[ ! -d "/etc/letsencrypt/live/$DOMAIN" ]]; then
+ if [[ "$MODE" == "--dns-cloudflare" ]]; then
+ certbot certonly --dns-cloudflare --dns-cloudflare-credentials /root/.secrets/cloudflare-openvibe.ini -d "$DOMAIN" -d "www.$DOMAIN" --non-interactive --agree-tos
+ else
+ cat > "$CONF" <<NGINX
+server { listen 80; listen [::]:80; server_name $DOMAIN www.$DOMAIN; location /.well-known/acme-challenge/ { root $WEBROOT; } location / { return 404; } }
+NGINX
+ ln -sf "$CONF" "/etc/nginx/sites-enabled/custom-$DOMAIN.conf"; nginx -t && systemctl reload nginx
+ certbot certonly --webroot -w "$WEBROOT" -d "$DOMAIN" -d "www.$DOMAIN" --non-interactive --agree-tos
+ fi
+fi
+
+cat > "$CONF" <<NGINX
+# Custom domain for an OpenVibe tool. Generated by add-custom-domain.sh; the gateway decides
+# which tool this host is (openvibe.network/admin → Domains).
+server { listen 80; listen [::]:80; server_name $DOMAIN www.$DOMAIN;
+ location /.well-known/acme-challenge/ { root $WEBROOT; }
+ location / { return 301 https://$DOMAIN\$request_uri; } }
+server { listen 443 ssl http2; listen [::]:443 ssl http2; server_name www.$DOMAIN;
+ ssl_certificate /etc/letsencrypt/live/$DOMAIN/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/$DOMAIN/privkey.pem;
+ return 301 https://$DOMAIN\$request_uri; }
+server { listen 443 ssl http2; listen [::]:443 ssl http2; server_name $DOMAIN;
+ ssl_certificate /etc/letsencrypt/live/$DOMAIN/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/$DOMAIN/privkey.pem;
+ client_max_body_size 512m;
+ add_header Strict-Transport-Security "max-age=31536000" always;
+ location / {
+ proxy_pass http://127.0.0.1:$PORT; proxy_http_version 1.1;
+ proxy_set_header Host \$host; proxy_set_header X-Real-IP \$remote_addr;
+ proxy_set_header X-Forwarded-For \$proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto https;
+ # Host-role headers come from the gateway only, never from a client.
+ proxy_set_header X-OV-Tool ""; proxy_set_header X-OV-Host-Role ""; proxy_set_header X-OV-Canonical-Host ""; proxy_set_header X-OV-Short-Host "";
+ proxy_buffering off; proxy_read_timeout 600s; proxy_send_timeout 600s;
+ } }
+NGINX
+ln -sf "$CONF" "/etc/nginx/sites-enabled/custom-$DOMAIN.conf"
+nginx -t && systemctl reload nginx
+echo "Done. Now attach $DOMAIN to a tool in https://openvibe.network/admin (Domains tab)."
@@ -308,7 +308,14 @@
@@ -308,7 +308,14 @@
}
}
}
}
+ /** Drop the server-held cookies (ov_sso is httpOnly, so only the server can). Best effort. */
+ function serverLogout() {
+ try { fetch(`${_config.apiBase}/api/auth/logout`, { method: 'POST', credentials: 'include', keepalive: true }).catch(() => {}); } catch { /* */ }
+ try { localStorage.setItem('ov_sso_hint', 'guest'); } catch { /* */ }
+ }
+
function logout() {
function logout() {
+ serverLogout();
const activeId = getActiveId();
const activeId = getActiveId();
if (activeId) removeAccount(activeId);
if (activeId) removeAccount(activeId);
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(TOKEN_KEY);
@@ -319,6 +326,7 @@
@@ -319,6 +326,7 @@
}
}
function logoutAll() {
function logoutAll() {
+ serverLogout();
localStorage.removeItem(STORAGE_KEY);
localStorage.removeItem(STORAGE_KEY);
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(TOKEN_KEY);
localStorage.removeItem(ACTIVE_KEY);
localStorage.removeItem(ACTIVE_KEY);
@@ -19,7 +19,7 @@ const BRAND_DEFAULTS = Object.freeze({
@@ -19,7 +19,7 @@ const BRAND_DEFAULTS = Object.freeze({
gamesServiceName: 'OpenVibe.Games',
gamesServiceName: 'OpenVibe.Games',
mediaServiceName: 'OpenVibe.Media',
mediaServiceName: 'OpenVibe.Media',
tagline: 'One Account. All of OpenVibe.',
tagline: 'One Account. All of OpenVibe.',
- campTagline: 'Free & Open Live Streaming',
+ campTagline: 'Open Live Streaming, Community Run',
toolsSubdomainBase: 'openvibe.tools',
toolsSubdomainBase: 'openvibe.tools',
discord: 'https://discord.gg/M6MuRUaeJj',
discord: 'https://discord.gg/M6MuRUaeJj',
github: 'https://github.com/OpenVibe.Live',
github: 'https://github.com/OpenVibe.Live',
@@ -99,8 +99,8 @@ function buildBrandFromRegistry(registry = {}, env = process.env) {
@@ -99,8 +99,8 @@ function buildBrandFromRegistry(registry = {}, env = process.env) {
// Build service list using registry URLs where available
// Build service list using registry URLs where available
const services = [
const services = [
- { id: 'live', name: streamerServiceName, url: liveUrl, description: 'Free & Open Live Streaming' },
- { id: 'tools', name: toolsServiceName, url: toolsUrl, description: 'Free Online Tools' },
+ { id: 'live', name: streamerServiceName, url: liveUrl, description: 'Open live streaming, community run' },
+ { id: 'tools', name: toolsServiceName, url: toolsUrl, description: 'Online tools for everyone' },
{ id: 'games', name: gamesServiceName, url: gamesUrl, description: 'Games & Community Canvas' },
{ id: 'games', name: gamesServiceName, url: gamesUrl, description: 'Games & Community Canvas' },
{ id: 'media', name: mediaServiceName, url: mediaUrl, description: 'VODs, Clips, Pastes & Files' },
{ id: 'media', name: mediaServiceName, url: mediaUrl, description: 'VODs, Clips, Pastes & Files' },
{ id: 'network', name: BRAND_DEFAULTS.networkServiceName, url: networkUrl, description: 'Accounts, SSO & OpenCoins' },
{ id: 'network', name: BRAND_DEFAULTS.networkServiceName, url: networkUrl, description: 'Accounts, SSO & OpenCoins' },
@@ -155,8 +155,8 @@ const BRAND = Object.freeze({
@@ -155,8 +155,8 @@ const BRAND = Object.freeze({
// Primary services in the network (for dashboard display, health checks, etc.)
// Primary services in the network (for dashboard display, health checks, etc.)
// Static list with OpenVibe defaults — use buildBrandFromRegistry() for live values.
// Static list with OpenVibe defaults — use buildBrandFromRegistry() for live values.
services: Object.freeze([
services: Object.freeze([
- { id: 'live', name: 'OpenVibe.Live', url: 'https://openvibe.live', description: 'Free & Open Live Streaming' },
- { id: 'tools', name: 'OpenVibe.Tools', url: 'https://openvibe.tools', description: 'Free Online Tools' },
+ { id: 'live', name: 'OpenVibe.Live', url: 'https://openvibe.live', description: 'Open live streaming, community run' },
+ { id: 'tools', name: 'OpenVibe.Tools', url: 'https://openvibe.tools', description: 'Online tools for everyone' },
{ id: 'games', name: 'OpenVibe.Games', url: 'https://openvibe.games', description: 'Games & Community Canvas' },
{ id: 'games', name: 'OpenVibe.Games', url: 'https://openvibe.games', description: 'Games & Community Canvas' },
{ id: 'media', name: 'OpenVibe.Media', url: 'https://openvibe.media', description: 'VODs, Clips, Pastes & Files' },
{ id: 'media', name: 'OpenVibe.Media', url: 'https://openvibe.media', description: 'VODs, Clips, Pastes & Files' },
{ id: 'network', name: 'OpenVibe.Network', url: 'https://openvibe.network', description: 'Accounts, SSO & OpenCoins' },
{ id: 'network', name: 'OpenVibe.Network', url: 'https://openvibe.network', description: 'Accounts, SSO & OpenCoins' },
@@ -34,7 +34,7 @@
@@ -34,7 +34,7 @@
// the link, with no edit anywhere else.
// the link, with no edit anywhere else.
const NETWORK = [
const NETWORK = [
{ id: 'live', name: 'Live streaming', url: 'https://openvibe.live', desc: 'Go live, restream and clip' },
{ id: 'live', name: 'Live streaming', url: 'https://openvibe.live', desc: 'Go live, restream and clip' },
- { id: 'tools', name: 'Free online tools', url: 'https://openvibe.tools', desc: 'The whole toolbox' },
+ { id: 'tools', name: 'Online tools', url: 'https://openvibe.tools', desc: 'The whole toolbox' },
{ id: 'dev', name: 'Developer tools', url: 'https://dev.openvibe.tools', desc: 'Formatters, encoders, validators' },
{ id: 'dev', name: 'Developer tools', url: 'https://dev.openvibe.tools', desc: 'Formatters, encoders, validators' },
{ id: 'net', name: 'Network diagnostics', url: 'https://net.openvibe.tools', desc: 'Ping, DNS, Whois, SSL' },
{ id: 'net', name: 'Network diagnostics', url: 'https://net.openvibe.tools', desc: 'Ping, DNS, Whois, SSL' },
{ id: 'img', name: 'Image converter', url: 'https://img.openvibe.tools', desc: 'Convert, resize, compress' },
{ id: 'img', name: 'Image converter', url: 'https://img.openvibe.tools', desc: 'Convert, resize, compress' },
@@ -42,7 +42,11 @@
@@ -42,7 +42,11 @@
{ id: 'docs', name: 'PDF & document tools', url: 'https://docs.openvibe.tools', desc: 'Merge, split, compress' },
{ id: 'docs', name: 'PDF & document tools', url: 'https://docs.openvibe.tools', desc: 'Merge, split, compress' },
{ id: 'text', name: 'Text tools', url: 'https://text.openvibe.tools', desc: 'Fancy text, case, ASCII' },
{ id: 'text', name: 'Text tools', url: 'https://text.openvibe.tools', desc: 'Fancy text, case, ASCII' },
{ id: 'yt', name: 'YouTube downloader', url: 'https://yt.openvibe.tools', desc: 'Video and audio' },
{ id: 'yt', name: 'YouTube downloader', url: 'https://yt.openvibe.tools', desc: 'Video and audio' },
+ { id: 'community', name: 'Community & pastes', url: 'https://openvibe.community', desc: 'Pastes, posts and the people of OpenVibe' },
+ { id: 'games', name: 'Games', url: 'https://openvibe.games', desc: 'Scraplandia and browser games' },
+ { id: 'media', name: 'Media', url: 'https://openvibe.media', desc: 'VODs, clips and files for every site' },
{ id: 'network', name: 'Account & themes', url: NETWORK_URL, desc: 'One account, every site' },
{ id: 'network', name: 'Account & themes', url: NETWORK_URL, desc: 'One account, every site' },
+ { id: 'more', name: 'The whole network →', url: NETWORK_URL + '/#network', desc: 'Every OpenVibe site, live and upcoming' },
];
];
const LEGAL = [
const LEGAL = [
@@ -59,7 +63,7 @@
@@ -59,7 +63,7 @@
const DEFAULTS = {
const DEFAULTS = {
service: null, // 'live' | 'tools' | 'dev' | … (auto-detected when omitted)
service: null, // 'live' | 'tools' | 'dev' | … (auto-detected when omitted)
brandName: null,
brandName: null,
- tagline: 'Free, open source and community run. Built in the open by the people using it.',
+ tagline: 'Open source and community run. Built in the open by the people using it.',
variant: 'full',
variant: 'full',
mount: '#ov-footer',
mount: '#ov-footer',
links: [], // [{ heading, items: [{ name, url, onclick? }] }]
links: [], // [{ heading, items: [{ name, url, onclick? }] }]
@@ -84,9 +88,20 @@
@@ -84,9 +88,20 @@
return sub === h ? 'tools' : (sub || 'tools');
return sub === h ? 'tools' : (sub || 'tools');
}
}
+ const TLD_LABELS = { live: 'Live', tools: 'Tools', network: 'Network', media: 'Media', games: 'Games', community: 'Community', chat: 'Chat', codes: 'Codes', blog: 'Blog', wiki: 'Wiki', news: 'News', reviews: 'Reviews', tips: 'Tips', vip: 'VIP', trade: 'Trade', host: 'Host', deals: 'Deals', coupons: 'Coupons' };
+ /** The full site name from the hostname — the same spelling the navbar uses (Pastes.OpenVibe.Tools). */
function brandFor(service) {
function brandFor(service) {
+ // The navbar already resolved the spelling (MergePDF, JSON, Pastes…): reuse it so the two
+ // never disagree on the same page.
+ try { const nb = typeof window !== 'undefined' && window.OpenVibeNavbar && window.OpenVibeNavbar.brand && window.OpenVibeNavbar.brand(); if (nb && nb.name) return nb.name; } catch { /* */ }
+ const h = typeof location !== 'undefined' ? location.hostname.toLowerCase() : '';
+ const cap = (w) => w ? w.charAt(0).toUpperCase() + w.slice(1) : '';
+ let m = h.match(/^(?:(.+)\.)?openvibe\.([a-z]+)$/);
+ if (m) { const sub = m[1] && m[1] !== 'www' ? m[1] : null; return [sub ? (sub.length <= 4 ? sub.toUpperCase() : cap(sub)) : null, 'OpenVibe', TLD_LABELS[m[2]] || cap(m[2])].filter(Boolean).join('.'); }
+ if ((m = h.match(/^(?:(.+)\.)?openre\.stream$/))) return 'OpenRe.Stream';
const found = NETWORK.find(n => n.id === service);
const found = NETWORK.find(n => n.id === service);
- if (found) return found.name === 'Account & themes' ? 'OpenVibe' : found.name;
+ if (service && TLD_LABELS[service]) return `OpenVibe.${TLD_LABELS[service]}`;
+ if (found && found.name !== 'Account & themes' && found.name !== 'The whole network →') return `${cap(service)}.OpenVibe.Tools`;
return 'OpenVibe';
return 'OpenVibe';
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,111 @@
@@ -0,0 +1,111 @@
+/**
+ * openvibe-shared/history.js — cross-site history for a signed-in account.
+ *
+ * OpenVibeHistory.record({ type, title, url, icon, service, meta }, { token, apiBase })
+ * OpenVibeHistory.recent({ limit, service, type, token, apiBase }) → [{...}]
+ * OpenVibeHistory.clear({ token }) → wipes it
+ *
+ * Every property records what the account touched — a tool used, a stream watched, a paste
+ * opened, a game played — so "Recently used" in the shared navbar and the History tab on the
+ * Network show one timeline across the whole network instead of one per site. Writes are
+ * deduplicated server-side (same URL within ten minutes bumps the entry instead of adding one)
+ * and the browser side throttles too, so a page cannot flood it. Nothing is recorded for
+ * anonymous sessions, and the user can pause or clear the timeline from their account page.
+ *
+ * Loaded lazily by navbar.js from https://openvibe.network/shared/history.js; safe to include
+ * directly. Token resolution matches the navbar: ov_token cookie → localStorage.ov_token.
+ */
+(function (root) {
+ 'use strict';
+ if (root.OpenVibeHistory) return;
+
+ const DEFAULT_API = 'https://openvibe.network';
+ const SENT_KEY = 'ov_history_sent'; // sessionStorage: url → ts, to avoid re-posting on every re-render
+ const THROTTLE_MS = 5 * 60 * 1000;
+
+ function token(opts) {
+ if (opts && opts.token) return opts.token;
+ const m = (typeof document !== 'undefined' ? document.cookie : '').match(/(?:^|;\s*)ov_token=([^;]*)/);
+ if (m && m[1]) { try { return decodeURIComponent(m[1]); } catch { return m[1]; } }
+ try { return localStorage.getItem('ov_token'); } catch { return null; }
+ }
+
+ function api(opts) { return (opts && opts.apiBase) || DEFAULT_API; }
+
+ function sent() { try { return JSON.parse(sessionStorage.getItem(SENT_KEY) || '{}'); } catch { return {}; } }
+ function markSent(url) {
+ try {
+ const m = sent(); m[url] = Date.now();
+ const keys = Object.keys(m); if (keys.length > 60) for (const k of keys.slice(0, keys.length - 60)) delete m[k];
+ sessionStorage.setItem(SENT_KEY, JSON.stringify(m));
+ } catch { /* storage unavailable */ }
+ }
+
+ /** Guess an icon and service label from the URL when the caller gives none. */
+ function inferService(url) {
+ let host = '';
+ try { host = new URL(url, location.href).hostname; } catch { return { service: null }; }
+ const m = host.match(/^(?:(.+)\.)?openvibe\.([a-z]+)$/) || host.match(/^(?:(.+)\.)?(openre)\.stream$/);
+ if (!m) return { service: null };
+ return { service: m[2], sub: m[1] || null };
+ }
+
+ const ICONS = { tool: 'fa-screwdriver-wrench', stream: 'fa-tower-broadcast', vod: 'fa-clapperboard', clip: 'fa-scissors', paste: 'fa-paste', game: 'fa-gamepad', page: 'fa-file-lines', post: 'fa-comments', account: 'fa-user', theme: 'fa-palette', download: 'fa-download', chat: 'fa-comment' };
+
+ async function record(entry, opts) {
+ const t = token(opts);
+ if (!t || !entry) return false;
+ const url = String(entry.url || (typeof location !== 'undefined' ? location.href : '')).slice(0, 2000);
+ if (!url) return false;
+ const last = sent()[url] || 0;
+ if (!entry.force && Date.now() - last < THROTTLE_MS) return false;
+ markSent(url);
+ const inf = inferService(url);
+ const body = {
+ type: String(entry.type || 'page').slice(0, 32),
+ title: String(entry.title || (typeof document !== 'undefined' ? document.title : '') || url).slice(0, 200),
+ url,
+ icon: entry.icon || ICONS[entry.type] || null,
+ service: entry.service || inf.service || null,
+ sub: entry.sub || inf.sub || null,
+ meta: entry.meta && typeof entry.meta === 'object' ? entry.meta : null,
+ };
+ try {
+ const res = await fetch(`${api(opts)}/api/history`, {
+ method: 'POST', keepalive: true,
+ headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${t}` },
+ body: JSON.stringify(body),
+ });
+ return res.ok;
+ } catch { return false; }
+ }
+
+ async function recent(opts) {
+ const t = token(opts);
+ if (!t) return [];
+ const q = new URLSearchParams();
+ if (opts && opts.limit) q.set('limit', String(opts.limit));
+ if (opts && opts.service) q.set('service', opts.service);
+ if (opts && opts.type) q.set('type', opts.type);
+ try {
+ const res = await fetch(`${api(opts)}/api/history?${q}`, { headers: { Authorization: `Bearer ${t}` } });
+ if (!res.ok) return [];
+ const data = await res.json();
+ return (data && data.items) || [];
+ } catch { return []; }
+ }
+
+ async function clear(opts) {
+ const t = token(opts);
+ if (!t) return false;
+ try { const res = await fetch(`${api(opts)}/api/history`, { method: 'DELETE', headers: { Authorization: `Bearer ${t}` } }); return res.ok; } catch { return false; }
+ }
+
+ async function remove(id, opts) {
+ const t = token(opts);
+ if (!t) return false;
+ try { const res = await fetch(`${api(opts)}/api/history/${encodeURIComponent(id)}`, { method: 'DELETE', headers: { Authorization: `Bearer ${t}` } }); return res.ok; } catch { return false; }
+ }
+
+ root.OpenVibeHistory = { record, recent, clear, remove, ICONS };
+})(typeof window !== 'undefined' ? window : globalThis);
new file mode 100644
new file mode 100644
@@ -0,0 +1,161 @@
@@ -0,0 +1,161 @@
+/**
+ * openvibe-shared/island.js — the activity island.
+ *
+ * A page tells the navbar what it is doing. The brand mark becomes the indicator (progress ring,
+ * state colour) and a pill grows out of the brand; hover, focus or tap expands it into a rich card
+ * (image, title, subtitle, progress bar, detail line, actions).
+ *
+ * OpenVibeIsland.start({ id, title, subtitle, icon, image, progress, state, detail, actions })
+ * OpenVibeIsland.update(id, patch) progress: 0–1 | null (indeterminate)
+ * OpenVibeIsland.finish(id, { state: 'ok', title, actions, ttl })
+ * OpenVibeIsland.fail(id, { title, detail })
+ * OpenVibeIsland.remove(id) · .list()
+ *
+ * Works without the navbar (fixed pill, top centre). All text goes through textContent; links are
+ * http(s) or same-origin paths only. Motion respects prefers-reduced-motion.
+ */
+(function (root) {
+ 'use strict';
+ if (root.OpenVibeIsland) return;
+ if (typeof document === 'undefined') { const n = () => {}; const stub = { start: () => ({ update: n, finish: n, fail: n, remove: n }), update: n, finish: n, fail: n, remove: n, list: () => [] }; if (typeof module !== 'undefined') module.exports = stub; return; }
+
+ const CSS = `
+.ov-island{position:relative;display:inline-flex;align-items:center;margin-left:10px;z-index:60;font:500 12.5px/1.2 system-ui,-apple-system,Segoe UI,Roboto,sans-serif;color:var(--text-primary,#e6edf7)}
+.ov-island.is-floating{position:fixed;top:10px;left:50%;transform:translateX(-50%);margin:0;z-index:2147482000}
+.ov-island[hidden]{display:none}
+.ovi-pill{appearance:none;border:1px solid color-mix(in srgb,var(--ovis-c) 45%,transparent);background:color-mix(in srgb,var(--ovis-c) 12%,var(--bg-elevated,var(--bg-secondary,#0d131d)));color:inherit;font:inherit;display:inline-flex;align-items:center;gap:8px;height:30px;padding:0 12px 0 5px;border-radius:999px;cursor:pointer;max-width:min(300px,46vw);animation:ovisGrow .4s cubic-bezier(.2,1.3,.3,1);box-shadow:0 4px 18px rgba(0,0,0,.28)}
+.ov-island{--ovis-c:var(--accent,#3b82f6)}
+.ov-island[data-state=ok]{--ovis-c:var(--success,#22c55e)}
+.ov-island[data-state=error]{--ovis-c:var(--danger,#ef4444)}
+.ovi-pill .ov-icon{--ovi-size:22px;--ovi-accent:var(--ovis-c)}
+.ovi-pill-text{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-weight:650}
+.ovi-pill-pct{font-variant-numeric:tabular-nums;color:var(--text-secondary,#a8b3c4)}
+.ovi-count{background:var(--ovis-c);color:var(--on-accent,#fff);border-radius:999px;font-size:10.5px;font-weight:800;padding:1px 6px}
+.ovi-card{position:absolute;top:calc(100% + 8px);left:0;width:min(340px,calc(100vw - 24px));background:var(--bg-elevated,var(--bg-secondary,#111826));border:1px solid var(--border,rgba(255,255,255,.12));border-radius:18px;box-shadow:0 24px 60px rgba(0,0,0,.5);padding:8px;opacity:0;transform:translateY(-6px) scale(.97);transform-origin:top left;pointer-events:none;transition:opacity .18s,transform .22s cubic-bezier(.2,1.2,.3,1)}
+.ov-island.is-floating .ovi-card{left:50%;translate:-50% 0;transform-origin:top center}
+.ov-island.is-open .ovi-card{opacity:1;transform:none;pointer-events:auto}
+.ovi-item{display:grid;grid-template-columns:auto 1fr;gap:10px;padding:8px;border-radius:12px;--ovis-c:var(--accent,#3b82f6)}
+.ovi-item[data-state=ok]{--ovis-c:var(--success,#22c55e)}
+.ovi-item[data-state=error]{--ovis-c:var(--danger,#ef4444)}
+.ovi-item+.ovi-item{border-top:1px solid var(--border,rgba(255,255,255,.08));border-radius:0}
+.ovi-thumb{width:64px;height:44px;border-radius:8px;object-fit:cover;background:#0006}
+.ovi-item .ov-icon{--ovi-size:40px;--ovi-accent:var(--ovis-c)}
+.ovi-t{font-weight:700;font-size:13.5px}
+.ovi-s{color:var(--text-secondary,#a8b3c4);margin-top:2px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
+.ovi-body{min-width:0}
+.ovi-bar{height:4px;border-radius:4px;background:color-mix(in srgb,var(--ovis-c) 18%,transparent);margin-top:8px;overflow:hidden}
+.ovi-bar i{display:block;height:100%;width:0;background:var(--ovis-c);border-radius:4px;transition:width .35s cubic-bezier(.2,.8,.2,1)}
+.ovi-bar.is-ind i{width:35%;animation:ovisInd 1.2s ease-in-out infinite}
+.ovi-d{color:var(--text-muted,#7d8aa0);font-size:11.5px;margin-top:6px;font-variant-numeric:tabular-nums;overflow-wrap:anywhere}
+.ovi-acts{display:flex;gap:6px;flex-wrap:wrap;margin-top:8px}
+.ovi-act{appearance:none;border:0;cursor:pointer;text-decoration:none;font:700 12px/1 system-ui,sans-serif;padding:7px 11px;border-radius:8px;color:var(--ovis-c);background:color-mix(in srgb,var(--ovis-c) 14%,transparent)}
+.ovi-act:first-child{background:var(--ovis-c);color:var(--on-accent,#fff)}
+.ovi-act:focus-visible,.ovi-pill:focus-visible{outline:2px solid var(--ovis-c);outline-offset:2px}
+@keyframes ovisGrow{from{max-width:30px;opacity:0}to{opacity:1}}
+@keyframes ovisInd{0%{transform:translateX(-110%)}100%{transform:translateX(300%)}}
+@media (max-width:640px){.ov-island{margin-left:6px}.ovi-pill{max-width:40vw;padding-right:9px}.ovi-pill-pct{display:none}.ovi-card{position:fixed;top:56px;left:12px;right:12px;width:auto}}
+@media (prefers-reduced-motion:reduce){.ovi-pill,.ovi-bar.is-ind i{animation:none}.ovi-card,.ovi-bar i{transition:none}}`;
+
+ const items = new Map(); // id → activity (insertion order = age)
+ const timers = new Map();
+ let host = null, pill = null, card = null, seq = 0;
+
+ const el = (tag, cls, text) => { const e = document.createElement(tag); if (cls) e.className = cls; if (text != null) e.textContent = String(text); return e; };
+ const safeUrl = (u) => { try { const x = new URL(u, location.href); return /^(https?|blob):$/.test(x.protocol) ? x.href : null; } catch { return null; } };
+ const clamp = (p) => (typeof p === 'number' && isFinite(p) ? Math.max(0, Math.min(1, p)) : null);
+
+ function ensure() {
+ if (host && host.isConnected) return;
+ if (!document.getElementById('ov-island-css')) { const s = el('style'); s.id = 'ov-island-css'; s.textContent = CSS; document.head.appendChild(s); }
+ if (!root.OpenVibeIcons && !document.getElementById('ov-icons-loader')) { const sc = el('script'); sc.id = 'ov-icons-loader'; sc.src = 'https://openvibe.network/shared/ov-icons.js'; sc.async = true; document.head.appendChild(sc); }
+ host = el('div', 'ov-island'); host.hidden = true;
+ pill = el('button', 'ovi-pill'); pill.type = 'button'; pill.setAttribute('aria-haspopup', 'true'); pill.setAttribute('aria-expanded', 'false');
+ card = el('div', 'ovi-card'); card.setAttribute('role', 'region'); card.setAttribute('aria-label', 'Activity');
+ host.append(pill, card);
+ const brand = document.querySelector('.openvibe-navbar .ovnav-brand, .openvibe-navbar .openvibe-brand, .navbar .nav-brand, .navbar .logo');
+ if (brand && brand.parentNode) brand.parentNode.insertBefore(host, brand.nextSibling);
+ else { host.classList.add('is-floating'); document.body.appendChild(host); }
+ const open = (v) => { host.classList.toggle('is-open', v); pill.setAttribute('aria-expanded', String(v)); };
+ pill.addEventListener('click', () => open(!host.classList.contains('is-open')));
+ if (matchMedia('(hover:hover)').matches) { host.addEventListener('mouseenter', () => open(true)); host.addEventListener('mouseleave', () => open(false)); }
+ document.addEventListener('click', (e) => { if (!host.contains(e.target)) open(false); });
+ document.addEventListener('keydown', (e) => { if (e.key === 'Escape') open(false); });
+ }
+
+ function top() { let t = null; for (const a of items.values()) t = a; return t; }
+
+ function brandMark(a) {
+ // The navbar's own mark mirrors the top activity: progress ring + state colour.
+ document.querySelectorAll('.openvibe-navbar .ov-mark, .navbar .ov-mark').forEach((m) => {
+ if (m.closest('.openvibe-network-badge')) return;
+ if (!a) { m.removeAttribute('data-state'); m.removeAttribute('data-progress'); m.style.removeProperty('--ovm-p'); return; }
+ m.setAttribute('data-state', a.state === 'info' ? 'idle' : a.state);
+ if (a.progress == null || a.state !== 'busy') m.removeAttribute('data-progress'); else { m.setAttribute('data-progress', String(a.progress)); m.style.setProperty('--ovm-p', String(a.progress)); }
+ });
+ try { root.dispatchEvent(new CustomEvent('ov:activity', { detail: a ? { id: a.id, state: a.state, progress: a.progress, title: a.title } : null })); } catch { /* */ }
+ }
+
+ function iconEl(a, size) { const i = el('span', 'ov-icon'); i.dataset.icon = a.icon || 'ov'; i.dataset.size = String(size); i.dataset.state = a.state === 'info' ? 'idle' : a.state; if (a.progress != null && a.state === 'busy') i.dataset.progress = String(a.progress); i.dataset.fx = a.state === 'busy' ? 'orbit' : 'none'; return i; }
+
+ function render() {
+ ensure();
+ const a = top();
+ brandMark(a);
+ if (!a) { host.hidden = true; host.classList.remove('is-open'); return; }
+ host.hidden = false; host.dataset.state = a.state;
+ pill.replaceChildren(iconEl(a, 22), el('span', 'ovi-pill-text', a.title || 'Working'));
+ if (a.state === 'busy' && a.progress != null) pill.appendChild(el('span', 'ovi-pill-pct', Math.round(a.progress * 100) + '%'));
+ if (items.size > 1) pill.appendChild(el('span', 'ovi-count', items.size));
+ pill.setAttribute('aria-label', `${a.title || 'Activity'}${a.progress != null ? ', ' + Math.round(a.progress * 100) + ' percent' : ''}`);
+
+ card.replaceChildren(...[...items.values()].reverse().map((it) => {
+ const row = el('div', 'ovi-item'); row.dataset.state = it.state;
+ const img = it.image && safeUrl(it.image);
+ if (img) { const im = el('img', 'ovi-thumb'); im.src = img; im.alt = ''; im.loading = 'lazy'; im.referrerPolicy = 'no-referrer'; row.appendChild(im); } else row.appendChild(iconEl(it, 40));
+ const body = el('div', 'ovi-body'); body.appendChild(el('div', 'ovi-t', it.title || ''));
+ if (it.subtitle) body.appendChild(el('div', 'ovi-s', it.subtitle));
+ if (it.state === 'busy') { const bar = el('div', 'ovi-bar' + (it.progress == null ? ' is-ind' : '')); const fill = el('i'); if (it.progress != null) fill.style.width = (it.progress * 100).toFixed(1) + '%'; bar.appendChild(fill); bar.setAttribute('role', 'progressbar'); if (it.progress != null) bar.setAttribute('aria-valuenow', String(Math.round(it.progress * 100))); body.appendChild(bar); }
+ if (it.detail) body.appendChild(el('div', 'ovi-d', it.detail));
+ if (it.actions && it.actions.length) {
+ const acts = el('div', 'ovi-acts');
+ it.actions.slice(0, 3).forEach((ac) => {
+ if (!ac || !ac.label) return;
+ const href = ac.href && safeUrl(ac.href);
+ const b = el(href ? 'a' : 'button', 'ovi-act', ac.label);
+ if (href) { b.href = href; if (ac.download) b.setAttribute('download', typeof ac.download === 'string' ? ac.download : ''); } else b.type = 'button';
+ if (ac.onClick) b.addEventListener('click', (ev) => { try { ac.onClick(ev); } catch { /* */ } });
+ acts.appendChild(b);
+ });
+ body.appendChild(acts);
+ }
+ row.appendChild(body);
+ return row;
+ }));
+ }
+
+ function put(id, patch, defaults) {
+ const prev = items.get(id) || Object.assign({ id, title: '', subtitle: '', icon: 'ov', image: '', progress: null, state: 'busy', detail: '', actions: [] }, defaults);
+ const next = Object.assign({}, prev, patch || {});
+ if (patch && 'progress' in patch) next.progress = clamp(patch.progress);
+ if (!['busy', 'ok', 'error', 'info'].includes(next.state)) next.state = 'busy';
+ items.set(id, next);
+ clearTimeout(timers.get(id));
+ if (next.ttl) timers.set(id, setTimeout(() => remove(id), next.ttl));
+ render();
+ return next;
+ }
+
+ function handle(id) { return { id, update: (p) => update(id, p), finish: (p) => finish(id, p), fail: (p) => fail(id, p), remove: () => remove(id) }; }
+ function start(o) { const id = (o && o.id) || 'act-' + (++seq); items.delete(id); put(id, Object.assign({ state: 'busy' }, o, { id })); return handle(id); }
+ function update(id, patch) { if (!items.has(id)) return; const p = Object.assign({}, patch); delete p.id; put(id, p); }
+ function finish(id, patch) { if (!items.has(id)) return; put(id, Object.assign({ state: 'ok', progress: 1, actions: [], ttl: 8000 }, patch)); if (host && top() && top().id === id && (patch && patch.actions && patch.actions.length)) { host.classList.add('is-open'); pill.setAttribute('aria-expanded', 'true'); setTimeout(() => { if (host && !host.matches(':hover')) host.classList.remove('is-open'); }, 5000); } }
+ function fail(id, patch) { if (!items.has(id)) return; put(id, Object.assign({ state: 'error', progress: null, actions: [], ttl: 12000 }, patch)); }
+ function remove(id) { clearTimeout(timers.get(id)); timers.delete(id); if (items.delete(id)) render(); }
+
+ const api = { start, update, finish, fail, remove, list: () => [...items.values()] };
+ root.OpenVibeIsland = api;
+ // The navbar exposes the same thing as OpenVibeNavbar.activity, whichever loads first.
+ const bind = () => { if (root.OpenVibeNavbar && !root.OpenVibeNavbar.activity) { try { root.OpenVibeNavbar.activity = api; } catch { /* */ } } };
+ bind(); root.addEventListener('load', bind);
+ if (typeof module !== 'undefined' && module.exports) module.exports = api;
+})(typeof window !== 'undefined' ? window : globalThis);
@@ -8,12 +8,43 @@
@@ -8,12 +8,43 @@
// onLogin/onLogout callbacks. When no `user` is passed the navbar resolves
// onLogin/onLogout callbacks. When no `user` is passed the navbar resolves
// it itself: ov_token cookie → localStorage → token opt → sessionUrl, then
// it itself: ov_token cookie → localStorage → token opt → sessionUrl, then
// GET {apiBase}/api/auth/me with `Authorization: Bearer <token>`.
// GET {apiBase}/api/auth/me with `Authorization: Bearer <token>`.
+//
+// Brand: derived from the hostname — `pastes.openvibe.tools` renders as
+// Pastes · OpenVibe · Tools (three segments, the subdomain first so the
+// context reads left-to-right), `openvibe.live` as OpenVibe · Live. Pass
+// brand: { sub, tld, name, icon, variant } to override any part, or the
+// legacy brandName/brandIcon. compact: 'auto' (default — the brand shortens
+// to the subdomain on narrow viewports), 'always', 'never'.
+// Menus are modular: every site keeps the shared account menu and adds its own
+// pieces — links: [{label, href, icon?, active?}] replaces the service's top
+// links; menu: { before: [item], after: [item] } adds dropdown rows
+// ({label, href, icon, onClick, danger, external}); OpenVibeNavbar.addMenuItem()
+// / setLinks() do the same at runtime. Signed-in users also get a
+// "Recently used" row fed by the shared history module when it is loaded.
// ═══════════════════════════════════════════════════════════════
// ═══════════════════════════════════════════════════════════════
(function (root) {
(function (root) {
'use strict';
'use strict';
- let _config = { service: 'network', token: null, user: null, apiBase: 'https://openvibe.network', onLogin: null, onLogout: null, loginUrl: null, sessionUrl: null };
+ let _config = {
+ service: 'network', token: null, user: null, apiBase: 'https://openvibe.network',
+ onLogin: null, onLogout: null, loginUrl: null, sessionUrl: null,
+ brand: null, brandName: null, brandIcon: null, compact: 'auto',
+ links: null, menu: null, recent: true,
+ // history: { type: 'tool'|'stream'|'paste'|'page'|…, title, url, icon } — recorded for the
+ // signed-in user once auth resolves (cross-site "Recently used" / History on the Network).
+ history: null,
+ // silentLogin: 'https://site/auth/login?silent=1&next={url}' — when nobody is signed in here
+ // but this browser has signed in to the network before (ov_sso_hint), try one silent
+ // prompt=none round trip per tab so a session on one site becomes a session on all.
+ silentLogin: null,
+ // fedcm: false to opt out; 'optional' (default) shows the browser's native chip the first
+ // time and re-authenticates silently afterwards; 'silent' only re-authenticates.
+ fedcm: 'optional',
+ fedcmLogin: null, // POST target for the assertion (default: this site's /auth/fedcm)
+ };
+ const _runtimeMenu = { before: [], after: [] };
+ let _runtimeLinks = null;
let _navEl = null;
let _navEl = null;
function injectStyles() {
function injectStyles() {
@@ -29,10 +60,65 @@
@@ -29,10 +60,65 @@
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
font-family: 'Inter', -apple-system, BlinkMacSystemFont, sans-serif;
color: var(--text-primary, #e0e0e0);
color: var(--text-primary, #e0e0e0);
}
}
- .openvibe-navbar-brand { display: flex; align-items: center; gap: 8px; text-decoration: none; color: inherit; margin-right: 8px; }
- .openvibe-navbar-brand .flame { font-size: 18px; color: var(--accent, #8b5cf6); }
- .openvibe-navbar-brand .name { font-size: 15px; font-weight: 700; letter-spacing: -.3px; }
- .openvibe-navbar-brand .service-name { font-size: 11px; color: var(--accent-light, #a78bfa); font-weight: 500; letter-spacing: .5px; text-transform: uppercase; }
+ .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)); }
+ .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; }
+ .ovnav-launcher .ovl-q:focus { border-color: var(--accent, #3b82f6); }
+ .ovnav-launcher .ovl-h { display: flex; justify-content: space-between; font-size: 11px; font-weight: 700; letter-spacing: .7px; text-transform: uppercase; color: var(--text-muted, #7d8aa0); margin: 12px 4px 6px; }
+ .ovnav-launcher .ovl-h a { color: var(--accent-light, var(--accent, #60a5fa)); text-decoration: none; text-transform: none; letter-spacing: 0; }
+ .ovnav-launcher .ovl-sites { display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; }
+ .ovnav-launcher .ovl-fams { display: grid; grid-template-columns: repeat(2, 1fr); gap: 2px; }
+ .ovnav-launcher .ovl-site, .ovnav-launcher .ovl-fam { display: flex; align-items: center; gap: 9px; padding: 8px; border-radius: 10px; color: var(--text-primary, #e6edf7); text-decoration: none; min-width: 0; }
+ .ovnav-launcher .ovl-site { flex-direction: column; text-align: center; gap: 6px; padding: 12px 6px; }
+ .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-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; } }
+ .openvibe-navbar-brand .flame { font-size: 18px; text-decoration: none; color: var(--accent, #8b5cf6); display: inline-grid; place-items: center; width: 28px; height: 28px; flex: none; }
+ .openvibe-navbar-brand .name { display: inline-flex; align-items: baseline; font-size: 15px; font-weight: 700; letter-spacing: -.3px; white-space: nowrap; line-height: 1; }
+ /* Segments: subdomain · OpenVibe · TLD. The part that names *this* site is the loud one. */
+ .openvibe-navbar-brand .b-sub { color: var(--text-primary, #e0e0e0); }
+ .openvibe-navbar-brand .b-core { color: var(--text-primary, #e0e0e0); }
+ .openvibe-navbar-brand .b-tld { color: var(--accent-light, var(--accent, #a78bfa)); }
+ .openvibe-navbar-brand.has-sub .b-core { color: var(--text-secondary, #b0b0b8); font-weight: 600; }
+ .openvibe-navbar-brand.has-sub .b-tld { color: var(--text-secondary, #b0b0b8); font-weight: 600; }
+ .openvibe-navbar-brand .b-dot { color: var(--accent, #8b5cf6); opacity: .75; margin: 0 1px; font-weight: 800; }
+ .openvibe-navbar-brand .b-sub, .openvibe-navbar-brand .b-core, .openvibe-navbar-brand .b-tld { transition: color .2s, opacity .2s; }
+ .openvibe-navbar-brand:hover .b-tld, .openvibe-navbar-brand:hover .b-sub { color: var(--accent-light, #a78bfa); }
+ .openvibe-navbar-brand:hover .b-dot { opacity: 1; }
+ .openvibe-navbar-brand .b-tag { font-size: 9px; font-weight: 700; letter-spacing: .6px; text-transform: uppercase; color: var(--accent-light, #a78bfa); background: var(--accent-glow, rgba(139,92,246,.14)); border-radius: 4px; padding: 2px 5px; margin-left: 6px; align-self: center; }
+ /* Compact: drop the trailing segments on narrow viewports, keep what identifies the page. */
+ .openvibe-navbar[data-compact="always"] .openvibe-navbar-brand.has-sub .b-core,
+ .openvibe-navbar[data-compact="always"] .openvibe-navbar-brand.has-sub .b-tld,
+ .openvibe-navbar[data-compact="always"] .openvibe-navbar-brand.has-sub .b-dot { display: none; }
+ @media (max-width: 860px) {
+ .openvibe-navbar[data-compact="auto"] .openvibe-navbar-brand.has-sub .b-core,
+ .openvibe-navbar[data-compact="auto"] .openvibe-navbar-brand.has-sub .b-tld,
+ .openvibe-navbar[data-compact="auto"] .openvibe-navbar-brand.has-sub .b-dot { display: none; }
+ }
+ @media (max-width: 420px) {
+ .openvibe-navbar[data-compact="auto"] .openvibe-navbar-brand:not(.has-sub) .b-core,
+ .openvibe-navbar[data-compact="auto"] .openvibe-navbar-brand:not(.has-sub) .b-dot { display: none; }
+ .openvibe-navbar[data-compact="auto"] .openvibe-navbar-brand .b-tag { display: none; }
+ }
+ .openvibe-navbar-links a .icon { margin-right: 5px; opacity: .8; }
+ .openvibe-navbar-dropdown-recent { padding: 6px 8px 2px; border-bottom: 1px solid var(--border, #333340); }
+ .openvibe-navbar-dropdown-recent .label { font-size: 10px; font-weight: 700; letter-spacing: .6px; text-transform: uppercase; color: var(--text-muted, #707080); padding: 2px 8px 4px; display: flex; justify-content: space-between; align-items: center; }
+ .openvibe-navbar-dropdown-recent .label a { color: var(--accent-light, #a78bfa); text-decoration: none; font-weight: 600; letter-spacing: 0; text-transform: none; }
+ .openvibe-navbar-dropdown-recent .item { display: flex; align-items: center; gap: 8px; padding: 6px 8px; border-radius: 6px; font-size: 12px; color: var(--text-secondary, #b0b0b8); text-decoration: none; transition: background .12s; min-width: 0; }
+ .openvibe-navbar-dropdown-recent .item:hover { background: var(--bg-hover, #2f2f3d); color: var(--text-primary, #e0e0e0); }
+ .openvibe-navbar-dropdown-recent .item .icon { width: 18px; text-align: center; color: var(--accent-light, #a78bfa); flex: none; }
+ .openvibe-navbar-dropdown-recent .item .t { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+ .openvibe-navbar-dropdown-recent .item .s { margin-left: auto; font-size: 10px; color: var(--text-muted, #707080); flex: none; }
+ .openvibe-navbar-dropdown-menu .sep { height: 1px; background: var(--border, #333340); margin: 4px -8px; }
.openvibe-navbar-links { display: flex; align-items: center; gap: 4px; margin-left: 8px; }
.openvibe-navbar-links { display: flex; align-items: center; gap: 4px; margin-left: 8px; }
.openvibe-navbar-links a {
.openvibe-navbar-links a {
@@ -350,6 +436,211 @@
@@ -350,6 +436,211 @@
'colors.openvibe.tools': { name: 'OpenVibeColor', icon: 'fa-palette' },
'colors.openvibe.tools': { name: 'OpenVibeColor', icon: 'fa-palette' },
};
};
+ // ─── Brand from hostname ───────────────────────────────────
+ // Every property is <sub?>.openvibe.<tld> (plus openre.stream). The navbar spells the
+ // whole name — Pastes.OpenVibe.Tools, not "Paste.OpenVibe" — because the subdomain and
+ // the TLD are what tell a visitor where they are in the network.
+ const TLD_LABELS = {
+ live: 'Live', tools: 'Tools', network: 'Network', media: 'Media', games: 'Games',
+ community: 'Community', chat: 'Chat', codes: 'Codes', blog: 'Blog', wiki: 'Wiki',
+ news: 'News', reviews: 'Reviews', tips: 'Tips', vip: 'VIP', trade: 'Trade', host: 'Host',
+ deals: 'Deals', coupons: 'Coupons',
+ };
+ const SUB_LABELS = {
+ json: 'JSON', yaml: 'YAML', xml: 'XML', csv: 'CSV', sql: 'SQL', html: 'HTML', jwt: 'JWT',
+ uuid: 'UUID', guid: 'GUID', url: 'URL', b64: 'B64', sha256: 'SHA256', og: 'OG', md: 'MD',
+ yt: 'YT', ip: 'IP', myip: 'MyIP', ipv4: 'IPv4', ipv6: 'IPv6', geoip: 'GeoIP', asn: 'ASN',
+ rdns: 'rDNS', dns: 'DNS', mx: 'MX', txt: 'TXT', ns: 'NS', spf: 'SPF', dkim: 'DKIM',
+ dmarc: 'DMARC', mtr: 'MTR', ssl: 'SSL', tls: 'TLS', ptr: 'PTR', smtp: 'SMTP', http: 'HTTP',
+ httpstatus: 'HTTPStatus', rdap: 'RDAP', isp: 'ISP', pdf: 'PDF', mergepdf: 'MergePDF',
+ splitpdf: 'SplitPDF', compresspdf: 'CompressPDF', rotatepdf: 'RotatePDF',
+ reorderpdf: 'ReorderPDF', watermarkpdf: 'WatermarkPDF', protectpdf: 'ProtectPDF',
+ unlockpdf: 'UnlockPDF', image2pdf: 'Image2PDF', jpg2pdf: 'JPG2PDF', png2pdf: 'PNG2PDF',
+ pdf2jpg: 'PDF2JPG', pdf2png: 'PDF2PNG', png: 'PNG', jpg: 'JPG', jpeg: 'JPEG', webp: 'WebP',
+ avif: 'AVIF', heic: 'HEIC', heif: 'HEIF', svg: 'SVG', gif: 'GIF', ico: 'ICO', tiff: 'TIFF',
+ bmp: 'BMP', mp3: 'MP3', wav: 'WAV', flac: 'FLAC', ogg: 'OGG', m4a: 'M4A', aac: 'AAC',
+ opus: 'Opus', wma: 'WMA', aiff: 'AIFF', ac3: 'AC3', eq: 'EQ', equalizer: 'EQ', mxn: 'MXN',
+ ascii: 'ASCII', smallcaps: 'SmallCaps', titlecase: 'TitleCase', textlogo: 'TextLogo',
+ textart: 'TextArt', channelart: 'ChannelArt', lowerthird: 'LowerThird', copypaste: 'CopyPaste',
+ dnspropagation: 'DNSPropagation', opengraph: 'OpenGraph', whip: 'WHIP', ingest: 'Ingest',
+ play: 'Play', my: 'My', auth: 'Auth', api: 'API', admin: 'Admin', docs: 'Docs', dev: 'Dev',
+ net: 'Net', img: 'Img', pastes: 'Pastes', paste: 'Pastes', maps: 'Maps', food: 'Food',
+ text: 'Text', logo: 'Logo', audio: 'Audio', ai: 'AI', cdn: 'CDN', status: 'Status',
+ };
+ const SERVICE_TLD = { live: 'live', tools: 'tools', games: 'games', media: 'media', network: 'network', community: 'community' };
+ const SERVICE_SUB = { net: 'net', dev: 'dev', paste: 'pastes', maps: 'maps', food: 'food', img: 'img', yt: 'yt', audio: 'audio', text: 'text', logo: 'logo', docs: 'docs' };
+
+ function titleCase(w) { return w ? w.charAt(0).toUpperCase() + w.slice(1) : ''; }
+ function subLabel(sub) { return SUB_LABELS[sub] || titleCase(sub); }
+
+ /**
+ * { sub, core, tld, name, short, icon, variant } for the current page.
+ * sub 'Pastes' | null tld 'Tools' core 'OpenVibe'
+ * name 'Pastes.OpenVibe.Tools' short 'Pastes' (what compact mode keeps)
+ * variant the ov-mark flavour: the TLD id ('live', 'tools', …) — every site gets its own twist
+ */
+ function resolveBrand() {
+ const b = Object.assign({}, _config.brand || {});
+ const host = currentHost().toLowerCase();
+ let sub = null, tld = null, core = 'OpenVibe';
+ let m = host.match(/^(?:(.+)\.)?openvibe\.([a-z]+)$/);
+ if (m) { sub = m[1] && m[1] !== 'www' ? m[1] : null; tld = m[2]; }
+ else if ((m = host.match(/^(?:(.+)\.)?openre\.stream$/))) { sub = m[1] && m[1] !== 'www' ? m[1] : null; core = 'OpenRe'; tld = 'stream'; }
+ // Off-network hosts (localhost, previews): fall back to the service id.
+ if (!tld) { tld = SERVICE_TLD[_config.service] || (SERVICE_SUB[_config.service] ? 'tools' : 'network'); sub = SERVICE_SUB[_config.service] || null; }
+ // Legacy brandName ("Paste.OpenVibe", "OpenVibe.Live") still steers the segments.
+ if (_config.brandName && !b.sub && !b.tld) {
+ const parts = String(_config.brandName).split('.');
+ if (parts.length >= 2 && /^openvibe$/i.test(parts[0])) tld = parts[1].toLowerCase();
+ else if (parts.length >= 2 && /^openvibe$/i.test(parts[1])) sub = parts[0].toLowerCase();
+ else if (parts.length === 1) sub = parts[0].toLowerCase();
+ }
+ if (b.sub !== undefined) sub = b.sub ? String(b.sub).toLowerCase() : null;
+ if (b.tld) tld = String(b.tld).toLowerCase();
+ const subText = b.subLabel || (sub ? subLabel(sub) : null);
+ const tldText = b.tldLabel || TLD_LABELS[tld] || titleCase(tld);
+ const name = b.name || [subText, core, tldText].filter(Boolean).join('.');
+ const icon = b.icon || _config.brandIcon || null;
+ const variant = b.variant || (core === 'OpenRe' ? 'stream' : tld);
+ return { sub, subText, core, tld, tldText, name, short: subText || `${core}.${tldText}`, icon, variant, tag: b.tag || null, href: b.href || '/' };
+ }
+
+ /** Where each brand segment goes: sub → this tool, OpenVibe → the network, TLD → the site's apex. */
+ function brandLinks(brand) {
+ const apex = brand.core === 'OpenRe' ? 'https://openre.stream/' : `https://openvibe.${brand.tld}/`;
+ if (!brand.subText) return { sub: brand.href, core: brand.href, tld: brand.href, apex };
+ return { sub: brand.href, core: 'https://openvibe.network/', tld: apex, apex };
+ }
+
+ function brandHTML(brand) {
+ const to = brandLinks(brand);
+ const seg = (cls, text, href, title) => `<a href="${escapeAttr(href)}"${title ? ` title="${escapeAttr(title)}"` : ''} class="${cls}">${escapeAttr(text)}</a>`;
+ const dot = '<span class="b-dot">.</span>';
+ const text = brand.subText
+ ? seg('b-sub', brand.subText, to.sub, brand.name) + dot + seg('b-core', brand.core, to.core, 'OpenVibe Network') + dot + seg('b-tld', brand.tldText, to.tld, `All of ${brand.core}.${brand.tldText}`)
+ : seg('b-core', brand.core, to.core, brand.name) + dot + seg('b-tld', brand.tldText, to.tld, brand.name);
+ const mark = brand.icon
+ ? `<i class="fa-solid ${escapeAttr(brand.icon)}"></i>`
+ : `<span class="ov-mark" data-size="28" data-variant="${escapeAttr(brand.variant)}"></span>`;
+ return `<div class="openvibe-navbar-brand${brand.subText ? ' has-sub' : ''}">
+ <a class="flame" href="${escapeAttr(brand.href)}" aria-label="${escapeAttr(brand.name)} home">${mark}</a>
+ <span class="name">${text}${brand.tag ? `<span class="b-tag">${escapeAttr(brand.tag)}</span>` : ''}</span>
+ </div>
+ ${_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>'}`;
+ }
+
+ // ── Network launcher ─────────────────────────────────────
+ const LAUNCHER_SITES = [
+ { name: 'Live', desc: 'Streams, clips and chat', icon: 'live', url: 'https://openvibe.live/' },
+ { name: 'Tools', desc: 'Every online tool', icon: 'tools', url: 'https://openvibe.tools/' },
+ { name: 'Community', desc: 'Pastes and posts', icon: 'community', url: 'https://openvibe.community/' },
+ { name: 'Games', desc: 'Browser games', icon: 'games', url: 'https://openvibe.games/' },
+ { name: 'Media', desc: 'VODs, clips, files', icon: 'media', url: 'https://openvibe.media/' },
+ { name: 'Network', desc: 'Account and themes', icon: 'network', url: 'https://openvibe.network/' },
+ ];
+ const LAUNCHER_FAMILIES = [
+ { name: 'Media Tools', icon: 'youtube', url: 'https://yt.openvibe.tools/' },
+ { name: 'Image Tools', icon: 'image', url: 'https://img.openvibe.tools/' },
+ { name: 'Audio Tools', icon: 'audio', url: 'https://audio.openvibe.tools/' },
+ { name: 'PDF & Documents', icon: 'pdf', url: 'https://docs.openvibe.tools/' },
+ { name: 'Text Tools', icon: 'text', url: 'https://text.openvibe.tools/' },
+ { name: 'Developer Tools', icon: 'code', url: 'https://dev.openvibe.tools/' },
+ { name: 'Network Tools', icon: 'dns', url: 'https://net.openvibe.tools/' },
+ ];
+ const CATALOG_URL = 'https://openvibe.tools/api/catalog.json';
+ const okUrl = (u) => { try { const x = new URL(u); return x.protocol === 'https:' ? x.href : null; } catch { return null; } };
+
+ async function launcherCatalog() {
+ try { const c = JSON.parse(sessionStorage.getItem('ov_catalog') || 'null'); if (c && Date.now() - c.at < 30 * 60000) return c.data; } catch { /* */ }
+ try {
+ const r = await fetch(CATALOG_URL, { credentials: 'omit' }); if (!r.ok) return null;
+ const j = await r.json();
+ const data = { families: (j.families || []).slice(0, 12).map(f => ({ name: String(f.name || ''), icon: String(f.icon || 'tools'), url: okUrl(f.url) })).filter(f => f.name && f.url),
+ tools: (j.tools || []).slice(0, 400).map(t => ({ name: String(t.name || ''), icon: String(t.icon || 'tools'), url: okUrl(t.url), k: [t.name, t.tagline].concat(t.keywords || []).join(' ').toLowerCase().slice(0, 400) })).filter(t => t.name && t.url) };
+ try { sessionStorage.setItem('ov_catalog', JSON.stringify({ at: Date.now(), data })); } catch { /* */ }
+ return data;
+ } catch { return null; }
+ }
+
+ function bindLauncher(nav) {
+ const btn = nav.querySelector('#openvibe-launcher-btn'); if (!btn) return;
+ let panel = null;
+ const tile = (it, cls) => `<a class="${cls}" href="${escapeAttr(it.url)}"><span class="ov-icon" data-icon="${escapeAttr(it.icon)}" data-size="${cls === 'ovl-site' ? 34 : 24}" data-fx="none"></span><span><b>${escapeAttr(it.name)}</b>${it.desc ? `<small>${escapeAttr(it.desc)}</small>` : ''}</span></a>`;
+ const close = () => { if (panel) panel.classList.remove('open'); btn.setAttribute('aria-expanded', 'false'); };
+ function paint(cat, q) {
+ const fams = (cat && cat.families.length ? cat.families : LAUNCHER_FAMILIES);
+ const body = panel.querySelector('.ovl-body');
+ if (q && cat) {
+ const hits = cat.tools.filter(t => t.k.includes(q)).slice(0, 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>`;
+ }
+ btn.addEventListener('click', async (e) => {
+ e.stopPropagation();
+ if (panel && panel.classList.contains('open')) return close();
+ if (!panel) {
+ panel = document.createElement('div'); panel.className = 'ovnav-launcher'; panel.setAttribute('role', 'dialog'); panel.setAttribute('aria-label', 'OpenVibe sites and tools');
+ panel.innerHTML = '<input type="search" class="ovl-q" placeholder="Find a tool or site" aria-label="Find a tool or site"><div class="ovl-body"></div>';
+ nav.appendChild(panel);
+ if (!root.OpenVibeIcons && !document.getElementById('ov-icons-loader')) { const sc = document.createElement('script'); sc.id = 'ov-icons-loader'; sc.src = 'https://openvibe.network/shared/ov-icons.js'; sc.async = true; document.head.appendChild(sc); }
+ let cat = null; paint(null, '');
+ const input = panel.querySelector('.ovl-q');
+ input.addEventListener('input', () => paint(cat, input.value.trim().toLowerCase()));
+ input.addEventListener('keydown', (ev) => { if (ev.key === 'Enter') { const first = panel.querySelector('.ovl-body a'); if (first) location.href = first.href; } if (ev.key === 'ArrowDown') { const first = panel.querySelector('.ovl-body a'); if (first) { ev.preventDefault(); first.focus(); } } });
+ panel.addEventListener('keydown', (ev) => {
+ if (ev.key === 'Escape') { close(); btn.focus(); return; }
+ if (ev.key !== 'ArrowDown' && ev.key !== 'ArrowUp') return;
+ const links = [...panel.querySelectorAll('.ovl-body a')]; const i = links.indexOf(document.activeElement); if (i < 0) return;
+ ev.preventDefault(); const n = i + (ev.key === 'ArrowDown' ? 1 : -1); (links[n] || (n < 0 ? input : links[0])).focus();
+ });
+ panel.addEventListener('click', (ev) => ev.stopPropagation());
+ document.addEventListener('click', close);
+ launcherCatalog().then((c) => { if (c) { cat = c; paint(cat, input.value.trim().toLowerCase()); } });
+ }
+ panel.classList.add('open'); btn.setAttribute('aria-expanded', 'true');
+ try { panel.querySelector('.ovl-q').focus({ preventScroll: true }); } catch { /* */ }
+ });
+ }
+
+ // ── Notification bell (mounted by the navbar itself) ─────
+ function mountBell(nav, u) {
+ if (_config.notifications === false || !u || u.is_anon || !_config.token) return;
+ const mount = nav.querySelector('#openvibe-bell-mount'); if (!mount || mount.childElementCount) return;
+ const go = () => {
+ const N = root.OpenVibeNotifications; if (!N || mount.childElementCount) return;
+ try { if (!N.__ovNavInit) { N.init({ token: _config.token, apiBase: 'https://openvibe.network' }); N.__ovNavInit = true; } N.createBell(mount); } catch { /* */ }
+ };
+ if (root.OpenVibeNotifications) return go();
+ if (document.getElementById('ov-notify-loader')) return;
+ const sc = document.createElement('script'); sc.id = 'ov-notify-loader'; sc.src = 'https://openvibe.network/shared/notification-ui.js'; sc.async = true; sc.onload = () => setTimeout(go, 0); document.head.appendChild(sc);
+ }
+
+ function menuItemHTML(item) {
+ if (!item) return '';
+ if (item.sep) return '<div class="sep"></div>';
+ const icon = item.icon ? `<span class="icon"><i class="fa-solid ${escapeAttr(item.icon)}"></i></span>` : '<span class="icon"></span>';
+ const cls = item.danger ? ' class="danger"' : '';
+ const id = item.id ? ` data-menu-id="${escapeAttr(item.id)}"` : '';
+ if (item.href) return `<a href="${escapeAttr(item.href)}"${cls}${id}${item.external ? ' target="_blank" rel="noopener"' : ''}>${icon} ${escapeAttr(item.label)}</a>`;
+ return `<button type="button"${cls}${id}>${icon} ${escapeAttr(item.label)}</button>`;
+ }
+
+ function bindMenuItems(container, items) {
+ for (const item of items) {
+ if (!item || !item.id || typeof item.onClick !== 'function') continue;
+ container.querySelector(`[data-menu-id="${item.id}"]`)?.addEventListener('click', (e) => { if (!item.href) e.preventDefault(); item.onClick(e); });
+ }
+ }
+
+ function currentLinks() {
+ const list = _runtimeLinks || _config.links || SERVICE_LINKS[_config.service] || [];
+ const path = (typeof location !== 'undefined' && location.pathname) || '/';
+ return list.map((l) => Object.assign({}, l, { active: l.active !== undefined ? l.active : (l.href === path && path !== '/') }));
+ }
+
const SERVICE_LINKS = {
const SERVICE_LINKS = {
live: [
live: [
{ label: 'Watch', href: '/' },
{ label: 'Watch', href: '/' },
@@ -610,6 +901,114 @@
@@ -610,6 +901,114 @@
let _authInFlight = null;
let _authInFlight = null;
+ function ssoHint() {
+ const m = (typeof document !== 'undefined' ? document.cookie : '').match(/(?:^|;\s*)ov_sso_hint=([^;]*)/);
+ if (m) return m[1];
+ try { return localStorage.getItem('ov_sso_hint'); } catch { return null; }
+ }
+
+ /**
+ * One silent sign-in attempt per tab: only when this browser has signed in to the network
+ * before (the hint survives token expiry), never after an explicit sign-out ('guest'), and
+ * never for bots. The site's login route turns silent=1 into prompt=none and comes straight
+ * back on error=login_required, so a signed-out visitor sees one quick redirect at most.
+ */
+ let _ssoClientLoading = null;
+ function loadSsoClient() {
+ if (root.OpenVibeSSO) return Promise.resolve(root.OpenVibeSSO);
+ if (_ssoClientLoading) return _ssoClientLoading;
+ _ssoClientLoading = new Promise((resolve) => {
+ const sc = document.createElement('script'); sc.async = true; sc.src = `${_config.apiBase}/shared/sso-client.js`;
+ sc.onload = () => resolve(root.OpenVibeSSO || null); sc.onerror = () => resolve(null);
+ document.head.appendChild(sc);
+ });
+ return _ssoClientLoading;
+ }
+
+ /** Signed in here: cross-site links carry the session along (see sso-client.js). */
+ function enableHandoff() {
+ loadSsoClient().then((sso) => { try { sso && sso.handoffLinks({ signedIn: !!_config.user && !_config.user.is_anon }); } catch { /* */ } });
+ }
+
+ function silentLoginNow() {
+ const url = String(_config.silentLogin).replace('{url}', encodeURIComponent(location.href));
+ location.replace(url);
+ return true;
+ }
+
+ /**
+ * Two ways to find out that this browser is signed in to the network without a session here:
+ * 1. the hint cookie this site set on an earlier sign-in ('account') — go straight to the
+ * silent sign-in (one quick redirect, back where you were);
+ * 2. otherwise ask the network in a hidden iframe (GET /sso/check) — invisible, no
+ * redirect unless the answer is yes. Browsers that partition third-party cookies answer
+ * no and nothing happens, which is the same as before.
+ * At most once per tab per 10 minutes; never after an explicit sign-out ('guest'); never for bots.
+ */
+ function maybeSilentLogin() {
+ if (!_config.silentLogin || typeof location === 'undefined') return false;
+ const hint = ssoHint();
+ if (hint === 'guest') return false;
+ if (/bot|crawl|spider|slurp|headless/i.test(navigator.userAgent || '')) return false;
+ if (/[?&]sso=none\b/.test(location.search)) return false;
+ try {
+ const last = +sessionStorage.getItem('ov_silent_sso_at') || 0;
+ if (Date.now() - last < 10 * 60 * 1000) return false;
+ sessionStorage.setItem('ov_silent_sso_at', String(Date.now()));
+ } catch { return false; }
+ if (hint === 'account') return silentLoginNow();
+ checkNetworkSession().then((state) => {
+ if (state && state.signedIn) return silentLoginNow();
+ // No answer or "not signed in" — either a guest, or a browser that keeps the network's
+ // cookie away from iframes. FedCM asks the browser itself; the network's login status
+ // makes it a no-op for guests, a native chip (then silent re-auth) for signed-in users.
+ if (_config.fedcm === false) return;
+ loadSsoClient().then(async (sso) => {
+ if (!sso || !sso.fedcmAvailable()) return;
+ const r = await sso.fedcm({ apiBase: _config.apiBase, fedcmLogin: _config.fedcmLogin || undefined, mediation: _config.fedcm === 'silent' ? 'silent' : 'optional' });
+ if (r && r.ok) { try { sessionStorage.removeItem('ov_silent_sso_at'); } catch { /* */ } location.reload(); }
+ });
+ });
+ return false;
+ }
+
+ /** Ask the network (hidden iframe + postMessage) whether this browser is signed in there. */
+ function checkNetworkSession(timeoutMs = 4000) {
+ return new Promise((resolve) => {
+ let done = false, frame = null, timer = null;
+ const finish = (v) => { if (done) return; done = true; clearTimeout(timer); window.removeEventListener('message', onMsg); try { frame?.remove(); } catch { /* */ } resolve(v); };
+ const onMsg = (e) => {
+ if (e.origin !== _config.apiBase || !e.data || e.data.type !== 'ov-sso') return;
+ finish({ signedIn: !!e.data.signedIn, username: e.data.username || null });
+ };
+ try {
+ window.addEventListener('message', onMsg);
+ frame = document.createElement('iframe');
+ frame.setAttribute('aria-hidden', 'true'); frame.setAttribute('tabindex', '-1');
+ frame.style.cssText = 'position:absolute;width:0;height:0;border:0;opacity:0;pointer-events:none';
+ frame.src = `${_config.apiBase}/sso/check?origin=${encodeURIComponent(location.origin)}`;
+ (document.body || document.documentElement).appendChild(frame);
+ timer = setTimeout(() => finish(null), timeoutMs);
+ } catch { finish(null); }
+ });
+ }
+
+ function recordHistory() {
+ const h = _config.history;
+ if (!h || !_config.user || _config.user.is_anon) return;
+ const rec = Object.assign({ url: location.href, title: document.title, service: resolveBrand().tld }, h);
+ const H = root.OpenVibeHistory;
+ if (H && typeof H.record === 'function') { H.record(rec, { token: _config.token, apiBase: _config.apiBase }); return; }
+ if (!document.getElementById('ov-history-loader')) {
+ const sc = document.createElement('script'); sc.id = 'ov-history-loader'; sc.async = true;
+ sc.src = `${_config.apiBase}/shared/history.js`;
+ sc.onload = () => { try { root.OpenVibeHistory.record(rec, { token: _config.token, apiBase: _config.apiBase }); } catch { /* */ } };
+ document.head.appendChild(sc);
+ } else {
+ document.getElementById('ov-history-loader').addEventListener('load', () => { try { root.OpenVibeHistory.record(rec, { token: _config.token, apiBase: _config.apiBase }); } catch { /* */ } });
+ }
+ }
+
function refreshAuthState() {
function refreshAuthState() {
if (_authInFlight) return _authInFlight;
if (_authInFlight) return _authInFlight;
_authInFlight = resolveSessionUser()
_authInFlight = resolveSessionUser()
@@ -619,6 +1018,10 @@
@@ -619,6 +1018,10 @@
_config.user = session.user;
_config.user = session.user;
if (session.token) _config.token = session.token;
if (session.token) _config.token = session.token;
render();
render();
+ recordHistory();
+ enableHandoff();
+ } else {
+ maybeSilentLogin();
}
}
try {
try {
document.dispatchEvent(new CustomEvent('openvibe-navbar-auth', {
document.dispatchEvent(new CustomEvent('openvibe-navbar-auth', {
@@ -673,28 +1076,37 @@
@@ -673,28 +1076,37 @@
});
});
}
}
+ /**
+ * "Recently used" rows in the dropdown: the last few things this account touched anywhere
+ * on the network, from the shared history module (loaded lazily from the Network).
+ */
+ function renderRecent(el) {
+ if (!el || !_config.user || _config.user.is_anon) return;
+ const draw = (items) => {
+ if (!items || !items.length) { el.hidden = true; return; }
+ el.hidden = false;
+ el.innerHTML = `<div class="label"><span>Recently used</span><a href="https://openvibe.network/my#history">All history</a></div>` +
+ items.slice(0, 4).map(h => `<a class="item" href="${escapeAttr(h.url)}"><span class="icon"><i class="fa-solid ${escapeAttr(h.icon || 'fa-clock-rotate-left')}"></i></span><span class="t">${escapeAttr(h.title || h.url)}</span><span class="s">${escapeAttr(h.service_label || h.service || '')}</span></a>`).join('');
+ };
+ const H = root.OpenVibeHistory;
+ if (H && typeof H.recent === 'function') { H.recent({ limit: 4, token: _config.token, apiBase: _config.apiBase }).then(draw).catch(() => draw(null)); return; }
+ if (document.getElementById('ov-history-loader')) return;
+ const sc = document.createElement('script'); sc.id = 'ov-history-loader'; sc.async = true;
+ sc.src = `${_config.apiBase}/shared/history.js`;
+ sc.onload = () => { try { root.OpenVibeHistory.recent({ limit: 4, token: _config.token, apiBase: _config.apiBase }).then(draw).catch(() => draw(null)); } catch { /* */ } };
+ document.head.appendChild(sc);
+ }
+
function render() {
function render() {
if (_navEl) _navEl.remove();
if (_navEl) _navEl.remove();
const nav = document.createElement('nav');
const nav = document.createElement('nav');
nav.className = 'openvibe-navbar';
nav.className = 'openvibe-navbar';
const svc = _config.service;
const svc = _config.service;
- const links = SERVICE_LINKS[svc] || [];
-
- // Resolve brand name + icon: config override > subdomain lookup > service defaults
- const host = (typeof location !== 'undefined' && location.hostname) || '';
- // Exact match first (e.g., hostname.openvibe.tools), then fallback to service name
- let subBrand = SUBDOMAIN_BRANDS[host];
- if (!subBrand && host && host.includes('.openvibe.tools')) {
- // Try matching against service names
- const parts = host.split('.');
- const potential = parts[0]; // e.g., 'json' from 'json.openvibe.tools'
- subBrand = SUBDOMAIN_BRANDS[`${potential}.openvibe.tools`];
- }
- const svcName = _config.brandName || (subBrand && subBrand.name) || SERVICE_NAMES[svc] || 'OpenVibe';
- const svcIcon = _config.brandIcon || (subBrand && subBrand.icon) || SERVICE_ICONS[svc] || 'fa-circle-nodes';
- const brandHref = '/';
+ const brand = resolveBrand();
+ if (typeof nav.setAttribute === 'function') { nav.setAttribute('data-compact', _config.compact || 'auto'); nav.setAttribute('data-service', svc); }
+ const links = currentLinks();
const u = _config.user;
const u = _config.user;
const accounts = getAccounts();
const accounts = getAccounts();
@@ -702,25 +1114,27 @@
@@ -702,25 +1114,27 @@
const loginHref = resolveLoginHref(currentHost(), window.location.href);
const loginHref = resolveLoginHref(currentHost(), window.location.href);
const addAccountHref = `${_config.apiBase}/login?add_account=1&return=${encodeURIComponent(window.location.href)}`;
const addAccountHref = `${_config.apiBase}/login?add_account=1&return=${encodeURIComponent(window.location.href)}`;
+ // The OV brand mark is a self-contained drop-in (mounts every .ov-mark it finds).
+ if (!window.__ovMark && !document.getElementById('ov-mark-loader')) {
+ try { const sc = document.createElement('script'); sc.id = 'ov-mark-loader'; sc.src = 'https://openvibe.network/shared/ov-mark.js'; sc.async = true; document.head.appendChild(sc); } catch { /* */ }
+ }
nav.innerHTML = `
nav.innerHTML = `
- <a class="openvibe-navbar-brand" href="${brandHref}">
- <span class="flame"><i class="fa-solid ${svcIcon}"></i></span>
- <div>
- <div class="name">${svcName}</div>
- </div>
- </a>
+ ${brandHTML(brand)}
<div class="openvibe-navbar-links">
<div class="openvibe-navbar-links">
- ${links.map(l => `<a href="${l.href}">${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('')}
${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>
<div class="openvibe-navbar-right">
<div class="openvibe-navbar-right">
- <a class="openvibe-network-badge" href="https://openvibe.network" title="Connected to OpenVibe"><i class="fa-solid fa-circle-nodes"></i> OpenVibe</a>
+ <a class="openvibe-network-badge" href="https://openvibe.network" title="Connected to OpenVibe"><span class="ov-mark" data-size="16" data-static="1"></span> OpenVibe</a>
<div id="openvibe-bell-mount"></div>
<div id="openvibe-bell-mount"></div>
${u ? avatarImg(u, 64, 'openvibe-navbar-avatar', 'openvibe-avatar-btn') :
${u ? avatarImg(u, 64, 'openvibe-navbar-avatar', 'openvibe-avatar-btn') :
`<a class="openvibe-navbar-login" id="openvibe-login-btn" href="${escapeAttr(loginHref)}">Sign In</a>`}
`<a class="openvibe-navbar-login" id="openvibe-login-btn" href="${escapeAttr(loginHref)}">Sign In</a>`}
</div>
</div>
`;
`;
+ bindLauncher(nav);
+ // Pages that wire the bell themselves run right after init(); give them the first go.
+ setTimeout(() => mountBell(nav, u), 0);
// Dropdown
// Dropdown
if (u) {
if (u) {
@@ -729,6 +1143,8 @@
@@ -729,6 +1143,8 @@
dropdown.id = 'openvibe-user-dropdown';
dropdown.id = 'openvibe-user-dropdown';
const otherAccounts = accounts.filter(a => isAnon ? !a.is_anon : String(a.id) !== String(u.id));
const otherAccounts = accounts.filter(a => isAnon ? !a.is_anon : String(a.id) !== String(u.id));
+ const before = ((_config.menu && _config.menu.before) || []).concat(_runtimeMenu.before);
+ const after = ((_config.menu && _config.menu.after) || []).concat(_runtimeMenu.after);
dropdown.innerHTML = `
dropdown.innerHTML = `
<div class="openvibe-navbar-dropdown-header">
<div class="openvibe-navbar-dropdown-header">
@@ -755,17 +1171,23 @@
@@ -755,17 +1171,23 @@
<span>Add another account</span>
<span>Add another account</span>
</a>
</a>
</div>
</div>
+ <div class="openvibe-navbar-dropdown-recent" id="openvibe-recent" hidden></div>
<div class="openvibe-navbar-dropdown-menu">
<div class="openvibe-navbar-dropdown-menu">
+ ${before.map(menuItemHTML).join('')}${before.length ? '<div class="sep"></div>' : ''}
<a href="https://openvibe.network/my"><span class="icon"><i class="fa-solid fa-user"></i></span> My Account</a>
<a href="https://openvibe.network/my"><span class="icon"><i class="fa-solid fa-user"></i></span> My Account</a>
- <a href="https://openvibe.network/my#notifications"><span class="icon"><i class="fa-solid fa-bell"></i></span> Notification Settings</a>
+ <a href="https://openvibe.network/my#history"><span class="icon"><i class="fa-solid fa-clock-rotate-left"></i></span> History</a>
+ <a href="https://openvibe.network/my#notifications"><span class="icon"><i class="fa-solid fa-bell"></i></span> Notifications</a>
<a href="https://openvibe.network/themes"><span class="icon"><i class="fa-solid fa-palette"></i></span> Themes</a>
<a href="https://openvibe.network/themes"><span class="icon"><i class="fa-solid fa-palette"></i></span> Themes</a>
<a href="https://openvibe.network/my#linked"><span class="icon"><i class="fa-solid fa-link"></i></span> Linked Services</a>
<a href="https://openvibe.network/my#linked"><span class="icon"><i class="fa-solid fa-link"></i></span> Linked Services</a>
${u.role === 'admin' ? `<a href="https://openvibe.network/admin"><span class="icon"><i class="fa-solid fa-screwdriver-wrench"></i></span> Admin Panel</a>` : ''}
${u.role === 'admin' ? `<a href="https://openvibe.network/admin"><span class="icon"><i class="fa-solid fa-screwdriver-wrench"></i></span> Admin Panel</a>` : ''}
- <div style="height:1px;background:var(--border,#333340);margin:4px -8px"></div>
+ ${after.length ? '<div class="sep"></div>' : ''}${after.map(menuItemHTML).join('')}
+ <div class="sep"></div>
<button id="openvibe-logout-btn" class="danger"><span class="icon"><i class="fa-solid fa-right-from-bracket"></i></span> Sign Out</button>
<button id="openvibe-logout-btn" class="danger"><span class="icon"><i class="fa-solid fa-right-from-bracket"></i></span> Sign Out</button>
</div>
</div>
`;
`;
nav.appendChild(dropdown);
nav.appendChild(dropdown);
+ bindMenuItems(dropdown, before.concat(after));
+ if (_config.recent !== false) renderRecent(dropdown.querySelector('#openvibe-recent'));
// Avatar click toggles dropdown
// Avatar click toggles dropdown
nav.querySelector('#openvibe-avatar-btn').addEventListener('click', () => {
nav.querySelector('#openvibe-avatar-btn').addEventListener('click', () => {
@@ -793,6 +1215,8 @@
@@ -793,6 +1215,8 @@
clearAuthState();
clearAuthState();
_config.user = null;
_config.user = null;
_config.token = null;
_config.token = null;
+ try { root.OpenVibeSSO && root.OpenVibeSSO.preventSilent(); } catch { /* */ }
+ try { localStorage.setItem('ov_sso_hint', 'guest'); } catch { /* */ }
if (onToolsDomain()) {
if (onToolsDomain()) {
// The gateway also clears the Domain=.openvibe.tools cookie
// The gateway also clears the Domain=.openvibe.tools cookie
// and the httpOnly refresh cookie, then sends us back here.
// and the httpOnly refresh cookie, then sends us back here.
@@ -834,9 +1258,13 @@
@@ -834,9 +1258,13 @@
// the shared SSO state (ov_token cookie / localStorage / optional
// the shared SSO state (ov_token cookie / localStorage / optional
// sessionUrl) and the navbar re-renders when it arrives.
// sessionUrl) and the navbar re-renders when it arrives.
if (!_config.user) refreshAuthState();
if (!_config.user) refreshAuthState();
+ else { recordHistory(); enableHandoff(); }
return el;
return el;
},
},
+ /** Record something the signed-in user did here ({type, title, url, icon}) in their network history. */
+ record(entry) { const h = _config.history; _config.history = Object.assign({}, h || {}, entry || {}); recordHistory(); _config.history = h; },
+
/** Re-resolve the signed-in user from the shared SSO state. */
/** Re-resolve the signed-in user from the shared SSO state. */
refreshAuth() { return refreshAuthState(); },
refreshAuth() { return refreshAuthState(); },
@@ -846,6 +1274,33 @@
@@ -846,6 +1274,33 @@
render();
render();
},
},
+ /** Replace this site's top links at runtime ([{label, href, icon?, active?, external?}]). */
+ setLinks(links) { _runtimeLinks = Array.isArray(links) ? links : null; if (_navEl) render(); },
+
+ /** Add a row to the account dropdown: {label, href|onClick, icon, danger, external, position:'before'|'after'}. */
+ addMenuItem(item) {
+ if (!item || !item.label) return;
+ const it = Object.assign({ id: item.id || `mi-${Math.random().toString(36).slice(2, 8)}` }, item);
+ (it.position === 'before' ? _runtimeMenu.before : _runtimeMenu.after).push(it);
+ if (_navEl && _config.user) render();
+ return it.id;
+ },
+
+ removeMenuItem(id) {
+ for (const k of ['before', 'after']) _runtimeMenu[k] = _runtimeMenu[k].filter(i => i.id !== id);
+ if (_navEl && _config.user) render();
+ },
+
+ /** Is this browser signed in to the network? ({ signedIn, username } or null when unknown). */
+ checkNetworkSession,
+
+ /** The resolved brand for this page ({ sub, core, tld, name, short, variant }). */
+ brand() { return resolveBrand(); },
+
+ /** The activity island (island.js), loaded on first use: OpenVibeNavbar.activity.start({...}). */
+ get activity() { return root.OpenVibeIsland || null; },
+ set activity(_v) { /* island.js binds itself; nothing to store */ },
+
setToken(token) { _config.token = token; },
setToken(token) { _config.token = token; },
/** Get the bell mount point for OpenVibeNotifications. */
/** Get the bell mount point for OpenVibeNotifications. */
new file mode 100644
new file mode 100644
@@ -0,0 +1,184 @@
@@ -0,0 +1,184 @@
+/**
+ * openvibe-shared/ov-icons.js — the OpenVibe animated icon family.
+ *
+ * <span class="ov-icon" data-icon="youtube" data-size="40"></span>
+ * <span class="ov-icon" data-icon="pdf" data-state="busy" data-progress="0.4" data-fx="orbit"></span>
+ *
+ * One construction for every icon so the network reads as one brand: the OV ring (with its comet)
+ * around a glyph drawn in the theme accent. The ring is also a progress meter (data-progress 0–1)
+ * and a status light (data-state idle|busy|ok|error), so the same icon that names a tool can show
+ * what the tool is doing. Glyphs are 24×24 stroke paths centred in the ring.
+ *
+ * OpenVibeIcons.names() every registered icon
+ * OpenVibeIcons.register(name, { glyph, fx?, accent? })
+ * OpenVibeIcons.mount(root?) (re)scan; new nodes are picked up automatically
+ * OpenVibeIcons.set(el, { progress, state, icon })
+ * OpenVibeIcons.svg(name, size) markup string, for server-side / templates
+ *
+ * Effects (data-fx): orbit (default) · pulse · draw · bounce · none. Motion is transform/opacity/
+ * stroke only and stops under prefers-reduced-motion. Unknown names fall back to the OV mark "V".
+ */
+(function (root) {
+ 'use strict';
+ if (root.OpenVibeIcons) return;
+
+ const P = (d) => `<path d="${d}"/>`;
+ // Glyphs: stroke-only, 24×24 box, designed to sit inside the ring.
+ const GLYPHS = {
+ ov: P('M6.5 7.5 12 17.5l5.5-10'),
+ network: '<circle cx="12" cy="6" r="2"/><circle cx="5.5" cy="17" r="2"/><circle cx="18.5" cy="17" r="2"/>' + P('M12 8v3.5M12 11.5 6.8 15.4M12 11.5l5.2 3.9'),
+ live: '<circle cx="12" cy="12" r="2.2"/>' + P('M7.5 7.5a6.4 6.4 0 0 0 0 9M16.5 7.5a6.4 6.4 0 0 1 0 9M4.8 5a10 10 0 0 0 0 14M19.2 5a10 10 0 0 1 0 14'),
+ tools: P('M14.5 5.5a4 4 0 0 0-5.2 5.2L4.5 15.5a1.8 1.8 0 0 0 2.5 2.5l4.8-4.8a4 4 0 0 0 5.2-5.2l-2.6 2.6-2.1-.5-.5-2.1z'),
+ media: '<rect x="4" y="6" width="16" height="12" rx="2"/>' + P('M10 9.5v5l4.5-2.5z'),
+ games: P('M7 9h10a4 4 0 0 1 3.9 4.9l-.6 2.6a2 2 0 0 1-3.5.8L15.5 16h-7l-1.3 1.3a2 2 0 0 1-3.5-.8l-.6-2.6A4 4 0 0 1 7 9zM8 11.5v3M6.5 13h3') + '<circle cx="15.5" cy="12" r=".6"/><circle cx="17.5" cy="14" r=".6"/>',
+ community: '<circle cx="9" cy="9" r="2.6"/><circle cx="16.5" cy="10" r="2"/>' + P('M3.8 18.5a5.2 5.2 0 0 1 10.4 0M14.6 14.2a4 4 0 0 1 5.6 3.8'),
+ chat: P('M5 6.5h14a1.5 1.5 0 0 1 1.5 1.5v7A1.5 1.5 0 0 1 19 16.5h-7l-4 3v-3H5A1.5 1.5 0 0 1 3.5 15V8A1.5 1.5 0 0 1 5 6.5zM8 10.5h8M8 13h5'),
+ codes: P('M9 8 4.5 12 9 16M15 8l4.5 4-4.5 4M13.2 6l-2.4 12'),
+ code: P('M9 8 4.5 12 9 16M15 8l4.5 4-4.5 4M13.2 6l-2.4 12'),
+ blog: P('M5 19l1.2-4.2L16 5l3 3-9.8 9.8zM14 7l3 3'),
+ wiki: P('M5 5.5h6a2 2 0 0 1 2 2V19a2 2 0 0 0-2-2H5zM19 5.5h-4a2 2 0 0 0-2 2V19a2 2 0 0 1 2-2h4z'),
+ news: '<rect x="4" y="5.5" width="13" height="13" rx="1.5"/>' + P('M17 9h3v8a1.5 1.5 0 0 1-3 0M7 9h7M7 12h7M7 15h4'),
+ reviews: P('M12 4.5l2.2 4.6 5 .7-3.6 3.5.9 5-4.5-2.4-4.5 2.4.9-5L4.8 9.8l5-.7z'),
+ tips: P('M12 19s-6.5-3.8-6.5-8.6A3.6 3.6 0 0 1 12 8a3.6 3.6 0 0 1 6.5 2.4C18.5 15.2 12 19 12 19z'),
+ vip: P('M4.5 16.5 6 8l3.8 3.5L12 6.5l2.2 5L18 8l1.5 8.5zM5 19h14'),
+ trade: P('M4.5 18.5h15M6.5 15l3.5-4 3 2.5 4.5-6M17.5 7.5h-3M17.5 7.5v3'),
+ host: '<rect x="4.5" y="5" width="15" height="5.5" rx="1.2"/><rect x="4.5" y="13.5" width="15" height="5.5" rx="1.2"/><circle cx="7.5" cy="7.75" r=".6"/><circle cx="7.5" cy="16.25" r=".6"/>',
+ deals: P('M4.5 12.5V5.5h7l8 8-7 7zM8.2 9.2h.01'),
+ coupons: P('M4 8.5h16v2.5a1.5 1.5 0 0 0 0 3v2.5H4V14a1.5 1.5 0 0 0 0-3zM10 8.5v8') ,
+ stream: P('M5 17.5 12 6l7 11.5M8 13h8') + '<circle cx="12" cy="6" r="1.4"/>',
+ youtube: '<rect x="3.5" y="6.5" width="17" height="11" rx="3"/>' + P('M10.5 9.8v4.4l3.8-2.2z'),
+ video: '<rect x="3.5" y="6.5" width="17" height="11" rx="3"/>' + P('M10.5 9.8v4.4l3.8-2.2z'),
+ download: P('M12 4.5v10M8 11l4 4 4-4M5 19h14'),
+ upload: P('M12 15.5v-10M8 9l4-4 4 4M5 19h14'),
+ image: '<rect x="4" y="5" width="16" height="14" rx="2"/><circle cx="9" cy="10" r="1.5"/>' + P('M4.5 17l4.5-4.5 3.5 3.5 2.5-2.5 4.5 4.5'),
+ audio: P('M9 17V7.5l9-2V15') + '<circle cx="6.8" cy="17" r="2.2"/><circle cx="15.8" cy="15" r="2.2"/>',
+ pdf: P('M7 4.5h7l4 4V19a.5.5 0 0 1-.5.5h-10.5A.5.5 0 0 1 6.5 19V5a.5.5 0 0 1 .5-.5zM14 4.5v4h4M9 13h6M9 16h4'),
+ docs: P('M7 4.5h7l4 4V19a.5.5 0 0 1-.5.5h-10.5A.5.5 0 0 1 6.5 19V5a.5.5 0 0 1 .5-.5zM14 4.5v4h4M9 13h6M9 16h4'),
+ text: P('M5.5 7V5.5h13V7M12 5.5v13M9.5 18.5h5'),
+ logo: P('M12 4.5l1.8 4.2 4.2 1.8-4.2 1.8L12 16.5l-1.8-4.2L6 10.5l4.2-1.8zM18 15.5l.8 1.7 1.7.8-1.7.8-.8 1.7-.8-1.7-1.7-.8 1.7-.8z'),
+ json: P('M9 5.5c-2 0-2 1.5-2 3s0 3.5-2 3.5c2 0 2 2 2 3.5s0 3 2 3M15 5.5c2 0 2 1.5 2 3s0 3.5 2 3.5c-2 0-2 2-2 3.5s0 3-2 3'),
+ dns: '<circle cx="12" cy="12" r="7.5"/>' + P('M4.5 12h15M12 4.5c2.5 2 2.5 13 0 15M12 4.5c-2.5 2-2.5 13 0 15'),
+ ip: P('M12 20s-6-5.2-6-10a6 6 0 0 1 12 0c0 4.8-6 10-6 10z') + '<circle cx="12" cy="10" r="2.2"/>',
+ ssl: '<rect x="6" y="11" width="12" height="8.5" rx="1.8"/>' + P('M8.5 11V8.5a3.5 3.5 0 0 1 7 0V11M12 14.5v2'),
+ ping: P('M3.5 12h4l2-5 3.5 10 2.5-7 1.5 2h3.5'),
+ whois: '<circle cx="11" cy="11" r="5.5"/>' + P('M15.2 15.2 19.5 19.5M9 10a2 2 0 0 1 4 0c0 1.5-2 1.6-2 3M11 15h.01'),
+ search: '<circle cx="11" cy="11" r="5.5"/>' + P('M15.2 15.2 19.5 19.5'),
+ map: P('M4.5 7l5-2 5 2 5-2v12l-5 2-5-2-5 2zM9.5 5v12M14.5 7v12'),
+ food: P('M7 4.5v6a2 2 0 0 0 4 0v-6M9 4.5v15M16.5 4.5c-2 1.5-2.5 4-2.5 6.5h2.5zM16.5 11v8.5'),
+ paste: '<rect x="6" y="6" width="12" height="14" rx="1.8"/>' + P('M9.5 6V5a1 1 0 0 1 1-1h3a1 1 0 0 1 1 1v1M9 11h6M9 14h6M9 17h3'),
+ account: '<circle cx="12" cy="9" r="3.2"/>' + P('M5.5 19a6.5 6.5 0 0 1 13 0'),
+ bell: P('M6.5 16.5h11l-1.2-2V11a4.3 4.3 0 0 0-8.6 0v3.5zM10.5 19a1.6 1.6 0 0 0 3 0'),
+ history: '<circle cx="12" cy="12" r="7.5"/>' + P('M12 8v4.2l2.8 1.8'),
+ theme: P('M12 4.5a7.5 7.5 0 1 0 0 15c1 0 1.6-.8 1.2-1.7-.5-1.1.3-2.3 1.5-2.3h1.6a3.2 3.2 0 0 0 3.2-3.2C19.5 8 16.2 4.5 12 4.5z') + '<circle cx="8.5" cy="11" r=".7"/><circle cx="11.5" cy="8" r=".7"/><circle cx="15" cy="9" r=".7"/>',
+ check: P('M6 12.5l4 4 8-9'),
+ error: P('M12 7v6M12 16.5h.01'),
+ clip: P('M6 6l12 12M6 18L18 6') ,
+ };
+ const ALIASES = { yt: 'youtube', net: 'dns', dev: 'code', img: 'image', sound: 'audio', document: 'pdf', pastes: 'paste', openre: 'stream', maps: 'map', user: 'account', notifications: 'bell' };
+ const registry = Object.create(null);
+ for (const [k, g] of Object.entries(GLYPHS)) registry[k] = { glyph: g };
+
+ const CSS = `
+.ov-icon{--ovi-size:32px;display:inline-grid;place-items:center;width:var(--ovi-size);height:var(--ovi-size);flex:none;vertical-align:middle;line-height:0;color:var(--ovi-accent,var(--accent,#3b82f6));position:relative}
+.ov-icon svg{width:100%;height:100%;overflow:visible;display:block}
+.ov-icon .ovi-bg{fill:currentColor;opacity:.1;transition:opacity .25s}
+.ov-icon .ovi-ring{fill:none;stroke:currentColor;stroke-width:2.4;opacity:.26}
+.ov-icon .ovi-comet{fill:none;stroke:currentColor;stroke-width:2.4;stroke-linecap:round;stroke-dasharray:26 100;transform-origin:24px 24px;animation:oviSpin 3.6s linear infinite}
+.ov-icon .ovi-prog{fill:none;stroke:currentColor;stroke-width:3;stroke-linecap:round;transform:rotate(-90deg);transform-origin:24px 24px;stroke-dasharray:125.7;stroke-dashoffset:125.7;transition:stroke-dashoffset .35s cubic-bezier(.2,.8,.2,1),opacity .2s;opacity:0}
+.ov-icon .ovi-glyph{fill:none;stroke:var(--ovi-glyph,var(--text-primary,#e6edf7));stroke-width:1.7;stroke-linecap:round;stroke-linejoin:round;transform-origin:24px 24px;transition:transform .25s cubic-bezier(.2,1.4,.3,1),stroke .2s}
+.ov-icon .ovi-glyph circle[r=".6"],.ov-icon .ovi-glyph circle[r=".7"]{fill:currentColor;stroke:none}
+.ov-icon:hover .ovi-glyph,a:hover>.ov-icon .ovi-glyph,button:hover>.ov-icon .ovi-glyph{transform:scale(1.1)}
+.ov-icon:hover .ovi-bg,a:hover>.ov-icon .ovi-bg{opacity:.18}
+.ov-icon[data-fx=none] .ovi-comet{display:none}
+.ov-icon[data-fx=pulse] .ovi-comet{display:none}
+.ov-icon[data-fx=pulse] .ovi-bg{animation:oviPulse 2s ease-in-out infinite;transform-origin:24px 24px}
+.ov-icon[data-fx=draw] .ovi-glyph path{stroke-dasharray:80;animation:oviDraw 1.2s cubic-bezier(.2,.8,.2,1) both}
+.ov-icon[data-fx=bounce] svg{animation:oviBounce 1.8s cubic-bezier(.3,1.4,.4,1) infinite}
+.ov-icon[data-progress] .ovi-prog{opacity:1}
+.ov-icon[data-progress] .ovi-comet{opacity:0}
+.ov-icon[data-state=busy] .ovi-comet{animation-duration:1.1s;opacity:1;stroke-dasharray:40 86}
+.ov-icon[data-state=busy][data-progress] .ovi-comet{opacity:.35}
+.ov-icon[data-state=ok]{--ovi-accent:var(--success,#22c55e)}
+.ov-icon[data-state=error]{--ovi-accent:var(--danger,#ef4444)}
+.ov-icon[data-state=ok] .ovi-comet,.ov-icon[data-state=error] .ovi-comet{display:none}
+.ov-icon[data-state=ok] .ovi-ring,.ov-icon[data-state=error] .ovi-ring{opacity:.9}
+.ov-icon[data-state=ok] svg{animation:oviPop .5s cubic-bezier(.2,1.6,.3,1)}
+.ov-icon[data-state=error] svg{animation:oviShake .4s ease}
+@keyframes oviSpin{to{transform:rotate(360deg)}}
+@keyframes oviPulse{0%,100%{transform:scale(.9);opacity:.08}50%{transform:scale(1.06);opacity:.22}}
+@keyframes oviDraw{from{stroke-dashoffset:80}to{stroke-dashoffset:0}}
+@keyframes oviBounce{0%,100%{transform:translateY(0)}50%{transform:translateY(-6%)}}
+@keyframes oviPop{0%{transform:scale(.8)}60%{transform:scale(1.12)}100%{transform:scale(1)}}
+@keyframes oviShake{0%,100%{transform:translateX(0)}25%{transform:translateX(-6%)}75%{transform:translateX(6%)}}
+@media (prefers-reduced-motion:reduce){.ov-icon *, .ov-icon svg{animation:none!important;transition:none!important}.ov-icon .ovi-comet{display:none}}`;
+
+ function resolve(name) {
+ const n = String(name || 'ov').toLowerCase();
+ return registry[n] ? n : (registry[ALIASES[n]] ? ALIASES[n] : 'ov');
+ }
+
+ /** Markup for one icon (usable server-side: no DOM needed). Glyph is scaled 24→22 and centred in the 48 box. */
+ function svg(name, size) {
+ const def = registry[resolve(name)];
+ const s = size ? ` width="${size}" height="${size}"` : '';
+ return `<svg viewBox="0 0 48 48"${s} aria-hidden="true" focusable="false"><circle class="ovi-bg" cx="24" cy="24" r="21"/><circle class="ovi-ring" cx="24" cy="24" r="20"/><circle class="ovi-comet" cx="24" cy="24" r="20"/><circle class="ovi-prog" cx="24" cy="24" r="20"/><g class="ovi-glyph" transform="translate(13 13) scale(.9167)">${def.glyph}</g></svg>`;
+ }
+
+ function applyProgress(el) {
+ const raw = el.getAttribute('data-progress');
+ const prog = el.querySelector('.ovi-prog');
+ if (!prog) return;
+ if (raw == null || raw === '') { prog.style.strokeDashoffset = ''; return; }
+ const p = Math.max(0, Math.min(1, parseFloat(raw) || 0));
+ prog.style.strokeDashoffset = String(125.7 * (1 - p));
+ }
+
+ function mountOne(el) {
+ const name = resolve(el.getAttribute('data-icon'));
+ if (el.getAttribute('data-ovi') === name) { applyProgress(el); return; }
+ el.setAttribute('data-ovi', name);
+ const size = parseInt(el.getAttribute('data-size'), 10);
+ if (size) el.style.setProperty('--ovi-size', size + 'px');
+ const def = registry[name];
+ if (def.accent) el.style.setProperty('--ovi-accent', def.accent);
+ if (def.fx && !el.hasAttribute('data-fx')) el.setAttribute('data-fx', def.fx);
+ el.innerHTML = svg(name);
+ if (!el.hasAttribute('role')) el.setAttribute('aria-hidden', 'true');
+ applyProgress(el);
+ }
+
+ function mount(rootEl) { (rootEl || document).querySelectorAll('.ov-icon').forEach(mountOne); }
+
+ function set(el, patch) {
+ if (!el || !patch) return;
+ if (patch.icon) el.setAttribute('data-icon', patch.icon);
+ if ('state' in patch) { if (patch.state) el.setAttribute('data-state', patch.state); else el.removeAttribute('data-state'); }
+ if ('progress' in patch) { if (patch.progress == null) el.removeAttribute('data-progress'); else el.setAttribute('data-progress', String(patch.progress)); }
+ mountOne(el);
+ }
+
+ function register(name, def) {
+ if (!name || !def || !def.glyph) return;
+ registry[String(name).toLowerCase()] = { glyph: def.glyph, fx: def.fx || null, accent: def.accent || null };
+ if (typeof document !== 'undefined') document.querySelectorAll(`.ov-icon[data-icon="${name}"]`).forEach((el) => { el.removeAttribute('data-ovi'); mountOne(el); });
+ }
+
+ const api = { names: () => Object.keys(registry), register, mount, set, svg, resolve, CSS };
+ root.OpenVibeIcons = api;
+ if (typeof module !== 'undefined' && module.exports) module.exports = api;
+ if (typeof document === 'undefined') return;
+
+ function init() {
+ if (!document.getElementById('ov-icons-css')) { const st = document.createElement('style'); st.id = 'ov-icons-css'; st.textContent = CSS; document.head.appendChild(st); }
+ mount();
+ try {
+ new MutationObserver((muts) => {
+ for (const m of muts) {
+ if (m.type === 'attributes') { if (m.target.classList && m.target.classList.contains('ov-icon')) mountOne(m.target); continue; }
+ m.addedNodes.forEach((n) => { if (n.nodeType !== 1) return; if (n.classList.contains('ov-icon')) mountOne(n); else if (n.querySelector) n.querySelectorAll('.ov-icon').forEach(mountOne); });
+ }
+ }).observe(document.documentElement, { childList: true, subtree: true, attributes: true, attributeFilter: ['data-icon', 'data-progress'] });
+ } catch { /* */ }
+ }
+ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init); else init();
+})(typeof window !== 'undefined' ? window : globalThis);
new file mode 100644
new file mode 100644
@@ -0,0 +1,91 @@
@@ -0,0 +1,91 @@
+/**
+ * ov-mark.js — the OpenVibe "OV" brand mark as a drop-in.
+ * <span class="ov-mark"></span> → 32px animated mark
+ * <span class="ov-mark" data-size="56"> → any size; add data-static="1" for no motion
+ * <span class="ov-mark" data-variant="live"> → the site's own twist on the motion (see VARIANTS)
+ * Self-contained: injects its own CSS once, works on any page (Live, Network, static pages).
+ * The O is a ring with a comet arc running around it, the V is drawn inside with a light
+ * sweep, and a dot orbits the ring. Transform/opacity/stroke motion only.
+ */
+(function () {
+ 'use strict';
+ if (window.__ovMark) return; window.__ovMark = true;
+ let n = 0;
+ const CSS = `
+.ov-mark{display:inline-grid;place-items:center;width:32px;height:32px;color:var(--accent,#8b5cf6);flex:none;vertical-align:middle;line-height:0}
+.ov-mark svg{width:100%;height:100%;overflow:visible;transform-origin:50% 50%;animation:ovmFloat 5s ease-in-out infinite}
+.ov-mark .g{fill:currentColor;opacity:.1;transform-box:fill-box;transform-origin:center;animation:ovmGlow 3.4s ease-in-out infinite}
+.ov-mark .r{fill:none;stroke:currentColor;stroke-width:4;opacity:.28}
+.ov-mark .c{fill:none;stroke-width:4;stroke-linecap:round;stroke-dasharray:34 79;transform-box:fill-box;transform-origin:center;animation:ovmComet 3.6s linear infinite}
+.ov-mark .v{fill:none;stroke-width:4.6;stroke-linecap:round;stroke-linejoin:round;stroke-dasharray:46;animation:ovmDraw 1.1s cubic-bezier(.2,.8,.2,1) both}
+.ov-mark .d{fill:var(--ovm-dot,#fff);transform-box:fill-box;transform-origin:center;animation:ovmDot 2.4s ease-in-out infinite}
+/* Per-site variants: one family, each site with its own motion so the network reads as one brand
+ with many rooms. Colours stay with the theme (currentColor); only the dot and the rhythm change. */
+.ov-mark[data-variant=live]{--ovm-dot:#ef4444}
+.ov-mark[data-variant=live] .d{animation:ovmOnAir 1.4s ease-in-out infinite}
+.ov-mark[data-variant=tools] .c{animation-duration:2.2s;stroke-dasharray:22 91}
+.ov-mark[data-variant=tools] .o2{display:none}
+.ov-mark[data-variant=media] .c{stroke-dasharray:14 14 14 71;animation-duration:4.4s}
+.ov-mark[data-variant=games] svg{animation:ovmBounce 1.6s cubic-bezier(.3,1.4,.4,1) infinite}
+.ov-mark[data-variant=games] .d{animation-duration:1.6s}
+.ov-mark[data-variant=community] .o2{opacity:.9;r:1.8}
+.ov-mark[data-variant=community] .c{animation-direction:reverse}
+.ov-mark[data-variant=network] .g{animation-duration:2.4s}
+.ov-mark[data-variant=chat] .d{animation:ovmDot 1s ease-in-out infinite}
+.ov-mark[data-variant=stream] .c{stroke-dasharray:60 53;animation-duration:2.8s}
+.ov-mark[data-variant=codes] .v{animation:ovmDraw 1.1s cubic-bezier(.2,.8,.2,1) infinite alternate}
+.ov-mark[data-variant=blog] .c,.ov-mark[data-variant=wiki] .c,.ov-mark[data-variant=news] .c{animation-duration:6s}
+.ov-mark[data-variant=vip]{--ovm-dot:#facc15}
+.ov-mark[data-variant=tips]{--ovm-dot:#22c55e}
+.ov-mark[data-variant=deals],.ov-mark[data-variant=coupons]{--ovm-dot:#f59e0b}
+.ov-mark[data-variant=trade] .c{animation-timing-function:cubic-bezier(.4,0,.2,1)}
+.ov-mark[data-variant=host] svg{animation:none}
+@keyframes ovmOnAir{0%,100%{transform:scale(1);opacity:.7}50%{transform:scale(1.7);opacity:1}}
+@keyframes ovmBounce{0%,100%{transform:translateY(0)}50%{transform:translateY(-2.5px)}}
+.ov-mark .o{fill:#fff;filter:drop-shadow(0 0 3px currentColor)}
+.ov-mark .o2{opacity:.5}
+.ov-mark:hover svg,a:hover>.ov-mark svg,button:hover>.ov-mark svg{animation:ovmSpin .9s cubic-bezier(.2,1.5,.3,1) 1}
+.ov-mark:hover .c{animation-duration:.9s}
+.ov-mark[data-static] svg,.ov-mark[data-static] .g,.ov-mark[data-static] .c,.ov-mark[data-static] .v,.ov-mark[data-static] .d{animation:none!important}
+.ov-mark[data-static] .o{display:none}
+.ov-mark .p{fill:none;stroke:currentColor;stroke-width:4.4;stroke-linecap:round;transform:rotate(-90deg);transform-box:fill-box;transform-origin:center;stroke-dasharray:113.1;stroke-dashoffset:calc(113.1px * (1 - var(--ovm-p,0)));opacity:0;transition:stroke-dashoffset .35s cubic-bezier(.2,.8,.2,1),opacity .2s}
+.ov-mark[data-progress] .p{opacity:1}
+.ov-mark[data-progress] .c,.ov-mark[data-progress] .o{opacity:.25}
+.ov-mark[data-state=busy] .c{animation-duration:1.1s}
+.ov-mark[data-state=ok]{color:var(--success,#22c55e)!important}
+.ov-mark[data-state=error]{color:var(--danger,#ef4444)!important}
+.ov-mark[data-state=ok] svg{animation:ovmSpin .9s cubic-bezier(.2,1.5,.3,1) 1}
+@keyframes ovmFloat{0%,100%{transform:translateY(0) rotate(0deg)}50%{transform:translateY(-1px) rotate(2deg)}}
+@keyframes ovmGlow{0%,100%{opacity:.08;transform:scale(.92)}50%{opacity:.2;transform:scale(1.06)}}
+@keyframes ovmComet{to{transform:rotate(360deg)}}
+@keyframes ovmDraw{from{stroke-dashoffset:46}to{stroke-dashoffset:0}}
+@keyframes ovmDot{0%,100%{transform:scale(1);opacity:.85}50%{transform:scale(1.5);opacity:1}}
+@keyframes ovmSpin{from{transform:rotate(0deg) scale(1)}40%{transform:rotate(200deg) scale(1.18)}to{transform:rotate(360deg) scale(1)}}
+@media (prefers-reduced-motion:reduce){.ov-mark svg,.ov-mark .g,.ov-mark .c,.ov-mark .v,.ov-mark .d{animation:none!important}.ov-mark .o{display:none}.ov-mark .c{stroke-dasharray:none;opacity:.9}}`;
+ function svg(id) {
+ return `<svg viewBox="0 0 48 48" aria-hidden="true"><defs>
+<linearGradient id="${id}g" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="currentColor"/><stop offset="1" stop-color="currentColor" stop-opacity="0.45"/></linearGradient>
+<linearGradient id="${id}v" x1="0" y1="0" x2="1" y2="1"><stop offset="0" stop-color="#fff"/><stop offset="0.5" stop-color="currentColor"/><stop offset="1" stop-color="#fff" stop-opacity="0.9"/><animateTransform attributeName="gradientTransform" type="translate" from="-1 0" to="1 0" dur="3.2s" repeatCount="indefinite"/></linearGradient>
+<path id="${id}p" d="M24,6 A18,18 0 1,1 23.99,6 Z"/></defs>
+<circle class="g" cx="24" cy="24" r="21"/><circle class="r" cx="24" cy="24" r="18"/><circle class="c" cx="24" cy="24" r="18" stroke="url(#${id}g)"/>
+<circle class="p" cx="24" cy="24" r="18"/>
+<path class="v" d="M14.5,17 L24,34 L33.5,17" stroke="url(#${id}v)"/><circle class="d" cx="24" cy="34" r="2.6"/>
+<circle class="o" r="2"><animateMotion dur="3.6s" repeatCount="indefinite"><mpath href="#${id}p"/></animateMotion></circle>
+<circle class="o o2" r="1.4"><animateMotion dur="3.6s" begin="0.14s" repeatCount="indefinite"><mpath href="#${id}p"/></animateMotion></circle></svg>`;
+ }
+ function mount(root) {
+ (root || document).querySelectorAll('.ov-mark:not([data-ov])').forEach(el => {
+ el.setAttribute('data-ov', '1');
+ const size = parseInt(el.getAttribute('data-size'), 10);
+ if (size) { el.style.width = size + 'px'; el.style.height = size + 'px'; }
+ el.innerHTML = svg('ovm' + (++n) + '_');
+ });
+ }
+ function init() {
+ if (!document.getElementById('ov-mark-css')) { const st = document.createElement('style'); st.id = 'ov-mark-css'; st.textContent = CSS; document.head.appendChild(st); }
+ mount();
+ try { new MutationObserver(() => mount()).observe(document.body, { childList: true, subtree: true }); } catch { /* */ }
+ }
+ window.ovMarkMount = mount;
+ if (document.readyState === 'loading') document.addEventListener('DOMContentLoaded', init); else init();
+})();
@@ -11,7 +11,9 @@
@@ -11,7 +11,9 @@
"./brand": "./brand.js",
"./brand": "./brand.js",
"./middleware": "./middleware.js",
"./middleware": "./middleware.js",
"./notifications": "./notifications.js",
"./notifications": "./notifications.js",
- "./url-resolver": "./url-resolver.js"
+ "./url-resolver": "./url-resolver.js",
+ "./seo": "./seo.js",
+ "./icons": "./ov-icons.js"
},
},
"license": "MIT",
"license": "MIT",
"dependencies": {
"dependencies": {
new file mode 100644
new file mode 100644
@@ -0,0 +1,51 @@
@@ -0,0 +1,51 @@
+#!/usr/bin/env node
+'use strict';
+/**
+ * Regenerates the built-in theme map inside theme-loader.js from builtin-themes.js.
+ *
+ * theme-loader.js runs in the browser before the first paint and cannot require() the catalog, so
+ * it carries a copy. That copy used to be maintained by hand and drifted. Now it is generated:
+ *
+ * node packages/openvibe-shared/scripts/build-theme-loader.js # rewrite
+ * node packages/openvibe-shared/scripts/build-theme-loader.js --check # exit 1 if stale
+ *
+ * Every entry carries the FULL variable set (the theme's own values over the default theme's), so
+ * switching themes replaces every token — a theme that does not name --success no longer keeps the
+ * previous theme's --success on <html>.
+ */
+const fs = require('fs');
+const path = require('path');
+const { BUILTIN_THEMES, DEFAULT_VARS } = require('../builtin-themes');
+
+const LOADER = path.join(__dirname, '..', 'theme-loader.js');
+const BEGIN = ' // THEMES:BEGIN — generated by scripts/build-theme-loader.js from builtin-themes.js; do not edit by hand';
+const END = ' // THEMES:END';
+
+function render() {
+ const lines = BUILTIN_THEMES.map((t) => {
+ const vars = { ...DEFAULT_VARS, ...t.variables };
+ const body = Object.entries(vars).map(([k, v]) => `'${k}':'${String(v).replace(/'/g, "\\'")}'`).join(',');
+ return ` '${t.id}': { ${body} },`;
+ });
+ return `${BEGIN}\n const THEMES = {\n${lines.join('\n')}\n };\n${END}`;
+}
+
+function currentBlock(src) {
+ const a = src.indexOf(BEGIN);
+ const b = src.indexOf(END);
+ if (a >= 0 && b > a) return { a, b: b + END.length };
+ // First run: the hand-written block.
+ const start = src.indexOf(' const THEMES = {');
+ if (start < 0) throw new Error('theme-loader.js: THEMES block not found');
+ const close = src.indexOf('\n };', start);
+ return { a: start, b: close + '\n };'.length };
+}
+
+const src = fs.readFileSync(LOADER, 'utf8');
+const { a, b } = currentBlock(src);
+const next = src.slice(0, a) + render() + src.slice(b);
+const check = process.argv.includes('--check');
+if (next === src) { if (!check) console.log('theme-loader.js is up to date'); process.exit(0); }
+if (check) { console.error('theme-loader.js is stale: run node packages/openvibe-shared/scripts/build-theme-loader.js'); process.exit(1); }
+fs.writeFileSync(LOADER, next);
+console.log(`theme-loader.js regenerated (${BUILTIN_THEMES.length} themes)`);
new file mode 100644
new file mode 100644
@@ -0,0 +1,174 @@
@@ -0,0 +1,174 @@
+'use strict';
+/**
+ * openvibe-shared/seo.js — one SEO toolkit for every OpenVibe site (Node, no dependencies).
+ *
+ * const seo = require('openvibe-shared/seo');
+ * seo.headTags({ title, description, canonical, image, jsonLd: [seo.jsonLd.website({...})] })
+ * seo.sitemapXml([{ loc, lastmod }]) seo.robotsTxt({ sitemaps }) seo.llmsTxt({...})
+ *
+ * Rules it encodes so pages do not have to remember them: one canonical per page, titles cut at
+ * 60 characters on a word, descriptions at 160, og/twitter mirrored from the same values, JSON-LD
+ * serialised safely (no `</script>` break-outs), sitemaps with absolute URLs only, robots.txt that
+ * welcomes search and AI crawlers by default and always names the sitemap, and an /llms.txt so
+ * language-model crawlers get a clean map of the site.
+ */
+
+const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
+const xml = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, (c) => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
+
+/** Cut on a word boundary with an ellipsis; never mid-word, never longer than `max`. */
+function clip(text, max) {
+ const t = String(text == null ? '' : text).replace(/\s+/g, ' ').trim();
+ if (t.length <= max) return t;
+ const cut = t.slice(0, max - 1);
+ const at = cut.lastIndexOf(' ');
+ return (at > max * 0.6 ? cut.slice(0, at) : cut).replace(/[\s,;:.\-–—]+$/, '') + '…';
+}
+
+function absolute(url, base) {
+ if (!url) return '';
+ try { return new URL(url, base || undefined).toString(); } catch { return String(url); }
+}
+
+/** JSON-LD script tag; `<` is escaped so content can never close the script element. */
+function jsonLdTag(obj) {
+ return `<script type="application/ld+json">${JSON.stringify(obj).replace(/</g, '\\u003c')}</script>`;
+}
+
+/**
+ * The whole <head> SEO block as an HTML string.
+ * @param {object} o title, description, canonical (absolute), image, imageAlt, type ('website'),
+ * siteName, robots ('index, follow'), locale ('en_US'), keywords (array|string), author,
+ * themeColor, twitterSite, alternates [{ hreflang, href }], prev, next, jsonLd (array|object),
+ * titleSuffix (appended when it fits), noTitle (skip <title> when the page has its own)
+ */
+function headTags(o = {}) {
+ let title = String(o.title || o.siteName || 'OpenVibe');
+ if (o.titleSuffix && (title + o.titleSuffix).length <= 60) title += o.titleSuffix;
+ title = clip(title, 60);
+ const description = clip(o.description || '', 160);
+ const canonical = absolute(o.canonical);
+ const image = o.image ? absolute(o.image, canonical) : '';
+ const keywords = Array.isArray(o.keywords) ? o.keywords.join(', ') : (o.keywords || '');
+ const robots = o.robots || 'index, follow, max-image-preview:large, max-snippet:-1';
+ const out = [];
+ if (!o.noTitle) out.push(`<title>${esc(title)}</title>`);
+ if (description) out.push(`<meta name="description" content="${esc(description)}">`);
+ if (keywords) out.push(`<meta name="keywords" content="${esc(keywords)}">`);
+ out.push(`<meta name="robots" content="${esc(robots)}">`);
+ if (o.author) out.push(`<meta name="author" content="${esc(o.author)}">`);
+ if (o.themeColor) out.push(`<meta name="theme-color" content="${esc(o.themeColor)}">`);
+ if (canonical) out.push(`<link rel="canonical" href="${esc(canonical)}">`);
+ for (const a of o.alternates || []) if (a && a.href) out.push(`<link rel="alternate" hreflang="${esc(a.hreflang || 'x-default')}" href="${esc(absolute(a.href))}">`);
+ if (o.prev) out.push(`<link rel="prev" href="${esc(absolute(o.prev))}">`);
+ if (o.next) out.push(`<link rel="next" href="${esc(absolute(o.next))}">`);
+ out.push(`<meta property="og:type" content="${esc(o.type || 'website')}">`);
+ if (o.siteName) out.push(`<meta property="og:site_name" content="${esc(o.siteName)}">`);
+ out.push(`<meta property="og:title" content="${esc(title)}">`);
+ if (description) out.push(`<meta property="og:description" content="${esc(description)}">`);
+ if (canonical) out.push(`<meta property="og:url" content="${esc(canonical)}">`);
+ out.push(`<meta property="og:locale" content="${esc(o.locale || 'en_US')}">`);
+ if (image) {
+ out.push(`<meta property="og:image" content="${esc(image)}">`);
+ if (o.imageAlt) out.push(`<meta property="og:image:alt" content="${esc(o.imageAlt)}">`);
+ }
+ out.push(`<meta name="twitter:card" content="${image && o.largeImage !== false ? 'summary_large_image' : 'summary'}">`);
+ if (o.twitterSite) out.push(`<meta name="twitter:site" content="${esc(o.twitterSite)}">`);
+ out.push(`<meta name="twitter:title" content="${esc(title)}">`);
+ if (description) out.push(`<meta name="twitter:description" content="${esc(description)}">`);
+ if (image) out.push(`<meta name="twitter:image" content="${esc(image)}">`);
+ const ld = o.jsonLd ? (Array.isArray(o.jsonLd) ? o.jsonLd : [o.jsonLd]) : [];
+ for (const item of ld) if (item) out.push(jsonLdTag(item));
+ return out.join('\n');
+}
+
+const ORG = { '@type': 'Organization', '@id': 'https://openvibe.network/#org', name: 'OpenVibe', url: 'https://openvibe.network/', logo: 'https://openvibe.network/assets/logo-512.png', sameAs: ['https://github.com/OpenVibers', 'https://discord.gg/M6MuRUaeJj'] };
+
+const jsonLd = {
+ organization(extra = {}) { return { '@context': 'https://schema.org', ...ORG, ...extra }; },
+ /** WebSite; `searchUrl` with {q} adds the sitelinks SearchAction. */
+ website({ name, url, description, searchUrl, inLanguage = 'en' } = {}) {
+ const o = { '@context': 'https://schema.org', '@type': 'WebSite', '@id': `${url}#site`, name, url, description, inLanguage, publisher: { '@id': ORG['@id'] } };
+ if (searchUrl) o.potentialAction = { '@type': 'SearchAction', target: { '@type': 'EntryPoint', urlTemplate: searchUrl.replace('{q}', '{search_term_string}') }, 'query-input': 'required name=search_term_string' };
+ return o;
+ },
+ webPage({ name, url, description, type = 'WebPage', siteUrl, dateModified } = {}) {
+ return { '@context': 'https://schema.org', '@type': type, '@id': `${url}#page`, name, url, description, ...(siteUrl ? { isPartOf: { '@id': `${siteUrl}#site` } } : {}), ...(dateModified ? { dateModified } : {}) };
+ },
+ /** A web tool. No price/offer fields: the platform makes no cost claims. */
+ softwareApp({ name, url, description, category = 'UtilitiesApplication', keywords, image, os = 'Any (web browser)' } = {}) {
+ return { '@context': 'https://schema.org', '@type': 'WebApplication', name, url, description, applicationCategory: category, operatingSystem: os, browserRequirements: 'Requires a modern web browser', ...(keywords ? { keywords: Array.isArray(keywords) ? keywords.join(', ') : keywords } : {}), ...(image ? { image } : {}), publisher: { '@id': ORG['@id'] } };
+ },
+ breadcrumbs(items = []) {
+ return { '@context': 'https://schema.org', '@type': 'BreadcrumbList', itemListElement: items.map((it, i) => ({ '@type': 'ListItem', position: i + 1, name: it.name, item: it.url })) };
+ },
+ itemList(name, items = [], { url } = {}) {
+ return { '@context': 'https://schema.org', '@type': 'ItemList', name, ...(url ? { url } : {}), numberOfItems: items.length, itemListElement: items.map((it, i) => ({ '@type': 'ListItem', position: i + 1, name: it.name, url: it.url, ...(it.description ? { description: it.description } : {}) })) };
+ },
+ faq(pairs = []) {
+ return { '@context': 'https://schema.org', '@type': 'FAQPage', mainEntity: pairs.map((p) => ({ '@type': 'Question', name: p.q, acceptedAnswer: { '@type': 'Answer', text: p.a } })) };
+ },
+ howTo({ name, description, steps = [] } = {}) {
+ return { '@context': 'https://schema.org', '@type': 'HowTo', name, description, step: steps.map((s, i) => ({ '@type': 'HowToStep', position: i + 1, name: s.name || `Step ${i + 1}`, text: s.text })) };
+ },
+ article({ headline, url, description, author, datePublished, dateModified, image } = {}) {
+ return { '@context': 'https://schema.org', '@type': 'Article', headline: clip(headline, 110), url, description, ...(author ? { author: { '@type': 'Person', name: author } } : {}), ...(datePublished ? { datePublished } : {}), ...(dateModified ? { dateModified } : {}), ...(image ? { image } : {}), publisher: { '@id': ORG['@id'] } };
+ },
+ video({ name, url, description, thumbnailUrl, uploadDate, duration, contentUrl, embedUrl } = {}) {
+ return { '@context': 'https://schema.org', '@type': 'VideoObject', name, url, description: description || name, thumbnailUrl, uploadDate, ...(duration ? { duration } : {}), ...(contentUrl ? { contentUrl } : {}), ...(embedUrl ? { embedUrl } : {}) };
+ },
+};
+
+/** urls: [{ loc, lastmod?, changefreq?, priority?, alternates?: [{hreflang, href}], images?: [url] }] — absolute http(s) only. */
+function sitemapXml(urls = []) {
+ const rows = urls.filter((u) => u && /^https?:\/\//i.test(u.loc)).map((u) => {
+ const parts = [`<loc>${xml(u.loc)}</loc>`];
+ if (u.lastmod) parts.push(`<lastmod>${xml(String(u.lastmod).slice(0, 10))}</lastmod>`);
+ if (u.changefreq) parts.push(`<changefreq>${xml(u.changefreq)}</changefreq>`);
+ if (u.priority != null) parts.push(`<priority>${Number(u.priority).toFixed(1)}</priority>`);
+ for (const a of u.alternates || []) parts.push(`<xhtml:link rel="alternate" hreflang="${xml(a.hreflang)}" href="${xml(a.href)}"/>`);
+ for (const img of u.images || []) parts.push(`<image:image><image:loc>${xml(img)}</image:loc></image:image>`);
+ return ` <url>${parts.join('')}</url>`;
+ });
+ return `<?xml version="1.0" encoding="UTF-8"?>\n<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9" xmlns:xhtml="http://www.w3.org/1999/xhtml" xmlns:image="http://www.google.com/schemas/sitemap-image/1.1">\n${rows.join('\n')}\n</urlset>\n`;
+}
+
+function sitemapIndexXml(maps = []) {
+ const rows = maps.filter((m) => m && m.loc).map((m) => ` <sitemap><loc>${xml(m.loc)}</loc>${m.lastmod ? `<lastmod>${xml(String(m.lastmod).slice(0, 10))}</lastmod>` : ''}</sitemap>`);
+ return `<?xml version="1.0" encoding="UTF-8"?>\n<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">\n${rows.join('\n')}\n</sitemapindex>\n`;
+}
+
+// Crawlers that build search and AI indexes — welcomed by name so a generic Disallow never hits them.
+const AI_AND_SEARCH_BOTS = ['Googlebot', 'Bingbot', 'DuckDuckBot', 'Applebot', 'GPTBot', 'OAI-SearchBot', 'ChatGPT-User', 'ClaudeBot', 'Claude-Web', 'anthropic-ai', 'PerplexityBot', 'CCBot', 'Google-Extended', 'Amazonbot', 'meta-externalagent', 'YouBot', 'Bytespider'];
+
+function robotsTxt({ sitemaps = [], disallow = [], allow = ['/'], allowAI = true, block = [], crawlDelay } = {}) {
+ const lines = ['User-agent: *'];
+ for (const a of allow) lines.push(`Allow: ${a}`);
+ for (const d of disallow) lines.push(`Disallow: ${d}`);
+ if (crawlDelay) lines.push(`Crawl-delay: ${crawlDelay}`);
+ if (allowAI) {
+ lines.push('');
+ for (const bot of AI_AND_SEARCH_BOTS) lines.push(`User-agent: ${bot}`);
+ lines.push('Allow: /');
+ for (const d of disallow) lines.push(`Disallow: ${d}`);
+ }
+ for (const b of block) { lines.push('', `User-agent: ${b}`, 'Disallow: /'); }
+ lines.push('');
+ for (const s of sitemaps) lines.push(`Sitemap: ${s}`);
+ return lines.join('\n') + '\n';
+}
+
+/** /llms.txt (llmstxt.org): a markdown map of the site for language-model crawlers. */
+function llmsTxt({ name, summary, details, sections = [] } = {}) {
+ const out = [`# ${name}`, '', `> ${String(summary || '').replace(/\s+/g, ' ').trim()}`, ''];
+ if (details) out.push(String(details).trim(), '');
+ for (const sec of sections) {
+ if (!sec || !sec.links || !sec.links.length) continue;
+ out.push(`## ${sec.title}`, '');
+ for (const l of sec.links) out.push(`- [${l.title}](${l.url})${l.note ? `: ${String(l.note).replace(/\s+/g, ' ').trim()}` : ''}`);
+ out.push('');
+ }
+ return out.join('\n');
+}
+
+module.exports = { headTags, jsonLd, jsonLdTag, sitemapXml, sitemapIndexXml, robotsTxt, llmsTxt, clip, esc, absolute, AI_AND_SEARCH_BOTS };
new file mode 100644
new file mode 100644
@@ -0,0 +1,117 @@
@@ -0,0 +1,117 @@
+/**
+ * openvibe-shared/sso-client.js — the pieces that make one sign-in feel like one sign-in
+ * across separate domains. Loaded lazily by navbar.js; Live loads it directly.
+ *
+ * OpenVibeSSO.handoffLinks({ signedIn }) cross-site links carry the session along
+ * OpenVibeSSO.fedcm({ apiBase, fedcmLogin, mediation }) browser-native sign-in (Chrome/Edge)
+ * OpenVibeSSO.loginUrlFor(href) the silent-login URL that lands on `href`
+ *
+ * Link hand-off: when this page has a session and a link points at another OpenVibe site, the
+ * navigation goes through that site's silent sign-in (`…/auth/login?silent=1&next=<link>`).
+ * It is a top-level navigation, so it works in every browser with no third-party cookies, and
+ * the target answers straight from its own session when it already has one. Sites that share a
+ * cookie domain (openvibe.tools and its subdomains) are one site here. Links can opt out with
+ * data-ov-nohandoff.
+ *
+ * FedCM: `navigator.credentials.get({ identity })` — the browser itself asks openvibe.network
+ * for an assertion (a native chip the first time per site, silent auto re-auth after that) and
+ * the page posts it to the site's /auth/fedcm, which exchanges it server-side for a session.
+ * Never called for browsers the network has told "logged-out", so guests see nothing.
+ */
+(function (root) {
+ 'use strict';
+ if (root.OpenVibeSSO) return;
+
+ const NETWORK = 'https://openvibe.network';
+ // Site → where its silent sign-in lives. Hosts sharing a session cookie share an entry.
+ const SITES = [
+ { test: (h) => h === 'openvibe.live' || h.endsWith('.openvibe.live'), key: 'live', login: 'https://openvibe.live/api/auth/sso/login?silent=1&next={url}', fedcm: 'https://openvibe.live/api/auth/fedcm' },
+ { test: (h) => h === 'openvibe.tools' || h.endsWith('.openvibe.tools'), key: 'tools', login: 'https://openvibe.tools/auth/login?silent=1&next={url}', fedcm: 'https://openvibe.tools/auth/fedcm' },
+ { test: (h) => h === 'openvibe.games' || h.endsWith('.openvibe.games'), key: 'games', login: 'https://openvibe.games/auth/login?silent=1&next={url}', fedcm: '/auth/fedcm' },
+ { test: (h) => h === 'openvibe.community' || h.endsWith('.openvibe.community'), key: 'community', login: 'https://openvibe.community/auth/login?silent=1&next={url}', fedcm: '/auth/fedcm' },
+ ];
+ const siteFor = (host) => SITES.find(s => s.test(String(host || '').toLowerCase())) || null;
+ const here = () => (typeof location !== 'undefined' ? siteFor(location.hostname) : null);
+
+ /** The URL that signs the browser in on the link's site and lands on the link, or null. */
+ function loginUrlFor(href) {
+ let u; try { u = new URL(href, location.href); } catch { return null; }
+ if (u.protocol !== 'https:') return null;
+ const target = siteFor(u.hostname);
+ if (!target) return null;
+ const mine = here();
+ if (mine && mine.key === target.key) return null; // same session cookie already
+ return target.login.replace('{url}', encodeURIComponent(u.toString()));
+ }
+
+ let _handoffOn = false, _signedIn = false;
+ function rewrite(a) {
+ if (!a || a.dataset.ovHandoff || a.hasAttribute('data-ov-nohandoff') || !a.getAttribute('href')) return;
+ const url = loginUrlFor(a.href);
+ if (!url) return;
+ a.dataset.ovHandoff = a.href;
+ a.href = url;
+ }
+ function onIntent(e) {
+ if (!_signedIn) return;
+ const a = e.target && e.target.closest ? e.target.closest('a[href]') : null;
+ if (a) rewrite(a);
+ }
+ /** Turn cross-site links into session hand-offs while signed in (idempotent; call again to update). */
+ function handoffLinks(opts) {
+ _signedIn = !!(opts && opts.signedIn);
+ if (_handoffOn) return;
+ _handoffOn = true;
+ // Rewrite just before the navigation can start: pointer/keyboard intent, and touch.
+ for (const ev of ['pointerdown', 'touchstart', 'focusin', 'auxclick']) document.addEventListener(ev, onIntent, { capture: true, passive: true });
+ document.addEventListener('click', onIntent, true);
+ document.addEventListener('contextmenu', (e) => {
+ // A copied link should stay a plain link.
+ const a = e.target && e.target.closest ? e.target.closest('a[data-ov-handoff]') : null;
+ if (a) { a.href = a.dataset.ovHandoff; delete a.dataset.ovHandoff; }
+ }, true);
+ }
+
+ /** FedCM availability: the API exists and this is a top-level, secure document. */
+ function fedcmAvailable() {
+ try { return typeof navigator !== 'undefined' && 'IdentityCredential' in root && root.isSecureContext && root.top === root; } catch { return false; }
+ }
+
+ /**
+ * Ask the browser for a FedCM assertion from openvibe.network and turn it into a session here.
+ * Resolves { ok, user } or { ok: false, reason }. mediation 'silent' = only auto re-auth
+ * (previously approved on this site), 'optional' = show the chip the first time.
+ */
+ async function fedcm(opts) {
+ const o = Object.assign({ apiBase: NETWORK, mediation: 'optional', timeoutMs: 15000 }, opts || {});
+ if (!fedcmAvailable()) return { ok: false, reason: 'unsupported' };
+ const site = here();
+ const fedcmLogin = o.fedcmLogin || (site && site.fedcm) || '/auth/fedcm';
+ const nonce = Array.from(crypto.getRandomValues(new Uint8Array(16)), b => b.toString(16).padStart(2, '0')).join('');
+ const ctrl = new AbortController(); const timer = setTimeout(() => ctrl.abort(), o.timeoutMs);
+ let cred;
+ try {
+ cred = await navigator.credentials.get({
+ identity: { context: 'signin', providers: [{ configURL: `${o.apiBase}/fedcm/config.json`, clientId: location.origin, nonce, params: { nonce } }] },
+ mediation: o.mediation, signal: ctrl.signal,
+ });
+ } catch (err) {
+ clearTimeout(timer);
+ return { ok: false, reason: (err && (err.name || err.message)) || 'declined' };
+ }
+ clearTimeout(timer);
+ if (!cred || !cred.token) return { ok: false, reason: 'no-token' };
+ try {
+ const res = await fetch(fedcmLogin, { method: 'POST', credentials: 'include', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ token: cred.token, nonce }) });
+ const data = await res.json().catch(() => ({}));
+ if (!res.ok) return { ok: false, reason: data.error || `http-${res.status}`, auto: !!cred.isAutoSelected };
+ try { sessionStorage.setItem('ov_sso_hint', 'account'); localStorage.setItem('ov_sso_hint', 'account'); } catch { /* */ }
+ return { ok: true, user: data.user || null, auto: !!cred.isAutoSelected };
+ } catch (err) { return { ok: false, reason: 'network' }; }
+ }
+
+ /** After an explicit sign-out: the browser must not auto re-auth us back in. */
+ function preventSilent() { try { if (navigator.credentials && navigator.credentials.preventSilentAccess) navigator.credentials.preventSilentAccess(); } catch { /* */ } }
+
+ root.OpenVibeSSO = { handoffLinks, loginUrlFor, fedcm, fedcmAvailable, preventSilent, siteFor, SITES };
+})(typeof window !== 'undefined' ? window : globalThis);
new file mode 100644
new file mode 100644
@@ -0,0 +1,124 @@
@@ -0,0 +1,124 @@
+/**
+ * openvibe-shared/ui.js — one toast / confirm / alert implementation for every OpenVibe site.
+ *
+ * OpenVibeUI.toast('Saved', { type: 'success' })
+ * OpenVibeUI.toast('Upload failed', { type: 'error', title: 'Images', action: { label: 'Retry', onClick } })
+ * if (await OpenVibeUI.confirm({ title: 'Delete paste?', message: 'This cannot be undone.', danger: true })) …
+ * await OpenVibeUI.alert({ title: 'Heads up', message: '…' })
+ *
+ * Themed from the site's CSS variables, stacked bottom-right (bottom-centre on phones), announced
+ * to assistive tech (role=status, role=alert for errors), pause-on-hover, Escape closes dialogs,
+ * focus is trapped in dialogs and restored afterwards. Text is always set with textContent.
+ */
+(function (root) {
+ 'use strict';
+ if (root.OpenVibeUI) return;
+ if (typeof document === 'undefined') { const noop = { toast() { return { close() {} }; }, confirm: async () => false, alert: async () => {} }; if (typeof module !== 'undefined') module.exports = noop; return; }
+
+ const CSS = `
+.ovui-toasts{position:fixed;right:16px;bottom:16px;z-index:2147483000;display:flex;flex-direction:column;gap:8px;max-width:min(380px,calc(100vw - 24px));pointer-events:none}
+.ovui-toast{pointer-events:auto;display:grid;grid-template-columns:auto 1fr auto;gap:10px;align-items:start;padding:11px 12px;border-radius:12px;background:var(--bg-elevated,var(--bg-secondary,#131a26));color:var(--text-primary,#e6edf7);border:1px solid var(--border,rgba(255,255,255,.1));box-shadow:0 10px 30px rgba(0,0,0,.35);font:500 13.5px/1.4 system-ui,-apple-system,Segoe UI,Roboto,sans-serif;animation:ovuiIn .28s cubic-bezier(.2,1.2,.3,1);border-left:3px solid var(--ovui-c,var(--accent,#3b82f6))}
+.ovui-toast[data-type=success]{--ovui-c:var(--success,#22c55e)}
+.ovui-toast[data-type=error]{--ovui-c:var(--danger,#ef4444)}
+.ovui-toast[data-type=warning]{--ovui-c:var(--warning,#f59e0b)}
+.ovui-toast.is-out{animation:ovuiOut .2s ease forwards}
+.ovui-dot{width:8px;height:8px;border-radius:50%;background:var(--ovui-c,var(--accent,#3b82f6));margin-top:6px}
+.ovui-title{font-weight:700;margin-bottom:1px}
+.ovui-msg{color:var(--text-secondary,#a8b3c4);overflow-wrap:anywhere}
+.ovui-title+.ovui-msg{font-weight:400}
+.ovui-toast .ovui-msg:first-child{color:inherit}
+.ovui-x,.ovui-act{appearance:none;border:0;background:none;color:inherit;font:inherit;cursor:pointer;border-radius:8px}
+.ovui-x{opacity:.55;padding:2px 6px;line-height:1;font-size:16px}
+.ovui-x:hover{opacity:1}
+.ovui-act{margin-top:6px;padding:4px 10px;font-weight:700;color:var(--accent,#3b82f6);background:color-mix(in srgb,var(--accent,#3b82f6) 14%,transparent);text-decoration:none;display:inline-block}
+.ovui-back{position:fixed;inset:0;z-index:2147483100;background:rgba(3,6,12,.6);backdrop-filter:blur(3px);display:grid;place-items:center;padding:16px;animation:ovuiFade .15s ease}
+.ovui-dialog{width:min(420px,100%);background:var(--bg-elevated,var(--bg-secondary,#131a26));color:var(--text-primary,#e6edf7);border:1px solid var(--border,rgba(255,255,255,.1));border-radius:16px;padding:20px;box-shadow:0 24px 60px rgba(0,0,0,.5);font:400 14.5px/1.5 system-ui,-apple-system,Segoe UI,Roboto,sans-serif;animation:ovuiIn .22s cubic-bezier(.2,1.2,.3,1)}
+.ovui-dialog h2{margin:0 0 6px;font-size:17px;font-weight:700}
+.ovui-dialog p{margin:0;color:var(--text-secondary,#a8b3c4);white-space:pre-wrap;overflow-wrap:anywhere}
+.ovui-btns{display:flex;justify-content:flex-end;gap:8px;margin-top:18px}
+.ovui-btn{appearance:none;border:1px solid var(--border,rgba(255,255,255,.14));background:transparent;color:inherit;font:600 14px/1 system-ui,sans-serif;padding:10px 16px;border-radius:10px;cursor:pointer}
+.ovui-btn:hover{background:rgba(127,127,127,.12)}
+.ovui-btn.is-primary{background:var(--accent,#3b82f6);border-color:transparent;color:var(--on-accent,#fff)}
+.ovui-btn.is-danger{background:var(--danger,#ef4444);border-color:transparent;color:#fff}
+.ovui-btn:focus-visible,.ovui-x:focus-visible,.ovui-act:focus-visible{outline:2px solid var(--accent,#3b82f6);outline-offset:2px}
+@keyframes ovuiIn{from{opacity:0;transform:translateY(10px) scale(.97)}to{opacity:1;transform:none}}
+@keyframes ovuiOut{to{opacity:0;transform:translateX(16px)}}
+@keyframes ovuiFade{from{opacity:0}to{opacity:1}}
+@media (max-width:560px){.ovui-toasts{left:12px;right:12px;bottom:calc(12px + env(safe-area-inset-bottom,0px));max-width:none}}
+@media (prefers-reduced-motion:reduce){.ovui-toast,.ovui-dialog,.ovui-back{animation:none!important}}`;
+
+ function css() { if (document.getElementById('ovui-css')) return; const s = document.createElement('style'); s.id = 'ovui-css'; s.textContent = CSS; document.head.appendChild(s); }
+ function el(tag, cls, text) { const e = document.createElement(tag); if (cls) e.className = cls; if (text != null) e.textContent = String(text); return e; }
+ function safeHref(h) { try { const u = new URL(h, location.href); return /^https?:$/.test(u.protocol) ? u.href : null; } catch { return null; } }
+
+ let stack = null;
+ const MAX = 4;
+
+ function toast(message, opts) {
+ const o = opts || {};
+ css();
+ if (!stack || !stack.isConnected) { stack = el('div', 'ovui-toasts'); stack.setAttribute('aria-live', 'polite'); document.body.appendChild(stack); }
+ const type = ['info', 'success', 'error', 'warning'].includes(o.type) ? o.type : 'info';
+ const t = el('div', 'ovui-toast'); t.dataset.type = type; t.setAttribute('role', type === 'error' ? 'alert' : 'status');
+ t.appendChild(el('span', 'ovui-dot'));
+ const body = el('div');
+ if (o.title) body.appendChild(el('div', 'ovui-title', o.title));
+ body.appendChild(el('div', 'ovui-msg', message));
+ if (o.action && o.action.label) {
+ const href = o.action.href && safeHref(o.action.href);
+ const a = el(href ? 'a' : 'button', 'ovui-act', o.action.label);
+ if (href) a.href = href; else a.type = 'button';
+ a.addEventListener('click', (ev) => { try { o.action.onClick && o.action.onClick(ev); } finally { close(); } });
+ body.appendChild(a);
+ }
+ t.appendChild(body);
+ const x = el('button', 'ovui-x', '×'); x.type = 'button'; x.setAttribute('aria-label', 'Dismiss'); x.addEventListener('click', () => close()); t.appendChild(x);
+ stack.appendChild(t);
+ while (stack.children.length > MAX) stack.firstChild.remove();
+
+ const ttl = o.ttl === 0 ? 0 : (o.ttl || (type === 'error' ? 8000 : 4500));
+ let timer = null, closed = false;
+ const arm = () => { if (ttl) timer = setTimeout(close, ttl); };
+ function close() { if (closed) return; closed = true; clearTimeout(timer); t.classList.add('is-out'); setTimeout(() => t.remove(), 220); }
+ t.addEventListener('mouseenter', () => clearTimeout(timer)); t.addEventListener('mouseleave', arm);
+ arm();
+ return { close, el: t };
+ }
+
+ function dialog(o, withCancel) {
+ css();
+ return new Promise((resolve) => {
+ const prev = document.activeElement;
+ const back = el('div', 'ovui-back');
+ const d = el('div', 'ovui-dialog'); d.setAttribute('role', withCancel ? 'alertdialog' : 'dialog'); d.setAttribute('aria-modal', 'true');
+ const h = el('h2', null, o.title || (withCancel ? 'Are you sure?' : 'Notice')); h.id = 'ovui-h-' + Date.now(); d.setAttribute('aria-labelledby', h.id); d.appendChild(h);
+ if (o.message) d.appendChild(el('p', null, o.message));
+ const btns = el('div', 'ovui-btns');
+ let cancel = null;
+ if (withCancel) { cancel = el('button', 'ovui-btn', o.cancelLabel || 'Cancel'); cancel.type = 'button'; btns.appendChild(cancel); }
+ const ok = el('button', 'ovui-btn ' + (o.danger ? 'is-danger' : 'is-primary'), o.confirmLabel || (withCancel ? 'Confirm' : 'OK')); ok.type = 'button'; btns.appendChild(ok);
+ d.appendChild(btns); back.appendChild(d); document.body.appendChild(back);
+ function done(v) { document.removeEventListener('keydown', onKey, true); back.remove(); try { prev && prev.focus && prev.focus(); } catch { /* */ } resolve(v); }
+ function onKey(e) {
+ if (e.key === 'Escape') { e.preventDefault(); done(false); }
+ else if (e.key === 'Tab') { const f = [cancel, ok].filter(Boolean); const i = f.indexOf(document.activeElement); e.preventDefault(); f[(i + (e.shiftKey ? f.length - 1 : 1)) % f.length].focus(); }
+ }
+ document.addEventListener('keydown', onKey, true);
+ back.addEventListener('mousedown', (e) => { if (e.target === back) done(false); });
+ ok.addEventListener('click', () => done(true));
+ if (cancel) cancel.addEventListener('click', () => done(false));
+ (o.danger && cancel ? cancel : ok).focus();
+ });
+ }
+
+ const api = {
+ toast,
+ success: (m, o) => toast(m, Object.assign({}, o, { type: 'success' })),
+ error: (m, o) => toast(m, Object.assign({}, o, { type: 'error' })),
+ warning: (m, o) => toast(m, Object.assign({}, o, { type: 'warning' })),
+ confirm: (o) => dialog(typeof o === 'string' ? { message: o } : (o || {}), true),
+ alert: (o) => dialog(typeof o === 'string' ? { message: o } : (o || {}), false).then(() => undefined),
+ };
+ root.OpenVibeUI = api;
+ if (typeof module !== 'undefined' && module.exports) module.exports = api;
+})(typeof window !== 'undefined' ? window : globalThis);