This repository has no description
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">×</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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
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">← 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">← 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">← 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);