openvibers/openvibe.tools Commits
Icon sprite on server-rendered pages; footer baked into the HTML; vendor sync
Changes
@@@ -22,7 +22,8 @@ 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-ovi="${esc(icons.resolve(name))}" data-fx="none" style="--ovi-size:${size}px" aria-hidden="true">${icons.svg(name)}</span>`;++let _used = new Set();++const icon = (name, size) => { _used.add(name); return icons.use(name, size); };const hostLabel = (h) => h.replace(/\.openvibe\.tools$/, '') === h ? h : h;// Links name the search-friendly host (what crawlers follow and index). A person's click goes straight to@@@ -102,18 +103,20 @@ const NETWORK = [];function shell({ head, body, families }) {++ // Every icon on the page once, as a sprite; the markup above only references them.++ const spriteSvg = icons.sprite([..._used, ...NETWORK.map(n => n[0])]); _used = new Set();return `<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">${head}${appIcon.headTags({ site: 'tools', iconBase: '/assets' })}<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" defer></script>--<style>${CSS}</style></head><body>++<style>${CSS}</style></head><body>${spriteSvg}<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>++</main>${require('openvibe-shared/footer').ssr({ service: 'tools', variant: 'full' })}<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>`;}nnew file mode 100644@@@ -0,0 +1,17 @@++'use strict';++// openvibe-shared/chrome-ssr — the parts of the shared chrome that can be baked into HTML on the server.++// footer(cfg) the full shared footer (HTML + CSS); the browser's footer.js renders into the same element++// noscriptNav(site) a plain navigation bar inside <noscript>: the JavaScript navbar cannot exist without++// scripts, so visitors who block them (and text browsers) still get the brand and the network++const footerModule = require('./footer');++++const SITES = [['Live', 'https://openvibe.live/'], ['Tools', 'https://openvibe.tools/'], ['Community', 'https://openvibe.community/'], ['Games', 'https://openvibe.games/'], ['Media', 'https://openvibe.media/'], ['Network', 'https://openvibe.network/']];++const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));++++function noscriptNav({ name, home = '/', links = [] } = {}) {++ const own = links.map(l => `<a href="${esc(l.href)}">${esc(l.label)}</a>`).join('');++ const net = SITES.filter(([n]) => !String(name || '').endsWith('.' + n)).map(([n, u]) => `<a href="${u}">${n}</a>`).join('');++ return `<noscript><nav aria-label="Site" style="display:flex;flex-wrap:wrap;align-items:center;gap:6px 16px;padding:12px 20px;border-bottom:1px solid rgba(255,255,255,.1);font:500 14px/1.4 system-ui,sans-serif"><a href="${esc(home)}" style="font-weight:700;color:inherit;text-decoration:none">${esc(name || 'OpenVibe')}</a>${own}<span style="flex:1"></span>${net}</nav></noscript>`;++}++++module.exports = { footer: (cfg) => footerModule.ssr(cfg), noscriptNav };@@@ -47,6 +47,7 @@}function cached() { try { const c = JSON.parse(localStorage.getItem(KEY) || 'null'); return c && c.host === host() && c.data ? c : null; } catch { return null; } }function refresh() {++ if (typeof window === 'undefined' || typeof document === 'undefined') return Promise.resolve(null); // server-side render: never call outif (inflight || typeof fetch === 'undefined') return inflight || Promise.resolve(null);inflight = fetch('https://openvibe.network/api/chrome?host=' + encodeURIComponent(host()), { credentials: 'omit' }).then(r => (r.ok ? r.json() : null)).then(clean)@@@ -379,5 +380,16 @@render();}-- return { init, setVariant, buildHTML, render, CSS, NETWORK, LEGAL, detectService, get config() { return { ..._cfg }; } };++ /**++ * Server-side render: the complete footer as HTML + its CSS, so every page carries the network's links for++ * crawlers and for visitors without JavaScript. In the browser, init() renders into the same element.++ * ssr({ service, variant, links, mountId }) → '<style>…</style><footer id="ov-footer" …>…</footer>'++ */++ function ssr(cfg = {}) {++ const c = { ...DEFAULTS, ...cfg };++ const id = String(cfg.mountId || 'ov-footer').replace(/[^\w-]/g, '');++ return `<style id="ovf-css">${CSS}</style><footer id="${id}" class="ovf" data-variant="${c.variant === 'compact' ? 'compact' : 'full'}">${buildHTML(c)}</footer>`;++ }++++ return { init, ssr, setVariant, buildHTML, render, CSS, NETWORK, LEGAL, detectService, get config() { return { ..._cfg }; } };});@@@ -180,8 +180,8 @@ function page(kind, site) {${head}${require('./app-icon').headTags({ site: site.id })}<script src="https://openvibe.network/shared/theme-loader.js" defer></script><style>${CSS}</style></head><body>--<noscript><p style="padding:12px 20px;margin:0"><a href="/">${esc(site.name)}</a></p></noscript>--<main>${article(kind, site)}</main><div id="ov-footer"></div>++${require('./chrome-ssr').noscriptNav({ name: site.name })}++<main>${article(kind, site)}</main>${require('./footer').ssr({ service: site.service || site.id || 'network', variant: 'compact' })}<script src="https://openvibe.network/shared/navbar.js" defer></script><script src="https://openvibe.network/shared/footer.js" defer></script><script>addEventListener('DOMContentLoaded',function(){var t=null;try{t=(document.cookie.match(/(?:^|; )ov_token=([^;]*)/)||[])[1]||localStorage.getItem('ov_token')}catch(e){}try{OpenVibeNavbar.init({service:${JSON.stringify(site.service || site.id || 'network')},apiBase:'https://openvibe.network',token:t?decodeURIComponent(t):null})}catch(e){}@@@ -83,6 +83,7 @@.ovnav-launcher a { all: unset; cursor: pointer; box-sizing: border-box; }.openvibe-navbar .ovnav-sep { width: 1px; height: 18px; background: var(--border, rgba(255,255,255,.12)); margin: 0 6px; flex: none; align-self: center; }@media (max-width: 1180px) { .openvibe-navbar .ovnav-net, .openvibe-navbar .ovnav-sep { display: none; } }++ .openvibe-navbar .ovnav-ic, .openvibe-navbar-dropdown .ovnav-ic { flex: none; font-size: 1.05em; vertical-align: -.15em; }.ovnav-launcher { position: absolute; top: calc(100% + 6px); left: 12px; width: min(440px, calc(100vw - 24px)); 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; }@@@ -255,225 +256,30 @@document.head.appendChild(s);}-- // Primary services (per CONTRACTS branding) + tool sub-brands (<Name>.OpenVibe)-- const SERVICE_NAMES = {-- live: 'OpenVibe.Live', tools: 'OpenVibe.Tools', games: 'OpenVibe.Games',-- media: 'OpenVibe.Media', network: 'OpenVibe.Network',-- net: 'Net.OpenVibe', dev: 'Dev.OpenVibe', paste: 'Paste.OpenVibe',-- maps: 'Maps.OpenVibe', food: 'Food.OpenVibe', img: 'Img.OpenVibe',-- yt: 'YT.OpenVibe', audio: 'Audio.OpenVibe', text: 'Text.OpenVibe',-- logo: 'Logo.OpenVibe', docs: 'Docs.OpenVibe',-- };---- const SERVICE_ICONS = {-- live: 'fa-tower-broadcast', tools: 'fa-screwdriver-wrench', games: 'fa-gamepad',-- media: 'fa-photo-film', network: 'fa-circle-nodes',-- net: 'fa-network-wired', dev: 'fa-code', paste: 'fa-paste',-- maps: 'fa-map-location-dot', food: 'fa-utensils', img: 'fa-images',-- yt: 'fa-circle-play', audio: 'fa-headphones', text: 'fa-pen-fancy',-- logo: 'fa-wand-magic-sparkles', docs: 'fa-file-pdf',-- };---- // Subdomain → brand override for multi-subdomain services (Img.OpenVibe)-- const SUBDOMAIN_BRANDS = {-- 'png.openvibe.tools': { name: 'OpenVibePNG', icon: 'fa-file-image' },-- 'jpg.openvibe.tools': { name: 'OpenVibeJPG', icon: 'fa-file-image' },-- 'jpeg.openvibe.tools': { name: 'OpenVibeJPG', icon: 'fa-file-image' },-- 'webp.openvibe.tools': { name: 'OpenVibeWebP', icon: 'fa-file-image' },-- 'avif.openvibe.tools': { name: 'OpenVibeAVIF', icon: 'fa-file-image' },-- 'heic.openvibe.tools': { name: 'OpenVibeHEIC', icon: 'fa-file-image' },-- 'heif.openvibe.tools': { name: 'OpenVibeHEIC', icon: 'fa-file-image' },-- 'svg.openvibe.tools': { name: 'OpenVibeSVG', icon: 'fa-bezier-curve' },-- 'gif.openvibe.tools': { name: 'OpenVibeGIF', icon: 'fa-film' },-- 'ico.openvibe.tools': { name: 'OpenVibeICO', icon: 'fa-icons' },-- 'tiff.openvibe.tools': { name: 'OpenVibeTIFF', icon: 'fa-file-image' },-- 'bmp.openvibe.tools': { name: 'OpenVibeBMP', icon: 'fa-file-image' },-- 'compress.openvibe.tools': { name: 'OpenVibeCompress', icon: 'fa-compress' },-- 'resize.openvibe.tools': { name: 'OpenVibeResize', icon: 'fa-up-right-and-down-left-from-center' },-- 'crop.openvibe.tools': { name: 'OpenVibeCrop', icon: 'fa-crop-simple' },-- 'convert.openvibe.tools': { name: 'OpenVibeConvert', icon: 'fa-arrows-rotate' },-- 'favicon.openvibe.tools': { name: 'OpenVibeFavicon', icon: 'fa-icons' },-- 'yt.openvibe.tools': { name: 'YT.OpenVibe', icon: 'fa-circle-play' },-- 'maps.openvibe.tools': { name: 'Maps.OpenVibe', icon: 'fa-map-location-dot' },-- 'food.openvibe.tools': { name: 'Food.OpenVibe', icon: 'fa-utensils' },-- // Audio tool subdomains-- 'audio.openvibe.tools': { name: 'Audio.OpenVibe', icon: 'fa-headphones' },-- 'mp3.openvibe.tools': { name: 'OpenVibeMP3', icon: 'fa-file-audio' },-- 'wav.openvibe.tools': { name: 'OpenVibeWAV', icon: 'fa-file-audio' },-- 'flac.openvibe.tools': { name: 'OpenVibeFLAC', icon: 'fa-file-audio' },-- 'ogg.openvibe.tools': { name: 'OpenVibeOGG', icon: 'fa-file-audio' },-- 'm4a.openvibe.tools': { name: 'OpenVibeM4A', icon: 'fa-file-audio' },-- 'aac.openvibe.tools': { name: 'OpenVibeAAC', icon: 'fa-file-audio' },-- 'opus.openvibe.tools': { name: 'OpenVibeOPUS', icon: 'fa-file-audio' },-- 'wma.openvibe.tools': { name: 'OpenVibeWMA', icon: 'fa-file-audio' },-- 'aiff.openvibe.tools': { name: 'OpenVibeAIFF', icon: 'fa-file-audio' },-- 'ac3.openvibe.tools': { name: 'OpenVibeAC3', icon: 'fa-file-audio' },-- 'trim.openvibe.tools': { name: 'OpenVibeTrim', icon: 'fa-scissors' },-- 'pitch.openvibe.tools': { name: 'OpenVibePitch', icon: 'fa-wave-square' },-- 'speed.openvibe.tools': { name: 'OpenVibeSpeed', icon: 'fa-gauge-high' },-- 'normalize.openvibe.tools':{ name: 'OpenVibeNormalize', icon: 'fa-sliders' },-- 'fade.openvibe.tools': { name: 'OpenVibeFade', icon: 'fa-volume-low' },-- 'bass.openvibe.tools': { name: 'OpenVibeBass', icon: 'fa-volume-high' },-- 'equalizer.openvibe.tools':{ name: 'OpenVibeEQ', icon: 'fa-bars-staggered' },-- 'echo.openvibe.tools': { name: 'OpenVibeEcho', icon: 'fa-tower-broadcast' },-- 'reverb.openvibe.tools': { name: 'OpenVibeReverb', icon: 'fa-church' },-- 'voice.openvibe.tools': { name: 'OpenVibeVoiceFX', icon: 'fa-user-astronaut' },-- 'extract.openvibe.tools': { name: 'OpenVibeExtract', icon: 'fa-music' },-- 'ringtone.openvibe.tools': { name: 'OpenVibeRingtone', icon: 'fa-bell' },-- // Text tool subdomains-- 'text.openvibe.tools': { name: 'Text.OpenVibe', icon: 'fa-pen-fancy' },-- 'type.openvibe.tools': { name: 'Text.OpenVibe', icon: 'fa-pen-fancy' },-- 'fonts.openvibe.tools': { name: 'OpenVibeFonts', icon: 'fa-font' },-- 'fancy.openvibe.tools': { name: 'OpenVibeFancy', icon: 'fa-wand-sparkles' },-- 'zalgo.openvibe.tools': { name: 'OpenVibeZalgo', icon: 'fa-skull' },-- 'ascii.openvibe.tools': { name: 'OpenVibeASCII', icon: 'fa-terminal' },-- 'symbols.openvibe.tools': { name: 'OpenVibeSymbols', icon: 'fa-icons' },-- 'unicode.openvibe.tools': { name: 'OpenVibeUnicode', icon: 'fa-magnifying-glass' },-- 'bubble.openvibe.tools': { name: 'OpenVibeBubble', icon: 'fa-circle' },-- 'glitch.openvibe.tools': { name: 'OpenVibeGlitch', icon: 'fa-bug' },-- 'smallcaps.openvibe.tools': { name: 'OpenVibeSmallCaps', icon: 'fa-text-height' },-- 'cursive.openvibe.tools': { name: 'OpenVibeCursive', icon: 'fa-pen-nib' },-- 'gothic.openvibe.tools': { name: 'OpenVibeGothic', icon: 'fa-book-skull' },-- 'wide.openvibe.tools': { name: 'OpenVibeWide', icon: 'fa-arrows-left-right' },-- 'monospaced.openvibe.tools': { name: 'OpenVibeMono', icon: 'fa-code' },-- 'braille.openvibe.tools': { name: 'OpenVibeBraille', icon: 'fa-braille' },-- 'morse.openvibe.tools': { name: 'OpenVibeMorse', icon: 'fa-tower-broadcast' },-- 'binary.openvibe.tools': { name: 'OpenVibeBinary', icon: 'fa-microchip' },-- 'case.openvibe.tools': { name: 'OpenVibeCase', icon: 'fa-text-height' },-- 'caps.openvibe.tools': { name: 'OpenVibeCaps', icon: 'fa-text-height' },-- 'titlecase.openvibe.tools': { name: 'OpenVibeTitleCase', icon: 'fa-heading' },-- 'reverse.openvibe.tools': { name: 'OpenVibeReverse', icon: 'fa-right-left' },-- 'clean.openvibe.tools': { name: 'OpenVibeClean', icon: 'fa-broom' },-- 'strip.openvibe.tools': { name: 'OpenVibeStrip', icon: 'fa-broom' },-- 'count.openvibe.tools': { name: 'OpenVibeCount', icon: 'fa-calculator' },-- 'lines.openvibe.tools': { name: 'OpenVibeLines', icon: 'fa-list-ol' },-- 'sort.openvibe.tools': { name: 'OpenVibeSort', icon: 'fa-arrow-down-a-z' },-- 'dedupe.openvibe.tools': { name: 'OpenVibeDedupe', icon: 'fa-filter' },-- 'slug.openvibe.tools': { name: 'OpenVibeSlug', icon: 'fa-link' },-- 'compare.openvibe.tools': { name: 'OpenVibeCompare', icon: 'fa-code-compare' },-- 'diff.openvibe.tools': { name: 'OpenVibeDiff', icon: 'fa-code-compare' },-- 'markdown.openvibe.tools': { name: 'OpenVibeMarkdown', icon: 'fa-file-lines' },-- 'json.openvibe.tools': { name: 'OpenVibeJSON', icon: 'fa-brackets-curly' },-- 'escape.openvibe.tools': { name: 'OpenVibeEscape', icon: 'fa-shield-halved' },-- 'bio.openvibe.tools': { name: 'OpenVibeBio', icon: 'fa-id-card' },-- 'nickname.openvibe.tools': { name: 'OpenVibeNickname', icon: 'fa-signature' },-- 'username.openvibe.tools': { name: 'OpenVibeUsername', icon: 'fa-at' },-- 'gamertag.openvibe.tools': { name: 'OpenVibeGamertag', icon: 'fa-gamepad' },-- 'kaomoji.openvibe.tools': { name: 'OpenVibeKaomoji', icon: 'fa-face-smile' },-- 'emojis.openvibe.tools': { name: 'OpenVibeEmojis', icon: 'fa-face-grin' },-- 'copypaste.openvibe.tools': { name: 'OpenVibeCopyPaste', icon: 'fa-paste' },-- 'banner.openvibe.tools': { name: 'OpenVibeBanner', icon: 'fa-rectangle-ad' },-- 'textart.openvibe.tools': { name: 'Text.OpenVibeArt', icon: 'fa-border-all' },-- 'figlet.openvibe.tools': { name: 'OpenVibeFiglet', icon: 'fa-terminal' },-- // Logo / design subdomains-- 'logo.openvibe.tools': { name: 'Logo.OpenVibe', icon: 'fa-wand-magic-sparkles' },-- 'title.openvibe.tools': { name: 'OpenVibeTitle', icon: 'fa-heading' },-- 'wordmark.openvibe.tools': { name: 'OpenVibeWordmark', icon: 'fa-font' },-- 'textlogo.openvibe.tools': { name: 'Text.OpenVibeLogo', icon: 'fa-font' },-- 'transparent.openvibe.tools':{ name: 'OpenVibeTransparent', icon: 'fa-eye-slash' },-- 'badge.openvibe.tools': { name: 'OpenVibeBadge', icon: 'fa-certificate' },-- 'sticker.openvibe.tools': { name: 'OpenVibeSticker', icon: 'fa-note-sticky' },-- 'thumbnail.openvibe.tools': { name: 'OpenVibeThumbnail', icon: 'fa-photo-film' },-- 'cover.openvibe.tools': { name: 'OpenVibeCover', icon: 'fa-image' },-- 'channelart.openvibe.tools': { name: 'OpenVibeChannelArt', icon: 'fa-panorama' },-- 'watermark.openvibe.tools': { name: 'OpenVibeWatermark', icon: 'fa-droplet' },-- 'neon.openvibe.tools': { name: 'OpenVibeNeon', icon: 'fa-lightbulb' },-- // Document / PDF subdomains-- 'docs.openvibe.tools': { name: 'Docs.OpenVibe', icon: 'fa-file-pdf' },-- 'pdf.openvibe.tools': { name: 'OpenVibePDF', icon: 'fa-file-pdf' },-- 'mergepdf.openvibe.tools': { name: 'MergePDF', icon: 'fa-object-group' },-- 'splitpdf.openvibe.tools': { name: 'SplitPDF', icon: 'fa-scissors' },-- 'compresspdf.openvibe.tools':{ name: 'CompressPDF', icon: 'fa-compress' },-- 'rotatepdf.openvibe.tools': { name: 'RotatePDF', icon: 'fa-rotate' },-- 'reorderpdf.openvibe.tools': { name: 'ReorderPDF', icon: 'fa-sort' },-- 'watermarkpdf.openvibe.tools':{ name: 'WatermarkPDF', icon: 'fa-stamp' },-- 'protectpdf.openvibe.tools': { name: 'ProtectPDF', icon: 'fa-lock' },-- 'unlockpdf.openvibe.tools': { name: 'UnlockPDF', icon: 'fa-lock-open' },-- 'image2pdf.openvibe.tools': { name: 'Image2PDF', icon: 'fa-file-image' },-- 'jpg2pdf.openvibe.tools': { name: 'JPG2PDF', icon: 'fa-file-image' },-- 'png2pdf.openvibe.tools': { name: 'PNG2PDF', icon: 'fa-file-image' },-- 'pdf2jpg.openvibe.tools': { name: 'PDF2JPG', icon: 'fa-image' },-- 'pdf2png.openvibe.tools': { name: 'PDF2PNG', icon: 'fa-image' },-- // Network tool subdomains-- 'net.openvibe.tools': { name: 'Net.OpenVibe', icon: 'fa-network-wired' },-- 'lookup.openvibe.tools': { name: 'OpenVibeLookup', icon: 'fa-magnifying-glass' },-- 'myip.openvibe.tools': { name: 'OpenVibeMyIP', icon: 'fa-location-crosshairs' },-- 'ip.openvibe.tools': { name: 'OpenVibeIP', icon: 'fa-at' },-- 'geoip.openvibe.tools': { name: 'OpenVibeGeoIP', icon: 'fa-earth-americas' },-- 'hostname.openvibe.tools': { name: 'OpenVibeHostname', icon: 'fa-server' },-- 'isp.openvibe.tools': { name: 'OpenVibeISP', icon: 'fa-building' },-- 'asn.openvibe.tools': { name: 'OpenVibeASN', icon: 'fa-diagram-project' },-- 'ipv4.openvibe.tools': { name: 'OpenVibeIPv4', icon: 'fa-hashtag' },-- 'ipv6.openvibe.tools': { name: 'OpenVibeIPv6', icon: 'fa-code' },-- 'rdns.openvibe.tools': { name: 'OpenVibeReverseDNS',icon: 'fa-rotate-left' },-- 'whois.openvibe.tools': { name: 'OpenVibeWhois', icon: 'fa-address-book' },-- 'rdap.openvibe.tools': { name: 'OpenVibeRDAP', icon: 'fa-id-card' },-- 'dns.openvibe.tools': { name: 'OpenVibeDNS', icon: 'fa-sitemap' },-- 'dig.openvibe.tools': { name: 'OpenVibeDig', icon: 'fa-terminal' },-- 'nslookup.openvibe.tools': { name: 'OpenVibeNSLookup', icon: 'fa-magnifying-glass-arrow-right' },-- 'dnspropagation.openvibe.tools': { name: 'OpenVibeDNSPropagation', icon: 'fa-globe' },-- 'mx.openvibe.tools': { name: 'OpenVibeMX', icon: 'fa-envelope' },-- 'txt.openvibe.tools': { name: 'OpenVibeTXT', icon: 'fa-file-lines' },-- 'ns.openvibe.tools': { name: 'OpenVibeNS', icon: 'fa-server' },-- 'spf.openvibe.tools': { name: 'OpenVibeSPF', icon: 'fa-shield-halved' },-- 'dkim.openvibe.tools': { name: 'OpenVibeDKIM', icon: 'fa-key' },-- 'dmarc.openvibe.tools': { name: 'OpenVibeDMARC', icon: 'fa-user-shield' },-- 'ping.openvibe.tools': { name: 'OpenVibePing', icon: 'fa-satellite-dish' },-- 'traceroute.openvibe.tools': { name: 'OpenVibeTraceroute', icon: 'fa-route' },-- 'mtr.openvibe.tools': { name: 'OpenVibeMTR', icon: 'fa-chart-line' },-- 'port.openvibe.tools': { name: 'OpenVibePortCheck', icon: 'fa-door-open' },-- 'headers.openvibe.tools': { name: 'OpenVibeHeaders', icon: 'fa-list' },-- 'redirects.openvibe.tools': { name: 'OpenVibeRedirects', icon: 'fa-share' },-- 'ssl.openvibe.tools': { name: 'OpenVibeSSL', icon: 'fa-lock' },-- 'curl.openvibe.tools': { name: 'OpenVibeCurl', icon: 'fa-download' },-- 'httpstatus.openvibe.tools': { name: 'OpenVibeHTTPStatus', icon: 'fa-circle-check' },-- 'latency.openvibe.tools': { name: 'OpenVibeLatency', icon: 'fa-gauge-high' },-- // Dev.OpenVibe subdomains-- 'dev.openvibe.tools': { name: 'Dev.OpenVibe', icon: 'fa-code' },-- 'code.openvibe.tools': { name: 'Dev.OpenVibe', icon: 'fa-code' },-- 'json.openvibe.tools': { name: 'OpenVibeJSON', icon: 'fa-code' },-- 'yaml.openvibe.tools': { name: 'OpenVibeYAML', icon: 'fa-file-code' },-- 'xml.openvibe.tools': { name: 'OpenVibeXML', icon: 'fa-file-code' },-- 'csv.openvibe.tools': { name: 'OpenVibeCSV', icon: 'fa-table' },-- 'sql.openvibe.tools': { name: 'OpenVibeSQL', icon: 'fa-database' },-- 'markdown.openvibe.tools': { name: 'OpenVibeMarkdown', icon: 'fa-file-lines' },-- 'html.openvibe.tools': { name: 'OpenVibeHTML', icon: 'fa-file-code' },-- 'base64.openvibe.tools': { name: 'OpenVibeBase64', icon: 'fa-lock' },-- 'url.openvibe.tools': { name: 'OpenVibeURL', icon: 'fa-link' },-- 'jwt.openvibe.tools': { name: 'OpenVibeJWT', icon: 'fa-key' },-- 'uuid.openvibe.tools': { name: 'OpenVibeUUID', icon: 'fa-fingerprint' },-- 'hash.openvibe.tools': { name: 'OpenVibeHash', icon: 'fa-hashtag' },-- 'hex.openvibe.tools': { name: 'OpenVibeHex', icon: 'fa-barcode' },-- 'escape.openvibe.tools': { name: 'OpenVibeEscape', icon: 'fa-shield-halved' },-- 'timestamp.openvibe.tools': { name: 'OpenVibeTimestamp', icon: 'fa-clock' },-- 'cron.openvibe.tools': { name: 'OpenVibeCron', icon: 'fa-calendar-check' },-- 'beautify.openvibe.tools': { name: 'OpenVibeBeautify', icon: 'fa-wand-magic-sparkles' },-- 'minify.openvibe.tools': { name: 'OpenVibeMinify', icon: 'fa-compress' },-- 'diff.openvibe.tools': { name: 'OpenVibeDiff', icon: 'fa-code-compare' },-- 'regex.openvibe.tools': { name: 'OpenVibeRegex', icon: 'fa-magnifying-glass' },-- 'slug.openvibe.tools': { name: 'OpenVibeSlug', icon: 'fa-link' },-- 'lorem.openvibe.tools': { name: 'OpenVibeLorem', icon: 'fa-paragraph' },-- 'curl.openvibe.tools': { name: 'OpenVibeCurl', icon: 'fa-terminal' },-- 'webhook.openvibe.tools': { name: 'OpenVibeWebhook', icon: 'fa-satellite-dish' },-- 'color.openvibe.tools': { name: 'OpenVibeColor', icon: 'fa-palette' },-- 'opengraph.openvibe.tools': { name: 'OpenVibeOpenGraph', icon: 'fa-share-nodes' },-- // Dev.OpenVibe aliases-- 'build.openvibe.tools': { name: 'Dev.OpenVibe', icon: 'fa-code' },-- 'debug.openvibe.tools': { name: 'Dev.OpenVibe', icon: 'fa-code' },-- 'compare.openvibe.tools': { name: 'OpenVibeDiff', icon: 'fa-code-compare' },-- 'format.openvibe.tools': { name: 'OpenVibeBeautify', icon: 'fa-wand-magic-sparkles' },-- 'prettier.openvibe.tools': { name: 'OpenVibeBeautify', icon: 'fa-wand-magic-sparkles' },-- 'md.openvibe.tools': { name: 'OpenVibeMarkdown', icon: 'fa-file-lines' },-- 'unix.openvibe.tools': { name: 'OpenVibeTimestamp', icon: 'fa-clock' },-- 'epoch.openvibe.tools': { name: 'OpenVibeTimestamp', icon: 'fa-clock' },-- 'b64.openvibe.tools': { name: 'OpenVibeBase64', icon: 'fa-lock' },-- 'guid.openvibe.tools': { name: 'OpenVibeUUID', icon: 'fa-fingerprint' },-- 'sha256.openvibe.tools': { name: 'OpenVibeHash', icon: 'fa-hashtag' },-- 'entities.openvibe.tools': { name: 'OpenVibeEscape', icon: 'fa-shield-halved' },-- 'http.openvibe.tools': { name: 'OpenVibeCurl', icon: 'fa-terminal' },-- 'og.openvibe.tools': { name: 'OpenVibeOpenGraph', icon: 'fa-share-nodes' },-- 'colors.openvibe.tools': { name: 'OpenVibeColor', icon: 'fa-palette' },++ // ── Icons ────────────────────────────────────────────────++ // The navbar draws its own icons. It used to borrow the page's Font Awesome, so on pages that do not load++ // that font every menu row had an empty slot. Known names get an inline SVG; an unknown name still falls++ // back to the page's icon font, so sites that pass their own Font Awesome icons keep working.++ const NAV_ICONS = {++ 'fa-user': '<circle cx="12" cy="9" r="3.2"/><path d="M5.5 19a6.5 6.5 0 0 1 13 0"/>',++ 'fa-user-secret': '<path d="M4 11h16M7.5 11l1.2-5h6.6l1.2 5"/><circle cx="9" cy="15" r="2"/><circle cx="15" cy="15" r="2"/><path d="M11 15h2"/>',++ 'fa-clock-rotate-left': '<path d="M4.5 12a7.5 7.5 0 1 0 2.4-5.5L4.5 8.8M4.5 4.8v4h4M12 8v4.2l2.8 1.8"/>',++ 'fa-bell': '<path d="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"/>',++ 'fa-palette': '<path d="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"/>',++ 'fa-link': '<path d="M10 14a4 4 0 0 0 5.7 0l3-3a4 4 0 0 0-5.7-5.700l-1 1M14 10a4 4 0 0 0-5.7 0l-3 3a4 4 0 0 0 5.7 5.700l1-1"/>',++ 'fa-shield-halved': '<path d="M12 3.5l7 2.500v5.500c0 4.3-3 7.3-7 9-4-1.7-7-4.7-7-9V6zM12 3.500v17"/>',++ 'fa-screwdriver-wrench': '<path d="M14.5 5.5a4 4 0 0 0-5.2 5.2L4.5 15.5a1.8 1.8 0 0 0 2.5 2.5l4.8-4.8a4 4 0 0 0 5.2-5.2l-2.6 2.6-2.1-.5-.5-2.1z"/>',++ 'fa-right-from-bracket': '<path d="M10 5H6.500A1.5 1.5 0 0 0 5 6.500v11A1.5 1.5 0 0 0 6.5 19H10M14 8l4 4-4 4M18 12H9.5"/>',++ 'fa-plus': '<path d="M12 5.500v13M5.5 12h13"/>',++ 'fa-paste': '<rect x="6" y="6" width="12" height="14" rx="1.8"/><path d="M9.5 6V5a1 1 0 0 1 1-1h3a1 1 0 0 1 1 1v1M9 11h6M9 14h6"/>',++ 'fa-house': '<path d="M4.5 11.5 12 5l7.5 6.500M6.5 10v8.500h11V10"/>',++ 'fa-gauge-high': '<path d="M4.5 17a8 8 0 1 1 15 0M12 13l3.5-4"/><circle cx="12" cy="13.5" r="1.3"/>',};++ function navIcon(cls) {++ const key = String(cls || '').split(/\s+/).find(c => NAV_ICONS[c]);++ if (!key) return cls ? `<i class="fa-solid ${escapeAttr(cls)}"></i>` : '';++ return `<svg class="ovnav-ic" viewBox="0 0 24 24" width="1em" height="1em" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">${NAV_ICONS[key]}</svg>`;++ }// ─── Brand from hostname ───────────────────────────────────// Every property is <sub?>.openvibe.<tld> (plus openre.stream). The navbar spells the@@@ -759,7 +565,7 @@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 icon = item.icon ? `<span class="icon">${navIcon(item.icon)}</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>`;@@@ -1259,9 +1065,9 @@nav.innerHTML = `${brandHTML(brand)}<div class="openvibe-navbar-links">-- ${links.map(l => `<a href="${escapeAttr(l.href)}"${l.active ? ' class="active"' : ''}${l.external ? ' target="_blank" rel="noopener"' : ''}>${l.icon ? `<i class="fa-solid ${escapeAttr(l.icon)} icon"></i>` : ''}${escapeAttr(l.label)}</a>`).join('')}++ ${links.map(l => `<a href="${escapeAttr(l.href)}"${l.active ? ' class="active"' : ''}${l.external ? ' target="_blank" rel="noopener"' : ''}>${l.icon ? `<span class="icon">${navIcon(l.icon)}</span>` : ''}${escapeAttr(l.label)}</a>`).join('')}${networkLinksHTML(links)}-- ${u && u.role === 'admin' ? `<a href="https://openvibe.network/admin"><i class="fa-solid fa-shield-halved"></i> Admin</a>` : ''}++ ${u && u.role === 'admin' ? `<a href="https://openvibe.network/admin">${navIcon('fa-shield-halved')} Admin</a>` : ''}</div><div class="openvibe-navbar-spacer"></div><div class="openvibe-navbar-right">@@@ -1302,26 +1108,26 @@</div>`).join('')}<div class="account-item" data-account-id="anon" style="${isAnon ? 'display:none' : ''}">-- <span style="width:24px;text-align:center"><i class="fa-solid fa-user-secret"></i></span>++ <span style="width:24px;text-align:center">${navIcon('fa-user-secret')}</span><span>Switch to Anonymous</span></div><a class="add-account" id="openvibe-add-account" href="${escapeAttr(addAccountHref)}">-- <span style="width:24px;text-align:center"><i class="fa-solid fa-plus"></i></span>++ <span style="width:24px;text-align:center">${navIcon('fa-plus')}</span><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#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>` : ''}++ <a href="https://openvibe.network/my"><span class="icon">${navIcon('fa-user')}</span> My Account</a>++ <a href="https://openvibe.network/my#history"><span class="icon">${navIcon('fa-clock-rotate-left')}</span> History</a>++ <a href="https://openvibe.network/my#notifications"><span class="icon">${navIcon('fa-bell')}</span> Notifications</a>++ <a href="https://openvibe.network/themes"><span class="icon">${navIcon('fa-palette')}</span> Themes</a>++ <a href="https://openvibe.network/my#linked"><span class="icon">${navIcon('fa-link')}</span> Linked Services</a>++ ${u.role === 'admin' ? `<a href="https://openvibe.network/admin"><span class="icon">${navIcon('fa-screwdriver-wrench')}</span> Admin Panel</a>` : ''}${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">${navIcon('fa-right-from-bracket')}</span> Sign Out</button></div>`;nav.appendChild(dropdown);@@@ -131,9 +131,21 @@.openvibe-nv-foot a { color: var(--accent-light, #60a5fa); text-decoration: none; }.openvibe-nv-note { font-size: 11px; color: var(--text-muted, #707080); padding: 6px 12px; background: color-mix(in srgb, var(--warning, #f39c12) 10%, transparent); border-bottom: 1px solid var(--border, #333340); }++ /* The panel hangs under whatever navbar the site has (its top is measured when it opens) and never++ leaves the visible viewport: dvh instead of vh, room for the home indicator, list scrolls inside. */++ .openvibe-notif-panel { top: var(--ovn-top, 62px); right: max(8px, env(safe-area-inset-right, 0px)); width: min(420px, calc(100vw - 16px));++ max-height: calc(100vh - var(--ovn-top, 62px) - 12px); max-height: calc(100dvh - var(--ovn-top, 62px) - 12px - env(safe-area-inset-bottom, 0px)); border-radius: 16px; }++ .openvibe-notif-item .title .count { display: inline-block; margin-left: 4px; padding: 1px 7px; border-radius: 999px; font-size: 11px; font-weight: 700; color: var(--accent-light, var(--accent, #60a5fa)); background: color-mix(in srgb, var(--accent, #3b82f6) 16%, transparent); vertical-align: 1px; }++ /* Phones and narrow windows: a sheet from edge to edge under the bar, down to the bottom inset. */++ @media (max-width: 640px) {++ .openvibe-notif-panel { left: 8px; right: 8px; width: auto; bottom: calc(8px + env(safe-area-inset-bottom, 0px)); max-height: none; }++ .openvibe-notif-panel [data-act] > span { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); } /* icon-only actions, labels stay for screen readers */++ .openvibe-notif-panel [data-act] { min-width: 36px; min-height: 36px; justify-content: center; }++ .openvibe-notif-item { padding-top: 10px; padding-bottom: 10px; }++ }++ @media (max-height: 520px) { .openvibe-notif-item .msg { display: -webkit-box; -webkit-line-clamp: 1; -webkit-box-orient: vertical; overflow: hidden; } }@media (max-width: 460px) {.openvibe-toast-container { right: 8px; left: 8px; max-width: none; }-- .openvibe-notif-panel { right: 6px; left: 6px; width: auto; top: 56px; max-height: calc(100vh - 66px); }}`;document.head.appendChild(style);@@@ -372,9 +384,11 @@const item = e.target.closest('.openvibe-notif-item');if (!item) return;const id = item.dataset.id;-- if (dismissBtn) { e.stopPropagation(); dismiss(v, id); return; }++ const ids = (item.dataset.ids || id).split(',').filter(Boolean);++ if (dismissBtn) { e.stopPropagation(); ids.forEach(x => dismiss(v, x)); return; }const n = v.items.find(x => String(x.id) === id);-- if (n && !isRead(n)) markRead([id]);++ const unreadIds = ids.filter(x => { const it = v.items.find(y => String(y.id) === x); return it && !isRead(it); });++ if (unreadIds.length) markRead(unreadIds);const u = urlOf(n);if (u) navigateTo(u);});@@@ -385,14 +399,21 @@function renderItems(v, { append = false } = {}) {const list = v.root.querySelector('[data-role="list"]');-- const html = v.items.map(n => {++ // The same event repeated back to back ("X is live!" six times) reads as one row with a count.++ const groups = [];++ for (const n of v.items) {++ const key = [n.title, n.message || '', n.service || '', n.category || ''].join('\u0001');++ const g = groups[groups.length - 1];++ if (g && g.key === key) { g.ids.push(String(n.id)); if (!isRead(n)) g.unread = true; } else groups.push({ key, n, ids: [String(n.id)], unread: !isRead(n) });++ }++ const html = groups.map(({ n, ids, unread }) => {try {const cat = CATEGORY_LABELS[n.category] || n.category || '';const service = n.service && n.service !== 'network' ? `<span class="tag">${esc(SERVICE_LABELS[n.service] || n.service)}</span>` : '';-- return `<div class="openvibe-notif-item ${isRead(n) ? '' : 'unread'}" data-id="${esc(n.id)}" title="${esc(urlOf(n) ? 'Open' : '')}">++ return `<div class="openvibe-notif-item ${unread ? 'unread' : ''}" data-id="${esc(n.id)}" data-ids="${esc(ids.join(','))}" title="${esc(urlOf(n) ? 'Open' : '')}"><span class="icon">${iconHtml(n)}</span><div class="content">-- <div class="title">${esc(n.title)}</div>++ <div class="title">${esc(n.title)}${ids.length > 1 ? ` <span class="count" title="${ids.length} times">×${ids.length}</span>` : ''}</div>${n.message ? `<div class="msg">${esc(n.message)}</div>` : ''}<div class="meta"><span title="${esc(parseDate(n.created_at)?.toLocaleString() || '')}">${esc(timeAgo(n.created_at))}</span>${service}${cat ? `<span class="tag">${esc(cat)}</span>` : ''}</div></div>@@@ -546,6 +567,13 @@function togglePanel() {if (!_panelEl) createPanel();const opening = !_panelEl.classList.contains('open');++ if (opening) {++ // Sit directly under the site's navbar, whatever its height (sticky bars, banners, safe areas).++ const bar = _bellEl && _bellEl.closest('nav, header, .navbar');++ const bottom = bar ? bar.getBoundingClientRect().bottom : (_bellEl ? _bellEl.getBoundingClientRect().bottom : 56);++ _panelEl.style.setProperty('--ovn-top', Math.max(8, Math.round(bottom) + 6) + 'px');++ if (!_panelEl.__esc) { _panelEl.__esc = (e) => { if (e.key === 'Escape' && _panelEl.classList.contains('open')) togglePanel(); }; document.addEventListener('keydown', _panelEl.__esc); }++ }_panelEl.classList.toggle('open', opening);_bellEl?.classList.toggle('open', opening);if (opening) { reload(_views.get(_panelEl)); refreshPushButton(_views.get(_panelEl)); pollUnread(); }@@@ -129,6 +129,24 @@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 transform="translate(${(13 + off[0] * .9167).toFixed(2)} ${(13 + off[1] * .9167).toFixed(2)}) scale(.9167)"><g class="ovi-glyph">${def.glyph}</g></g></svg>`;}++ // ── Sprite mode (server-rendered lists) ──────────────────────────────────────────────++ // A page that shows 170 icons should not ship 170 copies of the same ring and glyph. sprite(names) emits each++ // icon once as a <symbol>; use(name) references it. Colours come from currentColor and inherited custom++ // properties, so a referenced icon is themed exactly like an inline one. Static by design (no comet, no++ // progress): pages use it for listings and keep the full inline icon where state or motion matters.++ function sprite(names) {++ const seen = new Set();++ const symbols = (names || []).map(resolve).filter(n => !seen.has(n) && seen.add(n)).map((key) => {++ const def = registry[key]; const off = def.offset || OFFSETS[key] || [0, 0];++ return `<symbol id="ovi-${key}" viewBox="0 0 48 48"><circle cx="24" cy="24" r="21" fill="currentColor" opacity=".1"/><circle cx="24" cy="24" r="20" fill="none" stroke="currentColor" stroke-width="2.4" opacity=".3"/><g transform="translate(${(13 + off[0] * .9167).toFixed(2)} ${(13 + off[1] * .9167).toFixed(2)}) scale(.9167)" fill="none" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" style="stroke:var(--ovi-glyph,var(--text-primary,#e6edf7))">${def.glyph}</g></symbol>`;++ }).join('');++ return `<svg xmlns="http://www.w3.org/2000/svg" width="0" height="0" style="position:absolute" aria-hidden="true" focusable="false">${symbols}</svg>`;++ }++ function use(name, size) {++ const key = resolve(name);++ return `<span class="ov-icon" data-icon="${key}" data-ovi="${key}" data-fx="none" style="--ovi-size:${parseInt(size, 10) || 32}px" aria-hidden="true"><svg viewBox="0 0 48 48" focusable="false"><use href="#ovi-${key}"/></svg></span>`;++ }++function applyProgress(el) {const raw = el.getAttribute('data-progress');const prog = el.querySelector('.ovi-prog');@@@ -168,7 +186,7 @@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 };++ const api = { names: () => Object.keys(registry), register, mount, set, svg, sprite, use, resolve, CSS };root.OpenVibeIcons = api;if (typeof module !== 'undefined' && module.exports) module.exports = api;if (typeof document === 'undefined') return;@@@ -15,6 +15,8 @@"./seo": "./seo.js","./icons": "./ov-icons.js","./legal": "./legal.js",++ "./footer": "./footer.js",++ "./chrome-ssr": "./chrome-ssr.js","./app-icon": "./app-icon.js"},"license": "MIT",
@@ -22,7 +22,8 @@ const NAME = 'OpenVibe.Tools';
@@ -22,7 +22,8 @@ const NAME = 'OpenVibe.Tools';
const esc = seo.esc;
const esc = seo.esc;
const POPULAR = ['yt', 'convert', 'compress', 'mergepdf', 'jsonfmt', 'mp3', 'dns', 'whois', 'fancy', 'logo', 'ssl', 'resize', 'trim', 'regex', 'maps', 'food'];
const POPULAR = ['yt', 'convert', 'compress', 'mergepdf', 'jsonfmt', 'mp3', 'dns', 'whois', 'fancy', 'logo', 'ssl', 'resize', 'trim', 'regex', 'maps', 'food'];
-const icon = (name, size) => `<span class="ov-icon" data-icon="${esc(name)}" data-ovi="${esc(icons.resolve(name))}" data-fx="none" style="--ovi-size:${size}px" aria-hidden="true">${icons.svg(name)}</span>`;
+let _used = new Set();
+const icon = (name, size) => { _used.add(name); return icons.use(name, size); };
const hostLabel = (h) => h.replace(/\.openvibe\.tools$/, '') === h ? h : h;
const hostLabel = (h) => h.replace(/\.openvibe\.tools$/, '') === h ? h : h;
// Links name the search-friendly host (what crawlers follow and index). A person's click goes straight to
// Links name the search-friendly host (what crawlers follow and index). A person's click goes straight to
@@ -102,18 +103,20 @@ const NETWORK = [
@@ -102,18 +103,20 @@ const NETWORK = [
];
];
function shell({ head, body, families }) {
function shell({ head, body, families }) {
+ // Every icon on the page once, as a sprite; the markup above only references them.
+ const spriteSvg = icons.sprite([..._used, ...NETWORK.map(n => n[0])]); _used = new Set();
return `<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
return `<!DOCTYPE html><html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
${head}
${head}
${appIcon.headTags({ site: 'tools', iconBase: '/assets' })}
${appIcon.headTags({ site: 'tools', iconBase: '/assets' })}
<link rel="alternate" type="application/json" href="${SITE}/api/catalog.json" title="Tool catalog">
<link rel="alternate" type="application/json" href="${SITE}/api/catalog.json" title="Tool catalog">
<link rel="preconnect" href="https://openvibe.network">
<link rel="preconnect" href="https://openvibe.network">
<script src="https://openvibe.network/shared/theme-loader.js" defer></script>
<script src="https://openvibe.network/shared/theme-loader.js" defer></script>
-<style>${CSS}</style></head><body>
+<style>${CSS}</style></head><body>${spriteSvg}
<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>
<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}
<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>
<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>
<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>
+</main>${require('openvibe-shared/footer').ssr({ service: 'tools', variant: 'full' })}
<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>
<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>`;
</body></html>`;
}
}
new file mode 100644
new file mode 100644
@@ -0,0 +1,17 @@
@@ -0,0 +1,17 @@
+'use strict';
+// openvibe-shared/chrome-ssr — the parts of the shared chrome that can be baked into HTML on the server.
+// footer(cfg) the full shared footer (HTML + CSS); the browser's footer.js renders into the same element
+// noscriptNav(site) a plain navigation bar inside <noscript>: the JavaScript navbar cannot exist without
+// scripts, so visitors who block them (and text browsers) still get the brand and the network
+const footerModule = require('./footer');
+
+const SITES = [['Live', 'https://openvibe.live/'], ['Tools', 'https://openvibe.tools/'], ['Community', 'https://openvibe.community/'], ['Games', 'https://openvibe.games/'], ['Media', 'https://openvibe.media/'], ['Network', 'https://openvibe.network/']];
+const esc = (s) => String(s == null ? '' : s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]));
+
+function noscriptNav({ name, home = '/', links = [] } = {}) {
+ const own = links.map(l => `<a href="${esc(l.href)}">${esc(l.label)}</a>`).join('');
+ const net = SITES.filter(([n]) => !String(name || '').endsWith('.' + n)).map(([n, u]) => `<a href="${u}">${n}</a>`).join('');
+ return `<noscript><nav aria-label="Site" style="display:flex;flex-wrap:wrap;align-items:center;gap:6px 16px;padding:12px 20px;border-bottom:1px solid rgba(255,255,255,.1);font:500 14px/1.4 system-ui,sans-serif"><a href="${esc(home)}" style="font-weight:700;color:inherit;text-decoration:none">${esc(name || 'OpenVibe')}</a>${own}<span style="flex:1"></span>${net}</nav></noscript>`;
+}
+
+module.exports = { footer: (cfg) => footerModule.ssr(cfg), noscriptNav };
@@ -47,6 +47,7 @@
@@ -47,6 +47,7 @@
}
}
function cached() { try { const c = JSON.parse(localStorage.getItem(KEY) || 'null'); return c && c.host === host() && c.data ? c : null; } catch { return null; } }
function cached() { try { const c = JSON.parse(localStorage.getItem(KEY) || 'null'); return c && c.host === host() && c.data ? c : null; } catch { return null; } }
function refresh() {
function refresh() {
+ if (typeof window === 'undefined' || typeof document === 'undefined') return Promise.resolve(null); // server-side render: never call out
if (inflight || typeof fetch === 'undefined') return inflight || Promise.resolve(null);
if (inflight || typeof fetch === 'undefined') return inflight || Promise.resolve(null);
inflight = fetch('https://openvibe.network/api/chrome?host=' + encodeURIComponent(host()), { credentials: 'omit' })
inflight = fetch('https://openvibe.network/api/chrome?host=' + encodeURIComponent(host()), { credentials: 'omit' })
.then(r => (r.ok ? r.json() : null)).then(clean)
.then(r => (r.ok ? r.json() : null)).then(clean)
@@ -379,5 +380,16 @@
@@ -379,5 +380,16 @@
render();
render();
}
}
- return { init, setVariant, buildHTML, render, CSS, NETWORK, LEGAL, detectService, get config() { return { ..._cfg }; } };
+ /**
+ * Server-side render: the complete footer as HTML + its CSS, so every page carries the network's links for
+ * crawlers and for visitors without JavaScript. In the browser, init() renders into the same element.
+ * ssr({ service, variant, links, mountId }) → '<style>…</style><footer id="ov-footer" …>…</footer>'
+ */
+ function ssr(cfg = {}) {
+ const c = { ...DEFAULTS, ...cfg };
+ const id = String(cfg.mountId || 'ov-footer').replace(/[^\w-]/g, '');
+ return `<style id="ovf-css">${CSS}</style><footer id="${id}" class="ovf" data-variant="${c.variant === 'compact' ? 'compact' : 'full'}">${buildHTML(c)}</footer>`;
+ }
+
+ return { init, ssr, setVariant, buildHTML, render, CSS, NETWORK, LEGAL, detectService, get config() { return { ..._cfg }; } };
});
});
@@ -180,8 +180,8 @@ function page(kind, site) {
@@ -180,8 +180,8 @@ function page(kind, site) {
${head}
${head}
${require('./app-icon').headTags({ site: site.id })}
${require('./app-icon').headTags({ site: site.id })}
<script src="https://openvibe.network/shared/theme-loader.js" defer></script><style>${CSS}</style></head><body>
<script src="https://openvibe.network/shared/theme-loader.js" defer></script><style>${CSS}</style></head><body>
-<noscript><p style="padding:12px 20px;margin:0"><a href="/">${esc(site.name)}</a></p></noscript>
-<main>${article(kind, site)}</main><div id="ov-footer"></div>
+${require('./chrome-ssr').noscriptNav({ name: site.name })}
+<main>${article(kind, site)}</main>${require('./footer').ssr({ service: site.service || site.id || 'network', variant: 'compact' })}
<script src="https://openvibe.network/shared/navbar.js" defer></script><script src="https://openvibe.network/shared/footer.js" defer></script>
<script src="https://openvibe.network/shared/navbar.js" defer></script><script src="https://openvibe.network/shared/footer.js" defer></script>
<script>addEventListener('DOMContentLoaded',function(){var t=null;try{t=(document.cookie.match(/(?:^|; )ov_token=([^;]*)/)||[])[1]||localStorage.getItem('ov_token')}catch(e){}
<script>addEventListener('DOMContentLoaded',function(){var t=null;try{t=(document.cookie.match(/(?:^|; )ov_token=([^;]*)/)||[])[1]||localStorage.getItem('ov_token')}catch(e){}
try{OpenVibeNavbar.init({service:${JSON.stringify(site.service || site.id || 'network')},apiBase:'https://openvibe.network',token:t?decodeURIComponent(t):null})}catch(e){}
try{OpenVibeNavbar.init({service:${JSON.stringify(site.service || site.id || 'network')},apiBase:'https://openvibe.network',token:t?decodeURIComponent(t):null})}catch(e){}
@@ -83,6 +83,7 @@
@@ -83,6 +83,7 @@
.ovnav-launcher a { all: unset; cursor: pointer; box-sizing: border-box; }
.ovnav-launcher a { all: unset; cursor: pointer; box-sizing: border-box; }
.openvibe-navbar .ovnav-sep { width: 1px; height: 18px; background: var(--border, rgba(255,255,255,.12)); margin: 0 6px; flex: none; align-self: center; }
.openvibe-navbar .ovnav-sep { width: 1px; height: 18px; background: var(--border, rgba(255,255,255,.12)); margin: 0 6px; flex: none; align-self: center; }
@media (max-width: 1180px) { .openvibe-navbar .ovnav-net, .openvibe-navbar .ovnav-sep { display: none; } }
@media (max-width: 1180px) { .openvibe-navbar .ovnav-net, .openvibe-navbar .ovnav-sep { display: none; } }
+ .openvibe-navbar .ovnav-ic, .openvibe-navbar-dropdown .ovnav-ic { flex: none; font-size: 1.05em; vertical-align: -.15em; }
.ovnav-launcher { position: absolute; top: calc(100% + 6px); left: 12px; width: min(440px, calc(100vw - 24px)); background: var(--bg-elevated, var(--bg-secondary, #111826)); border: 1px solid var(--border, rgba(255,255,255,.12)); border-radius: 16px; box-shadow: 0 24px 60px rgba(0,0,0,.5); padding: 12px; z-index: 1000; opacity: 0; transform: translateY(-6px) scale(.98); transform-origin: top left; pointer-events: none; transition: opacity .16s, transform .2s cubic-bezier(.2,1.2,.3,1); }
.ovnav-launcher { position: absolute; top: calc(100% + 6px); left: 12px; width: min(440px, calc(100vw - 24px)); background: var(--bg-elevated, var(--bg-secondary, #111826)); border: 1px solid var(--border, rgba(255,255,255,.12)); border-radius: 16px; box-shadow: 0 24px 60px rgba(0,0,0,.5); padding: 12px; z-index: 1000; opacity: 0; transform: translateY(-6px) scale(.98); transform-origin: top left; pointer-events: none; transition: opacity .16s, transform .2s cubic-bezier(.2,1.2,.3,1); }
.ovnav-launcher.open { opacity: 1; transform: none; pointer-events: auto; }
.ovnav-launcher.open { opacity: 1; transform: none; pointer-events: auto; }
.ovnav-launcher .ovl-q { width: 100%; box-sizing: border-box; padding: 9px 12px; border-radius: 10px; border: 1px solid var(--border, rgba(255,255,255,.12)); background: var(--bg-primary, #0a0f18); color: var(--text-primary, #e6edf7); font: 500 14px/1.2 inherit; outline: none; }
.ovnav-launcher .ovl-q { width: 100%; box-sizing: border-box; padding: 9px 12px; border-radius: 10px; border: 1px solid var(--border, rgba(255,255,255,.12)); background: var(--bg-primary, #0a0f18); color: var(--text-primary, #e6edf7); font: 500 14px/1.2 inherit; outline: none; }
@@ -255,225 +256,30 @@
@@ -255,225 +256,30 @@
document.head.appendChild(s);
document.head.appendChild(s);
}
}
- // Primary services (per CONTRACTS branding) + tool sub-brands (<Name>.OpenVibe)
- const SERVICE_NAMES = {
- live: 'OpenVibe.Live', tools: 'OpenVibe.Tools', games: 'OpenVibe.Games',
- media: 'OpenVibe.Media', network: 'OpenVibe.Network',
- net: 'Net.OpenVibe', dev: 'Dev.OpenVibe', paste: 'Paste.OpenVibe',
- maps: 'Maps.OpenVibe', food: 'Food.OpenVibe', img: 'Img.OpenVibe',
- yt: 'YT.OpenVibe', audio: 'Audio.OpenVibe', text: 'Text.OpenVibe',
- logo: 'Logo.OpenVibe', docs: 'Docs.OpenVibe',
- };
-
- const SERVICE_ICONS = {
- live: 'fa-tower-broadcast', tools: 'fa-screwdriver-wrench', games: 'fa-gamepad',
- media: 'fa-photo-film', network: 'fa-circle-nodes',
- net: 'fa-network-wired', dev: 'fa-code', paste: 'fa-paste',
- maps: 'fa-map-location-dot', food: 'fa-utensils', img: 'fa-images',
- yt: 'fa-circle-play', audio: 'fa-headphones', text: 'fa-pen-fancy',
- logo: 'fa-wand-magic-sparkles', docs: 'fa-file-pdf',
- };
-
- // Subdomain → brand override for multi-subdomain services (Img.OpenVibe)
- const SUBDOMAIN_BRANDS = {
- 'png.openvibe.tools': { name: 'OpenVibePNG', icon: 'fa-file-image' },
- 'jpg.openvibe.tools': { name: 'OpenVibeJPG', icon: 'fa-file-image' },
- 'jpeg.openvibe.tools': { name: 'OpenVibeJPG', icon: 'fa-file-image' },
- 'webp.openvibe.tools': { name: 'OpenVibeWebP', icon: 'fa-file-image' },
- 'avif.openvibe.tools': { name: 'OpenVibeAVIF', icon: 'fa-file-image' },
- 'heic.openvibe.tools': { name: 'OpenVibeHEIC', icon: 'fa-file-image' },
- 'heif.openvibe.tools': { name: 'OpenVibeHEIC', icon: 'fa-file-image' },
- 'svg.openvibe.tools': { name: 'OpenVibeSVG', icon: 'fa-bezier-curve' },
- 'gif.openvibe.tools': { name: 'OpenVibeGIF', icon: 'fa-film' },
- 'ico.openvibe.tools': { name: 'OpenVibeICO', icon: 'fa-icons' },
- 'tiff.openvibe.tools': { name: 'OpenVibeTIFF', icon: 'fa-file-image' },
- 'bmp.openvibe.tools': { name: 'OpenVibeBMP', icon: 'fa-file-image' },
- 'compress.openvibe.tools': { name: 'OpenVibeCompress', icon: 'fa-compress' },
- 'resize.openvibe.tools': { name: 'OpenVibeResize', icon: 'fa-up-right-and-down-left-from-center' },
- 'crop.openvibe.tools': { name: 'OpenVibeCrop', icon: 'fa-crop-simple' },
- 'convert.openvibe.tools': { name: 'OpenVibeConvert', icon: 'fa-arrows-rotate' },
- 'favicon.openvibe.tools': { name: 'OpenVibeFavicon', icon: 'fa-icons' },
- 'yt.openvibe.tools': { name: 'YT.OpenVibe', icon: 'fa-circle-play' },
- 'maps.openvibe.tools': { name: 'Maps.OpenVibe', icon: 'fa-map-location-dot' },
- 'food.openvibe.tools': { name: 'Food.OpenVibe', icon: 'fa-utensils' },
- // Audio tool subdomains
- 'audio.openvibe.tools': { name: 'Audio.OpenVibe', icon: 'fa-headphones' },
- 'mp3.openvibe.tools': { name: 'OpenVibeMP3', icon: 'fa-file-audio' },
- 'wav.openvibe.tools': { name: 'OpenVibeWAV', icon: 'fa-file-audio' },
- 'flac.openvibe.tools': { name: 'OpenVibeFLAC', icon: 'fa-file-audio' },
- 'ogg.openvibe.tools': { name: 'OpenVibeOGG', icon: 'fa-file-audio' },
- 'm4a.openvibe.tools': { name: 'OpenVibeM4A', icon: 'fa-file-audio' },
- 'aac.openvibe.tools': { name: 'OpenVibeAAC', icon: 'fa-file-audio' },
- 'opus.openvibe.tools': { name: 'OpenVibeOPUS', icon: 'fa-file-audio' },
- 'wma.openvibe.tools': { name: 'OpenVibeWMA', icon: 'fa-file-audio' },
- 'aiff.openvibe.tools': { name: 'OpenVibeAIFF', icon: 'fa-file-audio' },
- 'ac3.openvibe.tools': { name: 'OpenVibeAC3', icon: 'fa-file-audio' },
- 'trim.openvibe.tools': { name: 'OpenVibeTrim', icon: 'fa-scissors' },
- 'pitch.openvibe.tools': { name: 'OpenVibePitch', icon: 'fa-wave-square' },
- 'speed.openvibe.tools': { name: 'OpenVibeSpeed', icon: 'fa-gauge-high' },
- 'normalize.openvibe.tools':{ name: 'OpenVibeNormalize', icon: 'fa-sliders' },
- 'fade.openvibe.tools': { name: 'OpenVibeFade', icon: 'fa-volume-low' },
- 'bass.openvibe.tools': { name: 'OpenVibeBass', icon: 'fa-volume-high' },
- 'equalizer.openvibe.tools':{ name: 'OpenVibeEQ', icon: 'fa-bars-staggered' },
- 'echo.openvibe.tools': { name: 'OpenVibeEcho', icon: 'fa-tower-broadcast' },
- 'reverb.openvibe.tools': { name: 'OpenVibeReverb', icon: 'fa-church' },
- 'voice.openvibe.tools': { name: 'OpenVibeVoiceFX', icon: 'fa-user-astronaut' },
- 'extract.openvibe.tools': { name: 'OpenVibeExtract', icon: 'fa-music' },
- 'ringtone.openvibe.tools': { name: 'OpenVibeRingtone', icon: 'fa-bell' },
- // Text tool subdomains
- 'text.openvibe.tools': { name: 'Text.OpenVibe', icon: 'fa-pen-fancy' },
- 'type.openvibe.tools': { name: 'Text.OpenVibe', icon: 'fa-pen-fancy' },
- 'fonts.openvibe.tools': { name: 'OpenVibeFonts', icon: 'fa-font' },
- 'fancy.openvibe.tools': { name: 'OpenVibeFancy', icon: 'fa-wand-sparkles' },
- 'zalgo.openvibe.tools': { name: 'OpenVibeZalgo', icon: 'fa-skull' },
- 'ascii.openvibe.tools': { name: 'OpenVibeASCII', icon: 'fa-terminal' },
- 'symbols.openvibe.tools': { name: 'OpenVibeSymbols', icon: 'fa-icons' },
- 'unicode.openvibe.tools': { name: 'OpenVibeUnicode', icon: 'fa-magnifying-glass' },
- 'bubble.openvibe.tools': { name: 'OpenVibeBubble', icon: 'fa-circle' },
- 'glitch.openvibe.tools': { name: 'OpenVibeGlitch', icon: 'fa-bug' },
- 'smallcaps.openvibe.tools': { name: 'OpenVibeSmallCaps', icon: 'fa-text-height' },
- 'cursive.openvibe.tools': { name: 'OpenVibeCursive', icon: 'fa-pen-nib' },
- 'gothic.openvibe.tools': { name: 'OpenVibeGothic', icon: 'fa-book-skull' },
- 'wide.openvibe.tools': { name: 'OpenVibeWide', icon: 'fa-arrows-left-right' },
- 'monospaced.openvibe.tools': { name: 'OpenVibeMono', icon: 'fa-code' },
- 'braille.openvibe.tools': { name: 'OpenVibeBraille', icon: 'fa-braille' },
- 'morse.openvibe.tools': { name: 'OpenVibeMorse', icon: 'fa-tower-broadcast' },
- 'binary.openvibe.tools': { name: 'OpenVibeBinary', icon: 'fa-microchip' },
- 'case.openvibe.tools': { name: 'OpenVibeCase', icon: 'fa-text-height' },
- 'caps.openvibe.tools': { name: 'OpenVibeCaps', icon: 'fa-text-height' },
- 'titlecase.openvibe.tools': { name: 'OpenVibeTitleCase', icon: 'fa-heading' },
- 'reverse.openvibe.tools': { name: 'OpenVibeReverse', icon: 'fa-right-left' },
- 'clean.openvibe.tools': { name: 'OpenVibeClean', icon: 'fa-broom' },
- 'strip.openvibe.tools': { name: 'OpenVibeStrip', icon: 'fa-broom' },
- 'count.openvibe.tools': { name: 'OpenVibeCount', icon: 'fa-calculator' },
- 'lines.openvibe.tools': { name: 'OpenVibeLines', icon: 'fa-list-ol' },
- 'sort.openvibe.tools': { name: 'OpenVibeSort', icon: 'fa-arrow-down-a-z' },
- 'dedupe.openvibe.tools': { name: 'OpenVibeDedupe', icon: 'fa-filter' },
- 'slug.openvibe.tools': { name: 'OpenVibeSlug', icon: 'fa-link' },
- 'compare.openvibe.tools': { name: 'OpenVibeCompare', icon: 'fa-code-compare' },
- 'diff.openvibe.tools': { name: 'OpenVibeDiff', icon: 'fa-code-compare' },
- 'markdown.openvibe.tools': { name: 'OpenVibeMarkdown', icon: 'fa-file-lines' },
- 'json.openvibe.tools': { name: 'OpenVibeJSON', icon: 'fa-brackets-curly' },
- 'escape.openvibe.tools': { name: 'OpenVibeEscape', icon: 'fa-shield-halved' },
- 'bio.openvibe.tools': { name: 'OpenVibeBio', icon: 'fa-id-card' },
- 'nickname.openvibe.tools': { name: 'OpenVibeNickname', icon: 'fa-signature' },
- 'username.openvibe.tools': { name: 'OpenVibeUsername', icon: 'fa-at' },
- 'gamertag.openvibe.tools': { name: 'OpenVibeGamertag', icon: 'fa-gamepad' },
- 'kaomoji.openvibe.tools': { name: 'OpenVibeKaomoji', icon: 'fa-face-smile' },
- 'emojis.openvibe.tools': { name: 'OpenVibeEmojis', icon: 'fa-face-grin' },
- 'copypaste.openvibe.tools': { name: 'OpenVibeCopyPaste', icon: 'fa-paste' },
- 'banner.openvibe.tools': { name: 'OpenVibeBanner', icon: 'fa-rectangle-ad' },
- 'textart.openvibe.tools': { name: 'Text.OpenVibeArt', icon: 'fa-border-all' },
- 'figlet.openvibe.tools': { name: 'OpenVibeFiglet', icon: 'fa-terminal' },
- // Logo / design subdomains
- 'logo.openvibe.tools': { name: 'Logo.OpenVibe', icon: 'fa-wand-magic-sparkles' },
- 'title.openvibe.tools': { name: 'OpenVibeTitle', icon: 'fa-heading' },
- 'wordmark.openvibe.tools': { name: 'OpenVibeWordmark', icon: 'fa-font' },
- 'textlogo.openvibe.tools': { name: 'Text.OpenVibeLogo', icon: 'fa-font' },
- 'transparent.openvibe.tools':{ name: 'OpenVibeTransparent', icon: 'fa-eye-slash' },
- 'badge.openvibe.tools': { name: 'OpenVibeBadge', icon: 'fa-certificate' },
- 'sticker.openvibe.tools': { name: 'OpenVibeSticker', icon: 'fa-note-sticky' },
- 'thumbnail.openvibe.tools': { name: 'OpenVibeThumbnail', icon: 'fa-photo-film' },
- 'cover.openvibe.tools': { name: 'OpenVibeCover', icon: 'fa-image' },
- 'channelart.openvibe.tools': { name: 'OpenVibeChannelArt', icon: 'fa-panorama' },
- 'watermark.openvibe.tools': { name: 'OpenVibeWatermark', icon: 'fa-droplet' },
- 'neon.openvibe.tools': { name: 'OpenVibeNeon', icon: 'fa-lightbulb' },
- // Document / PDF subdomains
- 'docs.openvibe.tools': { name: 'Docs.OpenVibe', icon: 'fa-file-pdf' },
- 'pdf.openvibe.tools': { name: 'OpenVibePDF', icon: 'fa-file-pdf' },
- 'mergepdf.openvibe.tools': { name: 'MergePDF', icon: 'fa-object-group' },
- 'splitpdf.openvibe.tools': { name: 'SplitPDF', icon: 'fa-scissors' },
- 'compresspdf.openvibe.tools':{ name: 'CompressPDF', icon: 'fa-compress' },
- 'rotatepdf.openvibe.tools': { name: 'RotatePDF', icon: 'fa-rotate' },
- 'reorderpdf.openvibe.tools': { name: 'ReorderPDF', icon: 'fa-sort' },
- 'watermarkpdf.openvibe.tools':{ name: 'WatermarkPDF', icon: 'fa-stamp' },
- 'protectpdf.openvibe.tools': { name: 'ProtectPDF', icon: 'fa-lock' },
- 'unlockpdf.openvibe.tools': { name: 'UnlockPDF', icon: 'fa-lock-open' },
- 'image2pdf.openvibe.tools': { name: 'Image2PDF', icon: 'fa-file-image' },
- 'jpg2pdf.openvibe.tools': { name: 'JPG2PDF', icon: 'fa-file-image' },
- 'png2pdf.openvibe.tools': { name: 'PNG2PDF', icon: 'fa-file-image' },
- 'pdf2jpg.openvibe.tools': { name: 'PDF2JPG', icon: 'fa-image' },
- 'pdf2png.openvibe.tools': { name: 'PDF2PNG', icon: 'fa-image' },
- // Network tool subdomains
- 'net.openvibe.tools': { name: 'Net.OpenVibe', icon: 'fa-network-wired' },
- 'lookup.openvibe.tools': { name: 'OpenVibeLookup', icon: 'fa-magnifying-glass' },
- 'myip.openvibe.tools': { name: 'OpenVibeMyIP', icon: 'fa-location-crosshairs' },
- 'ip.openvibe.tools': { name: 'OpenVibeIP', icon: 'fa-at' },
- 'geoip.openvibe.tools': { name: 'OpenVibeGeoIP', icon: 'fa-earth-americas' },
- 'hostname.openvibe.tools': { name: 'OpenVibeHostname', icon: 'fa-server' },
- 'isp.openvibe.tools': { name: 'OpenVibeISP', icon: 'fa-building' },
- 'asn.openvibe.tools': { name: 'OpenVibeASN', icon: 'fa-diagram-project' },
- 'ipv4.openvibe.tools': { name: 'OpenVibeIPv4', icon: 'fa-hashtag' },
- 'ipv6.openvibe.tools': { name: 'OpenVibeIPv6', icon: 'fa-code' },
- 'rdns.openvibe.tools': { name: 'OpenVibeReverseDNS',icon: 'fa-rotate-left' },
- 'whois.openvibe.tools': { name: 'OpenVibeWhois', icon: 'fa-address-book' },
- 'rdap.openvibe.tools': { name: 'OpenVibeRDAP', icon: 'fa-id-card' },
- 'dns.openvibe.tools': { name: 'OpenVibeDNS', icon: 'fa-sitemap' },
- 'dig.openvibe.tools': { name: 'OpenVibeDig', icon: 'fa-terminal' },
- 'nslookup.openvibe.tools': { name: 'OpenVibeNSLookup', icon: 'fa-magnifying-glass-arrow-right' },
- 'dnspropagation.openvibe.tools': { name: 'OpenVibeDNSPropagation', icon: 'fa-globe' },
- 'mx.openvibe.tools': { name: 'OpenVibeMX', icon: 'fa-envelope' },
- 'txt.openvibe.tools': { name: 'OpenVibeTXT', icon: 'fa-file-lines' },
- 'ns.openvibe.tools': { name: 'OpenVibeNS', icon: 'fa-server' },
- 'spf.openvibe.tools': { name: 'OpenVibeSPF', icon: 'fa-shield-halved' },
- 'dkim.openvibe.tools': { name: 'OpenVibeDKIM', icon: 'fa-key' },
- 'dmarc.openvibe.tools': { name: 'OpenVibeDMARC', icon: 'fa-user-shield' },
- 'ping.openvibe.tools': { name: 'OpenVibePing', icon: 'fa-satellite-dish' },
- 'traceroute.openvibe.tools': { name: 'OpenVibeTraceroute', icon: 'fa-route' },
- 'mtr.openvibe.tools': { name: 'OpenVibeMTR', icon: 'fa-chart-line' },
- 'port.openvibe.tools': { name: 'OpenVibePortCheck', icon: 'fa-door-open' },
- 'headers.openvibe.tools': { name: 'OpenVibeHeaders', icon: 'fa-list' },
- 'redirects.openvibe.tools': { name: 'OpenVibeRedirects', icon: 'fa-share' },
- 'ssl.openvibe.tools': { name: 'OpenVibeSSL', icon: 'fa-lock' },
- 'curl.openvibe.tools': { name: 'OpenVibeCurl', icon: 'fa-download' },
- 'httpstatus.openvibe.tools': { name: 'OpenVibeHTTPStatus', icon: 'fa-circle-check' },
- 'latency.openvibe.tools': { name: 'OpenVibeLatency', icon: 'fa-gauge-high' },
- // Dev.OpenVibe subdomains
- 'dev.openvibe.tools': { name: 'Dev.OpenVibe', icon: 'fa-code' },
- 'code.openvibe.tools': { name: 'Dev.OpenVibe', icon: 'fa-code' },
- 'json.openvibe.tools': { name: 'OpenVibeJSON', icon: 'fa-code' },
- 'yaml.openvibe.tools': { name: 'OpenVibeYAML', icon: 'fa-file-code' },
- 'xml.openvibe.tools': { name: 'OpenVibeXML', icon: 'fa-file-code' },
- 'csv.openvibe.tools': { name: 'OpenVibeCSV', icon: 'fa-table' },
- 'sql.openvibe.tools': { name: 'OpenVibeSQL', icon: 'fa-database' },
- 'markdown.openvibe.tools': { name: 'OpenVibeMarkdown', icon: 'fa-file-lines' },
- 'html.openvibe.tools': { name: 'OpenVibeHTML', icon: 'fa-file-code' },
- 'base64.openvibe.tools': { name: 'OpenVibeBase64', icon: 'fa-lock' },
- 'url.openvibe.tools': { name: 'OpenVibeURL', icon: 'fa-link' },
- 'jwt.openvibe.tools': { name: 'OpenVibeJWT', icon: 'fa-key' },
- 'uuid.openvibe.tools': { name: 'OpenVibeUUID', icon: 'fa-fingerprint' },
- 'hash.openvibe.tools': { name: 'OpenVibeHash', icon: 'fa-hashtag' },
- 'hex.openvibe.tools': { name: 'OpenVibeHex', icon: 'fa-barcode' },
- 'escape.openvibe.tools': { name: 'OpenVibeEscape', icon: 'fa-shield-halved' },
- 'timestamp.openvibe.tools': { name: 'OpenVibeTimestamp', icon: 'fa-clock' },
- 'cron.openvibe.tools': { name: 'OpenVibeCron', icon: 'fa-calendar-check' },
- 'beautify.openvibe.tools': { name: 'OpenVibeBeautify', icon: 'fa-wand-magic-sparkles' },
- 'minify.openvibe.tools': { name: 'OpenVibeMinify', icon: 'fa-compress' },
- 'diff.openvibe.tools': { name: 'OpenVibeDiff', icon: 'fa-code-compare' },
- 'regex.openvibe.tools': { name: 'OpenVibeRegex', icon: 'fa-magnifying-glass' },
- 'slug.openvibe.tools': { name: 'OpenVibeSlug', icon: 'fa-link' },
- 'lorem.openvibe.tools': { name: 'OpenVibeLorem', icon: 'fa-paragraph' },
- 'curl.openvibe.tools': { name: 'OpenVibeCurl', icon: 'fa-terminal' },
- 'webhook.openvibe.tools': { name: 'OpenVibeWebhook', icon: 'fa-satellite-dish' },
- 'color.openvibe.tools': { name: 'OpenVibeColor', icon: 'fa-palette' },
- 'opengraph.openvibe.tools': { name: 'OpenVibeOpenGraph', icon: 'fa-share-nodes' },
- // Dev.OpenVibe aliases
- 'build.openvibe.tools': { name: 'Dev.OpenVibe', icon: 'fa-code' },
- 'debug.openvibe.tools': { name: 'Dev.OpenVibe', icon: 'fa-code' },
- 'compare.openvibe.tools': { name: 'OpenVibeDiff', icon: 'fa-code-compare' },
- 'format.openvibe.tools': { name: 'OpenVibeBeautify', icon: 'fa-wand-magic-sparkles' },
- 'prettier.openvibe.tools': { name: 'OpenVibeBeautify', icon: 'fa-wand-magic-sparkles' },
- 'md.openvibe.tools': { name: 'OpenVibeMarkdown', icon: 'fa-file-lines' },
- 'unix.openvibe.tools': { name: 'OpenVibeTimestamp', icon: 'fa-clock' },
- 'epoch.openvibe.tools': { name: 'OpenVibeTimestamp', icon: 'fa-clock' },
- 'b64.openvibe.tools': { name: 'OpenVibeBase64', icon: 'fa-lock' },
- 'guid.openvibe.tools': { name: 'OpenVibeUUID', icon: 'fa-fingerprint' },
- 'sha256.openvibe.tools': { name: 'OpenVibeHash', icon: 'fa-hashtag' },
- 'entities.openvibe.tools': { name: 'OpenVibeEscape', icon: 'fa-shield-halved' },
- 'http.openvibe.tools': { name: 'OpenVibeCurl', icon: 'fa-terminal' },
- 'og.openvibe.tools': { name: 'OpenVibeOpenGraph', icon: 'fa-share-nodes' },
- 'colors.openvibe.tools': { name: 'OpenVibeColor', icon: 'fa-palette' },
+ // ── Icons ────────────────────────────────────────────────
+ // The navbar draws its own icons. It used to borrow the page's Font Awesome, so on pages that do not load
+ // that font every menu row had an empty slot. Known names get an inline SVG; an unknown name still falls
+ // back to the page's icon font, so sites that pass their own Font Awesome icons keep working.
+ const NAV_ICONS = {
+ 'fa-user': '<circle cx="12" cy="9" r="3.2"/><path d="M5.5 19a6.5 6.5 0 0 1 13 0"/>',
+ 'fa-user-secret': '<path d="M4 11h16M7.5 11l1.2-5h6.6l1.2 5"/><circle cx="9" cy="15" r="2"/><circle cx="15" cy="15" r="2"/><path d="M11 15h2"/>',
+ 'fa-clock-rotate-left': '<path d="M4.5 12a7.5 7.5 0 1 0 2.4-5.5L4.5 8.8M4.5 4.8v4h4M12 8v4.2l2.8 1.8"/>',
+ 'fa-bell': '<path d="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"/>',
+ 'fa-palette': '<path d="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"/>',
+ 'fa-link': '<path d="M10 14a4 4 0 0 0 5.7 0l3-3a4 4 0 0 0-5.7-5.700l-1 1M14 10a4 4 0 0 0-5.7 0l-3 3a4 4 0 0 0 5.7 5.700l1-1"/>',
+ 'fa-shield-halved': '<path d="M12 3.5l7 2.500v5.500c0 4.3-3 7.3-7 9-4-1.7-7-4.7-7-9V6zM12 3.500v17"/>',
+ 'fa-screwdriver-wrench': '<path d="M14.5 5.5a4 4 0 0 0-5.2 5.2L4.5 15.5a1.8 1.8 0 0 0 2.5 2.5l4.8-4.8a4 4 0 0 0 5.2-5.2l-2.6 2.6-2.1-.5-.5-2.1z"/>',
+ 'fa-right-from-bracket': '<path d="M10 5H6.500A1.5 1.5 0 0 0 5 6.500v11A1.5 1.5 0 0 0 6.5 19H10M14 8l4 4-4 4M18 12H9.5"/>',
+ 'fa-plus': '<path d="M12 5.500v13M5.5 12h13"/>',
+ 'fa-paste': '<rect x="6" y="6" width="12" height="14" rx="1.8"/><path d="M9.5 6V5a1 1 0 0 1 1-1h3a1 1 0 0 1 1 1v1M9 11h6M9 14h6"/>',
+ 'fa-house': '<path d="M4.5 11.5 12 5l7.5 6.500M6.5 10v8.500h11V10"/>',
+ 'fa-gauge-high': '<path d="M4.5 17a8 8 0 1 1 15 0M12 13l3.5-4"/><circle cx="12" cy="13.5" r="1.3"/>',
};
};
+ function navIcon(cls) {
+ const key = String(cls || '').split(/\s+/).find(c => NAV_ICONS[c]);
+ if (!key) return cls ? `<i class="fa-solid ${escapeAttr(cls)}"></i>` : '';
+ return `<svg class="ovnav-ic" viewBox="0 0 24 24" width="1em" height="1em" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">${NAV_ICONS[key]}</svg>`;
+ }
// ─── Brand from hostname ───────────────────────────────────
// ─── Brand from hostname ───────────────────────────────────
// Every property is <sub?>.openvibe.<tld> (plus openre.stream). The navbar spells the
// Every property is <sub?>.openvibe.<tld> (plus openre.stream). The navbar spells the
@@ -759,7 +565,7 @@
@@ -759,7 +565,7 @@
function menuItemHTML(item) {
function menuItemHTML(item) {
if (!item) return '';
if (!item) return '';
if (item.sep) return '<div class="sep"></div>';
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 icon = item.icon ? `<span class="icon">${navIcon(item.icon)}</span>` : '<span class="icon"></span>';
const cls = item.danger ? ' class="danger"' : '';
const cls = item.danger ? ' class="danger"' : '';
const id = item.id ? ` data-menu-id="${escapeAttr(item.id)}"` : '';
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>`;
if (item.href) return `<a href="${escapeAttr(item.href)}"${cls}${id}${item.external ? ' target="_blank" rel="noopener"' : ''}>${icon} ${escapeAttr(item.label)}</a>`;
@@ -1259,9 +1065,9 @@
@@ -1259,9 +1065,9 @@
nav.innerHTML = `
nav.innerHTML = `
${brandHTML(brand)}
${brandHTML(brand)}
<div class="openvibe-navbar-links">
<div class="openvibe-navbar-links">
- ${links.map(l => `<a href="${escapeAttr(l.href)}"${l.active ? ' class="active"' : ''}${l.external ? ' target="_blank" rel="noopener"' : ''}>${l.icon ? `<i class="fa-solid ${escapeAttr(l.icon)} icon"></i>` : ''}${escapeAttr(l.label)}</a>`).join('')}
+ ${links.map(l => `<a href="${escapeAttr(l.href)}"${l.active ? ' class="active"' : ''}${l.external ? ' target="_blank" rel="noopener"' : ''}>${l.icon ? `<span class="icon">${navIcon(l.icon)}</span>` : ''}${escapeAttr(l.label)}</a>`).join('')}
${networkLinksHTML(links)}
${networkLinksHTML(links)}
- ${u && u.role === 'admin' ? `<a href="https://openvibe.network/admin"><i class="fa-solid fa-shield-halved"></i> Admin</a>` : ''}
+ ${u && u.role === 'admin' ? `<a href="https://openvibe.network/admin">${navIcon('fa-shield-halved')} 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">
@@ -1302,26 +1108,26 @@
@@ -1302,26 +1108,26 @@
</div>
</div>
`).join('')}
`).join('')}
<div class="account-item" data-account-id="anon" style="${isAnon ? 'display:none' : ''}">
<div class="account-item" data-account-id="anon" style="${isAnon ? 'display:none' : ''}">
- <span style="width:24px;text-align:center"><i class="fa-solid fa-user-secret"></i></span>
+ <span style="width:24px;text-align:center">${navIcon('fa-user-secret')}</span>
<span>Switch to Anonymous</span>
<span>Switch to Anonymous</span>
</div>
</div>
<a class="add-account" id="openvibe-add-account" href="${escapeAttr(addAccountHref)}">
<a class="add-account" id="openvibe-add-account" href="${escapeAttr(addAccountHref)}">
- <span style="width:24px;text-align:center"><i class="fa-solid fa-plus"></i></span>
+ <span style="width:24px;text-align:center">${navIcon('fa-plus')}</span>
<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-recent" id="openvibe-recent" hidden></div>
<div class="openvibe-navbar-dropdown-menu">
<div class="openvibe-navbar-dropdown-menu">
${before.map(menuItemHTML).join('')}${before.length ? '<div class="sep"></div>' : ''}
${before.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#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>` : ''}
+ <a href="https://openvibe.network/my"><span class="icon">${navIcon('fa-user')}</span> My Account</a>
+ <a href="https://openvibe.network/my#history"><span class="icon">${navIcon('fa-clock-rotate-left')}</span> History</a>
+ <a href="https://openvibe.network/my#notifications"><span class="icon">${navIcon('fa-bell')}</span> Notifications</a>
+ <a href="https://openvibe.network/themes"><span class="icon">${navIcon('fa-palette')}</span> Themes</a>
+ <a href="https://openvibe.network/my#linked"><span class="icon">${navIcon('fa-link')}</span> Linked Services</a>
+ ${u.role === 'admin' ? `<a href="https://openvibe.network/admin"><span class="icon">${navIcon('fa-screwdriver-wrench')}</span> Admin Panel</a>` : ''}
${after.length ? '<div class="sep"></div>' : ''}${after.map(menuItemHTML).join('')}
${after.length ? '<div class="sep"></div>' : ''}${after.map(menuItemHTML).join('')}
<div class="sep"></div>
<div class="sep"></div>
- <button id="openvibe-logout-btn" class="danger"><span class="icon"><i class="fa-solid fa-right-from-bracket"></i></span> Sign Out</button>
+ <button id="openvibe-logout-btn" class="danger"><span class="icon">${navIcon('fa-right-from-bracket')}</span> Sign Out</button>
</div>
</div>
`;
`;
nav.appendChild(dropdown);
nav.appendChild(dropdown);
@@ -131,9 +131,21 @@
@@ -131,9 +131,21 @@
.openvibe-nv-foot a { color: var(--accent-light, #60a5fa); text-decoration: none; }
.openvibe-nv-foot a { color: var(--accent-light, #60a5fa); text-decoration: none; }
.openvibe-nv-note { font-size: 11px; color: var(--text-muted, #707080); padding: 6px 12px; background: color-mix(in srgb, var(--warning, #f39c12) 10%, transparent); border-bottom: 1px solid var(--border, #333340); }
.openvibe-nv-note { font-size: 11px; color: var(--text-muted, #707080); padding: 6px 12px; background: color-mix(in srgb, var(--warning, #f39c12) 10%, transparent); border-bottom: 1px solid var(--border, #333340); }
+ /* The panel hangs under whatever navbar the site has (its top is measured when it opens) and never
+ leaves the visible viewport: dvh instead of vh, room for the home indicator, list scrolls inside. */
+ .openvibe-notif-panel { top: var(--ovn-top, 62px); right: max(8px, env(safe-area-inset-right, 0px)); width: min(420px, calc(100vw - 16px));
+ max-height: calc(100vh - var(--ovn-top, 62px) - 12px); max-height: calc(100dvh - var(--ovn-top, 62px) - 12px - env(safe-area-inset-bottom, 0px)); border-radius: 16px; }
+ .openvibe-notif-item .title .count { display: inline-block; margin-left: 4px; padding: 1px 7px; border-radius: 999px; font-size: 11px; font-weight: 700; color: var(--accent-light, var(--accent, #60a5fa)); background: color-mix(in srgb, var(--accent, #3b82f6) 16%, transparent); vertical-align: 1px; }
+ /* Phones and narrow windows: a sheet from edge to edge under the bar, down to the bottom inset. */
+ @media (max-width: 640px) {
+ .openvibe-notif-panel { left: 8px; right: 8px; width: auto; bottom: calc(8px + env(safe-area-inset-bottom, 0px)); max-height: none; }
+ .openvibe-notif-panel [data-act] > span { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); } /* icon-only actions, labels stay for screen readers */
+ .openvibe-notif-panel [data-act] { min-width: 36px; min-height: 36px; justify-content: center; }
+ .openvibe-notif-item { padding-top: 10px; padding-bottom: 10px; }
+ }
+ @media (max-height: 520px) { .openvibe-notif-item .msg { display: -webkit-box; -webkit-line-clamp: 1; -webkit-box-orient: vertical; overflow: hidden; } }
@media (max-width: 460px) {
@media (max-width: 460px) {
.openvibe-toast-container { right: 8px; left: 8px; max-width: none; }
.openvibe-toast-container { right: 8px; left: 8px; max-width: none; }
- .openvibe-notif-panel { right: 6px; left: 6px; width: auto; top: 56px; max-height: calc(100vh - 66px); }
}
}
`;
`;
document.head.appendChild(style);
document.head.appendChild(style);
@@ -372,9 +384,11 @@
@@ -372,9 +384,11 @@
const item = e.target.closest('.openvibe-notif-item');
const item = e.target.closest('.openvibe-notif-item');
if (!item) return;
if (!item) return;
const id = item.dataset.id;
const id = item.dataset.id;
- if (dismissBtn) { e.stopPropagation(); dismiss(v, id); return; }
+ const ids = (item.dataset.ids || id).split(',').filter(Boolean);
+ if (dismissBtn) { e.stopPropagation(); ids.forEach(x => dismiss(v, x)); return; }
const n = v.items.find(x => String(x.id) === id);
const n = v.items.find(x => String(x.id) === id);
- if (n && !isRead(n)) markRead([id]);
+ const unreadIds = ids.filter(x => { const it = v.items.find(y => String(y.id) === x); return it && !isRead(it); });
+ if (unreadIds.length) markRead(unreadIds);
const u = urlOf(n);
const u = urlOf(n);
if (u) navigateTo(u);
if (u) navigateTo(u);
});
});
@@ -385,14 +399,21 @@
@@ -385,14 +399,21 @@
function renderItems(v, { append = false } = {}) {
function renderItems(v, { append = false } = {}) {
const list = v.root.querySelector('[data-role="list"]');
const list = v.root.querySelector('[data-role="list"]');
- const html = v.items.map(n => {
+ // The same event repeated back to back ("X is live!" six times) reads as one row with a count.
+ const groups = [];
+ for (const n of v.items) {
+ const key = [n.title, n.message || '', n.service || '', n.category || ''].join('\u0001');
+ const g = groups[groups.length - 1];
+ if (g && g.key === key) { g.ids.push(String(n.id)); if (!isRead(n)) g.unread = true; } else groups.push({ key, n, ids: [String(n.id)], unread: !isRead(n) });
+ }
+ const html = groups.map(({ n, ids, unread }) => {
try {
try {
const cat = CATEGORY_LABELS[n.category] || n.category || '';
const cat = CATEGORY_LABELS[n.category] || n.category || '';
const service = n.service && n.service !== 'network' ? `<span class="tag">${esc(SERVICE_LABELS[n.service] || n.service)}</span>` : '';
const service = n.service && n.service !== 'network' ? `<span class="tag">${esc(SERVICE_LABELS[n.service] || n.service)}</span>` : '';
- return `<div class="openvibe-notif-item ${isRead(n) ? '' : 'unread'}" data-id="${esc(n.id)}" title="${esc(urlOf(n) ? 'Open' : '')}">
+ return `<div class="openvibe-notif-item ${unread ? 'unread' : ''}" data-id="${esc(n.id)}" data-ids="${esc(ids.join(','))}" title="${esc(urlOf(n) ? 'Open' : '')}">
<span class="icon">${iconHtml(n)}</span>
<span class="icon">${iconHtml(n)}</span>
<div class="content">
<div class="content">
- <div class="title">${esc(n.title)}</div>
+ <div class="title">${esc(n.title)}${ids.length > 1 ? ` <span class="count" title="${ids.length} times">×${ids.length}</span>` : ''}</div>
${n.message ? `<div class="msg">${esc(n.message)}</div>` : ''}
${n.message ? `<div class="msg">${esc(n.message)}</div>` : ''}
<div class="meta"><span title="${esc(parseDate(n.created_at)?.toLocaleString() || '')}">${esc(timeAgo(n.created_at))}</span>${service}${cat ? `<span class="tag">${esc(cat)}</span>` : ''}</div>
<div class="meta"><span title="${esc(parseDate(n.created_at)?.toLocaleString() || '')}">${esc(timeAgo(n.created_at))}</span>${service}${cat ? `<span class="tag">${esc(cat)}</span>` : ''}</div>
</div>
</div>
@@ -546,6 +567,13 @@
@@ -546,6 +567,13 @@
function togglePanel() {
function togglePanel() {
if (!_panelEl) createPanel();
if (!_panelEl) createPanel();
const opening = !_panelEl.classList.contains('open');
const opening = !_panelEl.classList.contains('open');
+ if (opening) {
+ // Sit directly under the site's navbar, whatever its height (sticky bars, banners, safe areas).
+ const bar = _bellEl && _bellEl.closest('nav, header, .navbar');
+ const bottom = bar ? bar.getBoundingClientRect().bottom : (_bellEl ? _bellEl.getBoundingClientRect().bottom : 56);
+ _panelEl.style.setProperty('--ovn-top', Math.max(8, Math.round(bottom) + 6) + 'px');
+ if (!_panelEl.__esc) { _panelEl.__esc = (e) => { if (e.key === 'Escape' && _panelEl.classList.contains('open')) togglePanel(); }; document.addEventListener('keydown', _panelEl.__esc); }
+ }
_panelEl.classList.toggle('open', opening);
_panelEl.classList.toggle('open', opening);
_bellEl?.classList.toggle('open', opening);
_bellEl?.classList.toggle('open', opening);
if (opening) { reload(_views.get(_panelEl)); refreshPushButton(_views.get(_panelEl)); pollUnread(); }
if (opening) { reload(_views.get(_panelEl)); refreshPushButton(_views.get(_panelEl)); pollUnread(); }
@@ -129,6 +129,24 @@
@@ -129,6 +129,24 @@
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 transform="translate(${(13 + off[0] * .9167).toFixed(2)} ${(13 + off[1] * .9167).toFixed(2)}) scale(.9167)"><g class="ovi-glyph">${def.glyph}</g></g></svg>`;
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 transform="translate(${(13 + off[0] * .9167).toFixed(2)} ${(13 + off[1] * .9167).toFixed(2)}) scale(.9167)"><g class="ovi-glyph">${def.glyph}</g></g></svg>`;
}
}
+ // ── Sprite mode (server-rendered lists) ──────────────────────────────────────────────
+ // A page that shows 170 icons should not ship 170 copies of the same ring and glyph. sprite(names) emits each
+ // icon once as a <symbol>; use(name) references it. Colours come from currentColor and inherited custom
+ // properties, so a referenced icon is themed exactly like an inline one. Static by design (no comet, no
+ // progress): pages use it for listings and keep the full inline icon where state or motion matters.
+ function sprite(names) {
+ const seen = new Set();
+ const symbols = (names || []).map(resolve).filter(n => !seen.has(n) && seen.add(n)).map((key) => {
+ const def = registry[key]; const off = def.offset || OFFSETS[key] || [0, 0];
+ return `<symbol id="ovi-${key}" viewBox="0 0 48 48"><circle cx="24" cy="24" r="21" fill="currentColor" opacity=".1"/><circle cx="24" cy="24" r="20" fill="none" stroke="currentColor" stroke-width="2.4" opacity=".3"/><g transform="translate(${(13 + off[0] * .9167).toFixed(2)} ${(13 + off[1] * .9167).toFixed(2)}) scale(.9167)" fill="none" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" style="stroke:var(--ovi-glyph,var(--text-primary,#e6edf7))">${def.glyph}</g></symbol>`;
+ }).join('');
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="0" height="0" style="position:absolute" aria-hidden="true" focusable="false">${symbols}</svg>`;
+ }
+ function use(name, size) {
+ const key = resolve(name);
+ return `<span class="ov-icon" data-icon="${key}" data-ovi="${key}" data-fx="none" style="--ovi-size:${parseInt(size, 10) || 32}px" aria-hidden="true"><svg viewBox="0 0 48 48" focusable="false"><use href="#ovi-${key}"/></svg></span>`;
+ }
+
function applyProgress(el) {
function applyProgress(el) {
const raw = el.getAttribute('data-progress');
const raw = el.getAttribute('data-progress');
const prog = el.querySelector('.ovi-prog');
const prog = el.querySelector('.ovi-prog');
@@ -168,7 +186,7 @@
@@ -168,7 +186,7 @@
if (typeof document !== 'undefined') document.querySelectorAll(`.ov-icon[data-icon="${name}"]`).forEach((el) => { el.removeAttribute('data-ovi'); mountOne(el); });
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 };
+ const api = { names: () => Object.keys(registry), register, mount, set, svg, sprite, use, resolve, CSS };
root.OpenVibeIcons = api;
root.OpenVibeIcons = api;
if (typeof module !== 'undefined' && module.exports) module.exports = api;
if (typeof module !== 'undefined' && module.exports) module.exports = api;
if (typeof document === 'undefined') return;
if (typeof document === 'undefined') return;
@@ -15,6 +15,8 @@
@@ -15,6 +15,8 @@
"./seo": "./seo.js",
"./seo": "./seo.js",
"./icons": "./ov-icons.js",
"./icons": "./ov-icons.js",
"./legal": "./legal.js",
"./legal": "./legal.js",
+ "./footer": "./footer.js",
+ "./chrome-ssr": "./chrome-ssr.js",
"./app-icon": "./app-icon.js"
"./app-icon": "./app-icon.js"
},
},
"license": "MIT",
"license": "MIT",