openvibers/openvibe.live Commits
Search facets and autocomplete (WS-O task 10): /search shows the first page's category and channel facets with counts and narrows to one (?category=&channel=, a new query starts without them); the box suggests titles as you type (GET /api/search/suggest → OpenVibe.Search /api/v1/suggest, Live's types only, never an error; an ARIA combobox: arrows, Enter opens, Escape closes)
Changes
@@@ -22,3 +22,17 @@.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; }++/* Suggestions as you type (a combobox listbox under the box) */++.ls-combo { position: relative; }++.ls-suggest { position: absolute; z-index: 20; left: 0; right: 0; top: calc(100% + 4px); margin: 0; padding: 4px; list-style: none; border: 1px solid var(--border); border-radius: var(--radius); background: var(--bg-card); box-shadow: 0 8px 24px rgba(0, 0, 0, 0.25); max-height: 60vh; overflow-y: auto; }++.ls-suggest li { display: flex; align-items: center; gap: 8px; padding: 8px 10px; border-radius: calc(var(--radius) - 2px); cursor: pointer; color: var(--text-primary); overflow-wrap: anywhere; }++.ls-suggest li[aria-selected="true"], .ls-suggest li:hover { background: var(--bg-tertiary); }++.ls-suggest li .ls-kind { flex: none; font-size: 0.75rem; }++/* Facets: narrow to one category or channel, with counts */++.ls-facets { display: flex; flex-direction: column; gap: 8px; margin: 0 0 8px; }++.ls-facet { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; font-size: 0.8rem; color: var(--text-muted); }++.ls-facet-label { margin-right: 2px; }++.ls-facet button { padding: 3px 10px; border-radius: 999px; border: 1px solid var(--border); background: transparent; color: var(--text-secondary); font: inherit; cursor: pointer; overflow-wrap: anywhere; max-width: 100%; }++.ls-facet button[aria-pressed="true"] { border-color: var(--accent); color: var(--text-primary); background: var(--bg-tertiary); }++.ls-facet button .ls-count { color: var(--text-muted); margin-left: 4px; }++@@@ -4,7 +4,10 @@<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-combo">++ <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" role="combobox" aria-autocomplete="list" aria-expanded="false" aria-controls="ls-suggest"></label>++ <ul class="ls-suggest" id="ls-suggest" role="listbox" aria-label="Suggestions" hidden></ul>++ </div><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>@@@ -12,6 +15,10 @@<button type="button" class="ls-type" data-ls-type="clip" aria-pressed="false">Clips</button></div></form>++ <div class="ls-facets" hidden>++ <div class="ls-facet" data-ls-facet="category" role="group" aria-label="Category"></div>++ <div class="ls-facet" data-ls-facet="channel" role="group" aria-label="Channel"></div>++ </div><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>@@@ -2,7 +2,9 @@* 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.++ * nothing else. The first page's category and channel facets (with counts) narrow the results++ * (?category=&channel=); a new query starts without them. The box suggests titles as you type++ * (GET /api/search/suggest; a combobox: arrows, Enter opens, Escape closes).*/(function () {'use strict';@@@ -53,20 +55,54 @@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 };++ const one = (k) => (sp.get(k) || '').trim().slice(0, 64);++ return { q: (sp.get('q') || '').trim().slice(0, 200), type, category: one('category'), channel: one('channel') };}-- async function run(root, { q, type }, cursor) {++ // ── Facets ──++ function renderFacets(root, state, facets) {++ const box = root.querySelector('.ls-facets');++ let any = false;++ for (const group of box.querySelectorAll('.ls-facet')) {++ const key = group.dataset.lsFacet;++ const rows = ((facets && facets[key]) || []).slice();++ if (state[key] && !rows.some((r) => r.value === state[key])) rows.unshift({ value: state[key], count: null });++ group.replaceChildren();++ if (!rows.length || (rows.length === 1 && !state[key])) continue; // one value narrows nothing++ any = true;++ group.append(el('span', 'ls-facet-label', key === 'category' ? 'Category:' : 'Channel:'));++ for (const r of rows) {++ const b = el('button');++ b.type = 'button';++ b.textContent = key === 'channel' ? `@${r.value}` : r.value;++ if (r.count != null) b.append(el('span', 'ls-count', String(r.count)));++ const on = state[key] === r.value;++ b.setAttribute('aria-pressed', String(on));++ b.addEventListener('click', () => {++ const next = { ...readUrl(), [key]: on ? '' : r.value };++ setUrl(next);++ run(root, next);++ });++ group.append(b);++ }++ }++ box.hidden = !any;++ }++++ async function run(root, state, cursor) {++ const { q, type } = state;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; }++ if (!q) { status.textContent = 'Type a streamer, a game or something said on stream.'; renderFacets(root, {}, null); return; }status.textContent = 'Searching…';const params = new URLSearchParams({ q });if (type) params.set('type', type);++ if (state.category) params.set('category', state.category);++ if (state.channel) params.set('channel', state.channel);if (cursor) params.set('cursor', cursor);let out;try {@@@ -78,20 +114,81 @@return;}if (mine !== seq) return;++ if (!cursor) renderFacets(root, state, out.facets);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); }++ if (out.next_cursor) { more.hidden = false; more.onclick = () => run(root, state, 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);++ if (state.category) sp.set('category', state.category);++ if (state.channel) sp.set('channel', state.channel);const url = `/search${sp.toString() ? `?${sp}` : ''}`;if (url !== location.pathname + location.search) history.pushState({}, '', url);}++ // ── Suggestions as you type ──++ function bindSuggest(root, input) {++ const list = root.querySelector('.ls-suggest');++ let items = [], active = -1, timer = null, asked = 0;++ const close = () => { list.hidden = true; list.replaceChildren(); items = []; active = -1; input.setAttribute('aria-expanded', 'false'); input.removeAttribute('aria-activedescendant'); };++ const open = (s) => {++ const href = localPath(s.canonical_url);++ close();++ if (href.startsWith('/') && typeof navigate === 'function') navigate(href);++ else location.href = href;++ };++ const mark = (i) => {++ active = i;++ items.forEach((li, n) => li.setAttribute('aria-selected', String(n === i)));++ if (i >= 0) { input.setAttribute('aria-activedescendant', items[i].id); items[i].scrollIntoView({ block: 'nearest' }); } else input.removeAttribute('aria-activedescendant');++ };++ async function ask() {++ const q = input.value.trim();++ const mine = ++asked;++ if (q.length < 2) return close();++ const params = new URLSearchParams({ q });++ const type = readUrl().type;++ if (type) params.set('type', type);++ let out = null;++ try { out = await (await fetch(`/api/search/suggest?${params}`, { headers: { accept: 'application/json' } })).json(); } catch { out = null; }++ if (mine !== asked || document.activeElement !== input) return;++ const sugg = (out && out.suggestions) || [];++ if (!sugg.length) return close();++ list.replaceChildren();++ items = sugg.map((sg, n) => {++ const li = el('li');++ li.id = `ls-sug-${n}`;++ li.setAttribute('role', 'option');++ li.setAttribute('aria-selected', 'false');++ const [, label] = KIND[sg.type] || ['', sg.type];++ li.append(el('span', 'ls-kind', label), el('span', null, sg.title));++ li.addEventListener('mousedown', (e) => e.preventDefault()); // keep focus in the box++ li.addEventListener('click', () => open(sg));++ li._s = sg;++ list.append(li);++ return li;++ });++ active = -1;++ list.hidden = false;++ input.setAttribute('aria-expanded', 'true');++ }++ input.addEventListener('input', () => { clearTimeout(timer); timer = setTimeout(ask, 150); });++ input.addEventListener('keydown', (e) => {++ if (list.hidden) return;++ if (e.key === 'ArrowDown') { e.preventDefault(); mark(Math.min(items.length - 1, active + 1)); }++ else if (e.key === 'ArrowUp') { e.preventDefault(); mark(Math.max(-1, active - 1)); }++ else if (e.key === 'Escape') { e.preventDefault(); close(); }++ else if (e.key === 'Enter' && active >= 0) { e.preventDefault(); open(items[active]._s); }++ });++ input.addEventListener('blur', () => setTimeout(close, 100));++ return close;++ }++window.loadSearchPage = function loadSearchPage() {const root = document.querySelector('#page-search .ls-wrap');if (!root) return;@@@ -102,15 +199,18 @@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';++ const closeSuggest = bindSuggest(root, input);form.addEventListener('submit', (e) => {e.preventDefault();-- const next = { q: input.value.trim().slice(0, 200), type: readUrl().type };++ closeSuggest();++ // A new query starts without the previous one's category and channel.++ const next = { q: input.value.trim().slice(0, 200), type: readUrl().type, category: '', channel: '' };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 };++ const next = { ...readUrl(), 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);@@@ -1,11 +1,15 @@'use strict';/**-- * GET /api/search?q=&type=&cursor= (roadmap WS-O task 10: product search boxes use the query API)++ * GET /api/search?q=&type=&category=&channel=&cursor= (roadmap WS-O task 10: product search boxes use the query API)++ * GET /api/search/suggest?q=&type= title suggestions as someone types** 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).++ * origin, so only public, published, indexable documents come back. → { results, next_cursor, facets? }:++ * the first page also carries the category and channel facets with counts ({ category: [{ value, count }],++ * channel: [...] }), and category=/channel= narrow to one value. 400 for a malformed query; 503 { error }++ * when Search does not answer (the page says so). /suggest → { suggestions: [{ type, id, title,++ * canonical_url }] }, [] for fewer than 2 characters, and never an error the box would have to show.*/const express = require('express');@@@ -20,8 +24,13 @@ function createSearchRouter({ fetchImpl = globalThis.fetch, baseUrl = SEARCH_URLif (!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);++ for (const k of ['category', 'channel']) {++ const v = String(req.query[k] || '').trim();++ if (v && v.length <= 64 && !/[\u0000-\u001f]/.test(v)) params.set(`facet.${k}`, v);++ }const cursor = String(req.query.cursor || '');if (/^[A-Za-z0-9_-]{1,512}$/.test(cursor)) params.set('cursor', cursor);++ else params.set('facets', 'category,channel');let r;try {r = await fetchImpl(`${baseUrl}/api/v1/search?${params}`, { headers: { accept: 'application/json' }, signal: AbortSignal.timeout(timeoutMs) });@@@ -33,11 +42,29 @@ function createSearchRouter({ fetchImpl = globalThis.fetch, baseUrl = SEARCH_URLif (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');++ const facetList = (rows) => (Array.isArray(rows) ? rows.filter((r) => r && typeof r.value === 'string' && Number.isFinite(r.count)).slice(0, 8).map((r) => ({ value: r.value, count: r.count })) : []);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,++ ...(body.facets ? { facets: { category: facetList(body.facets.category), channel: facetList(body.facets.channel) } } : {}),});});++++ router.get('/suggest', async (req, res) => {++ res.set('Cache-Control', 'private, max-age=30');++ const q = String(req.query.q || '').trim().slice(0, 100);++ if (q.length < 2) return res.json({ suggestions: [] });++ const params = new URLSearchParams({ q, owner: 'live', limit: '8' });++ if (TYPES.has(req.query.type)) params.set('type', req.query.type);++ try {++ const r = await fetchImpl(`${baseUrl}/api/v1/suggest?${params}`, { headers: { accept: 'application/json' }, signal: AbortSignal.timeout(2000) });++ const body = r.ok ? await r.json().catch(() => null) : null;++ const list = body && Array.isArray(body.suggestions) ? body.suggestions : [];++ res.json({ suggestions: list.filter((s) => s && TYPES.has(s.type) && s.title).map((s) => ({ type: s.type, id: s.id, title: s.title, canonical_url: s.canonical_url })) });++ } catch {++ res.json({ suggestions: [] }); // typing never shows an error; submitting does++ }++ });return router;}@@@ -16,7 +16,13 @@ const { createSearchRouter } = require('../server/search/routes');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 u = new URL(url);++ if (u.pathname === '/api/v1/suggest') {++ if (mode === 'suggest-down') throw new Error('timeout');++ return new Response(JSON.stringify({ suggestions: [{ owner: 'live', type: 'channel', id: '7', title: 'Forum builders', canonical_url: 'https://openvibe.live/@forum' }, { owner: 'community', type: 'thread', id: 'x', title: 'Not ours' }] }), { status: 200 });++ }++ const facets = u.searchParams.get('facets') ? { facets: { category: [{ value: 'irl', count: 12 }, { value: 'gaming', count: 3 }], channel: [{ value: 'alex', count: 9 }, { value: 42, count: 1 }], secret: [{ value: 'x', count: 1 }] } } : {};++ return new Response(JSON.stringify({ ...facets, 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' }));@@@ -32,14 +38,34 @@ const { createSearchRouter } = require('../server/search/routes');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' });++ assert.strictEqual(u.searchParams.get('facets'), null, 'a later page asks no facets');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);++ // The first page carries the category and channel facets (well-formed rows only); category=/channel= narrow.++ r = await get('/api/search?q=forum&category=irl&channel=alex');++ const first = asked[asked.length - 1].url;++ assert.deepStrictEqual([first.searchParams.get('facets'), first.searchParams.get('facet.category'), first.searchParams.get('facet.channel')], ['category,channel', 'irl', 'alex']);++ assert.deepStrictEqual(r.body.facets, { category: [{ value: 'irl', count: 12 }, { value: 'gaming', count: 3 }], channel: [{ value: 'alex', count: 9 }] });++ const before = asked.length;r = await get('/api/search?q=');assert.deepStrictEqual(r.body, { results: [], next_cursor: null });-- assert.strictEqual(asked.length, 2, 'an empty query asks nothing');++ assert.strictEqual(asked.length, before, 'an empty query asks nothing');++++ // Suggestions: Live's own types only, nothing for one character, never an error.++ r = await get('/api/search/suggest?q=fo&type=channel');++ const su = asked[asked.length - 1].url;++ assert.deepStrictEqual([su.pathname, su.searchParams.get('owner'), su.searchParams.get('type'), su.searchParams.get('limit')], ['/api/v1/suggest', 'live', 'channel', '8']);++ assert.deepStrictEqual(r.body, { suggestions: [{ type: 'channel', id: '7', title: 'Forum builders', canonical_url: 'https://openvibe.live/@forum' }] });++ const n = asked.length;++ assert.deepStrictEqual((await get('/api/search/suggest?q=f')).body, { suggestions: [] });++ assert.strictEqual(asked.length, n, 'one character asks nothing');++ mode = 'suggest-down';++ r = await get('/api/search/suggest?q=forum');++ assert.deepStrictEqual([r.status, r.body], [200, { suggestions: [] }]);++ mode = 'ok';mode = 'down'; r = await get('/api/search?q=forum');assert.strictEqual(r.status, 503); assert.ok(/not answering/.test(r.body.error));
@@ -22,3 +22,17 @@
@@ -22,3 +22,17 @@
.ls-snippet { margin: 4px 0 0; color: var(--text-secondary); font-size: 0.9rem; overflow-wrap: anywhere; }
.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-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; }
.ls-more-wrap { display: flex; justify-content: center; margin-top: 16px; }
+/* Suggestions as you type (a combobox listbox under the box) */
+.ls-combo { position: relative; }
+.ls-suggest { position: absolute; z-index: 20; left: 0; right: 0; top: calc(100% + 4px); margin: 0; padding: 4px; list-style: none; border: 1px solid var(--border); border-radius: var(--radius); background: var(--bg-card); box-shadow: 0 8px 24px rgba(0, 0, 0, 0.25); max-height: 60vh; overflow-y: auto; }
+.ls-suggest li { display: flex; align-items: center; gap: 8px; padding: 8px 10px; border-radius: calc(var(--radius) - 2px); cursor: pointer; color: var(--text-primary); overflow-wrap: anywhere; }
+.ls-suggest li[aria-selected="true"], .ls-suggest li:hover { background: var(--bg-tertiary); }
+.ls-suggest li .ls-kind { flex: none; font-size: 0.75rem; }
+/* Facets: narrow to one category or channel, with counts */
+.ls-facets { display: flex; flex-direction: column; gap: 8px; margin: 0 0 8px; }
+.ls-facet { display: flex; flex-wrap: wrap; gap: 6px; align-items: center; font-size: 0.8rem; color: var(--text-muted); }
+.ls-facet-label { margin-right: 2px; }
+.ls-facet button { padding: 3px 10px; border-radius: 999px; border: 1px solid var(--border); background: transparent; color: var(--text-secondary); font: inherit; cursor: pointer; overflow-wrap: anywhere; max-width: 100%; }
+.ls-facet button[aria-pressed="true"] { border-color: var(--accent); color: var(--text-primary); background: var(--bg-tertiary); }
+.ls-facet button .ls-count { color: var(--text-muted); margin-left: 4px; }
+
@@ -4,7 +4,10 @@
@@ -4,7 +4,10 @@
<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>
<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>
</header>
<form class="ls-form" role="search" action="/search" method="get">
<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-combo">
+ <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" role="combobox" aria-autocomplete="list" aria-expanded="false" aria-controls="ls-suggest"></label>
+ <ul class="ls-suggest" id="ls-suggest" role="listbox" aria-label="Suggestions" hidden></ul>
+ </div>
<div class="ls-types" role="group" aria-label="Show">
<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="" 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="channel" aria-pressed="false">Channels</button>
@@ -12,6 +15,10 @@
@@ -12,6 +15,10 @@
<button type="button" class="ls-type" data-ls-type="clip" aria-pressed="false">Clips</button>
<button type="button" class="ls-type" data-ls-type="clip" aria-pressed="false">Clips</button>
</div>
</div>
</form>
</form>
+ <div class="ls-facets" hidden>
+ <div class="ls-facet" data-ls-facet="category" role="group" aria-label="Category"></div>
+ <div class="ls-facet" data-ls-facet="channel" role="group" aria-label="Channel"></div>
+ </div>
<p class="ls-status" role="status" aria-live="polite"></p>
<p class="ls-status" role="status" aria-live="polite"></p>
<ol class="ls-results"></ol>
<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 class="ls-more-wrap"><button type="button" class="btn btn-outline ls-more" hidden>More results</button></div>
@@ -2,7 +2,9 @@
@@ -2,7 +2,9 @@
* The /search page (fragment search.html): Live's channels, VODs and clips through OpenVibe.Search
* 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
* (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
* be linked and Back works. Results are built as DOM nodes; a snippet keeps Search's <mark> and
- * nothing else.
+ * nothing else. The first page's category and channel facets (with counts) narrow the results
+ * (?category=&channel=); a new query starts without them. The box suggests titles as you type
+ * (GET /api/search/suggest; a combobox: arrows, Enter opens, Escape closes).
*/
*/
(function () {
(function () {
'use strict';
'use strict';
@@ -53,20 +55,54 @@
@@ -53,20 +55,54 @@
function readUrl() {
function readUrl() {
const sp = new URLSearchParams(location.search);
const sp = new URLSearchParams(location.search);
const type = ['channel', 'vod', 'clip'].includes(sp.get('type')) ? sp.get('type') : '';
const type = ['channel', 'vod', 'clip'].includes(sp.get('type')) ? sp.get('type') : '';
- return { q: (sp.get('q') || '').trim().slice(0, 200), type };
+ const one = (k) => (sp.get(k) || '').trim().slice(0, 64);
+ return { q: (sp.get('q') || '').trim().slice(0, 200), type, category: one('category'), channel: one('channel') };
}
}
- async function run(root, { q, type }, cursor) {
+ // ── Facets ──
+ function renderFacets(root, state, facets) {
+ const box = root.querySelector('.ls-facets');
+ let any = false;
+ for (const group of box.querySelectorAll('.ls-facet')) {
+ const key = group.dataset.lsFacet;
+ const rows = ((facets && facets[key]) || []).slice();
+ if (state[key] && !rows.some((r) => r.value === state[key])) rows.unshift({ value: state[key], count: null });
+ group.replaceChildren();
+ if (!rows.length || (rows.length === 1 && !state[key])) continue; // one value narrows nothing
+ any = true;
+ group.append(el('span', 'ls-facet-label', key === 'category' ? 'Category:' : 'Channel:'));
+ for (const r of rows) {
+ const b = el('button');
+ b.type = 'button';
+ b.textContent = key === 'channel' ? `@${r.value}` : r.value;
+ if (r.count != null) b.append(el('span', 'ls-count', String(r.count)));
+ const on = state[key] === r.value;
+ b.setAttribute('aria-pressed', String(on));
+ b.addEventListener('click', () => {
+ const next = { ...readUrl(), [key]: on ? '' : r.value };
+ setUrl(next);
+ run(root, next);
+ });
+ group.append(b);
+ }
+ }
+ box.hidden = !any;
+ }
+
+ async function run(root, state, cursor) {
+ const { q, type } = state;
const status = root.querySelector('.ls-status');
const status = root.querySelector('.ls-status');
const list = root.querySelector('.ls-results');
const list = root.querySelector('.ls-results');
const more = root.querySelector('.ls-more');
const more = root.querySelector('.ls-more');
const mine = ++seq;
const mine = ++seq;
if (!cursor) list.replaceChildren();
if (!cursor) list.replaceChildren();
more.hidden = true;
more.hidden = true;
- if (!q) { status.textContent = 'Type a streamer, a game or something said on stream.'; return; }
+ if (!q) { status.textContent = 'Type a streamer, a game or something said on stream.'; renderFacets(root, {}, null); return; }
status.textContent = 'Searching…';
status.textContent = 'Searching…';
const params = new URLSearchParams({ q });
const params = new URLSearchParams({ q });
if (type) params.set('type', type);
if (type) params.set('type', type);
+ if (state.category) params.set('category', state.category);
+ if (state.channel) params.set('channel', state.channel);
if (cursor) params.set('cursor', cursor);
if (cursor) params.set('cursor', cursor);
let out;
let out;
try {
try {
@@ -78,20 +114,81 @@
@@ -78,20 +114,81 @@
return;
return;
}
}
if (mine !== seq) return;
if (mine !== seq) return;
+ if (!cursor) renderFacets(root, state, out.facets);
for (const r of out.results || []) list.append(hit(r));
for (const r of out.results || []) list.append(hit(r));
const n = list.children.length;
const n = list.children.length;
status.textContent = n ? `${n}${out.next_cursor ? '+' : ''} result${n === 1 ? '' : 's'} for “${q}”` : `Nothing public matches “${q}”.`;
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); }
+ if (out.next_cursor) { more.hidden = false; more.onclick = () => run(root, state, out.next_cursor); }
}
}
function setUrl(state) {
function setUrl(state) {
const sp = new URLSearchParams();
const sp = new URLSearchParams();
if (state.q) sp.set('q', state.q);
if (state.q) sp.set('q', state.q);
if (state.type) sp.set('type', state.type);
if (state.type) sp.set('type', state.type);
+ if (state.category) sp.set('category', state.category);
+ if (state.channel) sp.set('channel', state.channel);
const url = `/search${sp.toString() ? `?${sp}` : ''}`;
const url = `/search${sp.toString() ? `?${sp}` : ''}`;
if (url !== location.pathname + location.search) history.pushState({}, '', url);
if (url !== location.pathname + location.search) history.pushState({}, '', url);
}
}
+ // ── Suggestions as you type ──
+ function bindSuggest(root, input) {
+ const list = root.querySelector('.ls-suggest');
+ let items = [], active = -1, timer = null, asked = 0;
+ const close = () => { list.hidden = true; list.replaceChildren(); items = []; active = -1; input.setAttribute('aria-expanded', 'false'); input.removeAttribute('aria-activedescendant'); };
+ const open = (s) => {
+ const href = localPath(s.canonical_url);
+ close();
+ if (href.startsWith('/') && typeof navigate === 'function') navigate(href);
+ else location.href = href;
+ };
+ const mark = (i) => {
+ active = i;
+ items.forEach((li, n) => li.setAttribute('aria-selected', String(n === i)));
+ if (i >= 0) { input.setAttribute('aria-activedescendant', items[i].id); items[i].scrollIntoView({ block: 'nearest' }); } else input.removeAttribute('aria-activedescendant');
+ };
+ async function ask() {
+ const q = input.value.trim();
+ const mine = ++asked;
+ if (q.length < 2) return close();
+ const params = new URLSearchParams({ q });
+ const type = readUrl().type;
+ if (type) params.set('type', type);
+ let out = null;
+ try { out = await (await fetch(`/api/search/suggest?${params}`, { headers: { accept: 'application/json' } })).json(); } catch { out = null; }
+ if (mine !== asked || document.activeElement !== input) return;
+ const sugg = (out && out.suggestions) || [];
+ if (!sugg.length) return close();
+ list.replaceChildren();
+ items = sugg.map((sg, n) => {
+ const li = el('li');
+ li.id = `ls-sug-${n}`;
+ li.setAttribute('role', 'option');
+ li.setAttribute('aria-selected', 'false');
+ const [, label] = KIND[sg.type] || ['', sg.type];
+ li.append(el('span', 'ls-kind', label), el('span', null, sg.title));
+ li.addEventListener('mousedown', (e) => e.preventDefault()); // keep focus in the box
+ li.addEventListener('click', () => open(sg));
+ li._s = sg;
+ list.append(li);
+ return li;
+ });
+ active = -1;
+ list.hidden = false;
+ input.setAttribute('aria-expanded', 'true');
+ }
+ input.addEventListener('input', () => { clearTimeout(timer); timer = setTimeout(ask, 150); });
+ input.addEventListener('keydown', (e) => {
+ if (list.hidden) return;
+ if (e.key === 'ArrowDown') { e.preventDefault(); mark(Math.min(items.length - 1, active + 1)); }
+ else if (e.key === 'ArrowUp') { e.preventDefault(); mark(Math.max(-1, active - 1)); }
+ else if (e.key === 'Escape') { e.preventDefault(); close(); }
+ else if (e.key === 'Enter' && active >= 0) { e.preventDefault(); open(items[active]._s); }
+ });
+ input.addEventListener('blur', () => setTimeout(close, 100));
+ return close;
+ }
+
window.loadSearchPage = function loadSearchPage() {
window.loadSearchPage = function loadSearchPage() {
const root = document.querySelector('#page-search .ls-wrap');
const root = document.querySelector('#page-search .ls-wrap');
if (!root) return;
if (!root) return;
@@ -102,15 +199,18 @@
@@ -102,15 +199,18 @@
for (const b of root.querySelectorAll('.ls-type')) b.setAttribute('aria-pressed', String(b.dataset.lsType === state.type));
for (const b of root.querySelectorAll('.ls-type')) b.setAttribute('aria-pressed', String(b.dataset.lsType === state.type));
if (!form.dataset.bound) {
if (!form.dataset.bound) {
form.dataset.bound = '1';
form.dataset.bound = '1';
+ const closeSuggest = bindSuggest(root, input);
form.addEventListener('submit', (e) => {
form.addEventListener('submit', (e) => {
e.preventDefault();
e.preventDefault();
- const next = { q: input.value.trim().slice(0, 200), type: readUrl().type };
+ closeSuggest();
+ // A new query starts without the previous one's category and channel.
+ const next = { q: input.value.trim().slice(0, 200), type: readUrl().type, category: '', channel: '' };
setUrl(next);
setUrl(next);
run(root, next);
run(root, next);
});
});
for (const b of root.querySelectorAll('.ls-type')) {
for (const b of root.querySelectorAll('.ls-type')) {
b.addEventListener('click', () => {
b.addEventListener('click', () => {
- const next = { q: input.value.trim().slice(0, 200), type: b.dataset.lsType };
+ const next = { ...readUrl(), 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));
for (const o of root.querySelectorAll('.ls-type')) o.setAttribute('aria-pressed', String(o === b));
setUrl(next);
setUrl(next);
run(root, next);
run(root, next);
@@ -1,11 +1,15 @@
@@ -1,11 +1,15 @@
'use strict';
'use strict';
/**
/**
- * GET /api/search?q=&type=&cursor= (roadmap WS-O task 10: product search boxes use the query API)
+ * GET /api/search?q=&type=&category=&channel=&cursor= (roadmap WS-O task 10: product search boxes use the query API)
+ * GET /api/search/suggest?q=&type= title suggestions as someone types
*
*
* Live's /search page asks OpenVibe.Search for Live's own documents (channels, VODs, clips:
* 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
* 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).
+ * origin, so only public, published, indexable documents come back. → { results, next_cursor, facets? }:
+ * the first page also carries the category and channel facets with counts ({ category: [{ value, count }],
+ * channel: [...] }), and category=/channel= narrow to one value. 400 for a malformed query; 503 { error }
+ * when Search does not answer (the page says so). /suggest → { suggestions: [{ type, id, title,
+ * canonical_url }] }, [] for fewer than 2 characters, and never an error the box would have to show.
*/
*/
const express = require('express');
const express = require('express');
@@ -20,8 +24,13 @@ function createSearchRouter({ fetchImpl = globalThis.fetch, baseUrl = SEARCH_URL
@@ -20,8 +24,13 @@ function createSearchRouter({ fetchImpl = globalThis.fetch, baseUrl = SEARCH_URL
if (!q) return res.json({ results: [], next_cursor: null });
if (!q) return res.json({ results: [], next_cursor: null });
const params = new URLSearchParams({ q, owner: 'live', limit: '24' });
const params = new URLSearchParams({ q, owner: 'live', limit: '24' });
if (TYPES.has(req.query.type)) params.set('type', req.query.type);
if (TYPES.has(req.query.type)) params.set('type', req.query.type);
+ for (const k of ['category', 'channel']) {
+ const v = String(req.query[k] || '').trim();
+ if (v && v.length <= 64 && !/[\u0000-\u001f]/.test(v)) params.set(`facet.${k}`, v);
+ }
const cursor = String(req.query.cursor || '');
const cursor = String(req.query.cursor || '');
if (/^[A-Za-z0-9_-]{1,512}$/.test(cursor)) params.set('cursor', cursor);
if (/^[A-Za-z0-9_-]{1,512}$/.test(cursor)) params.set('cursor', cursor);
+ else params.set('facets', 'category,channel');
let r;
let r;
try {
try {
r = await fetchImpl(`${baseUrl}/api/v1/search?${params}`, { headers: { accept: 'application/json' }, signal: AbortSignal.timeout(timeoutMs) });
r = await fetchImpl(`${baseUrl}/api/v1/search?${params}`, { headers: { accept: 'application/json' }, signal: AbortSignal.timeout(timeoutMs) });
@@ -33,11 +42,29 @@ function createSearchRouter({ fetchImpl = globalThis.fetch, baseUrl = SEARCH_URL
@@ -33,11 +42,29 @@ function createSearchRouter({ fetchImpl = globalThis.fetch, baseUrl = SEARCH_URL
if (r.status === 400) return res.status(400).json({ error: 'That search could not be read' });
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' });
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.set('Cache-Control', 'private, max-age=30');
+ const facetList = (rows) => (Array.isArray(rows) ? rows.filter((r) => r && typeof r.value === 'string' && Number.isFinite(r.count)).slice(0, 8).map((r) => ({ value: r.value, count: r.count })) : []);
res.json({
res.json({
results: body.results.map((d) => Object.fromEntries(FIELDS.filter((k) => d[k] !== undefined).map((k) => [k, d[k]]))),
results: body.results.map((d) => Object.fromEntries(FIELDS.filter((k) => d[k] !== undefined).map((k) => [k, d[k]]))),
next_cursor: body.next_cursor || null,
next_cursor: body.next_cursor || null,
+ ...(body.facets ? { facets: { category: facetList(body.facets.category), channel: facetList(body.facets.channel) } } : {}),
});
});
});
});
+
+ router.get('/suggest', async (req, res) => {
+ res.set('Cache-Control', 'private, max-age=30');
+ const q = String(req.query.q || '').trim().slice(0, 100);
+ if (q.length < 2) return res.json({ suggestions: [] });
+ const params = new URLSearchParams({ q, owner: 'live', limit: '8' });
+ if (TYPES.has(req.query.type)) params.set('type', req.query.type);
+ try {
+ const r = await fetchImpl(`${baseUrl}/api/v1/suggest?${params}`, { headers: { accept: 'application/json' }, signal: AbortSignal.timeout(2000) });
+ const body = r.ok ? await r.json().catch(() => null) : null;
+ const list = body && Array.isArray(body.suggestions) ? body.suggestions : [];
+ res.json({ suggestions: list.filter((s) => s && TYPES.has(s.type) && s.title).map((s) => ({ type: s.type, id: s.id, title: s.title, canonical_url: s.canonical_url })) });
+ } catch {
+ res.json({ suggestions: [] }); // typing never shows an error; submitting does
+ }
+ });
return router;
return router;
}
}
@@ -16,7 +16,13 @@ const { createSearchRouter } = require('../server/search/routes');
@@ -16,7 +16,13 @@ const { createSearchRouter } = require('../server/search/routes');
if (mode === 'down') throw new Error('connect ECONNREFUSED');
if (mode === 'down') throw new Error('connect ECONNREFUSED');
if (mode === 'bad') return new Response('{"code":"search.bad_query"}', { status: 400 });
if (mode === 'bad') return new Response('{"code":"search.bad_query"}', { status: 400 });
if (mode === 'error') return new Response('{}', { status: 500 });
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 u = new URL(url);
+ if (u.pathname === '/api/v1/suggest') {
+ if (mode === 'suggest-down') throw new Error('timeout');
+ return new Response(JSON.stringify({ suggestions: [{ owner: 'live', type: 'channel', id: '7', title: 'Forum builders', canonical_url: 'https://openvibe.live/@forum' }, { owner: 'community', type: 'thread', id: 'x', title: 'Not ours' }] }), { status: 200 });
+ }
+ const facets = u.searchParams.get('facets') ? { facets: { category: [{ value: 'irl', count: 12 }, { value: 'gaming', count: 3 }], channel: [{ value: 'alex', count: 9 }, { value: 42, count: 1 }], secret: [{ value: 'x', count: 1 }] } } : {};
+ return new Response(JSON.stringify({ ...facets, 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();
const app = express();
app.use('/api/search', createSearchRouter({ fetchImpl, baseUrl: 'http://search.test' }));
app.use('/api/search', createSearchRouter({ fetchImpl, baseUrl: 'http://search.test' }));
@@ -32,14 +38,34 @@ const { createSearchRouter } = require('../server/search/routes');
@@ -32,14 +38,34 @@ const { createSearchRouter } = require('../server/search/routes');
assert.strictEqual(u.searchParams.get('cursor'), 'abc_1');
assert.strictEqual(u.searchParams.get('cursor'), 'abc_1');
assert.ok(!asked[0].opts.headers.authorization, 'anonymous: public documents only');
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' });
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' });
+ assert.strictEqual(u.searchParams.get('facets'), null, 'a later page asks no facets');
await get('/api/search?q=forum&type=paste&cursor=bad%20cursor');
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('type'), null, 'only Live\'s types');
assert.strictEqual(asked[1].url.searchParams.get('cursor'), null);
assert.strictEqual(asked[1].url.searchParams.get('cursor'), null);
+ // The first page carries the category and channel facets (well-formed rows only); category=/channel= narrow.
+ r = await get('/api/search?q=forum&category=irl&channel=alex');
+ const first = asked[asked.length - 1].url;
+ assert.deepStrictEqual([first.searchParams.get('facets'), first.searchParams.get('facet.category'), first.searchParams.get('facet.channel')], ['category,channel', 'irl', 'alex']);
+ assert.deepStrictEqual(r.body.facets, { category: [{ value: 'irl', count: 12 }, { value: 'gaming', count: 3 }], channel: [{ value: 'alex', count: 9 }] });
+ const before = asked.length;
r = await get('/api/search?q=');
r = await get('/api/search?q=');
assert.deepStrictEqual(r.body, { results: [], next_cursor: null });
assert.deepStrictEqual(r.body, { results: [], next_cursor: null });
- assert.strictEqual(asked.length, 2, 'an empty query asks nothing');
+ assert.strictEqual(asked.length, before, 'an empty query asks nothing');
+
+ // Suggestions: Live's own types only, nothing for one character, never an error.
+ r = await get('/api/search/suggest?q=fo&type=channel');
+ const su = asked[asked.length - 1].url;
+ assert.deepStrictEqual([su.pathname, su.searchParams.get('owner'), su.searchParams.get('type'), su.searchParams.get('limit')], ['/api/v1/suggest', 'live', 'channel', '8']);
+ assert.deepStrictEqual(r.body, { suggestions: [{ type: 'channel', id: '7', title: 'Forum builders', canonical_url: 'https://openvibe.live/@forum' }] });
+ const n = asked.length;
+ assert.deepStrictEqual((await get('/api/search/suggest?q=f')).body, { suggestions: [] });
+ assert.strictEqual(asked.length, n, 'one character asks nothing');
+ mode = 'suggest-down';
+ r = await get('/api/search/suggest?q=forum');
+ assert.deepStrictEqual([r.status, r.body], [200, { suggestions: [] }]);
+ mode = 'ok';
mode = 'down'; r = await get('/api/search?q=forum');
mode = 'down'; r = await get('/api/search?q=forum');
assert.strictEqual(r.status, 503); assert.ok(/not answering/.test(r.body.error));
assert.strictEqual(r.status, 503); assert.ok(/not answering/.test(r.body.error));