open source is social v-it.org
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

add skill indexing and display to explore tool

Add the skills table to the explore schema.
Subscribe to skill records in Jetstream and index create, update, and delete events.
Expose skills and skill publisher counts through the explore API.
Add a skills view, pagination, and stats cards to the explore UI.

+188 -1
+85
explore/public/index.html
··· 217 217 text-decoration: underline; 218 218 } 219 219 220 + .skill-item { 221 + padding: 12px 0; 222 + border-bottom: 1px solid #e5e7eb; 223 + } 224 + 225 + .skill-item:last-child { 226 + border-bottom: none; 227 + } 228 + 229 + .skill-title { 230 + font-weight: 600; 231 + margin-bottom: 4px; 232 + } 233 + 234 + .skill-meta { 235 + font-size: 0.85rem; 236 + color: #6b7280; 237 + } 238 + 239 + .skill-tag { 240 + display: inline-block; 241 + background: #e5e7eb; 242 + color: #374151; 243 + font-size: 0.75rem; 244 + padding: 1px 6px; 245 + border-radius: 3px; 246 + margin-right: 4px; 247 + } 248 + 220 249 .beacon-item { 221 250 display: flex; 222 251 justify-content: space-between; ··· 368 397 <main id="content"> 369 398 <nav class="sub-nav"> 370 399 <a href="#">caps</a> 400 + <a href="#skills">skills</a> 371 401 <a href="#beacons">beacons</a> 372 402 <a href="#stats">stats</a> 373 403 </nav> ··· 391 421 let capsCursor = null; 392 422 let capsContainer = null; 393 423 let currentBeaconFilter = null; 424 + let skillsCursor = null; 425 + let skillsContainer = null; 394 426 395 427 function timeAgo(dateStr) { 396 428 const now = Date.now(); ··· 428 460 const hash = location.hash.slice(1); 429 461 if (hash.startsWith('beacon/')) return { view: 'beacon', value: decodeURIComponent(hash.slice(7)) }; 430 462 if (hash === 'beacons') return { view: 'beacons' }; 463 + if (hash === 'skills') return { view: 'skills' }; 431 464 if (hash === 'stats') return { view: 'stats' }; 432 465 return { view: 'caps' }; 433 466 } ··· 437 470 document.querySelectorAll('.sub-nav a').forEach(function(a) { 438 471 const href = a.getAttribute('href'); 439 472 if (route.view === 'caps') a.classList.toggle('active', href === '#'); 473 + else if (route.view === 'skills') a.classList.toggle('active', href === '#skills'); 440 474 else if (route.view === 'beacons' || route.view === 'beacon') a.classList.toggle('active', href === '#beacons'); 441 475 else if (route.view === 'stats') a.classList.toggle('active', href === '#stats'); 442 476 else a.classList.remove('active'); ··· 453 487 return '<div class="cap-item"><div class="cap-title">' + title + '</div><div class="cap-meta">' + metaParts.join(' · ') + '</div></div>'; 454 488 } 455 489 490 + function renderSkillItem(skill) { 491 + const name = esc(skill.name) || 'unnamed'; 492 + const desc = skill.description ? '<div>' + esc(skill.description) + '</div>' : ''; 493 + const author = esc(displayName(skill)); 494 + const version = skill.version ? 'v' + esc(skill.version) : ''; 495 + const tags = skill.tags ? skill.tags.split(',').map(function(t) { return '<span class="skill-tag">' + esc(t.trim()) + '</span>'; }).join('') : ''; 496 + const time = timeAgo(skill.created_at); 497 + const metaParts = [author, version, time].filter(Boolean); 498 + return '<div class="skill-item"><div class="skill-title">' + name + '</div>' + desc + '<div class="skill-meta">' + metaParts.join(' · ') + (tags ? ' · ' + tags : '') + '</div></div>'; 499 + } 500 + 456 501 function appendLoadMore(el) { 457 502 const existing = el.querySelector('.load-more'); 458 503 if (existing) existing.remove(); ··· 475 520 el.appendChild(btn); 476 521 } 477 522 523 + function appendSkillsLoadMore(el) { 524 + const existing = el.querySelector('.load-more'); 525 + if (existing) existing.remove(); 526 + 527 + if (!skillsCursor) return; 528 + 529 + const btn = document.createElement('button'); 530 + btn.className = 'load-more'; 531 + btn.textContent = 'load more'; 532 + btn.onclick = async function() { 533 + btn.disabled = true; 534 + btn.textContent = 'loading...'; 535 + const data = await api('skills?cursor=' + encodeURIComponent(skillsCursor)); 536 + skillsCursor = data.cursor; 537 + skillsContainer.innerHTML += data.skills.map(renderSkillItem).join(''); 538 + btn.remove(); 539 + appendSkillsLoadMore(el); 540 + }; 541 + el.appendChild(btn); 542 + } 543 + 478 544 async function renderCaps(el) { 479 545 currentBeaconFilter = null; 480 546 capsCursor = null; ··· 492 558 appendLoadMore(el); 493 559 } 494 560 561 + async function renderSkills(el) { 562 + skillsCursor = null; 563 + const data = await api('skills'); 564 + skillsCursor = data.cursor; 565 + 566 + if (data.skills.length === 0) { 567 + el.innerHTML = '<div class="empty-state"><p>no skills yet — publish one with <code>vit ship --skill</code></p></div>'; 568 + return; 569 + } 570 + 571 + el.innerHTML = '<div id="skills-list"></div>'; 572 + skillsContainer = document.getElementById('skills-list'); 573 + skillsContainer.innerHTML = data.skills.map(renderSkillItem).join(''); 574 + appendSkillsLoadMore(el); 575 + } 576 + 495 577 async function renderBeaconCaps(el, beacon) { 496 578 currentBeaconFilter = beacon; 497 579 capsCursor = null; ··· 533 615 '<div class="stat-card"><div class="stat-number">' + data.total_vouches + '</div><div class="stat-label">vouches</div></div>' + 534 616 '<div class="stat-card"><div class="stat-number">' + data.total_beacons + '</div><div class="stat-label">beacons</div></div>' + 535 617 '<div class="stat-card"><div class="stat-number">' + data.active_dids + '</div><div class="stat-label">active participants</div></div>' + 618 + '<div class="stat-card"><div class="stat-number">' + data.total_skills + '</div><div class="stat-label">skills</div></div>' + 619 + '<div class="stat-card"><div class="stat-number">' + data.skill_publishers + '</div><div class="stat-label">skill publishers</div></div>' + 536 620 '</div>'; 537 621 } 538 622 ··· 542 626 const view = document.getElementById('view'); 543 627 544 628 if (route.view === 'caps') await renderCaps(view); 629 + else if (route.view === 'skills') await renderSkills(view); 545 630 else if (route.view === 'beacons') await renderBeacons(view); 546 631 else if (route.view === 'beacon') await renderBeaconCaps(view, route.value); 547 632 else if (route.view === 'stats') await renderStats(view);
+20
explore/schema.sql
··· 48 48 handle TEXT NOT NULL, 49 49 fetched_at TEXT NOT NULL DEFAULT (datetime('now')) 50 50 ); 51 + 52 + CREATE TABLE IF NOT EXISTS skills ( 53 + id INTEGER PRIMARY KEY AUTOINCREMENT, 54 + did TEXT NOT NULL, 55 + rkey TEXT NOT NULL, 56 + uri TEXT NOT NULL UNIQUE, 57 + cid TEXT, 58 + name TEXT NOT NULL, 59 + description TEXT, 60 + ref TEXT NOT NULL, 61 + version TEXT, 62 + tags TEXT, 63 + record_json TEXT NOT NULL, 64 + created_at TEXT NOT NULL, 65 + indexed_at TEXT NOT NULL DEFAULT (datetime('now')), 66 + UNIQUE(did, rkey) 67 + ); 68 + 69 + CREATE INDEX IF NOT EXISTS idx_skills_created_at ON skills(created_at DESC); 70 + CREATE INDEX IF NOT EXISTS idx_skills_name ON skills(name);
+37 -1
explore/src/api.js
··· 99 99 return json({ beacons: results }); 100 100 } 101 101 102 + if (pathname === '/api/skills') { 103 + const cursor = parseCursor(searchParams.get('cursor')); 104 + const limit = parseLimit(searchParams.get('limit')); 105 + const tag = searchParams.get('tag'); 106 + 107 + const conditions = []; 108 + const bindings = []; 109 + 110 + if (tag) { 111 + conditions.push('INSTR(s.tags, ?) > 0'); 112 + bindings.push(tag); 113 + } 114 + 115 + if (cursor) { 116 + conditions.push('s.id < ?'); 117 + bindings.push(cursor); 118 + } 119 + 120 + let sql = 'SELECT s.*, h.handle FROM skills s LEFT JOIN handles h ON s.did = h.did'; 121 + if (conditions.length > 0) { 122 + sql += ` WHERE ${conditions.join(' AND ')}`; 123 + } 124 + sql += ' ORDER BY s.id DESC LIMIT ?'; 125 + bindings.push(limit); 126 + 127 + const { results } = await env.DB.prepare(sql).bind(...bindings).all(); 128 + return json({ 129 + skills: results, 130 + cursor: results.length > 0 ? results[results.length - 1].id : null, 131 + }); 132 + } 133 + 102 134 if (pathname === '/api/stats') { 103 - const [caps, vouches, beacons, dids] = await env.DB.batch([ 135 + const [caps, vouches, beacons, dids, skills, skillPubs] = await env.DB.batch([ 104 136 env.DB.prepare('SELECT COUNT(*) as count FROM caps'), 105 137 env.DB.prepare('SELECT COUNT(*) as count FROM vouches'), 106 138 env.DB.prepare('SELECT COUNT(*) as count FROM beacons'), 107 139 env.DB.prepare('SELECT COUNT(DISTINCT did) as count FROM caps'), 140 + env.DB.prepare('SELECT COUNT(*) as count FROM skills'), 141 + env.DB.prepare('SELECT COUNT(DISTINCT did) as count FROM skills'), 108 142 ]); 109 143 110 144 return json({ ··· 112 146 total_vouches: vouches.results[0]?.count ?? 0, 113 147 total_beacons: beacons.results[0]?.count ?? 0, 114 148 active_dids: dids.results[0]?.count ?? 0, 149 + total_skills: skills.results[0]?.count ?? 0, 150 + skill_publishers: skillPubs.results[0]?.count ?? 0, 115 151 }); 116 152 } 117 153
+46
explore/src/jetstream.js
··· 5 5 6 6 const CAP_COLLECTION = 'org.v-it.cap'; 7 7 const VOUCH_COLLECTION = 'org.v-it.vouch'; 8 + const SKILL_COLLECTION = 'org.v-it.skill'; 8 9 const JETSTREAM_URL = 'wss://jetstream2.us-east.bsky.network/subscribe'; 9 10 const STREAM_DURATION_MS = 55_000; 10 11 ··· 192 193 } 193 194 } 194 195 196 + async function processSkillEvent(env, did, commit) { 197 + const { operation, rkey, record, cid } = commit; 198 + const uri = `at://${did}/${SKILL_COLLECTION}/${rkey}`; 199 + 200 + if (operation === 'create' || operation === 'update') { 201 + await env.DB.batch([ 202 + env.DB.prepare( 203 + `INSERT INTO skills (did, rkey, uri, cid, name, description, ref, version, tags, record_json, created_at) 204 + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) 205 + ON CONFLICT(did, rkey) DO UPDATE SET 206 + cid = excluded.cid, 207 + name = excluded.name, 208 + description = excluded.description, 209 + ref = excluded.ref, 210 + version = excluded.version, 211 + tags = excluded.tags, 212 + record_json = excluded.record_json, 213 + created_at = excluded.created_at`, 214 + ).bind( 215 + did, 216 + rkey, 217 + uri, 218 + cid ?? null, 219 + record.name, 220 + record.description || '', 221 + 'skill-' + record.name, 222 + record.version || null, 223 + (record.tags || []).join(','), 224 + JSON.stringify(record), 225 + record.createdAt, 226 + ), 227 + ]); 228 + return; 229 + } 230 + 231 + if (operation === 'delete') { 232 + await env.DB.prepare('DELETE FROM skills WHERE did = ? AND rkey = ?') 233 + .bind(did, rkey) 234 + .run(); 235 + } 236 + } 237 + 195 238 export async function streamEvents(env, cursor) { 196 239 const url = new URL(JETSTREAM_URL); 197 240 url.searchParams.append('wantedCollections', CAP_COLLECTION); 198 241 url.searchParams.append('wantedCollections', VOUCH_COLLECTION); 242 + url.searchParams.append('wantedCollections', SKILL_COLLECTION); 199 243 if (cursor) { 200 244 url.searchParams.set('cursor', cursor); 201 245 } ··· 251 295 await processCapEvent(env, msg.did, commit); 252 296 } else if (commit.collection === VOUCH_COLLECTION) { 253 297 await processVouchEvent(env, msg.did, commit); 298 + } else if (commit.collection === SKILL_COLLECTION) { 299 + await processSkillEvent(env, msg.did, commit); 254 300 } 255 301 })(); 256 302