openvibers/openvibe.tools Commits
People on a canonical host are sent to the short host, crawlers stay; YT status uses the shared notice; vendor sync
Changes
@@@ -11,6 +11,17 @@ const registry = require('./index');const HOP = new Set(['connection', 'keep-alive', 'proxy-authenticate', 'proxy-authorization', 'te', 'trailer', 'transfer-encoding', 'upgrade']);++const BOT_RE = /bot|crawl|spider|slurp|bingpreview|facebookexternalhit|embedly|quora|pinterest|vkshare|w3c_validator|whatsapp|telegram|discord|slack|twitter|linkedin|preview|lighthouse|headless|curl|wget|python|httpclient|go-http|java\/|ruby|perl|scrapy|gpt|claude|anthropic|perplexity|ccbot|bytespider|amazonbot|applebot|yandex|baidu|duckduck|archive\.org|ia_archiver/i;++/** A browser loading a page for a person: GET, wants HTML, has a browser UA, is not a crawler or link preview. */++function isPersonNavigating(req) {++ if (req.method !== 'GET') return false;++ const ua = String(req.headers['user-agent'] || '');++ if (!/Mozilla\//.test(ua) || BOT_RE.test(ua)) return false;++ if (!/text\/html/.test(String(req.headers.accept || ''))) return false;++ const dest = req.headers['sec-fetch-dest'];++ return !dest || dest === 'document';++}++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;@@@ -57,9 +68,17 @@ function hostRoles(opts) {res.set('Cache-Control', 'public, max-age=300');return res.redirect(301, `https://${info.shortHost}${req.originalUrl || '/'}`);}++ // People get the address that is easiest to remember; crawlers stay on the canonical host.++ // The descriptive host (or a custom domain) is what links, sitemaps and rel=canonical name, so++ // search engines index that one. A person who follows such a link is sent on to the short host++ // (302, same path). Same page either way, so this is a convenience redirect, not different content.++ if (info.role === 'canonical' && info.shortHost && info.shortHost !== info.host && isPersonNavigating(req)) {++ res.set('Cache-Control', 'private, no-store'); res.set('Vary', 'User-Agent, Accept');++ return res.redirect(302, `https://${info.shortHost}${req.originalUrl || '/'}`);++ }if (info.port) return proxyTo(info.port, info, req, res);next();};}--module.exports = { hostRoles, proxyTo };++module.exports = { hostRoles, proxyTo, isPersonNavigating };@@@ -34,3 +34,16 @@ 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');++++// People are sent from the canonical host to the short one; crawlers are not.++{++ const { isPersonNavigating } = require('../server/registry/host-middleware');++ const req = (ua, accept, extra) => ({ method: 'GET', headers: Object.assign({ 'user-agent': ua, accept }, extra || {}) });++ const chrome = 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36';++ assert.equal(isPersonNavigating(req(chrome, 'text/html,application/xhtml+xml', { 'sec-fetch-dest': 'document' })), true);++ assert.equal(isPersonNavigating(req('Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)', 'text/html')), false);++ assert.equal(isPersonNavigating(req('Mozilla/5.0 AppleWebKit/537.36 (compatible; GPTBot/1.2)', 'text/html')), false);++ assert.equal(isPersonNavigating(req(chrome, 'application/json')), false, 'API calls are never redirected');++ assert.equal(isPersonNavigating(req(chrome, 'text/html', { 'sec-fetch-dest': 'iframe' })), false);++ console.log('short-host redirect rule: ok');++}@@@ -114,6 +114,7 @@<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="https://openvibe.network/shared/ui.js" defer></script><script src="/js/download-state.js"></script><script src="/js/app.js"></script></body>@@@ -203,19 +203,19 @@}// --- Upstream status ----- // The server checks YouTube on a timer. When YouTube is refusing it, say so before anyone pastes a link.-- (function upstreamBanner() {++ // The server checks YouTube on a timer. When YouTube is refusing it, say so before anyone pastes a++ // link, with the network's shared notice (OpenVibeUI.notice: one design, always under the navbar).++ (function upstreamNotice() {fetch('/api/health', { cache: 'no-store' }).then(function (r) { return r.ok ? r.json() : null; }).then(function (h) {-- if (!h || h.youtube !== 'blocked' || document.getElementById('yt-upstream')) return;-- var b = document.createElement('div'); b.id = 'yt-upstream'; b.setAttribute('role', 'status');-- b.style.cssText = 'max-width:680px;margin:0 auto 16px;padding:12px 16px;border-radius:12px;border:1px solid color-mix(in srgb,var(--warning,#f59e0b) 45%,transparent);background:color-mix(in srgb,var(--warning,#f59e0b) 10%,transparent);color:var(--text-primary,#e6edf7);font-size:14px;line-height:1.5';-- var t = document.createElement('b'); t.textContent = 'YouTube is refusing our server right now. ';-- var m = document.createElement('span'); m.textContent = 'Downloads will fail until that clears. It is on YouTube\'s side and we re-check every 30 minutes. ';-- var a = document.createElement('a'); a.href = 'https://openvibe.tools/audio-tools'; a.textContent = 'Audio tools'; a.style.color = 'var(--accent-light,#60a5fa)';-- var a2 = document.createElement('a'); a2.href = 'https://openvibe.tools/'; a2.textContent = 'every other tool'; a2.style.color = 'var(--accent-light,#60a5fa)';-- b.appendChild(t); b.appendChild(m); b.appendChild(a); b.appendChild(document.createTextNode(' and ')); b.appendChild(a2); b.appendChild(document.createTextNode(' work as usual.'));-- var anchor = (typeof urlInput !== 'undefined' && urlInput && urlInput.closest('form, .input-row, .url-row, .search-box')) || document.querySelector('main') || document.body;-- anchor.parentNode.insertBefore(b, anchor);++ if (!h || h.youtube !== 'blocked') return;++ var show = function () {++ if (!window.OpenVibeUI || !OpenVibeUI.notice) return false;++ OpenVibeUI.notice({ id: 'yt-upstream', type: 'warning', title: 'YouTube is refusing our server right now.',++ message: 'Downloads will fail until that clears. It is on YouTube\'s side, and we re-check every 30 minutes.',++ links: [{ label: 'Audio tools', href: 'https://openvibe.tools/audio-tools' }, { label: 'Every other tool', href: 'https://openvibe.tools/' }] });++ return true;++ };++ var tries = 0; (function wait() { if (!show() && ++tries < 30) setTimeout(wait, 200); })();}).catch(function () {});})();@@@ -499,7 +499,9 @@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); }++ // Descriptive hosts ('youtube-downloader') are for search engines; the brand shows the name people use.++ const LONG_SUBS = { 'youtube-downloader': 'YT', youtubedownloader: 'YT', 'youtube-download': 'YT', youtube: 'YT', ytdl: 'YT' };++ function subLabel(sub) { return SUB_LABELS[sub] || SUB_LABELS[LONG_SUBS[sub] && LONG_SUBS[sub].toLowerCase()] || LONG_SUBS[sub] || titleCase(String(sub).replace(/-/g, ' ')).replace(/ /g, ''); }/*** { sub, core, tld, name, short, icon, variant } for the current page.@@@ -5,6 +5,9 @@* 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: '…' })++ * OpenVibeUI.notice({ id: 'yt-upstream', type: 'warning', title: '…', message: '…', links: [{ label, href }], dismissible: true })++ * a page-level notice that always sits directly BELOW the site's navbar (never above it), one++ * design for service status, maintenance, read-only mode and similar. Returns { close, update }.** 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,@@@ -13,7 +16,7 @@(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; }++ if (typeof document === 'undefined') { const noop = { toast() { return { close() {} }; }, notice() { return { close() {}, update() {} }; }, 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}@@@ -41,6 +44,14 @@.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}++.ovui-notices{display:flex;flex-direction:column;gap:8px;max-width:1080px;margin:14px auto 0;padding:0 16px;box-sizing:border-box;width:100%}++.ovui-notices:empty{display:none}++.ovui-notice{--ovui-c:var(--accent,#3b82f6);display:grid;grid-template-columns:auto 1fr auto;gap:12px;align-items:start;padding:12px 14px;border-radius:14px;border:1px solid color-mix(in srgb,var(--ovui-c) 40%,transparent);background:color-mix(in srgb,var(--ovui-c) 9%,var(--bg-secondary,#111826));color:var(--text-primary,#e6edf7);font:400 14px/1.5 system-ui,-apple-system,Segoe UI,Roboto,sans-serif;animation:ovuiIn .28s cubic-bezier(.2,1.2,.3,1)}++.ovui-notice[data-type=success]{--ovui-c:var(--success,#22c55e)}.ovui-notice[data-type=error]{--ovui-c:var(--danger,#ef4444)}.ovui-notice[data-type=warning]{--ovui-c:var(--warning,#f59e0b)}++.ovui-notice-ic{width:22px;height:22px;border-radius:50%;display:grid;place-items:center;background:var(--ovui-c);color:#0b1220;font:800 13px/1 system-ui,sans-serif;margin-top:1px}++.ovui-notice b{font-weight:700}.ovui-notice-msg{color:var(--text-secondary,#a8b3c4)}++.ovui-notice-links{display:flex;flex-wrap:wrap;gap:6px 14px;margin-top:6px}.ovui-notice-links a{color:var(--accent-light,var(--accent,#60a5fa));font-weight:600;text-decoration:none}.ovui-notice-links a:hover{text-decoration:underline}++@media (max-width:560px){.ovui-notices{margin-top:10px;padding:0 12px}}@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}}@@@ -111,8 +122,48 @@});}++ // ── Page notices ─────────────────────────────────────────++ const notices = new Map();++ let noticeHost = null;++ function placeNoticeHost() {++ if (!noticeHost) { noticeHost = el('div', 'ovui-notices'); noticeHost.setAttribute('aria-live', 'polite'); }++ // Directly after the navbar, whichever flavour the site uses; otherwise at the top of the content.++ const nav = document.querySelector('nav.openvibe-navbar, #navbar-mount, .navbar, header[role="banner"]');++ const anchor = nav ? (nav.id === 'navbar-mount' || nav.parentNode === document.body ? nav : nav) : null;++ if (anchor && anchor.parentNode) { if (anchor.nextSibling !== noticeHost) anchor.parentNode.insertBefore(noticeHost, anchor.nextSibling); return true; }++ const main = document.querySelector('main') || document.body.firstElementChild;++ if (!noticeHost.isConnected && main && main.parentNode) main.parentNode.insertBefore(noticeHost, main);++ return false;++ }++ function notice(o) {++ o = o || {}; css();++ const id = String(o.id || 'n' + Date.now());++ try { if (o.dismissible !== false && sessionStorage.getItem('ovui_notice_' + id) === '1') return { close() {}, update() {} }; } catch { /* */ }++ if (notices.has(id)) notices.get(id).close(true);++ const type = ['info', 'success', 'error', 'warning'].includes(o.type) ? o.type : 'info';++ const n = el('div', 'ovui-notice'); n.dataset.type = type; n.setAttribute('role', type === 'error' ? 'alert' : 'status');++ n.appendChild(el('span', 'ovui-notice-ic', type === 'success' ? '✓' : type === 'info' ? 'i' : '!'));++ const body = el('div');++ const fill = (p) => {++ body.replaceChildren();++ const line = el('div'); if (p.title) { line.appendChild(el('b', null, p.title)); line.appendChild(document.createTextNode(' ')); }++ if (p.message) line.appendChild(el('span', 'ovui-notice-msg', p.message)); body.appendChild(line);++ const links = (p.links || []).map((l) => { const h = l && l.label && safeHref(l.href); if (!h) return null; const a = el('a', null, l.label); a.href = h; return a; }).filter(Boolean);++ if (links.length) { const row = el('div', 'ovui-notice-links'); links.forEach((a) => row.appendChild(a)); body.appendChild(row); }++ };++ fill(o); n.appendChild(body);++ const close = (silent) => { n.remove(); notices.delete(id); if (!silent && o.dismissible !== false) { try { sessionStorage.setItem('ovui_notice_' + id, '1'); } catch { /* */ } } };++ if (o.dismissible !== false) { const x = el('button', 'ovui-x', '×'); x.type = 'button'; x.setAttribute('aria-label', 'Dismiss'); x.addEventListener('click', () => close(false)); n.appendChild(x); }++ // The navbar may mount after us: keep trying briefly until the notice sits under it.++ let tries = 0; const settle = () => { const ok = placeNoticeHost(); if (!ok && ++tries < 40) setTimeout(settle, 150); }; settle();++ noticeHost.appendChild(n);++ const handle = { close: () => close(true), update: (p) => fill(Object.assign({}, o, p)), el: n };++ notices.set(id, handle);++ return handle;++ }++const api = {-- toast,++ toast, notice,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' })),
@@ -11,6 +11,17 @@ const registry = require('./index');
@@ -11,6 +11,17 @@ const registry = require('./index');
const HOP = new Set(['connection', 'keep-alive', 'proxy-authenticate', 'proxy-authorization', 'te', 'trailer', 'transfer-encoding', 'upgrade']);
const HOP = new Set(['connection', 'keep-alive', 'proxy-authenticate', 'proxy-authorization', 'te', 'trailer', 'transfer-encoding', 'upgrade']);
+const BOT_RE = /bot|crawl|spider|slurp|bingpreview|facebookexternalhit|embedly|quora|pinterest|vkshare|w3c_validator|whatsapp|telegram|discord|slack|twitter|linkedin|preview|lighthouse|headless|curl|wget|python|httpclient|go-http|java\/|ruby|perl|scrapy|gpt|claude|anthropic|perplexity|ccbot|bytespider|amazonbot|applebot|yandex|baidu|duckduck|archive\.org|ia_archiver/i;
+/** A browser loading a page for a person: GET, wants HTML, has a browser UA, is not a crawler or link preview. */
+function isPersonNavigating(req) {
+ if (req.method !== 'GET') return false;
+ const ua = String(req.headers['user-agent'] || '');
+ if (!/Mozilla\//.test(ua) || BOT_RE.test(ua)) return false;
+ if (!/text\/html/.test(String(req.headers.accept || ''))) return false;
+ const dest = req.headers['sec-fetch-dest'];
+ return !dest || dest === 'document';
+}
+
function proxyTo(port, info, req, res) {
function proxyTo(port, info, req, res) {
const headers = {};
const headers = {};
for (const [k, v] of Object.entries(req.headers)) if (!HOP.has(k) && !k.startsWith('x-ov-')) headers[k] = v;
for (const [k, v] of Object.entries(req.headers)) if (!HOP.has(k) && !k.startsWith('x-ov-')) headers[k] = v;
@@ -57,9 +68,17 @@ function hostRoles(opts) {
@@ -57,9 +68,17 @@ function hostRoles(opts) {
res.set('Cache-Control', 'public, max-age=300');
res.set('Cache-Control', 'public, max-age=300');
return res.redirect(301, `https://${info.shortHost}${req.originalUrl || '/'}`);
return res.redirect(301, `https://${info.shortHost}${req.originalUrl || '/'}`);
}
}
+ // People get the address that is easiest to remember; crawlers stay on the canonical host.
+ // The descriptive host (or a custom domain) is what links, sitemaps and rel=canonical name, so
+ // search engines index that one. A person who follows such a link is sent on to the short host
+ // (302, same path). Same page either way, so this is a convenience redirect, not different content.
+ if (info.role === 'canonical' && info.shortHost && info.shortHost !== info.host && isPersonNavigating(req)) {
+ res.set('Cache-Control', 'private, no-store'); res.set('Vary', 'User-Agent, Accept');
+ return res.redirect(302, `https://${info.shortHost}${req.originalUrl || '/'}`);
+ }
if (info.port) return proxyTo(info.port, info, req, res);
if (info.port) return proxyTo(info.port, info, req, res);
next();
next();
};
};
}
}
-module.exports = { hostRoles, proxyTo };
+module.exports = { hostRoles, proxyTo, isPersonNavigating };
@@ -34,3 +34,16 @@ assert.equal(site.search('merge pdf')[0].id, 'mergepdf');
@@ -34,3 +34,16 @@ assert.equal(site.search('merge pdf')[0].id, 'mergepdf');
assert.equal(site.search('youtube to mp3')[0].id, 'yt');
assert.equal(site.search('youtube to mp3')[0].id, 'yt');
assert.deepEqual(site.search(''), []);
assert.deepEqual(site.search(''), []);
console.log('registry + site: all checks passed');
console.log('registry + site: all checks passed');
+
+// People are sent from the canonical host to the short one; crawlers are not.
+{
+ const { isPersonNavigating } = require('../server/registry/host-middleware');
+ const req = (ua, accept, extra) => ({ method: 'GET', headers: Object.assign({ 'user-agent': ua, accept }, extra || {}) });
+ const chrome = 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/126.0 Safari/537.36';
+ assert.equal(isPersonNavigating(req(chrome, 'text/html,application/xhtml+xml', { 'sec-fetch-dest': 'document' })), true);
+ assert.equal(isPersonNavigating(req('Mozilla/5.0 (compatible; Googlebot/2.1; +http://www.google.com/bot.html)', 'text/html')), false);
+ assert.equal(isPersonNavigating(req('Mozilla/5.0 AppleWebKit/537.36 (compatible; GPTBot/1.2)', 'text/html')), false);
+ assert.equal(isPersonNavigating(req(chrome, 'application/json')), false, 'API calls are never redirected');
+ assert.equal(isPersonNavigating(req(chrome, 'text/html', { 'sec-fetch-dest': 'iframe' })), false);
+ console.log('short-host redirect rule: ok');
+}
@@ -114,6 +114,7 @@
@@ -114,6 +114,7 @@
<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/ov-icons.js" defer></script>
<script src="https://openvibe.network/shared/island.js" defer></script>
<script src="https://openvibe.network/shared/island.js" defer></script>
+ <script src="https://openvibe.network/shared/ui.js" defer></script>
<script src="/js/download-state.js"></script>
<script src="/js/download-state.js"></script>
<script src="/js/app.js"></script>
<script src="/js/app.js"></script>
</body>
</body>
@@ -203,19 +203,19 @@
@@ -203,19 +203,19 @@
}
}
// --- Upstream status ---
// --- Upstream status ---
- // The server checks YouTube on a timer. When YouTube is refusing it, say so before anyone pastes a link.
- (function upstreamBanner() {
+ // The server checks YouTube on a timer. When YouTube is refusing it, say so before anyone pastes a
+ // link, with the network's shared notice (OpenVibeUI.notice: one design, always under the navbar).
+ (function upstreamNotice() {
fetch('/api/health', { cache: 'no-store' }).then(function (r) { return r.ok ? r.json() : null; }).then(function (h) {
fetch('/api/health', { cache: 'no-store' }).then(function (r) { return r.ok ? r.json() : null; }).then(function (h) {
- if (!h || h.youtube !== 'blocked' || document.getElementById('yt-upstream')) return;
- var b = document.createElement('div'); b.id = 'yt-upstream'; b.setAttribute('role', 'status');
- b.style.cssText = 'max-width:680px;margin:0 auto 16px;padding:12px 16px;border-radius:12px;border:1px solid color-mix(in srgb,var(--warning,#f59e0b) 45%,transparent);background:color-mix(in srgb,var(--warning,#f59e0b) 10%,transparent);color:var(--text-primary,#e6edf7);font-size:14px;line-height:1.5';
- var t = document.createElement('b'); t.textContent = 'YouTube is refusing our server right now. ';
- var m = document.createElement('span'); m.textContent = 'Downloads will fail until that clears. It is on YouTube\'s side and we re-check every 30 minutes. ';
- var a = document.createElement('a'); a.href = 'https://openvibe.tools/audio-tools'; a.textContent = 'Audio tools'; a.style.color = 'var(--accent-light,#60a5fa)';
- var a2 = document.createElement('a'); a2.href = 'https://openvibe.tools/'; a2.textContent = 'every other tool'; a2.style.color = 'var(--accent-light,#60a5fa)';
- b.appendChild(t); b.appendChild(m); b.appendChild(a); b.appendChild(document.createTextNode(' and ')); b.appendChild(a2); b.appendChild(document.createTextNode(' work as usual.'));
- var anchor = (typeof urlInput !== 'undefined' && urlInput && urlInput.closest('form, .input-row, .url-row, .search-box')) || document.querySelector('main') || document.body;
- anchor.parentNode.insertBefore(b, anchor);
+ if (!h || h.youtube !== 'blocked') return;
+ var show = function () {
+ if (!window.OpenVibeUI || !OpenVibeUI.notice) return false;
+ OpenVibeUI.notice({ id: 'yt-upstream', type: 'warning', title: 'YouTube is refusing our server right now.',
+ message: 'Downloads will fail until that clears. It is on YouTube\'s side, and we re-check every 30 minutes.',
+ links: [{ label: 'Audio tools', href: 'https://openvibe.tools/audio-tools' }, { label: 'Every other tool', href: 'https://openvibe.tools/' }] });
+ return true;
+ };
+ var tries = 0; (function wait() { if (!show() && ++tries < 30) setTimeout(wait, 200); })();
}).catch(function () {});
}).catch(function () {});
})();
})();
@@ -499,7 +499,9 @@
@@ -499,7 +499,9 @@
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' };
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 titleCase(w) { return w ? w.charAt(0).toUpperCase() + w.slice(1) : ''; }
- function subLabel(sub) { return SUB_LABELS[sub] || titleCase(sub); }
+ // Descriptive hosts ('youtube-downloader') are for search engines; the brand shows the name people use.
+ const LONG_SUBS = { 'youtube-downloader': 'YT', youtubedownloader: 'YT', 'youtube-download': 'YT', youtube: 'YT', ytdl: 'YT' };
+ function subLabel(sub) { return SUB_LABELS[sub] || SUB_LABELS[LONG_SUBS[sub] && LONG_SUBS[sub].toLowerCase()] || LONG_SUBS[sub] || titleCase(String(sub).replace(/-/g, ' ')).replace(/ /g, ''); }
/**
/**
* { sub, core, tld, name, short, icon, variant } for the current page.
* { sub, core, tld, name, short, icon, variant } for the current page.
@@ -5,6 +5,9 @@
@@ -5,6 +5,9 @@
* OpenVibeUI.toast('Upload failed', { type: 'error', title: 'Images', action: { label: 'Retry', onClick } })
* 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 })) …
* if (await OpenVibeUI.confirm({ title: 'Delete paste?', message: 'This cannot be undone.', danger: true })) …
* await OpenVibeUI.alert({ title: 'Heads up', message: '…' })
* await OpenVibeUI.alert({ title: 'Heads up', message: '…' })
+ * OpenVibeUI.notice({ id: 'yt-upstream', type: 'warning', title: '…', message: '…', links: [{ label, href }], dismissible: true })
+ * a page-level notice that always sits directly BELOW the site's navbar (never above it), one
+ * design for service status, maintenance, read-only mode and similar. Returns { close, update }.
*
*
* Themed from the site's CSS variables, stacked bottom-right (bottom-centre on phones), announced
* 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,
* to assistive tech (role=status, role=alert for errors), pause-on-hover, Escape closes dialogs,
@@ -13,7 +16,7 @@
@@ -13,7 +16,7 @@
(function (root) {
(function (root) {
'use strict';
'use strict';
if (root.OpenVibeUI) return;
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; }
+ if (typeof document === 'undefined') { const noop = { toast() { return { close() {} }; }, notice() { return { close() {}, update() {} }; }, confirm: async () => false, alert: async () => {} }; if (typeof module !== 'undefined') module.exports = noop; return; }
const CSS = `
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-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}
@@ -41,6 +44,14 @@
@@ -41,6 +44,14 @@
.ovui-btn.is-primary{background:var(--accent,#3b82f6);border-color:transparent;color:var(--on-accent,#fff)}
.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.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}
.ovui-btn:focus-visible,.ovui-x:focus-visible,.ovui-act:focus-visible{outline:2px solid var(--accent,#3b82f6);outline-offset:2px}
+.ovui-notices{display:flex;flex-direction:column;gap:8px;max-width:1080px;margin:14px auto 0;padding:0 16px;box-sizing:border-box;width:100%}
+.ovui-notices:empty{display:none}
+.ovui-notice{--ovui-c:var(--accent,#3b82f6);display:grid;grid-template-columns:auto 1fr auto;gap:12px;align-items:start;padding:12px 14px;border-radius:14px;border:1px solid color-mix(in srgb,var(--ovui-c) 40%,transparent);background:color-mix(in srgb,var(--ovui-c) 9%,var(--bg-secondary,#111826));color:var(--text-primary,#e6edf7);font:400 14px/1.5 system-ui,-apple-system,Segoe UI,Roboto,sans-serif;animation:ovuiIn .28s cubic-bezier(.2,1.2,.3,1)}
+.ovui-notice[data-type=success]{--ovui-c:var(--success,#22c55e)}.ovui-notice[data-type=error]{--ovui-c:var(--danger,#ef4444)}.ovui-notice[data-type=warning]{--ovui-c:var(--warning,#f59e0b)}
+.ovui-notice-ic{width:22px;height:22px;border-radius:50%;display:grid;place-items:center;background:var(--ovui-c);color:#0b1220;font:800 13px/1 system-ui,sans-serif;margin-top:1px}
+.ovui-notice b{font-weight:700}.ovui-notice-msg{color:var(--text-secondary,#a8b3c4)}
+.ovui-notice-links{display:flex;flex-wrap:wrap;gap:6px 14px;margin-top:6px}.ovui-notice-links a{color:var(--accent-light,var(--accent,#60a5fa));font-weight:600;text-decoration:none}.ovui-notice-links a:hover{text-decoration:underline}
+@media (max-width:560px){.ovui-notices{margin-top:10px;padding:0 12px}}
@keyframes ovuiIn{from{opacity:0;transform:translateY(10px) scale(.97)}to{opacity:1;transform:none}}
@keyframes ovuiIn{from{opacity:0;transform:translateY(10px) scale(.97)}to{opacity:1;transform:none}}
@keyframes ovuiOut{to{opacity:0;transform:translateX(16px)}}
@keyframes ovuiOut{to{opacity:0;transform:translateX(16px)}}
@keyframes ovuiFade{from{opacity:0}to{opacity:1}}
@keyframes ovuiFade{from{opacity:0}to{opacity:1}}
@@ -111,8 +122,48 @@
@@ -111,8 +122,48 @@
});
});
}
}
+ // ── Page notices ─────────────────────────────────────────
+ const notices = new Map();
+ let noticeHost = null;
+ function placeNoticeHost() {
+ if (!noticeHost) { noticeHost = el('div', 'ovui-notices'); noticeHost.setAttribute('aria-live', 'polite'); }
+ // Directly after the navbar, whichever flavour the site uses; otherwise at the top of the content.
+ const nav = document.querySelector('nav.openvibe-navbar, #navbar-mount, .navbar, header[role="banner"]');
+ const anchor = nav ? (nav.id === 'navbar-mount' || nav.parentNode === document.body ? nav : nav) : null;
+ if (anchor && anchor.parentNode) { if (anchor.nextSibling !== noticeHost) anchor.parentNode.insertBefore(noticeHost, anchor.nextSibling); return true; }
+ const main = document.querySelector('main') || document.body.firstElementChild;
+ if (!noticeHost.isConnected && main && main.parentNode) main.parentNode.insertBefore(noticeHost, main);
+ return false;
+ }
+ function notice(o) {
+ o = o || {}; css();
+ const id = String(o.id || 'n' + Date.now());
+ try { if (o.dismissible !== false && sessionStorage.getItem('ovui_notice_' + id) === '1') return { close() {}, update() {} }; } catch { /* */ }
+ if (notices.has(id)) notices.get(id).close(true);
+ const type = ['info', 'success', 'error', 'warning'].includes(o.type) ? o.type : 'info';
+ const n = el('div', 'ovui-notice'); n.dataset.type = type; n.setAttribute('role', type === 'error' ? 'alert' : 'status');
+ n.appendChild(el('span', 'ovui-notice-ic', type === 'success' ? '✓' : type === 'info' ? 'i' : '!'));
+ const body = el('div');
+ const fill = (p) => {
+ body.replaceChildren();
+ const line = el('div'); if (p.title) { line.appendChild(el('b', null, p.title)); line.appendChild(document.createTextNode(' ')); }
+ if (p.message) line.appendChild(el('span', 'ovui-notice-msg', p.message)); body.appendChild(line);
+ const links = (p.links || []).map((l) => { const h = l && l.label && safeHref(l.href); if (!h) return null; const a = el('a', null, l.label); a.href = h; return a; }).filter(Boolean);
+ if (links.length) { const row = el('div', 'ovui-notice-links'); links.forEach((a) => row.appendChild(a)); body.appendChild(row); }
+ };
+ fill(o); n.appendChild(body);
+ const close = (silent) => { n.remove(); notices.delete(id); if (!silent && o.dismissible !== false) { try { sessionStorage.setItem('ovui_notice_' + id, '1'); } catch { /* */ } } };
+ if (o.dismissible !== false) { const x = el('button', 'ovui-x', '×'); x.type = 'button'; x.setAttribute('aria-label', 'Dismiss'); x.addEventListener('click', () => close(false)); n.appendChild(x); }
+ // The navbar may mount after us: keep trying briefly until the notice sits under it.
+ let tries = 0; const settle = () => { const ok = placeNoticeHost(); if (!ok && ++tries < 40) setTimeout(settle, 150); }; settle();
+ noticeHost.appendChild(n);
+ const handle = { close: () => close(true), update: (p) => fill(Object.assign({}, o, p)), el: n };
+ notices.set(id, handle);
+ return handle;
+ }
+
const api = {
const api = {
- toast,
+ toast, notice,
success: (m, o) => toast(m, Object.assign({}, o, { type: 'success' })),
success: (m, o) => toast(m, Object.assign({}, o, { type: 'success' })),
error: (m, o) => toast(m, Object.assign({}, o, { type: 'error' })),
error: (m, o) => toast(m, Object.assign({}, o, { type: 'error' })),
warning: (m, o) => toast(m, Object.assign({}, o, { type: 'warning' })),
warning: (m, o) => toast(m, Object.assign({}, o, { type: 'warning' })),