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)

OP OpenVibers committed 26 Sept 2026 · fe1fdf911f · 5 files, +187 −13


Changes

ddiff --git a/public/css/features/search.css b/public/css/features/search.css
iindex 4644d966..05bb40e3 100644
---- a/public/css/features/search.css
++++ b/public/css/features/search.css
@@@ -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; }
++
ddiff --git a/public/fragments/search.html b/public/fragments/search.html
iindex aa546477..d76267fd 100644
---- a/public/fragments/search.html
++++ b/public/fragments/search.html
@@@ -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>
ddiff --git a/public/js/app-search.js b/public/js/app-search.js
iindex ca203b5b..2a42b02a 100644
---- a/public/js/app-search.js
++++ b/public/js/app-search.js
@@@ -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);
ddiff --git a/server/search/routes.js b/server/search/routes.js
iindex 4aaf7fd3..73829290 100644
---- a/server/search/routes.js
++++ b/server/search/routes.js
@@@ -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_URL
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);
++ 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_URL
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');
++ 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;
}
ddiff --git a/test/search-page.test.js b/test/search-page.test.js
iindex d6e18891..ea1dc92e 100644
---- a/test/search-page.test.js
++++ b/test/search-page.test.js
@@@ -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));