This repository has no description
0

Configure Feed

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

add explore.v-it.org single-page explorer UI

Add the web frontend for the vit network explorer as a single static HTML file in explore/public.

Implement hash-based routing for the caps feed, beacon directory, per-beacon filtered feed, and stats dashboard. Include inline styling matching the docs site, cursor-based pagination for caps, responsive mobile behavior, and XSS-safe HTML rendering for user-provided data.

+453 -1
-1
explore/public/.gitkeep
··· 1 -
+453
explore/public/index.html
··· 1 + <!DOCTYPE html> 2 + <html lang="en"> 3 + <head> 4 + <meta charset="utf-8"> 5 + <meta name="viewport" content="width=device-width, initial-scale=1"> 6 + <title>vit explore</title> 7 + <meta name="description" content="vit network explorer — live capability and vouch activity"> 8 + <style> 9 + :root { 10 + color-scheme: light; 11 + } 12 + 13 + body { 14 + margin: 0; 15 + padding: 0; 16 + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; 17 + color: #111827; 18 + background: #ffffff; 19 + line-height: 1.6; 20 + } 21 + 22 + .container { 23 + max-width: 720px; 24 + margin: 0 auto; 25 + padding: 24px 20px 32px; 26 + } 27 + 28 + header h1 { 29 + margin: 0; 30 + font-size: 2rem; 31 + line-height: 1.2; 32 + cursor: default; 33 + } 34 + 35 + .tagline { 36 + margin: 6px 0 0; 37 + color: #4b5563; 38 + font-size: 1rem; 39 + } 40 + 41 + .header-bar { 42 + display: flex; 43 + align-items: baseline; 44 + justify-content: space-between; 45 + gap: 16px; 46 + } 47 + 48 + header { 49 + border-bottom: 1px solid #e5e7eb; 50 + padding-bottom: 16px; 51 + margin-bottom: 24px; 52 + } 53 + 54 + nav { 55 + display: flex; 56 + flex-wrap: wrap; 57 + gap: 14px; 58 + } 59 + 60 + nav a { 61 + color: #2563eb; 62 + text-decoration: none; 63 + } 64 + 65 + nav a:hover { 66 + text-decoration: underline; 67 + } 68 + 69 + nav a.active { 70 + text-decoration: underline; 71 + text-underline-offset: 3px; 72 + } 73 + 74 + main { 75 + min-height: 240px; 76 + } 77 + 78 + main h1, 79 + main h2, 80 + main h3, 81 + main h4 { 82 + line-height: 1.25; 83 + margin-top: 1.5em; 84 + margin-bottom: 0.6em; 85 + } 86 + 87 + main p, 88 + main ul, 89 + main ol, 90 + main blockquote, 91 + main pre { 92 + margin-top: 0; 93 + margin-bottom: 1em; 94 + } 95 + 96 + pre, 97 + code { 98 + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; 99 + } 100 + 101 + pre { 102 + background: #f3f4f6; 103 + padding: 12px; 104 + border-radius: 6px; 105 + overflow-x: auto; 106 + } 107 + 108 + code { 109 + background: #f3f4f6; 110 + padding: 0.1em 0.3em; 111 + border-radius: 4px; 112 + } 113 + 114 + pre code { 115 + background: transparent; 116 + padding: 0; 117 + border-radius: 0; 118 + } 119 + 120 + hr { 121 + border: 0; 122 + border-top: 1px solid #e5e7eb; 123 + margin: 1.5em 0; 124 + } 125 + 126 + footer { 127 + margin-top: 36px; 128 + padding-top: 16px; 129 + border-top: 1px solid #e5e7eb; 130 + font-size: 0.9rem; 131 + color: #6b7280; 132 + } 133 + 134 + footer a { 135 + color: #2563eb; 136 + } 137 + 138 + .cap-item { 139 + padding: 12px 0; 140 + border-bottom: 1px solid #e5e7eb; 141 + } 142 + 143 + .cap-item:last-child { 144 + border-bottom: none; 145 + } 146 + 147 + .cap-title { 148 + font-weight: 600; 149 + margin-bottom: 4px; 150 + } 151 + 152 + .cap-meta { 153 + font-size: 0.85rem; 154 + color: #6b7280; 155 + } 156 + 157 + .cap-meta a { 158 + color: #2563eb; 159 + text-decoration: none; 160 + } 161 + 162 + .cap-meta a:hover { 163 + text-decoration: underline; 164 + } 165 + 166 + .beacon-item { 167 + display: flex; 168 + justify-content: space-between; 169 + align-items: baseline; 170 + padding: 10px 0; 171 + border-bottom: 1px solid #e5e7eb; 172 + } 173 + 174 + .beacon-item:last-child { 175 + border-bottom: none; 176 + } 177 + 178 + .beacon-name a { 179 + color: #2563eb; 180 + text-decoration: none; 181 + font-weight: 600; 182 + } 183 + 184 + .beacon-name a:hover { 185 + text-decoration: underline; 186 + } 187 + 188 + .beacon-stats { 189 + font-size: 0.85rem; 190 + color: #6b7280; 191 + text-align: right; 192 + } 193 + 194 + .stat-grid { 195 + display: grid; 196 + grid-template-columns: 1fr 1fr; 197 + gap: 16px; 198 + } 199 + 200 + .stat-card { 201 + background: #f3f4f6; 202 + padding: 16px; 203 + border-radius: 6px; 204 + } 205 + 206 + .stat-number { 207 + font-size: 2rem; 208 + font-weight: 700; 209 + line-height: 1.2; 210 + } 211 + 212 + .stat-label { 213 + font-size: 0.85rem; 214 + color: #6b7280; 215 + margin-top: 4px; 216 + } 217 + 218 + .empty-state { 219 + color: #6b7280; 220 + padding: 32px 0; 221 + } 222 + 223 + .load-more { 224 + display: block; 225 + width: 100%; 226 + padding: 10px; 227 + margin-top: 16px; 228 + background: #f3f4f6; 229 + border: 1px solid #e5e7eb; 230 + border-radius: 6px; 231 + color: #2563eb; 232 + cursor: pointer; 233 + font-size: 0.9rem; 234 + font-family: inherit; 235 + } 236 + 237 + .load-more:hover { 238 + background: #e5e7eb; 239 + } 240 + 241 + .view-title { 242 + margin-top: 0; 243 + margin-bottom: 16px; 244 + font-size: 1.1rem; 245 + font-weight: 600; 246 + } 247 + 248 + @media (max-width: 480px) { 249 + .stat-grid { 250 + grid-template-columns: 1fr; 251 + } 252 + 253 + .beacon-item { 254 + flex-direction: column; 255 + gap: 4px; 256 + } 257 + 258 + .beacon-stats { 259 + text-align: left; 260 + } 261 + } 262 + </style> 263 + </head> 264 + <body> 265 + <div class="container"> 266 + <header> 267 + <div class="header-bar"> 268 + <div> 269 + <h1><a href="https://v-it.org" style="color: inherit; text-decoration: none;">vit explore</a></h1> 270 + <p class="tagline">network explorer</p> 271 + </div> 272 + <nav> 273 + <a href="#">caps</a> 274 + <a href="#beacons">beacons</a> 275 + <a href="#stats">stats</a> 276 + </nav> 277 + </div> 278 + </header> 279 + 280 + <main id="content"> 281 + <div id="view"></div> 282 + </main> 283 + 284 + <footer> 285 + part of <a href="https://solpbc.org">sol pbc</a> · <a href="https://v-it.org">v-it.org</a> 286 + </footer> 287 + </div> 288 + 289 + <script> 290 + let capsCursor = null; 291 + let capsContainer = null; 292 + let currentBeaconFilter = null; 293 + 294 + function timeAgo(dateStr) { 295 + const now = Date.now(); 296 + const then = new Date(dateStr).getTime(); 297 + const seconds = Math.floor((now - then) / 1000); 298 + if (seconds < 60) return 'just now'; 299 + const minutes = Math.floor(seconds / 60); 300 + if (minutes < 60) return minutes + (minutes === 1 ? ' minute ago' : ' minutes ago'); 301 + const hours = Math.floor(minutes / 60); 302 + if (hours < 24) return hours + (hours === 1 ? ' hour ago' : ' hours ago'); 303 + const days = Math.floor(hours / 24); 304 + if (days === 1) return 'yesterday'; 305 + if (days <= 7) return days + ' days ago'; 306 + return new Date(dateStr).toISOString().split('T')[0]; 307 + } 308 + 309 + function displayName(cap) { 310 + if (cap.handle) return cap.handle; 311 + const did = cap.did; 312 + if (did.length > 24) return did.slice(0, 12) + '...' + did.slice(-8); 313 + return did; 314 + } 315 + 316 + function esc(str) { 317 + if (!str) return ''; 318 + return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;'); 319 + } 320 + 321 + async function api(path) { 322 + const res = await fetch('/api/' + path); 323 + return res.json(); 324 + } 325 + 326 + function getRoute() { 327 + const hash = location.hash.slice(1); 328 + if (hash.startsWith('beacon/')) return { view: 'beacon', value: decodeURIComponent(hash.slice(7)) }; 329 + if (hash === 'beacons') return { view: 'beacons' }; 330 + if (hash === 'stats') return { view: 'stats' }; 331 + return { view: 'caps' }; 332 + } 333 + 334 + function updateNav() { 335 + const route = getRoute(); 336 + document.querySelectorAll('nav a').forEach(function(a) { 337 + const href = a.getAttribute('href'); 338 + if (route.view === 'caps') a.classList.toggle('active', href === '#'); 339 + else if (route.view === 'beacons' || route.view === 'beacon') a.classList.toggle('active', href === '#beacons'); 340 + else if (route.view === 'stats') a.classList.toggle('active', href === '#stats'); 341 + else a.classList.remove('active'); 342 + }); 343 + } 344 + 345 + function renderCapItem(cap) { 346 + const title = esc(cap.title) || 'untitled'; 347 + const name = esc(displayName(cap)); 348 + const ref = cap.ref ? '<code>' + esc(cap.ref) + '</code>' : ''; 349 + const beacon = cap.beacon ? '<a href="#beacon/' + encodeURIComponent(cap.beacon) + '">' + esc(cap.beacon) + '</a>' : ''; 350 + const time = timeAgo(cap.created_at); 351 + const metaParts = [name, beacon, ref, time].filter(Boolean); 352 + return '<div class="cap-item"><div class="cap-title">' + title + '</div><div class="cap-meta">' + metaParts.join(' · ') + '</div></div>'; 353 + } 354 + 355 + function appendLoadMore(el) { 356 + const existing = el.querySelector('.load-more'); 357 + if (existing) existing.remove(); 358 + 359 + if (!capsCursor) return; 360 + 361 + const btn = document.createElement('button'); 362 + btn.className = 'load-more'; 363 + btn.textContent = 'load more'; 364 + btn.onclick = async function() { 365 + btn.disabled = true; 366 + btn.textContent = 'loading...'; 367 + const path = 'caps?cursor=' + encodeURIComponent(capsCursor) + (currentBeaconFilter ? '&beacon=' + encodeURIComponent(currentBeaconFilter) : ''); 368 + const data = await api(path); 369 + capsCursor = data.cursor; 370 + capsContainer.innerHTML += data.caps.map(renderCapItem).join(''); 371 + btn.remove(); 372 + appendLoadMore(el); 373 + }; 374 + el.appendChild(btn); 375 + } 376 + 377 + async function renderCaps(el) { 378 + currentBeaconFilter = null; 379 + capsCursor = null; 380 + const data = await api('caps'); 381 + capsCursor = data.cursor; 382 + 383 + if (data.caps.length === 0) { 384 + el.innerHTML = '<div class="empty-state"><p>no caps yet — be the first to <code>vit ship</code></p></div>'; 385 + return; 386 + } 387 + 388 + el.innerHTML = '<div id="caps-list"></div>'; 389 + capsContainer = document.getElementById('caps-list'); 390 + capsContainer.innerHTML = data.caps.map(renderCapItem).join(''); 391 + appendLoadMore(el); 392 + } 393 + 394 + async function renderBeaconCaps(el, beacon) { 395 + currentBeaconFilter = beacon; 396 + capsCursor = null; 397 + const data = await api('caps?beacon=' + encodeURIComponent(beacon)); 398 + capsCursor = data.cursor; 399 + 400 + const heading = '<h2 class="view-title">' + esc(beacon) + '</h2>'; 401 + 402 + if (data.caps.length === 0) { 403 + el.innerHTML = heading + '<div class="empty-state"><p>no caps in this beacon yet</p></div>'; 404 + return; 405 + } 406 + 407 + el.innerHTML = heading + '<div id="caps-list"></div>'; 408 + capsContainer = document.getElementById('caps-list'); 409 + capsContainer.innerHTML = data.caps.map(renderCapItem).join(''); 410 + appendLoadMore(el); 411 + } 412 + 413 + async function renderBeacons(el) { 414 + const data = await api('beacons'); 415 + 416 + if (data.beacons.length === 0) { 417 + el.innerHTML = '<div class="empty-state"><p>no beacons yet — create one with <code>vit init</code></p></div>'; 418 + return; 419 + } 420 + 421 + el.innerHTML = data.beacons.map(function(beacon) { 422 + const name = esc(beacon.name); 423 + const time = beacon.last_activity ? timeAgo(beacon.last_activity) : 'no activity'; 424 + return '<div class="beacon-item"><div class="beacon-name"><a href="#beacon/' + encodeURIComponent(beacon.name) + '">' + name + '</a></div><div class="beacon-stats">' + beacon.cap_count + ' caps · ' + beacon.vouch_count + ' vouches · ' + time + '</div></div>'; 425 + }).join(''); 426 + } 427 + 428 + async function renderStats(el) { 429 + const data = await api('stats'); 430 + el.innerHTML = '<div class="stat-grid">' + 431 + '<div class="stat-card"><div class="stat-number">' + data.total_caps + '</div><div class="stat-label">capabilities</div></div>' + 432 + '<div class="stat-card"><div class="stat-number">' + data.total_vouches + '</div><div class="stat-label">vouches</div></div>' + 433 + '<div class="stat-card"><div class="stat-number">' + data.total_beacons + '</div><div class="stat-label">beacons</div></div>' + 434 + '<div class="stat-card"><div class="stat-number">' + data.active_dids + '</div><div class="stat-label">active participants</div></div>' + 435 + '</div>'; 436 + } 437 + 438 + async function navigate() { 439 + updateNav(); 440 + const route = getRoute(); 441 + const view = document.getElementById('view'); 442 + 443 + if (route.view === 'caps') await renderCaps(view); 444 + else if (route.view === 'beacons') await renderBeacons(view); 445 + else if (route.view === 'beacon') await renderBeaconCaps(view, route.value); 446 + else if (route.view === 'stats') await renderStats(view); 447 + } 448 + 449 + window.addEventListener('hashchange', navigate); 450 + navigate(); 451 + </script> 452 + </body> 453 + </html>