openvibers/openvibe.live Commits
Search page (WS-O task 10: product search boxes use the query API): /search finds Live's channels, VODs and clips through OpenVibe.Search (GET /api/search asks anonymously for owner=live at Search's internal origin and returns only public fields; 503 when Search does not answer), with type chips, More paging and the query in the URL; results built as DOM nodes, snippets keep only <mark>; Search in the navbar; a known page, noindex; a link to search the whole network
Changes
nnew file mode 100644@@@ -0,0 +1,24 @@++/* The /search page (public/js/app-search.js, fragment search.html). Theme tokens only. */++.ls-wrap [hidden] { display: none !important; }++.ls-wrap { max-width: 860px; margin: 0 auto; padding: 20px 16px 48px; }++.ls-h1 { margin: 0; font-size: clamp(1.5rem, 1.2rem + 1.2vw, 2rem); font-weight: 800; letter-spacing: -0.02em; display: flex; align-items: center; gap: 10px; }++.ls-sub { margin: 6px 0 14px; color: var(--text-secondary); font-size: 0.92rem; }++.ls-network { white-space: nowrap; }++.ls-form { display: flex; flex-direction: column; gap: 10px; margin-bottom: 12px; }++.ls-box { display: flex; align-items: center; gap: 10px; padding: 0 14px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--bg-secondary); color: var(--text-muted); }++.ls-box:focus-within { border-color: var(--accent); }++.ls-box input { flex: 1; min-width: 0; padding: 12px 0; border: 0; outline: 0; background: transparent; color: var(--text-primary); font-size: 1rem; }++.ls-types { display: flex; flex-wrap: wrap; gap: 8px; }++.ls-type { padding: 6px 12px; border-radius: 999px; border: 1px solid var(--border); background: transparent; color: var(--text-secondary); font: inherit; font-size: 0.85rem; cursor: pointer; }++.ls-type[aria-pressed="true"] { border-color: var(--accent); color: var(--text-primary); background: var(--bg-tertiary); }++.ls-status { color: var(--text-muted); font-size: 0.9rem; min-height: 1.2em; margin: 4px 0 10px; }++.ls-results { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 12px; }++.ls-hit { padding: 12px 14px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--bg-card); min-width: 0; }++.ls-title { font-weight: 700; font-size: 1.02rem; color: var(--text-primary); text-decoration: none; overflow-wrap: anywhere; }++.ls-title:hover { color: var(--accent); }++.ls-meta { display: flex; flex-wrap: wrap; gap: 6px 10px; align-items: center; margin: 4px 0; font-size: 0.8rem; color: var(--text-muted); }++.ls-kind { display: inline-flex; align-items: center; gap: 5px; padding: 1px 8px; border-radius: 999px; border: 1px solid var(--border); color: var(--text-secondary); }++.ls-live { color: var(--live-red); font-weight: 700; }++.ls-snippet { margin: 4px 0 0; color: var(--text-secondary); font-size: 0.9rem; overflow-wrap: anywhere; }++.ls-snippet mark { background: var(--bg-tertiary); color: var(--text-primary); border-radius: 3px; padding: 0 2px; box-shadow: inset 0 -2px 0 var(--accent); }++.ls-more-wrap { display: flex; justify-content: center; margin-top: 16px; }@@@ -283,6 +283,15 @@"js": ["/js/home-featured.js"]++ },++ "search": {++ "fragment": "search",++ "css": [++ "/css/features/search.css"++ ],++ "js": [++ "/js/app-search.js"++ ]}},"routes": [@@@ -304,6 +313,12 @@"moments"]},++ {++ "path": "^/search/?$",++ "features": [++ "search"++ ]++ },{"path": "^/vod/[^/]+","features": [nnew file mode 100644@@@ -0,0 +1,18 @@++<div class="ls-wrap">++ <header class="ls-head">++ <h1 class="ls-h1"><i class="fa-solid fa-magnifying-glass" aria-hidden="true"></i> Search</h1>++ <p class="ls-sub">Channels, VODs and clips on OpenVibe.Live. <a class="ls-network" href="https://search.openvibe.network/">Search the whole OpenVibe network</a></p>++ </header>++ <form class="ls-form" role="search" action="/search" method="get">++ <label class="ls-box"><i class="fa-solid fa-magnifying-glass" aria-hidden="true"></i><input type="search" name="q" maxlength="200" placeholder="Search channels, VODs and clips" aria-label="Search channels, VODs and clips" autocomplete="off"></label>++ <div class="ls-types" role="group" aria-label="Show">++ <button type="button" class="ls-type" data-ls-type="" aria-pressed="true">All</button>++ <button type="button" class="ls-type" data-ls-type="channel" aria-pressed="false">Channels</button>++ <button type="button" class="ls-type" data-ls-type="vod" aria-pressed="false">VODs</button>++ <button type="button" class="ls-type" data-ls-type="clip" aria-pressed="false">Clips</button>++ </div>++ </form>++ <p class="ls-status" role="status" aria-live="polite"></p>++ <ol class="ls-results"></ol>++ <div class="ls-more-wrap"><button type="button" class="btn btn-outline ls-more" hidden>More results</button></div>++</div>@@@ -680,6 +680,7 @@<!-- ── CONTENT (/content; /vods /clips /pastes are it with a filter) and AI MOMENTS (/moments) ── --><section id="page-content" class="page" data-fragment="content"><!--ov:fragment:content--><div class="ov-route-skel" aria-hidden="true"><span></span><span></span><span></span></div><!--/ov:fragment--></section><section id="page-moments" class="page" data-fragment="moments"><!--ov:fragment:moments--><div class="ov-route-skel" aria-hidden="true"><span></span><span></span><span></span></div><!--/ov:fragment--></section>++ <section id="page-search" class="page" data-fragment="search"><!--ov:fragment:search--><div class="ov-route-skel" aria-hidden="true"><span></span><span></span><span></span></div><!--/ov:fragment--></section><!-- ── CHANNEL PAGE (/:username) ─────────────────────── --><section id="page-channel" class="page" data-fragment="channel"><!--ov:fragment:channel--><div class="ov-route-skel" aria-hidden="true"><span></span><span></span><span></span></div><!--/ov:fragment--></section>nnew file mode 100644@@@ -0,0 +1,125 @@++/**++ * The /search page (fragment search.html): Live's channels, VODs and clips through OpenVibe.Search++ * (GET /api/search, server/search/routes.js). The query lives in the URL (?q=&type=), so a search can++ * be linked and Back works. Results are built as DOM nodes; a snippet keeps Search's <mark> and++ * nothing else.++ */++(function () {++ 'use strict';++ const KIND = { channel: ['fa-tower-broadcast', 'Channel'], vod: ['fa-film', 'VOD'], clip: ['fa-scissors', 'Clip'] };++ let seq = 0;++++ const el = (tag, cls, text) => { const n = document.createElement(tag); if (cls) n.className = cls; if (text != null) n.textContent = text; return n; };++++ // Only text and <mark> survive: Search escapes the text and adds <mark>, but nothing else is trusted.++ function snippetNode(html, fallback) {++ const p = el('p', 'ls-snippet');++ if (!html) { p.textContent = fallback || ''; return p; }++ const doc = new DOMParser().parseFromString(`<body>${html}</body>`, 'text/html');++ for (const n of doc.body.childNodes) {++ if (n.nodeType === 3) p.append(n.textContent);++ else if (n.nodeName === 'MARK') p.append(el('mark', null, n.textContent));++ else p.append(n.textContent || '');++ }++ return p;++ }++++ function localPath(url) {++ try { const u = new URL(url); return u.origin === location.origin || u.hostname === 'openvibe.live' ? u.pathname + u.search : url; } catch { return '#'; }++ }++++ function hit(r) {++ const li = el('li', 'ls-hit');++ const href = localPath(r.canonical_url);++ const a = el('a', 'ls-title', r.title || 'Untitled');++ a.href = href;++ if (href.startsWith('/')) a.addEventListener('click', (e) => { if (typeof handleLinkClick === 'function') handleLinkClick(e, href); });++ const meta = el('div', 'ls-meta');++ const [icon, label] = KIND[r.type] || ['fa-circle', r.type];++ const kind = el('span', 'ls-kind');++ const i = el('i', `fa-solid ${icon}`); i.setAttribute('aria-hidden', 'true');++ kind.append(i, ` ${label}`);++ meta.append(kind);++ const f = r.facets || {};++ if (r.type === 'channel' && f.live) meta.append(el('span', 'ls-live', 'LIVE'));++ if (r.type !== 'channel' && f.channel) meta.append(el('span', null, `@${f.channel}`));++ if (f.category) meta.append(el('span', null, f.category));++ if (r.type === 'channel' && Number.isFinite(f.followers)) meta.append(el('span', null, `${f.followers} follower${f.followers === 1 ? '' : 's'}`));++ if (r.authorship === 'ai_generated') meta.append(el('span', 'ls-kind', 'AI clip'));++ li.append(a, meta, snippetNode(r.snippet_html, r.summary));++ return li;++ }++++ function readUrl() {++ const sp = new URLSearchParams(location.search);++ const type = ['channel', 'vod', 'clip'].includes(sp.get('type')) ? sp.get('type') : '';++ return { q: (sp.get('q') || '').trim().slice(0, 200), type };++ }++++ async function run(root, { q, type }, cursor) {++ const status = root.querySelector('.ls-status');++ const list = root.querySelector('.ls-results');++ const more = root.querySelector('.ls-more');++ const mine = ++seq;++ if (!cursor) list.replaceChildren();++ more.hidden = true;++ if (!q) { status.textContent = 'Type a streamer, a game or something said on stream.'; return; }++ status.textContent = 'Searching…';++ const params = new URLSearchParams({ q });++ if (type) params.set('type', type);++ if (cursor) params.set('cursor', cursor);++ let out;++ try {++ const res = await fetch(`/api/search?${params}`, { headers: { accept: 'application/json' } });++ out = await res.json().catch(() => ({}));++ if (!res.ok) throw new Error(out.error || 'Search is not answering right now');++ } catch (err) {++ if (mine === seq) status.textContent = `${err.message}. Try again in a minute.`;++ return;++ }++ if (mine !== seq) return;++ for (const r of out.results || []) list.append(hit(r));++ const n = list.children.length;++ status.textContent = n ? `${n}${out.next_cursor ? '+' : ''} result${n === 1 ? '' : 's'} for “${q}”` : `Nothing public matches “${q}”.`;++ if (out.next_cursor) { more.hidden = false; more.onclick = () => run(root, { q, type }, out.next_cursor); }++ }++++ function setUrl(state) {++ const sp = new URLSearchParams();++ if (state.q) sp.set('q', state.q);++ if (state.type) sp.set('type', state.type);++ const url = `/search${sp.toString() ? `?${sp}` : ''}`;++ if (url !== location.pathname + location.search) history.pushState({}, '', url);++ }++++ window.loadSearchPage = function loadSearchPage() {++ const root = document.querySelector('#page-search .ls-wrap');++ if (!root) return;++ const state = readUrl();++ const form = root.querySelector('.ls-form');++ const input = form.querySelector('input[name="q"]');++ input.value = state.q;++ for (const b of root.querySelectorAll('.ls-type')) b.setAttribute('aria-pressed', String(b.dataset.lsType === state.type));++ if (!form.dataset.bound) {++ form.dataset.bound = '1';++ form.addEventListener('submit', (e) => {++ e.preventDefault();++ const next = { q: input.value.trim().slice(0, 200), type: readUrl().type };++ setUrl(next);++ run(root, next);++ });++ for (const b of root.querySelectorAll('.ls-type')) {++ b.addEventListener('click', () => {++ const next = { q: input.value.trim().slice(0, 200), type: b.dataset.lsType };++ for (const o of root.querySelectorAll('.ls-type')) o.setAttribute('aria-pressed', String(o === b));++ setUrl(next);++ run(root, next);++ });++ }++ }++ if (typeof setPageTitle === 'function') setPageTitle(state.q ? `Search: ${state.q}` : 'Search');++ else document.title = `${state.q ? `Search: ${state.q}` : 'Search'} — OpenVibe.Live`;++ run(root, state);++ if (!state.q) input.focus();++ };++})();@@@ -55,7 +55,7 @@ function isStaffUser(user = currentUser) {}// Reserved paths (not usernames)--const RESERVED = new Set(['content', 'moments', 'vods', 'clips', 'vod', 'clip', 'dashboard', 'settings', 'broadcast', 'admin', 'themes', 'game', 'canvas', 'chat', 'api', 'ws', 'media', 'pastes', 'p', 'updates', 'dmca', 'tos', 'terms', 'arena', 'recap']);++const RESERVED = new Set(['content', 'moments', 'search', 'vods', 'clips', 'vod', 'clip', 'dashboard', 'settings', 'broadcast', 'admin', 'themes', 'game', 'canvas', 'chat', 'api', 'ws', 'media', 'pastes', 'p', 'updates', 'dmca', 'tos', 'terms', 'arena', 'recap']);const CHANNEL_USERNAME_RE = /^[a-zA-Z0-9_]{3,24}$/;function normalizeChannelUsername(username) {@@@ -1073,6 +1073,10 @@ function routeFromURL() {// What the AI made: auto-clips, AI moments, AI recaps.showPage('moments');whenRouteReady('moments', () => loadMomentsPage());++ } else if (segments[0] === 'search') {++ // Channels, VODs and clips through OpenVibe.Search (public/js/app-search.js).++ showPage('search');++ whenRouteReady('search', () => loadSearchPage());} else if (segments[0] === 'vod' && segments[1]) {// VOD player: /vod/:id (optional ?t=<seconds> to auto-seek, e.g. from a clip link)showPage('vod-player');@@@ -1348,7 +1352,7 @@ function showPage(page) {}// Highlight nav link-- const pageToNav = { home: 'home', content: 'content', moments: 'moments', broadcast: 'broadcast', dashboard: 'dashboard', admin: 'admin', chat: 'chat', game: 'game', canvas: 'game', 'paste-viewer': 'content', arena: 'arena' };++ const pageToNav = { home: 'home', content: 'content', moments: 'moments', search: 'search', broadcast: 'broadcast', dashboard: 'dashboard', admin: 'admin', chat: 'chat', game: 'game', canvas: 'game', 'paste-viewer': 'content', arena: 'arena' };const navPage = pageToNav[page];if (navPage) {const link = document.querySelector(`.nav-link[data-page="${navPage}"]`);@@@ -23,6 +23,7 @@// VODs, clips and pastes people made are one feed; what the AI made is the other.{ id: 'content', page: 'content', label: 'Content', href: '/content', icon: 'fa-photo-film' },{ id: 'moments', page: 'moments', label: 'Moments', href: '/moments', icon: 'fa-wand-magic-sparkles' },++ { id: 'search', page: 'search', label: 'Search', href: '/search', icon: 'fa-magnifying-glass' },{ id: 'chat', page: 'chat', label: 'Chat', href: '/chat', icon: 'fa-comments' },{ id: 'arena', page: 'arena', label: 'Arena', href: '/arena', icon: 'fa-hand-fist' },{ id: 'game', page: 'game', label: 'Game', href: 'https://openvibe.games', icon: 'fa-gamepad' },@@@ -706,6 +706,7 @@ app.use('/api/game', (req, res) => res.status(410).json({ error: 'Game has movedapp.use('/api/meta', metaRoutes);app.use('/api/pastes', pasteRoutes);app.use('/api/content', require('./content/routes')); // Content (people's work) + Moments (AI) feeds++app.use('/api/search', require('./search/routes').createSearchRouter()); // /search page → OpenVibe.Search (Live's channels, VODs, clips)app.use('/api/home', require('./home/routes'));app.use('/api/i18n', require('./i18n/routes')); // on-demand translation for viewers (any line → your language)app.use('/api/kiosk', require('./kiosk/routes'));nnew file mode 100644@@@ -0,0 +1,44 @@++'use strict';++/**++ * GET /api/search?q=&type=&cursor= (roadmap WS-O task 10: product search boxes use the query API)++ *++ * Live's /search page asks OpenVibe.Search for Live's own documents (channels, VODs, clips:++ * server/events/search-documents.js and search-media-documents.js), anonymously at Search's internal++ * origin, so only public, published, indexable documents come back. → { results, next_cursor };++ * 400 for a malformed query; 503 { error } when Search does not answer (the page says so).++ */++const express = require('express');++++const SEARCH_URL = (process.env.OV_SEARCH_INTERNAL_URL || 'http://127.0.0.1:4710').replace(/\/+$/, '');++const TYPES = new Set(['channel', 'vod', 'clip']);++const FIELDS = ['type', 'id', 'title', 'summary', 'canonical_url', 'facets', 'snippet_html', 'authorship', 'published_at', 'updated_at'];++++function createSearchRouter({ fetchImpl = globalThis.fetch, baseUrl = SEARCH_URL, timeoutMs = 4000 } = {}) {++ const router = express.Router();++ router.get('/', async (req, res) => {++ const q = String(req.query.q || '').trim().slice(0, 200);++ if (!q) return res.json({ results: [], next_cursor: null });++ const params = new URLSearchParams({ q, owner: 'live', limit: '24' });++ if (TYPES.has(req.query.type)) params.set('type', req.query.type);++ const cursor = String(req.query.cursor || '');++ if (/^[A-Za-z0-9_-]{1,512}$/.test(cursor)) params.set('cursor', cursor);++ let r;++ try {++ r = await fetchImpl(`${baseUrl}/api/v1/search?${params}`, { headers: { accept: 'application/json' }, signal: AbortSignal.timeout(timeoutMs) });++ } catch (err) {++ console.warn('[Search] /api/search:', err.message);++ return res.status(503).json({ error: 'Search is not answering right now' });++ }++ const body = await r.json().catch(() => null);++ if (r.status === 400) return res.status(400).json({ error: 'That search could not be read' });++ if (!r.ok || !body || !Array.isArray(body.results)) return res.status(503).json({ error: 'Search is not answering right now' });++ res.set('Cache-Control', 'private, max-age=30');++ res.json({++ results: body.results.map((d) => Object.fromEntries(FIELDS.filter((k) => d[k] !== undefined).map((k) => [k, d[k]]))),++ next_cursor: body.next_cursor || null,++ });++ });++ return router;++}++++module.exports = { createSearchRouter };@@@ -228,6 +228,8 @@ async function _pageMeta(routePath, { page = 1 } = {}) {if ((m = p.match(CHANNEL_PATH_RE))) return _channelMeta(m[1], page);if ((m = p.match(/^\/recap\/(\d+)$/))) return _recapMeta(parseInt(m[1], 10));if (p === '/arena') return _arenaMeta();++ // Search results (public/js/app-search.js) are never indexed; the page itself is findable.++ if (p === '/search') return { title: `Search | ${SITE_NAME}`, description: 'Search channels, VODs and clips on OpenVibe.Live.', canonicalPath: '/search', ogType: 'website', robots: 'noindex,follow', jsonLd: [] };return null;void bu;@@@ -14,6 +14,7 @@** / home* /content /moments the feeds (people's work, AI's)++ * /search Live's channels, VODs and clips through OpenVibe.Search* /vods /clips /pastes /updates /documentation exact path (the first three are /content filtered)* /settings /admin /themes exact path (the client redirects them)* /dashboard/… /broadcast/… /chat/… /arena/… any depth: these pages route sub-paths themselves@@@ -41,7 +42,7 @@ const OK = 200;const NOT_FOUND = 404;// Pages that exist only at their exact path.--const EXACT = new Set(['content', 'moments', 'vods', 'clips', 'pastes', 'updates', 'documentation', 'settings', 'admin', 'themes']);++const EXACT = new Set(['content', 'moments', 'vods', 'clips', 'pastes', 'updates', 'documentation', 'settings', 'admin', 'themes', 'search']);// Pages that route their own sub-paths (dashboard tabs, broadcast slots, chat rooms, arena fighters).const PREFIX = new Set(['dashboard', 'broadcast', 'chat', 'arena']);@@@ -138,7 +138,7 @@ server.listen(0, '127.0.0.1', async () => {console.log('Page status (soft 404s)');await check('every real route answers 200', () => expectAll([-- '/', '/content', '/content/', '/moments', '/vods', '/vods/', '/clips', '/pastes', '/updates', '/documentation', '/settings', '/admin', '/themes',++ '/', '/content', '/content/', '/moments', '/vods', '/vods/', '/clips', '/pastes', '/updates', '/documentation', '/settings', '/admin', '/themes', '/search','/dashboard', '/dashboard/anything', '/broadcast', '/broadcast/slot-2', '/chat', '/chat/voice/lobby','/arena', '/arena/beef/4', '/arena/alice','/@alice', '/@Alice', '/@alice/', '/@alice/main', '/@bob',@@@ -147,7 +147,7 @@ server.listen(0, '127.0.0.1', async () => {], 200));await check('unknown paths answer 404', () => expectAll([-- '/nope', '/search', '/login', '/wp-login.php', '/favicon-missing.ico', '/Vods', '/vods/extra', '/updates/1', '/content/vods', '/moments/1',++ '/nope', '/find', '/login', '/wp-login.php', '/favicon-missing.ico', '/Vods', '/vods/extra', '/updates/1', '/content/vods', '/moments/1','/@nobody', '/@ab', '/@alice/main/extra', '/@bad.name', '/nobody', '/nobody/main', '/alice/main/extra', '/alice/bad!slot','/vod', '/vod/999', '/vod/abc', '/vod/100/extra', '/clip/999', '/clip/-1','/p/missing', '/p/bad!slug', '/p/abc123/extra',nnew file mode 100644@@@ -0,0 +1,60 @@++'use strict';++// Live's /search page (WS-O task 10: product search boxes use the query API): GET /api/search asks++// OpenVibe.Search anonymously for owner=live only, passes a known type and a well-formed cursor, returns++// only the public fields, and answers 503 when Search does not; an empty query asks nothing. /search is++// a known page (not the SPA 404) and loads the search feature.++const assert = require('assert');++const http = require('http');++const express = require('express');++const { createSearchRouter } = require('../server/search/routes');++++(async () => {++ const asked = [];++ let mode = 'ok';++ const fetchImpl = async (url, opts) => {++ asked.push({ url: new URL(url), opts });++ if (mode === 'down') throw new Error('connect ECONNREFUSED');++ if (mode === 'bad') return new Response('{"code":"search.bad_query"}', { status: 400 });++ if (mode === 'error') return new Response('{}', { status: 500 });++ return new Response(JSON.stringify({ results: [{ owner: 'live', type: 'vod', id: '912', title: 'Building a forum', canonical_url: 'https://openvibe.live/vod/912', facets: { channel: 'alex' }, snippet_html: '<mark>forum</mark>', acl: ['secret'], revision: 3 }], next_cursor: 'c1' }), { status: 200 });++ };++ const app = express();++ app.use('/api/search', createSearchRouter({ fetchImpl, baseUrl: 'http://search.test' }));++ const srv = await new Promise((r) => { const s = app.listen(0, '127.0.0.1', () => r(s)); });++ const get = async (p) => { const res = await fetch(`http://127.0.0.1:${srv.address().port}${p}`); return { status: res.status, body: await res.json() }; };++++ let r = await get('/api/search?q=forum&type=vod&cursor=abc_1');++ assert.strictEqual(r.status, 200);++ const u = asked[0].url;++ assert.strictEqual(u.origin + u.pathname, 'http://search.test/api/v1/search');++ assert.strictEqual(u.searchParams.get('owner'), 'live');++ assert.strictEqual(u.searchParams.get('type'), 'vod');++ assert.strictEqual(u.searchParams.get('cursor'), 'abc_1');++ assert.ok(!asked[0].opts.headers.authorization, 'anonymous: public documents only');++ assert.deepStrictEqual(r.body, { results: [{ type: 'vod', id: '912', title: 'Building a forum', canonical_url: 'https://openvibe.live/vod/912', facets: { channel: 'alex' }, snippet_html: '<mark>forum</mark>' }], next_cursor: 'c1' });++++ await get('/api/search?q=forum&type=paste&cursor=bad%20cursor');++ assert.strictEqual(asked[1].url.searchParams.get('type'), null, 'only Live\'s types');++ assert.strictEqual(asked[1].url.searchParams.get('cursor'), null);++++ r = await get('/api/search?q=');++ assert.deepStrictEqual(r.body, { results: [], next_cursor: null });++ assert.strictEqual(asked.length, 2, 'an empty query asks nothing');++++ mode = 'down'; r = await get('/api/search?q=forum');++ assert.strictEqual(r.status, 503); assert.ok(/not answering/.test(r.body.error));++ mode = 'error'; assert.strictEqual((await get('/api/search?q=forum')).status, 503);++ mode = 'bad'; assert.strictEqual((await get('/api/search?q=forum')).status, 400);++ srv.close();++++ const features = require('../public/features.json');++ const route = features.routes.find((x) => new RegExp(x.path).test('/search'));++ assert.deepStrictEqual(route && route.features, ['search']);++ assert.strictEqual(features.features.search.fragment, 'search');++ const fs = require('fs');++ assert.ok(fs.existsSync(require('path').join(__dirname, '../public/fragments/search.html')));++ const src = fs.readFileSync(require('path').join(__dirname, '../server/web/page-status.js'), 'utf8');++ assert.ok(/EXACT = new Set\([^)]*'search'/.test(src), '/search is a known page');++++ console.log('search page: all checks passed');++})().catch((err) => { console.error(err); process.exit(1); });
new file mode 100644
new file mode 100644
@@ -0,0 +1,24 @@
@@ -0,0 +1,24 @@
+/* The /search page (public/js/app-search.js, fragment search.html). Theme tokens only. */
+.ls-wrap [hidden] { display: none !important; }
+.ls-wrap { max-width: 860px; margin: 0 auto; padding: 20px 16px 48px; }
+.ls-h1 { margin: 0; font-size: clamp(1.5rem, 1.2rem + 1.2vw, 2rem); font-weight: 800; letter-spacing: -0.02em; display: flex; align-items: center; gap: 10px; }
+.ls-sub { margin: 6px 0 14px; color: var(--text-secondary); font-size: 0.92rem; }
+.ls-network { white-space: nowrap; }
+.ls-form { display: flex; flex-direction: column; gap: 10px; margin-bottom: 12px; }
+.ls-box { display: flex; align-items: center; gap: 10px; padding: 0 14px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--bg-secondary); color: var(--text-muted); }
+.ls-box:focus-within { border-color: var(--accent); }
+.ls-box input { flex: 1; min-width: 0; padding: 12px 0; border: 0; outline: 0; background: transparent; color: var(--text-primary); font-size: 1rem; }
+.ls-types { display: flex; flex-wrap: wrap; gap: 8px; }
+.ls-type { padding: 6px 12px; border-radius: 999px; border: 1px solid var(--border); background: transparent; color: var(--text-secondary); font: inherit; font-size: 0.85rem; cursor: pointer; }
+.ls-type[aria-pressed="true"] { border-color: var(--accent); color: var(--text-primary); background: var(--bg-tertiary); }
+.ls-status { color: var(--text-muted); font-size: 0.9rem; min-height: 1.2em; margin: 4px 0 10px; }
+.ls-results { list-style: none; margin: 0; padding: 0; display: flex; flex-direction: column; gap: 12px; }
+.ls-hit { padding: 12px 14px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--bg-card); min-width: 0; }
+.ls-title { font-weight: 700; font-size: 1.02rem; color: var(--text-primary); text-decoration: none; overflow-wrap: anywhere; }
+.ls-title:hover { color: var(--accent); }
+.ls-meta { display: flex; flex-wrap: wrap; gap: 6px 10px; align-items: center; margin: 4px 0; font-size: 0.8rem; color: var(--text-muted); }
+.ls-kind { display: inline-flex; align-items: center; gap: 5px; padding: 1px 8px; border-radius: 999px; border: 1px solid var(--border); color: var(--text-secondary); }
+.ls-live { color: var(--live-red); font-weight: 700; }
+.ls-snippet { margin: 4px 0 0; color: var(--text-secondary); font-size: 0.9rem; overflow-wrap: anywhere; }
+.ls-snippet mark { background: var(--bg-tertiary); color: var(--text-primary); border-radius: 3px; padding: 0 2px; box-shadow: inset 0 -2px 0 var(--accent); }
+.ls-more-wrap { display: flex; justify-content: center; margin-top: 16px; }
@@ -283,6 +283,15 @@
@@ -283,6 +283,15 @@
"js": [
"js": [
"/js/home-featured.js"
"/js/home-featured.js"
]
]
+ },
+ "search": {
+ "fragment": "search",
+ "css": [
+ "/css/features/search.css"
+ ],
+ "js": [
+ "/js/app-search.js"
+ ]
}
}
},
},
"routes": [
"routes": [
@@ -304,6 +313,12 @@
@@ -304,6 +313,12 @@
"moments"
"moments"
]
]
},
},
+ {
+ "path": "^/search/?$",
+ "features": [
+ "search"
+ ]
+ },
{
{
"path": "^/vod/[^/]+",
"path": "^/vod/[^/]+",
"features": [
"features": [
new file mode 100644
new file mode 100644
@@ -0,0 +1,18 @@
@@ -0,0 +1,18 @@
+<div class="ls-wrap">
+ <header class="ls-head">
+ <h1 class="ls-h1"><i class="fa-solid fa-magnifying-glass" aria-hidden="true"></i> Search</h1>
+ <p class="ls-sub">Channels, VODs and clips on OpenVibe.Live. <a class="ls-network" href="https://search.openvibe.network/">Search the whole OpenVibe network</a></p>
+ </header>
+ <form class="ls-form" role="search" action="/search" method="get">
+ <label class="ls-box"><i class="fa-solid fa-magnifying-glass" aria-hidden="true"></i><input type="search" name="q" maxlength="200" placeholder="Search channels, VODs and clips" aria-label="Search channels, VODs and clips" autocomplete="off"></label>
+ <div class="ls-types" role="group" aria-label="Show">
+ <button type="button" class="ls-type" data-ls-type="" aria-pressed="true">All</button>
+ <button type="button" class="ls-type" data-ls-type="channel" aria-pressed="false">Channels</button>
+ <button type="button" class="ls-type" data-ls-type="vod" aria-pressed="false">VODs</button>
+ <button type="button" class="ls-type" data-ls-type="clip" aria-pressed="false">Clips</button>
+ </div>
+ </form>
+ <p class="ls-status" role="status" aria-live="polite"></p>
+ <ol class="ls-results"></ol>
+ <div class="ls-more-wrap"><button type="button" class="btn btn-outline ls-more" hidden>More results</button></div>
+</div>
@@ -680,6 +680,7 @@
@@ -680,6 +680,7 @@
<!-- ── CONTENT (/content; /vods /clips /pastes are it with a filter) and AI MOMENTS (/moments) ── -->
<!-- ── CONTENT (/content; /vods /clips /pastes are it with a filter) and AI MOMENTS (/moments) ── -->
<section id="page-content" class="page" data-fragment="content"><!--ov:fragment:content--><div class="ov-route-skel" aria-hidden="true"><span></span><span></span><span></span></div><!--/ov:fragment--></section>
<section id="page-content" class="page" data-fragment="content"><!--ov:fragment:content--><div class="ov-route-skel" aria-hidden="true"><span></span><span></span><span></span></div><!--/ov:fragment--></section>
<section id="page-moments" class="page" data-fragment="moments"><!--ov:fragment:moments--><div class="ov-route-skel" aria-hidden="true"><span></span><span></span><span></span></div><!--/ov:fragment--></section>
<section id="page-moments" class="page" data-fragment="moments"><!--ov:fragment:moments--><div class="ov-route-skel" aria-hidden="true"><span></span><span></span><span></span></div><!--/ov:fragment--></section>
+ <section id="page-search" class="page" data-fragment="search"><!--ov:fragment:search--><div class="ov-route-skel" aria-hidden="true"><span></span><span></span><span></span></div><!--/ov:fragment--></section>
<!-- ── CHANNEL PAGE (/:username) ─────────────────────── -->
<!-- ── CHANNEL PAGE (/:username) ─────────────────────── -->
<section id="page-channel" class="page" data-fragment="channel"><!--ov:fragment:channel--><div class="ov-route-skel" aria-hidden="true"><span></span><span></span><span></span></div><!--/ov:fragment--></section>
<section id="page-channel" class="page" data-fragment="channel"><!--ov:fragment:channel--><div class="ov-route-skel" aria-hidden="true"><span></span><span></span><span></span></div><!--/ov:fragment--></section>
new file mode 100644
new file mode 100644
@@ -0,0 +1,125 @@
@@ -0,0 +1,125 @@
+/**
+ * The /search page (fragment search.html): Live's channels, VODs and clips through OpenVibe.Search
+ * (GET /api/search, server/search/routes.js). The query lives in the URL (?q=&type=), so a search can
+ * be linked and Back works. Results are built as DOM nodes; a snippet keeps Search's <mark> and
+ * nothing else.
+ */
+(function () {
+ 'use strict';
+ const KIND = { channel: ['fa-tower-broadcast', 'Channel'], vod: ['fa-film', 'VOD'], clip: ['fa-scissors', 'Clip'] };
+ let seq = 0;
+
+ const el = (tag, cls, text) => { const n = document.createElement(tag); if (cls) n.className = cls; if (text != null) n.textContent = text; return n; };
+
+ // Only text and <mark> survive: Search escapes the text and adds <mark>, but nothing else is trusted.
+ function snippetNode(html, fallback) {
+ const p = el('p', 'ls-snippet');
+ if (!html) { p.textContent = fallback || ''; return p; }
+ const doc = new DOMParser().parseFromString(`<body>${html}</body>`, 'text/html');
+ for (const n of doc.body.childNodes) {
+ if (n.nodeType === 3) p.append(n.textContent);
+ else if (n.nodeName === 'MARK') p.append(el('mark', null, n.textContent));
+ else p.append(n.textContent || '');
+ }
+ return p;
+ }
+
+ function localPath(url) {
+ try { const u = new URL(url); return u.origin === location.origin || u.hostname === 'openvibe.live' ? u.pathname + u.search : url; } catch { return '#'; }
+ }
+
+ function hit(r) {
+ const li = el('li', 'ls-hit');
+ const href = localPath(r.canonical_url);
+ const a = el('a', 'ls-title', r.title || 'Untitled');
+ a.href = href;
+ if (href.startsWith('/')) a.addEventListener('click', (e) => { if (typeof handleLinkClick === 'function') handleLinkClick(e, href); });
+ const meta = el('div', 'ls-meta');
+ const [icon, label] = KIND[r.type] || ['fa-circle', r.type];
+ const kind = el('span', 'ls-kind');
+ const i = el('i', `fa-solid ${icon}`); i.setAttribute('aria-hidden', 'true');
+ kind.append(i, ` ${label}`);
+ meta.append(kind);
+ const f = r.facets || {};
+ if (r.type === 'channel' && f.live) meta.append(el('span', 'ls-live', 'LIVE'));
+ if (r.type !== 'channel' && f.channel) meta.append(el('span', null, `@${f.channel}`));
+ if (f.category) meta.append(el('span', null, f.category));
+ if (r.type === 'channel' && Number.isFinite(f.followers)) meta.append(el('span', null, `${f.followers} follower${f.followers === 1 ? '' : 's'}`));
+ if (r.authorship === 'ai_generated') meta.append(el('span', 'ls-kind', 'AI clip'));
+ li.append(a, meta, snippetNode(r.snippet_html, r.summary));
+ return li;
+ }
+
+ function readUrl() {
+ const sp = new URLSearchParams(location.search);
+ const type = ['channel', 'vod', 'clip'].includes(sp.get('type')) ? sp.get('type') : '';
+ return { q: (sp.get('q') || '').trim().slice(0, 200), type };
+ }
+
+ async function run(root, { q, type }, cursor) {
+ const status = root.querySelector('.ls-status');
+ const list = root.querySelector('.ls-results');
+ const more = root.querySelector('.ls-more');
+ const mine = ++seq;
+ if (!cursor) list.replaceChildren();
+ more.hidden = true;
+ if (!q) { status.textContent = 'Type a streamer, a game or something said on stream.'; return; }
+ status.textContent = 'Searching…';
+ const params = new URLSearchParams({ q });
+ if (type) params.set('type', type);
+ if (cursor) params.set('cursor', cursor);
+ let out;
+ try {
+ const res = await fetch(`/api/search?${params}`, { headers: { accept: 'application/json' } });
+ out = await res.json().catch(() => ({}));
+ if (!res.ok) throw new Error(out.error || 'Search is not answering right now');
+ } catch (err) {
+ if (mine === seq) status.textContent = `${err.message}. Try again in a minute.`;
+ return;
+ }
+ if (mine !== seq) return;
+ for (const r of out.results || []) list.append(hit(r));
+ const n = list.children.length;
+ status.textContent = n ? `${n}${out.next_cursor ? '+' : ''} result${n === 1 ? '' : 's'} for “${q}”` : `Nothing public matches “${q}”.`;
+ if (out.next_cursor) { more.hidden = false; more.onclick = () => run(root, { q, type }, out.next_cursor); }
+ }
+
+ function setUrl(state) {
+ const sp = new URLSearchParams();
+ if (state.q) sp.set('q', state.q);
+ if (state.type) sp.set('type', state.type);
+ const url = `/search${sp.toString() ? `?${sp}` : ''}`;
+ if (url !== location.pathname + location.search) history.pushState({}, '', url);
+ }
+
+ window.loadSearchPage = function loadSearchPage() {
+ const root = document.querySelector('#page-search .ls-wrap');
+ if (!root) return;
+ const state = readUrl();
+ const form = root.querySelector('.ls-form');
+ const input = form.querySelector('input[name="q"]');
+ input.value = state.q;
+ for (const b of root.querySelectorAll('.ls-type')) b.setAttribute('aria-pressed', String(b.dataset.lsType === state.type));
+ if (!form.dataset.bound) {
+ form.dataset.bound = '1';
+ form.addEventListener('submit', (e) => {
+ e.preventDefault();
+ const next = { q: input.value.trim().slice(0, 200), type: readUrl().type };
+ setUrl(next);
+ run(root, next);
+ });
+ for (const b of root.querySelectorAll('.ls-type')) {
+ b.addEventListener('click', () => {
+ const next = { q: input.value.trim().slice(0, 200), type: b.dataset.lsType };
+ for (const o of root.querySelectorAll('.ls-type')) o.setAttribute('aria-pressed', String(o === b));
+ setUrl(next);
+ run(root, next);
+ });
+ }
+ }
+ if (typeof setPageTitle === 'function') setPageTitle(state.q ? `Search: ${state.q}` : 'Search');
+ else document.title = `${state.q ? `Search: ${state.q}` : 'Search'} — OpenVibe.Live`;
+ run(root, state);
+ if (!state.q) input.focus();
+ };
+})();
@@ -55,7 +55,7 @@ function isStaffUser(user = currentUser) {
@@ -55,7 +55,7 @@ function isStaffUser(user = currentUser) {
}
}
// Reserved paths (not usernames)
// Reserved paths (not usernames)
-const RESERVED = new Set(['content', 'moments', 'vods', 'clips', 'vod', 'clip', 'dashboard', 'settings', 'broadcast', 'admin', 'themes', 'game', 'canvas', 'chat', 'api', 'ws', 'media', 'pastes', 'p', 'updates', 'dmca', 'tos', 'terms', 'arena', 'recap']);
+const RESERVED = new Set(['content', 'moments', 'search', 'vods', 'clips', 'vod', 'clip', 'dashboard', 'settings', 'broadcast', 'admin', 'themes', 'game', 'canvas', 'chat', 'api', 'ws', 'media', 'pastes', 'p', 'updates', 'dmca', 'tos', 'terms', 'arena', 'recap']);
const CHANNEL_USERNAME_RE = /^[a-zA-Z0-9_]{3,24}$/;
const CHANNEL_USERNAME_RE = /^[a-zA-Z0-9_]{3,24}$/;
function normalizeChannelUsername(username) {
function normalizeChannelUsername(username) {
@@ -1073,6 +1073,10 @@ function routeFromURL() {
@@ -1073,6 +1073,10 @@ function routeFromURL() {
// What the AI made: auto-clips, AI moments, AI recaps.
// What the AI made: auto-clips, AI moments, AI recaps.
showPage('moments');
showPage('moments');
whenRouteReady('moments', () => loadMomentsPage());
whenRouteReady('moments', () => loadMomentsPage());
+ } else if (segments[0] === 'search') {
+ // Channels, VODs and clips through OpenVibe.Search (public/js/app-search.js).
+ showPage('search');
+ whenRouteReady('search', () => loadSearchPage());
} else if (segments[0] === 'vod' && segments[1]) {
} else if (segments[0] === 'vod' && segments[1]) {
// VOD player: /vod/:id (optional ?t=<seconds> to auto-seek, e.g. from a clip link)
// VOD player: /vod/:id (optional ?t=<seconds> to auto-seek, e.g. from a clip link)
showPage('vod-player');
showPage('vod-player');
@@ -1348,7 +1352,7 @@ function showPage(page) {
@@ -1348,7 +1352,7 @@ function showPage(page) {
}
}
// Highlight nav link
// Highlight nav link
- const pageToNav = { home: 'home', content: 'content', moments: 'moments', broadcast: 'broadcast', dashboard: 'dashboard', admin: 'admin', chat: 'chat', game: 'game', canvas: 'game', 'paste-viewer': 'content', arena: 'arena' };
+ const pageToNav = { home: 'home', content: 'content', moments: 'moments', search: 'search', broadcast: 'broadcast', dashboard: 'dashboard', admin: 'admin', chat: 'chat', game: 'game', canvas: 'game', 'paste-viewer': 'content', arena: 'arena' };
const navPage = pageToNav[page];
const navPage = pageToNav[page];
if (navPage) {
if (navPage) {
const link = document.querySelector(`.nav-link[data-page="${navPage}"]`);
const link = document.querySelector(`.nav-link[data-page="${navPage}"]`);
@@ -23,6 +23,7 @@
@@ -23,6 +23,7 @@
// VODs, clips and pastes people made are one feed; what the AI made is the other.
// VODs, clips and pastes people made are one feed; what the AI made is the other.
{ id: 'content', page: 'content', label: 'Content', href: '/content', icon: 'fa-photo-film' },
{ id: 'content', page: 'content', label: 'Content', href: '/content', icon: 'fa-photo-film' },
{ id: 'moments', page: 'moments', label: 'Moments', href: '/moments', icon: 'fa-wand-magic-sparkles' },
{ id: 'moments', page: 'moments', label: 'Moments', href: '/moments', icon: 'fa-wand-magic-sparkles' },
+ { id: 'search', page: 'search', label: 'Search', href: '/search', icon: 'fa-magnifying-glass' },
{ id: 'chat', page: 'chat', label: 'Chat', href: '/chat', icon: 'fa-comments' },
{ id: 'chat', page: 'chat', label: 'Chat', href: '/chat', icon: 'fa-comments' },
{ id: 'arena', page: 'arena', label: 'Arena', href: '/arena', icon: 'fa-hand-fist' },
{ id: 'arena', page: 'arena', label: 'Arena', href: '/arena', icon: 'fa-hand-fist' },
{ id: 'game', page: 'game', label: 'Game', href: 'https://openvibe.games', icon: 'fa-gamepad' },
{ id: 'game', page: 'game', label: 'Game', href: 'https://openvibe.games', icon: 'fa-gamepad' },
@@ -706,6 +706,7 @@ app.use('/api/game', (req, res) => res.status(410).json({ error: 'Game has moved
@@ -706,6 +706,7 @@ app.use('/api/game', (req, res) => res.status(410).json({ error: 'Game has moved
app.use('/api/meta', metaRoutes);
app.use('/api/meta', metaRoutes);
app.use('/api/pastes', pasteRoutes);
app.use('/api/pastes', pasteRoutes);
app.use('/api/content', require('./content/routes')); // Content (people's work) + Moments (AI) feeds
app.use('/api/content', require('./content/routes')); // Content (people's work) + Moments (AI) feeds
+app.use('/api/search', require('./search/routes').createSearchRouter()); // /search page → OpenVibe.Search (Live's channels, VODs, clips)
app.use('/api/home', require('./home/routes'));
app.use('/api/home', require('./home/routes'));
app.use('/api/i18n', require('./i18n/routes')); // on-demand translation for viewers (any line → your language)
app.use('/api/i18n', require('./i18n/routes')); // on-demand translation for viewers (any line → your language)
app.use('/api/kiosk', require('./kiosk/routes'));
app.use('/api/kiosk', require('./kiosk/routes'));
new file mode 100644
new file mode 100644
@@ -0,0 +1,44 @@
@@ -0,0 +1,44 @@
+'use strict';
+/**
+ * GET /api/search?q=&type=&cursor= (roadmap WS-O task 10: product search boxes use the query API)
+ *
+ * Live's /search page asks OpenVibe.Search for Live's own documents (channels, VODs, clips:
+ * server/events/search-documents.js and search-media-documents.js), anonymously at Search's internal
+ * origin, so only public, published, indexable documents come back. → { results, next_cursor };
+ * 400 for a malformed query; 503 { error } when Search does not answer (the page says so).
+ */
+const express = require('express');
+
+const SEARCH_URL = (process.env.OV_SEARCH_INTERNAL_URL || 'http://127.0.0.1:4710').replace(/\/+$/, '');
+const TYPES = new Set(['channel', 'vod', 'clip']);
+const FIELDS = ['type', 'id', 'title', 'summary', 'canonical_url', 'facets', 'snippet_html', 'authorship', 'published_at', 'updated_at'];
+
+function createSearchRouter({ fetchImpl = globalThis.fetch, baseUrl = SEARCH_URL, timeoutMs = 4000 } = {}) {
+ const router = express.Router();
+ router.get('/', async (req, res) => {
+ const q = String(req.query.q || '').trim().slice(0, 200);
+ if (!q) return res.json({ results: [], next_cursor: null });
+ const params = new URLSearchParams({ q, owner: 'live', limit: '24' });
+ if (TYPES.has(req.query.type)) params.set('type', req.query.type);
+ const cursor = String(req.query.cursor || '');
+ if (/^[A-Za-z0-9_-]{1,512}$/.test(cursor)) params.set('cursor', cursor);
+ let r;
+ try {
+ r = await fetchImpl(`${baseUrl}/api/v1/search?${params}`, { headers: { accept: 'application/json' }, signal: AbortSignal.timeout(timeoutMs) });
+ } catch (err) {
+ console.warn('[Search] /api/search:', err.message);
+ return res.status(503).json({ error: 'Search is not answering right now' });
+ }
+ const body = await r.json().catch(() => null);
+ if (r.status === 400) return res.status(400).json({ error: 'That search could not be read' });
+ if (!r.ok || !body || !Array.isArray(body.results)) return res.status(503).json({ error: 'Search is not answering right now' });
+ res.set('Cache-Control', 'private, max-age=30');
+ res.json({
+ results: body.results.map((d) => Object.fromEntries(FIELDS.filter((k) => d[k] !== undefined).map((k) => [k, d[k]]))),
+ next_cursor: body.next_cursor || null,
+ });
+ });
+ return router;
+}
+
+module.exports = { createSearchRouter };
@@ -228,6 +228,8 @@ async function _pageMeta(routePath, { page = 1 } = {}) {
@@ -228,6 +228,8 @@ async function _pageMeta(routePath, { page = 1 } = {}) {
if ((m = p.match(CHANNEL_PATH_RE))) return _channelMeta(m[1], page);
if ((m = p.match(CHANNEL_PATH_RE))) return _channelMeta(m[1], page);
if ((m = p.match(/^\/recap\/(\d+)$/))) return _recapMeta(parseInt(m[1], 10));
if ((m = p.match(/^\/recap\/(\d+)$/))) return _recapMeta(parseInt(m[1], 10));
if (p === '/arena') return _arenaMeta();
if (p === '/arena') return _arenaMeta();
+ // Search results (public/js/app-search.js) are never indexed; the page itself is findable.
+ if (p === '/search') return { title: `Search | ${SITE_NAME}`, description: 'Search channels, VODs and clips on OpenVibe.Live.', canonicalPath: '/search', ogType: 'website', robots: 'noindex,follow', jsonLd: [] };
return null;
return null;
void bu;
void bu;
@@ -14,6 +14,7 @@
@@ -14,6 +14,7 @@
*
*
* / home
* / home
* /content /moments the feeds (people's work, AI's)
* /content /moments the feeds (people's work, AI's)
+ * /search Live's channels, VODs and clips through OpenVibe.Search
* /vods /clips /pastes /updates /documentation exact path (the first three are /content filtered)
* /vods /clips /pastes /updates /documentation exact path (the first three are /content filtered)
* /settings /admin /themes exact path (the client redirects them)
* /settings /admin /themes exact path (the client redirects them)
* /dashboard/… /broadcast/… /chat/… /arena/… any depth: these pages route sub-paths themselves
* /dashboard/… /broadcast/… /chat/… /arena/… any depth: these pages route sub-paths themselves
@@ -41,7 +42,7 @@ const OK = 200;
@@ -41,7 +42,7 @@ const OK = 200;
const NOT_FOUND = 404;
const NOT_FOUND = 404;
// Pages that exist only at their exact path.
// Pages that exist only at their exact path.
-const EXACT = new Set(['content', 'moments', 'vods', 'clips', 'pastes', 'updates', 'documentation', 'settings', 'admin', 'themes']);
+const EXACT = new Set(['content', 'moments', 'vods', 'clips', 'pastes', 'updates', 'documentation', 'settings', 'admin', 'themes', 'search']);
// Pages that route their own sub-paths (dashboard tabs, broadcast slots, chat rooms, arena fighters).
// Pages that route their own sub-paths (dashboard tabs, broadcast slots, chat rooms, arena fighters).
const PREFIX = new Set(['dashboard', 'broadcast', 'chat', 'arena']);
const PREFIX = new Set(['dashboard', 'broadcast', 'chat', 'arena']);
@@ -138,7 +138,7 @@ server.listen(0, '127.0.0.1', async () => {
@@ -138,7 +138,7 @@ server.listen(0, '127.0.0.1', async () => {
console.log('Page status (soft 404s)');
console.log('Page status (soft 404s)');
await check('every real route answers 200', () => expectAll([
await check('every real route answers 200', () => expectAll([
- '/', '/content', '/content/', '/moments', '/vods', '/vods/', '/clips', '/pastes', '/updates', '/documentation', '/settings', '/admin', '/themes',
+ '/', '/content', '/content/', '/moments', '/vods', '/vods/', '/clips', '/pastes', '/updates', '/documentation', '/settings', '/admin', '/themes', '/search',
'/dashboard', '/dashboard/anything', '/broadcast', '/broadcast/slot-2', '/chat', '/chat/voice/lobby',
'/dashboard', '/dashboard/anything', '/broadcast', '/broadcast/slot-2', '/chat', '/chat/voice/lobby',
'/arena', '/arena/beef/4', '/arena/alice',
'/arena', '/arena/beef/4', '/arena/alice',
'/@alice', '/@Alice', '/@alice/', '/@alice/main', '/@bob',
'/@alice', '/@Alice', '/@alice/', '/@alice/main', '/@bob',
@@ -147,7 +147,7 @@ server.listen(0, '127.0.0.1', async () => {
@@ -147,7 +147,7 @@ server.listen(0, '127.0.0.1', async () => {
], 200));
], 200));
await check('unknown paths answer 404', () => expectAll([
await check('unknown paths answer 404', () => expectAll([
- '/nope', '/search', '/login', '/wp-login.php', '/favicon-missing.ico', '/Vods', '/vods/extra', '/updates/1', '/content/vods', '/moments/1',
+ '/nope', '/find', '/login', '/wp-login.php', '/favicon-missing.ico', '/Vods', '/vods/extra', '/updates/1', '/content/vods', '/moments/1',
'/@nobody', '/@ab', '/@alice/main/extra', '/@bad.name', '/nobody', '/nobody/main', '/alice/main/extra', '/alice/bad!slot',
'/@nobody', '/@ab', '/@alice/main/extra', '/@bad.name', '/nobody', '/nobody/main', '/alice/main/extra', '/alice/bad!slot',
'/vod', '/vod/999', '/vod/abc', '/vod/100/extra', '/clip/999', '/clip/-1',
'/vod', '/vod/999', '/vod/abc', '/vod/100/extra', '/clip/999', '/clip/-1',
'/p/missing', '/p/bad!slug', '/p/abc123/extra',
'/p/missing', '/p/bad!slug', '/p/abc123/extra',
new file mode 100644
new file mode 100644
@@ -0,0 +1,60 @@
@@ -0,0 +1,60 @@
+'use strict';
+// Live's /search page (WS-O task 10: product search boxes use the query API): GET /api/search asks
+// OpenVibe.Search anonymously for owner=live only, passes a known type and a well-formed cursor, returns
+// only the public fields, and answers 503 when Search does not; an empty query asks nothing. /search is
+// a known page (not the SPA 404) and loads the search feature.
+const assert = require('assert');
+const http = require('http');
+const express = require('express');
+const { createSearchRouter } = require('../server/search/routes');
+
+(async () => {
+ const asked = [];
+ let mode = 'ok';
+ const fetchImpl = async (url, opts) => {
+ asked.push({ url: new URL(url), opts });
+ if (mode === 'down') throw new Error('connect ECONNREFUSED');
+ if (mode === 'bad') return new Response('{"code":"search.bad_query"}', { status: 400 });
+ if (mode === 'error') return new Response('{}', { status: 500 });
+ return new Response(JSON.stringify({ results: [{ owner: 'live', type: 'vod', id: '912', title: 'Building a forum', canonical_url: 'https://openvibe.live/vod/912', facets: { channel: 'alex' }, snippet_html: '<mark>forum</mark>', acl: ['secret'], revision: 3 }], next_cursor: 'c1' }), { status: 200 });
+ };
+ const app = express();
+ app.use('/api/search', createSearchRouter({ fetchImpl, baseUrl: 'http://search.test' }));
+ const srv = await new Promise((r) => { const s = app.listen(0, '127.0.0.1', () => r(s)); });
+ const get = async (p) => { const res = await fetch(`http://127.0.0.1:${srv.address().port}${p}`); return { status: res.status, body: await res.json() }; };
+
+ let r = await get('/api/search?q=forum&type=vod&cursor=abc_1');
+ assert.strictEqual(r.status, 200);
+ const u = asked[0].url;
+ assert.strictEqual(u.origin + u.pathname, 'http://search.test/api/v1/search');
+ assert.strictEqual(u.searchParams.get('owner'), 'live');
+ assert.strictEqual(u.searchParams.get('type'), 'vod');
+ assert.strictEqual(u.searchParams.get('cursor'), 'abc_1');
+ assert.ok(!asked[0].opts.headers.authorization, 'anonymous: public documents only');
+ assert.deepStrictEqual(r.body, { results: [{ type: 'vod', id: '912', title: 'Building a forum', canonical_url: 'https://openvibe.live/vod/912', facets: { channel: 'alex' }, snippet_html: '<mark>forum</mark>' }], next_cursor: 'c1' });
+
+ await get('/api/search?q=forum&type=paste&cursor=bad%20cursor');
+ assert.strictEqual(asked[1].url.searchParams.get('type'), null, 'only Live\'s types');
+ assert.strictEqual(asked[1].url.searchParams.get('cursor'), null);
+
+ r = await get('/api/search?q=');
+ assert.deepStrictEqual(r.body, { results: [], next_cursor: null });
+ assert.strictEqual(asked.length, 2, 'an empty query asks nothing');
+
+ mode = 'down'; r = await get('/api/search?q=forum');
+ assert.strictEqual(r.status, 503); assert.ok(/not answering/.test(r.body.error));
+ mode = 'error'; assert.strictEqual((await get('/api/search?q=forum')).status, 503);
+ mode = 'bad'; assert.strictEqual((await get('/api/search?q=forum')).status, 400);
+ srv.close();
+
+ const features = require('../public/features.json');
+ const route = features.routes.find((x) => new RegExp(x.path).test('/search'));
+ assert.deepStrictEqual(route && route.features, ['search']);
+ assert.strictEqual(features.features.search.fragment, 'search');
+ const fs = require('fs');
+ assert.ok(fs.existsSync(require('path').join(__dirname, '../public/fragments/search.html')));
+ const src = fs.readFileSync(require('path').join(__dirname, '../server/web/page-status.js'), 'utf8');
+ assert.ok(/EXACT = new Set\([^)]*'search'/.test(src), '/search is a known page');
+
+ console.log('search page: all checks passed');
+})().catch((err) => { console.error(err); process.exit(1); });