This repository has no description
0

Configure Feed

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

stigmergic / src / frontend / app.ts
30 kB 841 lines
1/** 2 * Stigmergic frontend -- face-centric SPA. 3 * 4 * A face is the Yoneda embedding of a resource: all morphisms in and out, 5 * rendered as a single card. The face IS the object, defined by its 6 * relationships. If A->B, B->C, D->B, then B is a face with 7 * incoming from A,D and outgoing to C. 8 */ 9 10// ── Types ────────────────────────────────────────────────────────────────── 11 12interface Connection { 13 uri: string; 14 did: string; 15 source: string; 16 target: string; 17 connectionType: string; 18 note: string; 19 handle?: string | null; 20 createdAt?: string; 21} 22 23interface CardMetadata { 24 title: string; 25 description: string; 26 siteName?: string; 27 author?: string; 28 type?: string; 29 url: string; 30} 31 32interface Face { 33 uri: string; 34 meta: CardMetadata | null; 35 incoming: Connection[]; 36 outgoing: Connection[]; 37 degree: number; 38} 39 40interface Collection { 41 uri: string; 42 did: string; 43 rkey: string; 44 name: string; 45 description: string; 46 handle?: string | null; 47} 48 49interface GraphData { 50 connections: Connection[]; 51 resources: { uri: string; title: string; type: string }[]; 52 depth: number; 53 uri: string; 54} 55 56// ── State ────────────────────────────────────────────────────────────────── 57 58let session: { did: string; handle: string } | null = null; 59let currentPage: "explore" | "resource" | "graph" | "connect" = "explore"; 60const cardCache = new Map<string, CardMetadata>(); 61 62// ── XRPC helpers ─────────────────────────────────────────────────────────── 63 64async function xrpcGet<T>(method: string, params: Record<string, string>): Promise<T> { 65 const qs = new URLSearchParams(params).toString(); 66 const res = await fetch(`/xrpc/${method}?${qs}`); 67 if (!res.ok) { 68 const err = await res.text(); 69 throw new Error(`XRPC ${method} failed: ${res.status} ${err}`); 70 } 71 return res.json(); 72} 73 74// ── Card metadata resolution ──────────────────────────────────────────────── 75 76async function resolveCardMeta(url: string): Promise<CardMetadata | null> { 77 if (cardCache.has(url)) return cardCache.get(url)!; 78 79 try { 80 const data = await xrpcGet<any>( 81 "org.latha.strata.card.listRecords", 82 { "content.url": url, limit: "1" } 83 ); 84 const rec = data.records?.[0]; 85 if (rec?.value?.content?.metadata) { 86 const m = rec.value.content.metadata; 87 const meta: CardMetadata = { 88 title: m.title || url, 89 description: m.description || "", 90 siteName: m.siteName, 91 author: m.author, 92 type: m.type, 93 url, 94 }; 95 cardCache.set(url, meta); 96 return meta; 97 } 98 } catch {} 99 100 try { 101 const data = await xrpcGet<any>( 102 "org.latha.strata.citation.listRecords", 103 { url, limit: "1" } 104 ); 105 const rec = data.records?.[0]; 106 if (rec?.value) { 107 const meta: CardMetadata = { 108 title: rec.value.title || url, 109 description: rec.value.takeaway || "", 110 siteName: rec.value.domain, 111 type: "citation", 112 url, 113 }; 114 cardCache.set(url, meta); 115 return meta; 116 } 117 } catch {} 118 119 return null; 120} 121 122async function resolveBatch(uris: string[]): Promise<Map<string, CardMetadata | null>> { 123 const unique = [...new Set(uris.filter((u) => u.startsWith("http")))]; 124 const results = new Map<string, CardMetadata | null>(); 125 await Promise.all( 126 unique.map(async (url) => { 127 results.set(url, await resolveCardMeta(url)); 128 }) 129 ); 130 return results; 131} 132 133// ── Face construction ────────────────────────────────────────────────────── 134 135/** Build faces from a flat list of connections. 136 * Each unique URI that appears as source or target becomes a face. 137 * The face collects all edges touching that URI. */ 138function buildFaces(connections: Connection[]): Face[] { 139 const faceMap = new Map<string, { incoming: Connection[]; outgoing: Connection[] }>(); 140 141 for (const c of connections) { 142 if (!faceMap.has(c.source)) faceMap.set(c.source, { incoming: [], outgoing: [] }); 143 if (!faceMap.has(c.target)) faceMap.set(c.target, { incoming: [], outgoing: [] }); 144 145 faceMap.get(c.source)!.outgoing.push(c); 146 faceMap.get(c.target)!.incoming.push(c); 147 } 148 149 const faces: Face[] = []; 150 for (const [uri, edges] of faceMap) { 151 const degree = edges.incoming.length + edges.outgoing.length; 152 faces.push({ 153 uri, 154 meta: cardCache.get(uri) || null, 155 incoming: edges.incoming, 156 outgoing: edges.outgoing, 157 degree, 158 }); 159 } 160 161 // Sort by degree -- most connected faces first 162 faces.sort((a, b) => b.degree - a.degree); 163 return faces; 164} 165 166// ── Strata analysis ──────────────────────────────────────────────────────── 167 168async function runStrataAnalysis(url: string): Promise<{ citation: any; tid: string } | null> { 169 const res = await fetch("/xrpc/org.latha.strata.analyzeCard", { 170 method: "POST", 171 headers: { "Content-Type": "application/json" }, 172 body: JSON.stringify({ url }), 173 }); 174 if (!res.ok) { 175 const err = await res.text(); 176 throw new Error(`Analysis failed: ${err}`); 177 } 178 return res.json(); 179} 180 181function renderStrataModal(url: string): void { 182 const overlay = document.createElement("div"); 183 overlay.className = "modal-overlay"; 184 overlay.id = "strata-modal"; 185 186 overlay.innerHTML = ` 187 <div class="modal-content"> 188 <div class="modal-header"> 189 <h2>Strata Analysis</h2> 190 <button class="modal-close" id="strata-modal-close">&times;</button> 191 </div> 192 <div id="strata-modal-body"> 193 <div class="analysis-loading"> 194 <div class="spinner"></div> 195 <div>Extracting metadata from ${escHtml(truncateUri(url))}...</div> 196 </div> 197 </div> 198 </div> 199 `; 200 201 document.body.appendChild(overlay); 202 203 document.getElementById("strata-modal-close")?.addEventListener("click", closeStrataModal); 204 overlay.addEventListener("click", (e) => { 205 if (e.target === overlay) closeStrataModal(); 206 }); 207 208 runStrataAnalysis(url) 209 .then((result) => { 210 const body = document.getElementById("strata-modal-body"); 211 if (!body || !result) return; 212 213 const c = result.citation; 214 body.innerHTML = ` 215 <div class="analysis-section"> 216 <h3>Citation Generated</h3> 217 <div class="citation-card"> 218 <div class="citation-title">${escHtml(c.title)}</div> 219 <div class="citation-url">${escHtml(c.url)}</div> 220 <div class="citation-takeaway">${escHtml(c.takeaway)}</div> 221 <div class="citation-meta"> 222 <span class="citation-domain">${escHtml(c.domain)}</span> 223 <span class="citation-confidence ${escHtml(c.confidence?.split("#").pop() || "")}">${escHtml(c.confidence?.split("#").pop() || "medium")}</span> 224 </div> 225 ${c.tags?.length ? `<div class="citation-tags">${c.tags.map((t: string) => `<span class="tag">${escHtml(t)}</span>`).join("")}</div>` : ""} 226 </div> 227 </div> 228 <div class="analysis-section"> 229 <h3>Record</h3> 230 <pre class="citation-record">${escHtml(JSON.stringify(c, null, 2))}</pre> 231 </div> 232 `; 233 }) 234 .catch((err) => { 235 const body = document.getElementById("strata-modal-body"); 236 if (body) { 237 body.innerHTML = `<div class="analysis-error">Analysis failed: ${escHtml(err.message)}</div>`; 238 } 239 }); 240} 241 242function closeStrataModal(): void { 243 const modal = document.getElementById("strata-modal"); 244 if (modal) modal.remove(); 245} 246 247// ── Rendering helpers ────────────────────────────────────────────────────── 248 249function escHtml(s: string): string { 250 return s.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;"); 251} 252 253function truncateUri(uri: string): string { 254 if (uri.startsWith("http")) { 255 try { 256 const u = new URL(uri); 257 const path = u.pathname === "/" ? "" : u.pathname.slice(0, 40); 258 return u.hostname + path; 259 } catch { 260 return uri.slice(0, 60); 261 } 262 } 263 if (uri.startsWith("at://")) { 264 const parts = uri.split("/"); 265 return parts.slice(3).join("/") || uri.slice(0, 60); 266 } 267 return uri.slice(0, 60); 268} 269 270function connectionTypeLabel(t: string): string { 271 if (!t) return "relates"; 272 const parts = t.split("#"); 273 return parts[parts.length - 1] || t; 274} 275 276function connectionTypeIcon(t: string): string { 277 const label = connectionTypeLabel(t); 278 const icons: Record<string, string> = { 279 contains: "\u2192", 280 cites: "\u2190", 281 relates: "\u2194", 282 RELATED: "\u2194", 283 inspired: "\u2728", 284 contradicts: "\u26D4", 285 extends: "\u21D2", 286 forks: "\u21C4", 287 annotates: "\u270E", 288 }; 289 return icons[label] || "\u2194"; 290} 291 292function faviconUrl(hostname: string): string { 293 return `https://www.google.com/s2/favicons?domain=${encodeURIComponent(hostname)}&sz=32`; 294} 295 296/** Render a neighbor edge within a face -- compact pill with icon + metadata. */ 297function renderNeighborPill(uri: string, connType: string, direction: "in" | "out"): string { 298 const meta = cardCache.get(uri) || null; 299 const icon = direction === "in" ? "\u2190" : "\u2192"; 300 const typeLabel = connectionTypeLabel(connType); 301 const hostname = meta?.siteName || (() => { try { return new URL(uri).hostname; } catch { return ""; } })(); 302 const fav = faviconUrl(hostname.replace(/^www\./, "")); 303 304 if (meta) { 305 return `<a href="/resource?uri=${encodeURIComponent(uri)}" class="neighbor-pill" title="${escHtml(uri)}"> 306 <img class="pill-favicon" src="${fav}" alt="" width="14" height="14" loading="lazy"> 307 <span class="pill-type">${icon} ${escHtml(typeLabel)}</span> 308 <span class="pill-title">${escHtml(meta.title)}</span> 309 </a>`; 310 } 311 312 return `<a href="/resource?uri=${encodeURIComponent(uri)}" class="neighbor-pill" title="${escHtml(uri)}"> 313 <span class="pill-type">${icon} ${escHtml(typeLabel)}</span> 314 <span class="pill-title">${escHtml(truncateUri(uri))}</span> 315 </a>`; 316} 317 318// ── Page renderers ───────────────────────────────────────────────────────── 319 320function renderFaceFeed(faces: Face[]): void { 321 const feed = document.getElementById("face-feed"); 322 if (!feed) return; 323 324 if (faces.length === 0) { 325 feed.innerHTML = '<div class="empty">No faces yet. Connections create faces.</div>'; 326 return; 327 } 328 329 feed.innerHTML = faces 330 .map((face) => { 331 const meta = face.meta; 332 const hostname = meta?.siteName || (() => { try { return new URL(face.uri).hostname; } catch { return ""; } })(); 333 const fav = faviconUrl(hostname.replace(/^www\./, "")); 334 335 // Header: the face itself 336 const headerHtml = meta 337 ? `<a href="/resource?uri=${encodeURIComponent(face.uri)}" class="face-header"> 338 <img class="face-favicon" src="${fav}" alt="" width="20" height="20" loading="lazy"> 339 <div class="face-title-block"> 340 <div class="face-title">${escHtml(meta.title)}</div> 341 <div class="face-site">${escHtml(hostname)}${meta.author ? ` -- ${escHtml(meta.author)}` : ""}</div> 342 </div> 343 </a>` 344 : `<a href="/resource?uri=${encodeURIComponent(face.uri)}" class="face-header"> 345 <div class="face-title-block"> 346 <div class="face-title">${escHtml(truncateUri(face.uri))}</div> 347 </div> 348 </a>`; 349 350 // Degree badge 351 const degreeBadge = `<span class="face-degree">${face.degree}</span>`; 352 353 // Incoming neighbors 354 const incomingHtml = face.incoming.length > 0 355 ? `<div class="face-neighbors incoming"> 356 <div class="neighbors-label">${face.incoming.length} in</div> 357 <div class="neighbors-list">${face.incoming.map((c) => 358 renderNeighborPill(c.source, c.connectionType, "in") 359 ).join("")}</div> 360 </div>` 361 : ""; 362 363 // Outgoing neighbors 364 const outgoingHtml = face.outgoing.length > 0 365 ? `<div class="face-neighbors outgoing"> 366 <div class="neighbors-label">${face.outgoing.length} out</div> 367 <div class="neighbors-list">${face.outgoing.map((c) => 368 renderNeighborPill(c.target, c.connectionType, "out") 369 ).join("")}</div> 370 </div>` 371 : ""; 372 373 // Description 374 const descHtml = meta?.description 375 ? `<div class="face-desc">${escHtml(meta.description.slice(0, 200))}</div>` 376 : ""; 377 378 return ` 379 <div class="face-card" data-uri="${escHtml(face.uri)}"> 380 <div class="face-top"> 381 ${headerHtml} 382 <div class="face-actions"> 383 ${degreeBadge} 384 <a href="/graph?uri=${encodeURIComponent(face.uri)}" class="face-action-btn">graph</a> 385 ${face.uri.startsWith("http") ? `<button class="strata-btn" data-url="${escHtml(face.uri)}">Strata</button>` : ""} 386 </div> 387 </div> 388 ${descHtml} 389 <div class="face-edges"> 390 ${incomingHtml} 391 ${outgoingHtml} 392 </div> 393 </div>`; 394 }) 395 .join(""); 396} 397 398function renderCollectionsSidebar(collections: Collection[]): void { 399 const sidebar = document.getElementById("collections-sidebar"); 400 if (!sidebar) return; 401 402 if (collections.length === 0) { 403 sidebar.innerHTML = '<div class="sidebar-empty">No public collections</div>'; 404 return; 405 } 406 407 sidebar.innerHTML = collections 408 .map((c) => ` 409 <a href="/resource?uri=${encodeURIComponent(c.uri)}" class="sidebar-collection"> 410 <span class="sidebar-name">${escHtml(c.name)}</span> 411 <span class="sidebar-handle">${escHtml(c.handle || "")}</span> 412 </a>`) 413 .join(""); 414} 415 416function renderResourcePage(uri: string, incoming: any[], outgoing: any[], meta: CardMetadata | null): void { 417 const content = document.getElementById("page-content"); 418 if (!content) return; 419 420 const inConns = (incoming || []).map((r: any) => ({ 421 uri: r.uri, 422 did: r.did, 423 source: r.value?.source || "", 424 target: r.value?.target || "", 425 connectionType: r.value?.connectionType || "relates", 426 note: r.value?.note || "", 427 handle: r.profile?.handle || null, 428 })); 429 430 const outConns = (outgoing || []).map((r: any) => ({ 431 uri: r.uri, 432 did: r.did, 433 source: r.value?.source || "", 434 target: r.value?.target || "", 435 connectionType: r.value?.connectionType || "relates", 436 note: r.value?.note || "", 437 handle: r.profile?.handle || null, 438 })); 439 440 const headerHtml = meta 441 ? `<div class="resource-header-card"> 442 <img class="resource-favicon" src="${faviconUrl(meta.siteName || (() => { try { return new URL(uri).hostname; } catch { return ""; } })())}" alt="" width="32" height="32" loading="lazy"> 443 <div class="resource-meta"> 444 <h2 class="resource-title">${escHtml(meta.title)}</h2> 445 <div class="resource-site">${escHtml(meta.siteName || "")}${meta.author ? ` -- ${escHtml(meta.author)}` : ""}</div> 446 ${meta.description ? `<div class="resource-desc">${escHtml(meta.description)}</div>` : ""} 447 </div> 448 </div>` 449 : `<div class="resource-uri">${escHtml(uri)}</div>`; 450 451 content.innerHTML = ` 452 <div class="resource-page"> 453 <a href="/" class="back-link">&larr; Explore</a> 454 ${headerHtml} 455 <div class="resource-actions"> 456 <a href="/graph?uri=${encodeURIComponent(uri)}" class="action-btn">Graph</a> 457 <a href="/connect?source=${encodeURIComponent(uri)}" class="action-btn">Connect</a> 458 ${uri.startsWith("http") ? `<button class="action-btn strata-btn" data-url="${escHtml(uri)}">Strata</button>` : ""} 459 </div> 460 461 <div class="yoneda-section"> 462 <h3>Incoming <span class="yoneda-hint">C(-, a) -- what points here</span></h3> 463 <div class="connection-list" id="incoming-list"></div> 464 </div> 465 466 <div class="yoneda-section"> 467 <h3>Outgoing <span class="yoneda-hint">C(a, -) -- what this points at</span></h3> 468 <div class="connection-list" id="outgoing-list"></div> 469 </div> 470 </div> 471 `; 472 473 const renderList = (containerId: string, conns: Connection[]) => { 474 const el = document.getElementById(containerId); 475 if (!el) return; 476 if (conns.length === 0) { 477 el.innerHTML = '<div class="empty">No connections.</div>'; 478 return; 479 } 480 el.innerHTML = conns 481 .map((c) => { 482 const other = c.source === uri ? c.target : c.source; 483 const otherMeta = cardCache.get(other) || null; 484 const hostname = otherMeta?.siteName || (() => { try { return new URL(other).hostname; } catch { return ""; } })(); 485 const fav = faviconUrl(hostname.replace(/^www\./, "")); 486 487 if (otherMeta) { 488 return `<div class="connection-item compact"> 489 <img class="pill-favicon" src="${fav}" alt="" width="14" height="14" loading="lazy"> 490 <span class="conn-type-badge">${connectionTypeIcon(c.connectionType)} ${escHtml(connectionTypeLabel(c.connectionType))}</span> 491 <a href="/resource?uri=${encodeURIComponent(other)}" class="conn-uri">${escHtml(otherMeta.title)}</a> 492 <span class="conn-site">${escHtml(hostname)}</span> 493 ${c.note ? `<span class="conn-note-inline">${escHtml(c.note.slice(0, 80))}</span>` : ""} 494 <span class="conn-handle-inline">${escHtml(c.handle || "")}</span> 495 ${other.startsWith("http") ? `<button class="strata-btn compact" data-url="${escHtml(other)}">Strata</button>` : ""} 496 </div>`; 497 } 498 499 return `<div class="connection-item compact"> 500 <span class="conn-type-badge">${connectionTypeIcon(c.connectionType)} ${escHtml(connectionTypeLabel(c.connectionType))}</span> 501 <a href="/resource?uri=${encodeURIComponent(other)}" class="conn-uri">${escHtml(truncateUri(other))}</a> 502 ${c.note ? `<span class="conn-note-inline">${escHtml(c.note.slice(0, 80))}</span>` : ""} 503 <span class="conn-handle-inline">${escHtml(c.handle || "")}</span> 504 ${other.startsWith("http") ? `<button class="strata-btn compact" data-url="${escHtml(other)}">Strata</button>` : ""} 505 </div>`; 506 }) 507 .join(""); 508 }; 509 510 renderList("incoming-list", inConns); 511 renderList("outgoing-list", outConns); 512} 513 514function renderGraphPage(data: GraphData): void { 515 const content = document.getElementById("page-content"); 516 if (!content) return; 517 518 // Build faces from graph data 519 const faces = buildFaces(data.connections as Connection[]); 520 521 content.innerHTML = ` 522 <div class="graph-page"> 523 <a href="/" class="back-link">&larr; Explore</a> 524 <a href="/resource?uri=${encodeURIComponent(data.uri)}" class="back-link">Resource</a> 525 <h2>Graph: ${escHtml(truncateUri(data.uri))}</h2> 526 <p class="graph-meta">${data.connections.length} connections, ${faces.length} faces, depth ${data.depth}</p> 527 <div id="graph-faces" class="face-feed"></div> 528 </div> 529 `; 530 531 renderFaceFeed(faces); 532} 533 534function renderConnectForm(sourceUri?: string): void { 535 const content = document.getElementById("page-content"); 536 if (!content) return; 537 538 content.innerHTML = ` 539 <div class="connect-page"> 540 <a href="/" class="back-link">&larr; Explore</a> 541 <h2>Create Connection</h2> 542 <p class="connect-hint">A connection is a morphism in the knowledge graph. Source relates to target via a typed connection.</p> 543 544 <form id="connect-form" class="connect-form"> 545 <div class="form-field"> 546 <label for="source">Source</label> 547 <input type="text" id="source" name="source" value="${escHtml(sourceUri || "")}" placeholder="URL or AT URI" required> 548 </div> 549 <div class="form-field"> 550 <label for="target">Target</label> 551 <input type="text" id="target" name="target" placeholder="URL or AT URI" required> 552 </div> 553 <div class="form-field"> 554 <label for="connectionType">Type</label> 555 <select id="connectionType" name="connectionType"> 556 <option value="network.cosmik.connection#relates">relates</option> 557 <option value="network.cosmik.connection#contains">contains</option> 558 <option value="network.cosmik.connection#cites">cites</option> 559 <option value="network.cosmik.connection#inspired">inspired</option> 560 <option value="network.cosmik.connection#contradicts">contradicts</option> 561 <option value="network.cosmik.connection#extends">extends</option> 562 <option value="network.cosmik.connection#forks">forks</option> 563 <option value="network.cosmik.connection#annotates">annotates</option> 564 </select> 565 </div> 566 <div class="form-field"> 567 <label for="note">Note</label> 568 <textarea id="note" name="note" placeholder="Optional note about this connection" maxlength="1000"></textarea> 569 </div> 570 <div class="form-actions"> 571 <button type="submit" class="action-btn primary">Connect</button> 572 </div> 573 <div id="connect-result" class="connect-result"></div> 574 </form> 575 </div> 576 `; 577 578 document.getElementById("connect-form")?.addEventListener("submit", async (e) => { 579 e.preventDefault(); 580 const result = document.getElementById("connect-result"); 581 if (!result) return; 582 583 result.innerHTML = '<div class="pending">Creating connection on your PDS...</div>'; 584 585 const source = (document.getElementById("source") as HTMLInputElement).value; 586 const target = (document.getElementById("target") as HTMLInputElement).value; 587 const connectionType = (document.getElementById("connectionType") as unknown as HTMLSelectElement).value; 588 const note = (document.getElementById("note") as HTMLTextAreaElement).value; 589 590 const record = { 591 $type: "network.cosmik.connection", 592 source, 593 target, 594 connectionType, 595 note: note || undefined, 596 createdAt: new Date().toISOString(), 597 }; 598 599 result.innerHTML = `<div class="preview"> 600 <p>Record to create on your PDS:</p> 601 <pre>${escHtml(JSON.stringify(record, null, 2))}</pre> 602 <p class="preview-hint">OAuth write flow not yet connected. Create this record on your PDS to add it to the graph.</p> 603 </div>`; 604 }); 605} 606 607// ── Pages ────────────────────────────────────────────────────────────────── 608 609async function showExplore(): Promise<void> { 610 currentPage = "explore"; 611 const content = document.getElementById("page-content"); 612 if (!content) return; 613 614 content.innerHTML = ` 615 <div class="explore-page"> 616 <div class="explore-main"> 617 <div class="explore-header"> 618 <h2>Faces</h2> 619 <a href="/connect" class="action-btn">+ Connect</a> 620 </div> 621 <div id="face-feed" class="face-feed"></div> 622 </div> 623 <div class="explore-sidebar"> 624 <h3>Collections</h3> 625 <div id="collections-sidebar" class="collections-sidebar"></div> 626 </div> 627 </div> 628 `; 629 630 try { 631 const data = await xrpcGet<any>( 632 "org.latha.strata.connection.listRecords", 633 { limit: "100", profiles: "true" } 634 ); 635 const connections = (data.records || []).map((r: any) => ({ 636 uri: r.uri, 637 did: r.did, 638 source: r.value?.source || "", 639 target: r.value?.target || "", 640 connectionType: r.value?.connectionType || "relates", 641 note: r.value?.note || "", 642 handle: r.profile?.handle || null, 643 })); 644 645 // Resolve metadata for all URIs 646 const allUris = connections.flatMap((c: Connection) => [c.source, c.target]); 647 await resolveBatch(allUris); 648 649 // Build faces from connections 650 const faces = buildFaces(connections); 651 renderFaceFeed(faces); 652 } catch (err) { 653 console.error("Failed to load connections:", err); 654 const serverData = (window as any).__CONNECTIONS__; 655 if (serverData) { 656 await resolveBatch(serverData.flatMap((c: Connection) => [c.source, c.target])); 657 const faces = buildFaces(serverData); 658 renderFaceFeed(faces); 659 } 660 } 661 662 // Load collections sidebar 663 try { 664 const data = await xrpcGet<any>( 665 "org.latha.strata.collection.listRecords", 666 { accessType: "OPEN", limit: "20", profiles: "true" } 667 ); 668 const collections = (data.records || []).map((r: any) => ({ 669 uri: r.uri, 670 did: r.did, 671 rkey: r.rkey, 672 name: r.value?.name || "Untitled", 673 description: r.value?.description || "", 674 handle: r.profile?.handle || null, 675 })); 676 renderCollectionsSidebar(collections); 677 } catch (err) { 678 console.error("Failed to load collections:", err); 679 } 680} 681 682async function showResource(uri: string): Promise<void> { 683 currentPage = "resource"; 684 685 let incoming: any[] = []; 686 let outgoing: any[] = []; 687 688 try { 689 const [inData, outData] = await Promise.all([ 690 xrpcGet<any>("org.latha.strata.connection.getIncoming", { uri, limit: "100", profiles: "true" }), 691 xrpcGet<any>("org.latha.strata.connection.getOutgoing", { uri, limit: "100", profiles: "true" }), 692 ]); 693 incoming = inData.records || []; 694 outgoing = outData.records || []; 695 } catch (err) { 696 console.error("Failed to load resource connections:", err); 697 } 698 699 const meta = uri.startsWith("http") ? await resolveCardMeta(uri) : null; 700 701 const allUris: string[] = []; 702 for (const r of [...incoming, ...outgoing]) { 703 allUris.push(r.value?.source || "", r.value?.target || ""); 704 } 705 await resolveBatch(allUris.filter((u) => u && u.startsWith("http"))); 706 707 renderResourcePage(uri, incoming, outgoing, meta); 708} 709 710async function showGraph(uri: string): Promise<void> { 711 currentPage = "graph"; 712 713 try { 714 const data = await xrpcGet<GraphData>( 715 "org.latha.strata.connection.getGraph", 716 { uri, depth: "2", limit: "50" } 717 ); 718 719 // Resolve metadata for graph resources 720 const allUris = data.connections.flatMap((c: Connection) => [c.source, c.target]); 721 await resolveBatch(allUris); 722 723 renderGraphPage(data); 724 } catch (err) { 725 console.error("Failed to load graph:", err); 726 const content = document.getElementById("page-content"); 727 if (content) content.innerHTML = '<div class="empty">Failed to load graph.</div>'; 728 } 729} 730 731function showConnect(sourceUri?: string): void { 732 currentPage = "connect"; 733 renderConnectForm(sourceUri); 734} 735 736// ── Routing ─────────────────────────────────────────────────────────────── 737 738function getRoute(): { page: string; params: Record<string, string> } { 739 const url = new URL(window.location.href); 740 const path = url.pathname; 741 742 if (path === "/resource") { 743 return { page: "resource", params: { uri: url.searchParams.get("uri") || "" } }; 744 } 745 if (path === "/graph") { 746 return { page: "graph", params: { uri: url.searchParams.get("uri") || "" } }; 747 } 748 if (path === "/connect") { 749 return { page: "connect", params: { source: url.searchParams.get("source") || "" } }; 750 } 751 return { page: "explore", params: {} }; 752} 753 754async function route(): Promise<void> { 755 const { page, params } = getRoute(); 756 757 if (page === "resource" && params.uri) { 758 await showResource(params.uri); 759 } else if (page === "graph" && params.uri) { 760 await showGraph(params.uri); 761 } else if (page === "connect") { 762 showConnect(params.source); 763 } else { 764 await showExplore(); 765 } 766} 767 768// ── Auth ─────────────────────────────────────────────────────────────────── 769 770async function initAuth(): Promise<void> { 771 try { 772 const res = await fetch("/xrpc/org.latha.strata.getProfile", { 773 headers: { Authorization: "Bearer " + localStorage.getItem("stigmergic_at") }, 774 }); 775 if (res.ok) { 776 const data: any = await res.json(); 777 session = { did: data.did, handle: data.handle }; 778 updateAuthUI(); 779 return; 780 } 781 } catch {} 782 783 updateAuthUI(); 784} 785 786function updateAuthUI(): void { 787 const status = document.getElementById("auth-status"); 788 const loginBtn = document.getElementById("login-btn"); 789 if (!status || !loginBtn) return; 790 791 if (session) { 792 status.textContent = session.handle; 793 status.classList.add("visible"); 794 loginBtn.classList.add("hidden"); 795 } else { 796 status.classList.remove("visible"); 797 loginBtn.classList.remove("hidden"); 798 } 799} 800 801// ── Init ─────────────────────────────────────────────────────────────────── 802 803document.addEventListener("DOMContentLoaded", () => { 804 const searchInput = document.getElementById("search-input") as HTMLInputElement; 805 const searchBtn = document.getElementById("search-btn"); 806 807 searchBtn?.addEventListener("click", () => { 808 const q = searchInput?.value.trim(); 809 if (q) { 810 window.location.href = `/resource?uri=${encodeURIComponent(q)}`; 811 } 812 }); 813 814 searchInput?.addEventListener("keydown", (e: KeyboardEvent) => { 815 if (e.key === "Enter") { 816 const q = searchInput.value.trim(); 817 if (q) { 818 window.location.href = `/resource?uri=${encodeURIComponent(q)}`; 819 } 820 } 821 }); 822 823 document.getElementById("login-btn")?.addEventListener("click", () => { 824 alert("OAuth login not yet implemented. Use your PDS directly to create connection records."); 825 }); 826 827 // Strata analysis button delegation 828 document.addEventListener("click", (e: MouseEvent) => { 829 const target = e.target as HTMLElement; 830 831 if (target.classList.contains("strata-btn") && target.dataset.url) { 832 const url = target.dataset.url; 833 renderStrataModal(url); 834 } 835 }); 836 837 route(); 838 initAuth(); 839}); 840 841window.addEventListener("popstate", route);