openvibers/openvibe.directory Commits
Use shared composer for Directory search (#8)
Changes
@@@ -1,14 +1,19 @@'use strict';const kit=require('openvibe-shared/kit');++const thread=require('openvibe-shared/thread');const shell=require('openvibe-shared/shell');const serve=require('openvibe-shared/serve');const esc=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));const json=v=>JSON.stringify(v).replace(/</g,'\\u003c');const entryPath=e=>`/entries/${encodeURIComponent(e.kind)}/${encodeURIComponent(e.slug)}`;--function page(title,body,path='/',ld=[]){return shell.page({name:'OpenVibe.Directory',siteName:'OpenVibe.Directory',title,description:'Source-linked directories that keep themselves current.',canonical:`https://openvibe.directory${path}`,robots:'index, follow',jsonLd:ld,navbar:{service:'directory',apiBase:'https://openvibe.network',links:[{label:'Directories',href:'/'}]},footer:{service:'directory',variant:'full',mount:'#ov-footer'},head:`<link rel="stylesheet" href="${serve.url('kit.css')}">`,body:`<div id="navbar-mount"></div><main id="main" class="ovk-container">${body}</main>`});}++function page(title,body,path='/',ld=[]){return shell.page({name:'OpenVibe.Directory',siteName:'OpenVibe.Directory',title,description:'Source-linked directories that keep themselves current.',canonical:`https://openvibe.directory${path}`,robots:'index, follow',jsonLd:ld,navbar:{service:'directory',apiBase:'https://openvibe.network',links:[{label:'Directories',href:'/'}]},footer:{service:'directory',variant:'full',mount:'#ov-footer'},head:`<link rel="stylesheet" href="${serve.url('kit.css')}">${body.includes('data-ovt-composer')?`<link rel="stylesheet" href="${serve.url('thread.css')}"><script src="${serve.url('ov-composer.js')}" defer></script>`:''}`,body:`<div id="navbar-mount"></div><main id="main" class="ovk-container">${body}</main>`});}++function searchComposer(q='',kind=''){return thread.composer({action:'/search',method:'get',name:'q',value:q,++ id:'directory-search',label:'Search directory',placeholder:kind==='models'?'Find an AI model':'Search the directory',++ sendLabel:'Search directory',maxlength:120,hidden:kind&&kind!=='all'?{kind}:{},attrs:{'data-ovc-no-draft':'',style:'--ovt-line:var(--border,#1f2d47);--ovt-accent:var(--accent,#3b82f6)'},++ commands:[{name:'models',hint:'Find AI models',insert:'models'},{name:'providers',hint:'Find AI providers',insert:'providers'}]});}function card(e){return `<article class="ovk-card"><h2><a href="${entryPath(e)}">${esc(e.name)}</a></h2><p>${esc(e.description)}</p><p><small>Checked <time datetime="${esc(e.last_checked)}">${esc(e.last_checked.slice(0,10))}</time></small></p></article>`;}--function home(kinds,counts,entries,q=''){return `${kit.head({title:'Directory',sub:'Listings with sources and a last checked date.',icon:'directory',sticky:false})}<form action="/search"><label>Search <input type="search" name="q" value="${esc(q)}"></label> <button class="ovk-btn ovk-btn--primary">Search</button></form><p><a href="/people/me">Claim or manage your opt-in listing</a></p><ul>${Object.entries(kinds).map(([id,name])=>`<li><a href="/kinds/${id}">${esc(name)}</a> (${counts[id]||0})</li>`).join('')}</ul><h2>Recently checked</h2>${entries.slice(0,20).map(card).join('')}`;}--function listing(kind,name,entries,q=''){return `${kit.head({title:name,sub:`${entries.length} listings`,icon:'directory',sticky:false})}<form action="/search"><label>Search <input type="search" name="q" value="${esc(q)}"></label><input type="hidden" name="kind" value="${esc(kind)}"> <button class="ovk-btn ovk-btn--quiet">Search</button></form>${entries.length?entries.map(card).join(''):'<p>No listings available yet. The source will be checked again on the next refresh.</p>'}`;}++function home(kinds,counts,entries,q=''){return `${kit.head({title:'Directory',sub:'Listings with sources and a last checked date.',icon:'directory',sticky:false})}${searchComposer(q)}<p><a href="/people/me">Claim or manage your opt-in listing</a></p><ul>${Object.entries(kinds).map(([id,name])=>`<li><a href="/kinds/${id}">${esc(name)}</a> (${counts[id]||0})</li>`).join('')}</ul><h2>Recently checked</h2>${entries.slice(0,20).map(card).join('')}`;}++function listing(kind,name,entries,q=''){return `${kit.head({title:name,sub:`${entries.length} listings`,icon:'directory',sticky:false})}${searchComposer(q,kind)}${entries.length?entries.map(card).join(''):'<p>No listings available yet. The source will be checked again on the next refresh.</p>'}`;}function detail(e){return `${kit.head({title:e.name,sub:e.description,icon:'directory',sticky:false})}<p>Directory: <a href="/kinds/${esc(e.kind)}">${esc(e.kind)}</a></p><p>Last checked: <time datetime="${esc(e.last_checked)}">${esc(e.last_checked)}</time></p>${e.website?`<p><a class="ovk-btn ovk-btn--primary" href="${esc(e.website)}">Visit website</a></p>`:''}${e.phone?`<p>Business-published phone: ${esc(e.phone)}</p>`:''}<h2>Sources</h2><ul>${e.sources.map(s=>`<li><a href="${esc(s.url)}">${esc(s.url)}</a> · checked ${esc(s.checked_at)}</li>`).join('')}</ul>${e.osm_attribution?`<p>${esc(e.osm_attribution)} · <a href="https://www.openstreetmap.org/copyright">Licence and attribution</a></p>`:''}<p><a href="/api/v1/entries/${esc(e.kind)}/${encodeURIComponent(e.slug)}">JSON data</a></p>`;}function markdown(e){return `# ${e.name}\n\n${e.description}\n\nKind: ${e.kind}\nLast checked: ${e.last_checked}\n${e.website?`Website: ${e.website}\n`:''}${e.phone?`Business-published phone: ${e.phone}\n`:''}\nSources:\n${e.sources.map(s=>`- ${s.url} (checked ${s.checked_at})`).join('\n')}\n`;}function peopleForm(row){return `${kit.head({title:'Your listing',sub:'Choose what to publish in the opt-in people directory.',icon:'directory',sticky:false})}<form id="people-form"><label>Display name <input name="display_name" maxlength="100" required value="${esc(row?.display_name||'')}"></label><label>About <textarea name="bio" maxlength="500">${esc(row?.bio||'')}</textarea></label><label>Website <input name="website" type="url" value="${esc(row?.website||'')}"></label><label><input name="visible" type="checkbox" ${row?.visible?'checked':''}> Publish my listing</label><p>Only your display name, about text and website appear publicly. You can unpublish or delete this entry at any time.</p><button class="ovk-btn ovk-btn--primary" type="submit">Save listing</button> <button class="ovk-btn ovk-btn--quiet" type="button" id="delete-listing">Delete listing</button></form><p id="people-result" role="status"></p><script src="/people.js" defer></script>`;}@@@ -32,3 +32,19 @@ test('entry page emits structured data and escapes source text',async()=>{await new Promise(resolve=>server.once('listening',resolve));try{const base=`http://127.0.0.1:${server.address().port}`;const r=await fetch(base+'/entries/sites/x');const html=await r.text();assert.equal(r.status,200);assert.match(html,/schema.org/);assert.ok(!html.includes('<bad>'));const release=await fetch(base+'/release.json').then(x=>x.json());assert.equal(release.service,'directory');}finally{server.close();}});++test('directory and model searches use the shared composer and preserve the kind filter',async()=>{++ const store=createStore({fetchImpl:async()=>({ok:false,status:503})});store.start=()=>{};++ store.state.entries.set('models',[{kind:'models',id:'google/gemini',slug:'google-gemini',name:'Gemini',description:'Vision model',website:'https://openvibe.exchange/models/google%2Fgemini',last_checked:'2026-11-11T00:00:00.000Z',sources:[]}]);++ const server=createApp({store}).listen(0,'127.0.0.1');++ await new Promise(resolve=>server.once('listening',resolve));++ try{++ const base=`http://127.0.0.1:${server.address().port}`;++ const home=await fetch(base+'/').then(r=>r.text());++ assert.match(home,/data-ovt-composer/);assert.match(home,/ov-composer\.js\?v=[a-f0-9]+/);++ const modelsPage=await fetch(base+'/kinds/models').then(r=>r.text());++ assert.match(modelsPage,/class="ovt-c-row"/);assert.match(modelsPage,/class="ovt-c-tools"/);++ assert.match(modelsPage,/name="kind" value="models"/);++ const result=await fetch(base+'/search?kind=models&q=Gemini');++ assert.equal(result.status,200);assert.match(await result.text(),/name="q"[^>]*>Gemini<\/textarea>/);++ }finally{server.close();}++});
@@ -1,14 +1,19 @@
@@ -1,14 +1,19 @@
'use strict';
'use strict';
const kit=require('openvibe-shared/kit');
const kit=require('openvibe-shared/kit');
+const thread=require('openvibe-shared/thread');
const shell=require('openvibe-shared/shell');
const shell=require('openvibe-shared/shell');
const serve=require('openvibe-shared/serve');
const serve=require('openvibe-shared/serve');
const esc=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
const esc=v=>String(v??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
const json=v=>JSON.stringify(v).replace(/</g,'\\u003c');
const json=v=>JSON.stringify(v).replace(/</g,'\\u003c');
const entryPath=e=>`/entries/${encodeURIComponent(e.kind)}/${encodeURIComponent(e.slug)}`;
const entryPath=e=>`/entries/${encodeURIComponent(e.kind)}/${encodeURIComponent(e.slug)}`;
-function page(title,body,path='/',ld=[]){return shell.page({name:'OpenVibe.Directory',siteName:'OpenVibe.Directory',title,description:'Source-linked directories that keep themselves current.',canonical:`https://openvibe.directory${path}`,robots:'index, follow',jsonLd:ld,navbar:{service:'directory',apiBase:'https://openvibe.network',links:[{label:'Directories',href:'/'}]},footer:{service:'directory',variant:'full',mount:'#ov-footer'},head:`<link rel="stylesheet" href="${serve.url('kit.css')}">`,body:`<div id="navbar-mount"></div><main id="main" class="ovk-container">${body}</main>`});}
+function page(title,body,path='/',ld=[]){return shell.page({name:'OpenVibe.Directory',siteName:'OpenVibe.Directory',title,description:'Source-linked directories that keep themselves current.',canonical:`https://openvibe.directory${path}`,robots:'index, follow',jsonLd:ld,navbar:{service:'directory',apiBase:'https://openvibe.network',links:[{label:'Directories',href:'/'}]},footer:{service:'directory',variant:'full',mount:'#ov-footer'},head:`<link rel="stylesheet" href="${serve.url('kit.css')}">${body.includes('data-ovt-composer')?`<link rel="stylesheet" href="${serve.url('thread.css')}"><script src="${serve.url('ov-composer.js')}" defer></script>`:''}`,body:`<div id="navbar-mount"></div><main id="main" class="ovk-container">${body}</main>`});}
+function searchComposer(q='',kind=''){return thread.composer({action:'/search',method:'get',name:'q',value:q,
+ id:'directory-search',label:'Search directory',placeholder:kind==='models'?'Find an AI model':'Search the directory',
+ sendLabel:'Search directory',maxlength:120,hidden:kind&&kind!=='all'?{kind}:{},attrs:{'data-ovc-no-draft':'',style:'--ovt-line:var(--border,#1f2d47);--ovt-accent:var(--accent,#3b82f6)'},
+ commands:[{name:'models',hint:'Find AI models',insert:'models'},{name:'providers',hint:'Find AI providers',insert:'providers'}]});}
function card(e){return `<article class="ovk-card"><h2><a href="${entryPath(e)}">${esc(e.name)}</a></h2><p>${esc(e.description)}</p><p><small>Checked <time datetime="${esc(e.last_checked)}">${esc(e.last_checked.slice(0,10))}</time></small></p></article>`;}
function card(e){return `<article class="ovk-card"><h2><a href="${entryPath(e)}">${esc(e.name)}</a></h2><p>${esc(e.description)}</p><p><small>Checked <time datetime="${esc(e.last_checked)}">${esc(e.last_checked.slice(0,10))}</time></small></p></article>`;}
-function home(kinds,counts,entries,q=''){return `${kit.head({title:'Directory',sub:'Listings with sources and a last checked date.',icon:'directory',sticky:false})}<form action="/search"><label>Search <input type="search" name="q" value="${esc(q)}"></label> <button class="ovk-btn ovk-btn--primary">Search</button></form><p><a href="/people/me">Claim or manage your opt-in listing</a></p><ul>${Object.entries(kinds).map(([id,name])=>`<li><a href="/kinds/${id}">${esc(name)}</a> (${counts[id]||0})</li>`).join('')}</ul><h2>Recently checked</h2>${entries.slice(0,20).map(card).join('')}`;}
-function listing(kind,name,entries,q=''){return `${kit.head({title:name,sub:`${entries.length} listings`,icon:'directory',sticky:false})}<form action="/search"><label>Search <input type="search" name="q" value="${esc(q)}"></label><input type="hidden" name="kind" value="${esc(kind)}"> <button class="ovk-btn ovk-btn--quiet">Search</button></form>${entries.length?entries.map(card).join(''):'<p>No listings available yet. The source will be checked again on the next refresh.</p>'}`;}
+function home(kinds,counts,entries,q=''){return `${kit.head({title:'Directory',sub:'Listings with sources and a last checked date.',icon:'directory',sticky:false})}${searchComposer(q)}<p><a href="/people/me">Claim or manage your opt-in listing</a></p><ul>${Object.entries(kinds).map(([id,name])=>`<li><a href="/kinds/${id}">${esc(name)}</a> (${counts[id]||0})</li>`).join('')}</ul><h2>Recently checked</h2>${entries.slice(0,20).map(card).join('')}`;}
+function listing(kind,name,entries,q=''){return `${kit.head({title:name,sub:`${entries.length} listings`,icon:'directory',sticky:false})}${searchComposer(q,kind)}${entries.length?entries.map(card).join(''):'<p>No listings available yet. The source will be checked again on the next refresh.</p>'}`;}
function detail(e){return `${kit.head({title:e.name,sub:e.description,icon:'directory',sticky:false})}<p>Directory: <a href="/kinds/${esc(e.kind)}">${esc(e.kind)}</a></p><p>Last checked: <time datetime="${esc(e.last_checked)}">${esc(e.last_checked)}</time></p>${e.website?`<p><a class="ovk-btn ovk-btn--primary" href="${esc(e.website)}">Visit website</a></p>`:''}${e.phone?`<p>Business-published phone: ${esc(e.phone)}</p>`:''}<h2>Sources</h2><ul>${e.sources.map(s=>`<li><a href="${esc(s.url)}">${esc(s.url)}</a> · checked ${esc(s.checked_at)}</li>`).join('')}</ul>${e.osm_attribution?`<p>${esc(e.osm_attribution)} · <a href="https://www.openstreetmap.org/copyright">Licence and attribution</a></p>`:''}<p><a href="/api/v1/entries/${esc(e.kind)}/${encodeURIComponent(e.slug)}">JSON data</a></p>`;}
function detail(e){return `${kit.head({title:e.name,sub:e.description,icon:'directory',sticky:false})}<p>Directory: <a href="/kinds/${esc(e.kind)}">${esc(e.kind)}</a></p><p>Last checked: <time datetime="${esc(e.last_checked)}">${esc(e.last_checked)}</time></p>${e.website?`<p><a class="ovk-btn ovk-btn--primary" href="${esc(e.website)}">Visit website</a></p>`:''}${e.phone?`<p>Business-published phone: ${esc(e.phone)}</p>`:''}<h2>Sources</h2><ul>${e.sources.map(s=>`<li><a href="${esc(s.url)}">${esc(s.url)}</a> · checked ${esc(s.checked_at)}</li>`).join('')}</ul>${e.osm_attribution?`<p>${esc(e.osm_attribution)} · <a href="https://www.openstreetmap.org/copyright">Licence and attribution</a></p>`:''}<p><a href="/api/v1/entries/${esc(e.kind)}/${encodeURIComponent(e.slug)}">JSON data</a></p>`;}
function markdown(e){return `# ${e.name}\n\n${e.description}\n\nKind: ${e.kind}\nLast checked: ${e.last_checked}\n${e.website?`Website: ${e.website}\n`:''}${e.phone?`Business-published phone: ${e.phone}\n`:''}\nSources:\n${e.sources.map(s=>`- ${s.url} (checked ${s.checked_at})`).join('\n')}\n`;}
function markdown(e){return `# ${e.name}\n\n${e.description}\n\nKind: ${e.kind}\nLast checked: ${e.last_checked}\n${e.website?`Website: ${e.website}\n`:''}${e.phone?`Business-published phone: ${e.phone}\n`:''}\nSources:\n${e.sources.map(s=>`- ${s.url} (checked ${s.checked_at})`).join('\n')}\n`;}
function peopleForm(row){return `${kit.head({title:'Your listing',sub:'Choose what to publish in the opt-in people directory.',icon:'directory',sticky:false})}<form id="people-form"><label>Display name <input name="display_name" maxlength="100" required value="${esc(row?.display_name||'')}"></label><label>About <textarea name="bio" maxlength="500">${esc(row?.bio||'')}</textarea></label><label>Website <input name="website" type="url" value="${esc(row?.website||'')}"></label><label><input name="visible" type="checkbox" ${row?.visible?'checked':''}> Publish my listing</label><p>Only your display name, about text and website appear publicly. You can unpublish or delete this entry at any time.</p><button class="ovk-btn ovk-btn--primary" type="submit">Save listing</button> <button class="ovk-btn ovk-btn--quiet" type="button" id="delete-listing">Delete listing</button></form><p id="people-result" role="status"></p><script src="/people.js" defer></script>`;}
function peopleForm(row){return `${kit.head({title:'Your listing',sub:'Choose what to publish in the opt-in people directory.',icon:'directory',sticky:false})}<form id="people-form"><label>Display name <input name="display_name" maxlength="100" required value="${esc(row?.display_name||'')}"></label><label>About <textarea name="bio" maxlength="500">${esc(row?.bio||'')}</textarea></label><label>Website <input name="website" type="url" value="${esc(row?.website||'')}"></label><label><input name="visible" type="checkbox" ${row?.visible?'checked':''}> Publish my listing</label><p>Only your display name, about text and website appear publicly. You can unpublish or delete this entry at any time.</p><button class="ovk-btn ovk-btn--primary" type="submit">Save listing</button> <button class="ovk-btn ovk-btn--quiet" type="button" id="delete-listing">Delete listing</button></form><p id="people-result" role="status"></p><script src="/people.js" defer></script>`;}
@@ -32,3 +32,19 @@ test('entry page emits structured data and escapes source text',async()=>{
@@ -32,3 +32,19 @@ test('entry page emits structured data and escapes source text',async()=>{
await new Promise(resolve=>server.once('listening',resolve));
await new Promise(resolve=>server.once('listening',resolve));
try{const base=`http://127.0.0.1:${server.address().port}`;const r=await fetch(base+'/entries/sites/x');const html=await r.text();assert.equal(r.status,200);assert.match(html,/schema.org/);assert.ok(!html.includes('<bad>'));const release=await fetch(base+'/release.json').then(x=>x.json());assert.equal(release.service,'directory');}finally{server.close();}
try{const base=`http://127.0.0.1:${server.address().port}`;const r=await fetch(base+'/entries/sites/x');const html=await r.text();assert.equal(r.status,200);assert.match(html,/schema.org/);assert.ok(!html.includes('<bad>'));const release=await fetch(base+'/release.json').then(x=>x.json());assert.equal(release.service,'directory');}finally{server.close();}
});
});
+test('directory and model searches use the shared composer and preserve the kind filter',async()=>{
+ const store=createStore({fetchImpl:async()=>({ok:false,status:503})});store.start=()=>{};
+ store.state.entries.set('models',[{kind:'models',id:'google/gemini',slug:'google-gemini',name:'Gemini',description:'Vision model',website:'https://openvibe.exchange/models/google%2Fgemini',last_checked:'2026-11-11T00:00:00.000Z',sources:[]}]);
+ const server=createApp({store}).listen(0,'127.0.0.1');
+ await new Promise(resolve=>server.once('listening',resolve));
+ try{
+ const base=`http://127.0.0.1:${server.address().port}`;
+ const home=await fetch(base+'/').then(r=>r.text());
+ assert.match(home,/data-ovt-composer/);assert.match(home,/ov-composer\.js\?v=[a-f0-9]+/);
+ const modelsPage=await fetch(base+'/kinds/models').then(r=>r.text());
+ assert.match(modelsPage,/class="ovt-c-row"/);assert.match(modelsPage,/class="ovt-c-tools"/);
+ assert.match(modelsPage,/name="kind" value="models"/);
+ const result=await fetch(base+'/search?kind=models&q=Gemini');
+ assert.equal(result.status,200);assert.match(await result.text(),/name="q"[^>]*>Gemini<\/textarea>/);
+ }finally{server.close();}
+});