interactive intro to open social
at-me.zzstoatzz.io
1pub fn login_page() -> &'static str {
2 r#"
3<!DOCTYPE html>
4<html>
5<head>
6 <meta charset="UTF-8">
7 <title>@me - login</title>
8 <link rel="icon" type="image/svg+xml" href="/favicon.svg">
9 <style>
10 * { margin: 0; padding: 0; box-sizing: border-box; }
11 body { font-family: 'Monaco', 'Courier New', monospace; display: flex; align-items: center; justify-content: center; height: 100vh; background: #000; color: #0f0; }
12 .container { text-align: center; }
13 h1 { font-size: 2rem; margin-bottom: 2rem; }
14 input { font-family: 'Monaco', 'Courier New', monospace; font-size: 0.9rem; padding: 0.5rem; margin: 0.5rem; background: #000; border: 1px solid #0f0; color: #0f0; }
15 button { font-family: 'Monaco', 'Courier New', monospace; font-size: 0.9rem; padding: 0.5rem 1rem; cursor: pointer; background: #000; border: 1px solid #0f0; color: #0f0; }
16 button:hover { background: #0f0; color: #000; }
17 .hidden { display: none; }
18 .loading { color: #0f0; opacity: 0.5; }
19 </style>
20</head>
21<body>
22 <div class="container">
23 <div id="restoring" class="loading hidden">restoring session...</div>
24 <form id="loginForm" method="post" action="/login">
25 <h1>@me</h1>
26 <input type="text" name="handle" placeholder="handle.bsky.social" required autofocus>
27 <button type="submit">login</button>
28 </form>
29 </div>
30 <script>
31 const savedDid = localStorage.getItem('atme_did');
32 if (savedDid) {
33 document.getElementById('loginForm').classList.add('hidden');
34 document.getElementById('restoring').classList.remove('hidden');
35
36 fetch('/api/restore-session', {
37 method: 'POST',
38 headers: { 'Content-Type': 'application/json' },
39 body: JSON.stringify({ did: savedDid })
40 }).then(r => {
41 if (r.ok) {
42 window.location.href = '/';
43 } else {
44 localStorage.removeItem('atme_did');
45 document.getElementById('loginForm').classList.remove('hidden');
46 document.getElementById('restoring').classList.add('hidden');
47 }
48 }).catch(() => {
49 localStorage.removeItem('atme_did');
50 document.getElementById('loginForm').classList.remove('hidden');
51 document.getElementById('restoring').classList.add('hidden');
52 });
53 }
54 </script>
55</body>
56</html>
57 "#
58}
59
60pub fn app_page(did: &str) -> String {
61 format!(r#"
62<!DOCTYPE html>
63<html>
64<head>
65 <meta charset="UTF-8">
66 <meta name="viewport" content="width=device-width, initial-scale=1.0">
67 <title>@me</title>
68 <link rel="icon" type="image/svg+xml" href="/favicon.svg">
69 <style>
70 * {{ margin: 0; padding: 0; box-sizing: border-box; }}
71
72 :root {{
73 --bg: #f5f1e8;
74 --text: #4a4238;
75 --text-light: #8a7a6a;
76 --text-lighter: #6b5d4f;
77 --border: #c9bfa8;
78 --surface: #e5dbc8;
79 --surface-hover: #d9cdb5;
80 }}
81
82 @media (prefers-color-scheme: dark) {{
83 :root {{
84 --bg: #1a1a1a;
85 --text: #e5e5e5;
86 --text-light: #a0a0a0;
87 --text-lighter: #c0c0c0;
88 --border: #404040;
89 --surface: #2a2a2a;
90 --surface-hover: #353535;
91 }}
92 }}
93
94 body {{
95 font-family: ui-monospace, 'SF Mono', Monaco, 'Cascadia Code', 'Roboto Mono', Menlo, 'Courier New', monospace;
96 height: 100vh;
97 background: var(--bg);
98 color: var(--text);
99 overflow: hidden;
100 position: relative;
101 -webkit-font-smoothing: antialiased;
102 -moz-osx-font-smoothing: grayscale;
103 }}
104
105 .canvas {{
106 width: 100%;
107 height: 100%;
108 position: relative;
109 display: flex;
110 align-items: center;
111 justify-content: center;
112 }}
113
114 .logout {{
115 position: fixed;
116 top: 1.5rem;
117 right: 1.5rem;
118 font-size: 0.7rem;
119 color: var(--text-light);
120 text-decoration: none;
121 border: 1px solid var(--border);
122 padding: 0.4rem 0.8rem;
123 transition: all 0.2s ease;
124 z-index: 100;
125 -webkit-tap-highlight-color: transparent;
126 cursor: pointer;
127 border-radius: 2px;
128 }}
129
130 .logout:hover, .logout:active {{
131 background: var(--surface);
132 color: var(--text);
133 border-color: var(--text-light);
134 }}
135
136 @media (max-width: 768px) {{
137 .logout {{
138 padding: 0.6rem 1rem;
139 font-size: 0.75rem;
140 top: 1rem;
141 right: 1rem;
142 }}
143 }}
144
145 .info {{
146 position: fixed;
147 top: 1.5rem;
148 left: 1.5rem;
149 width: 32px;
150 height: 32px;
151 border-radius: 50%;
152 border: 1px solid var(--border);
153 display: flex;
154 align-items: center;
155 justify-content: center;
156 font-size: 0.75rem;
157 color: var(--text-light);
158 cursor: pointer;
159 transition: all 0.2s ease;
160 z-index: 100;
161 -webkit-tap-highlight-color: transparent;
162 }}
163
164 .info:hover, .info:active {{
165 background: var(--surface);
166 color: var(--text);
167 border-color: var(--text-light);
168 }}
169
170 @media (max-width: 768px) {{
171 .info {{
172 width: 40px;
173 height: 40px;
174 font-size: 0.85rem;
175 top: 1rem;
176 left: 1rem;
177 }}
178 }}
179
180 .info-modal {{
181 position: fixed;
182 top: 50%;
183 left: 50%;
184 transform: translate(-50%, -50%);
185 background: var(--surface);
186 border: 2px solid var(--border);
187 padding: 2rem;
188 max-width: 500px;
189 width: 90%;
190 z-index: 2000;
191 display: none;
192 border-radius: 4px;
193 }}
194
195 @media (max-width: 768px) {{
196 .info-modal {{
197 padding: 1.5rem;
198 width: 95%;
199 }}
200
201 .info-modal h2 {{
202 font-size: 0.9rem;
203 }}
204
205 .info-modal p {{
206 font-size: 0.7rem;
207 }}
208 }}
209
210 .info-modal.visible {{
211 display: block;
212 }}
213
214 .info-modal h2 {{
215 margin-bottom: 1rem;
216 font-size: 1rem;
217 color: var(--text);
218 }}
219
220 .info-modal p {{
221 margin-bottom: 0.75rem;
222 font-size: 0.75rem;
223 line-height: 1.5;
224 color: var(--text-lighter);
225 }}
226
227 .info-modal button {{
228 margin-top: 1rem;
229 padding: 0.5rem 1rem;
230 background: var(--bg);
231 border: 1px solid var(--border);
232 color: var(--text);
233 font-family: inherit;
234 font-size: 0.7rem;
235 cursor: pointer;
236 transition: all 0.2s ease;
237 -webkit-tap-highlight-color: transparent;
238 border-radius: 2px;
239 }}
240
241 .info-modal button:hover, .info-modal button:active {{
242 background: var(--surface-hover);
243 border-color: var(--text-light);
244 }}
245
246 @media (max-width: 768px) {{
247 .info-modal button {{
248 padding: 0.65rem 1.2rem;
249 font-size: 0.75rem;
250 }}
251 }}
252
253 .overlay {{
254 position: fixed;
255 top: 0;
256 left: 0;
257 right: 0;
258 bottom: 0;
259 background: rgba(0, 0, 0, 0.5);
260 z-index: 1999;
261 display: none;
262 }}
263
264 .overlay.visible {{
265 display: block;
266 }}
267
268 .identity {{
269 position: absolute;
270 background: var(--surface);
271 border: 2px solid var(--text-light);
272 border-radius: 50%;
273 width: 120px;
274 height: 120px;
275 display: flex;
276 flex-direction: column;
277 align-items: center;
278 justify-content: center;
279 gap: 0.3rem;
280 z-index: 10;
281 cursor: pointer;
282 transition: all 0.2s ease;
283 -webkit-tap-highlight-color: transparent;
284 }}
285
286 .identity:hover, .identity:active {{
287 transform: scale(1.05);
288 border-color: var(--text);
289 box-shadow: 0 0 20px rgba(255, 255, 255, 0.1);
290 }}
291
292 @media (max-width: 768px) {{
293 .identity {{
294 width: 100px;
295 height: 100px;
296 }}
297 }}
298
299 .identity-label {{
300 font-size: 0.45rem;
301 color: var(--text-lighter);
302 letter-spacing: 0.1em;
303 }}
304
305 .identity-value {{
306 font-size: 0.75rem;
307 color: var(--text);
308 text-align: center;
309 word-break: break-word;
310 max-width: 100px;
311 font-weight: 500;
312 }}
313
314 .identity-hint {{
315 font-size: 0.4rem;
316 color: var(--text-lighter);
317 margin-top: 0.2rem;
318 letter-spacing: 0.05em;
319 }}
320
321 .app-view {{
322 position: absolute;
323 display: flex;
324 flex-direction: column;
325 align-items: center;
326 gap: 0.4rem;
327 cursor: pointer;
328 transition: all 0.2s ease;
329 opacity: 0.7;
330 }}
331
332 .app-view:hover {{
333 opacity: 1;
334 transform: scale(1.1);
335 z-index: 100;
336 }}
337
338 .app-circle {{
339 background: var(--surface-hover);
340 border: 1px solid var(--border);
341 border-radius: 50%;
342 width: 60px;
343 height: 60px;
344 display: flex;
345 align-items: center;
346 justify-content: center;
347 transition: all 0.2s ease;
348 }}
349
350 .app-view:hover .app-circle {{
351 background: var(--surface);
352 border-color: var(--text-light);
353 }}
354
355 .app-name {{
356 font-size: 0.65rem;
357 color: var(--text);
358 text-align: center;
359 max-width: 100px;
360 }}
361
362 .detail-panel {{
363 position: fixed;
364 top: 0;
365 left: 0;
366 bottom: 0;
367 width: 320px;
368 background: var(--surface);
369 border-right: 2px solid var(--border);
370 padding: 2.5rem 2rem;
371 overflow-y: auto;
372 opacity: 0;
373 transform: translateX(-100%);
374 transition: all 0.25s ease;
375 z-index: 1000;
376 }}
377
378 .detail-panel.visible {{
379 opacity: 1;
380 transform: translateX(0);
381 }}
382
383 @media (max-width: 768px) {{
384 .detail-panel {{
385 width: 100%;
386 padding: 4rem 1.5rem 2rem;
387 border-right: none;
388 border-bottom: 2px solid var(--border);
389 }}
390 }}
391
392 .detail-panel h3 {{
393 margin-bottom: 0.75rem;
394 font-size: 0.85rem;
395 color: var(--text);
396 }}
397
398 .detail-panel .subtitle {{
399 font-size: 0.7rem;
400 color: var(--text-light);
401 margin-bottom: 1.5rem;
402 line-height: 1.4;
403 }}
404
405 .detail-close {{
406 position: absolute;
407 top: 1.5rem;
408 right: 1.5rem;
409 width: 32px;
410 height: 32px;
411 border: 1px solid var(--border);
412 background: var(--bg);
413 color: var(--text-light);
414 cursor: pointer;
415 display: flex;
416 align-items: center;
417 justify-content: center;
418 font-size: 1.2rem;
419 line-height: 1;
420 transition: all 0.2s ease;
421 border-radius: 2px;
422 -webkit-tap-highlight-color: transparent;
423 }}
424
425 .detail-close:hover, .detail-close:active {{
426 background: var(--surface-hover);
427 border-color: var(--text-light);
428 color: var(--text);
429 }}
430
431 @media (max-width: 768px) {{
432 .detail-close {{
433 top: 1rem;
434 right: 1rem;
435 width: 40px;
436 height: 40px;
437 font-size: 1.4rem;
438 }}
439 }}
440
441 .tree-item {{
442 padding: 0.65rem 0.75rem;
443 font-size: 0.75rem;
444 color: var(--text-lighter);
445 background: var(--bg);
446 border: 1px solid var(--border);
447 border-radius: 2px;
448 margin-bottom: 0.5rem;
449 transition: all 0.15s ease;
450 cursor: pointer;
451 -webkit-tap-highlight-color: transparent;
452 }}
453
454 .tree-item:hover, .tree-item:active {{
455 background: var(--surface-hover);
456 border-color: var(--text-light);
457 }}
458
459 @media (max-width: 768px) {{
460 .tree-item {{
461 padding: 0.8rem 0.9rem;
462 font-size: 0.8rem;
463 }}
464 }}
465
466 .tree-item:last-child {{
467 margin-bottom: 0;
468 }}
469
470 .tree-item-header {{
471 display: flex;
472 justify-content: space-between;
473 align-items: center;
474 }}
475
476 .tree-item-count {{
477 font-size: 0.65rem;
478 color: var(--text-light);
479 }}
480
481 .record-list {{
482 margin-top: 0.5rem;
483 padding-top: 0.5rem;
484 border-top: 1px solid var(--border);
485 }}
486
487 .record {{
488 padding: 0.6rem;
489 margin-bottom: 0.5rem;
490 background: var(--bg);
491 border: 1px solid var(--border);
492 border-radius: 4px;
493 font-size: 0.65rem;
494 color: var(--text-light);
495 transition: all 0.15s ease;
496 }}
497
498 .record:hover {{
499 border-color: var(--text-light);
500 background: var(--surface);
501 }}
502
503 .record:last-child {{
504 margin-bottom: 0;
505 }}
506
507 .record pre {{
508 margin: 0;
509 white-space: pre-wrap;
510 word-break: break-word;
511 line-height: 1.5;
512 }}
513
514 .load-more {{
515 margin-top: 0.5rem;
516 padding: 0.4rem 0.6rem;
517 background: var(--bg);
518 border: 1px solid var(--border);
519 color: var(--text);
520 font-family: inherit;
521 font-size: 0.65rem;
522 cursor: pointer;
523 width: 100%;
524 transition: all 0.15s ease;
525 -webkit-tap-highlight-color: transparent;
526 border-radius: 2px;
527 }}
528
529 .load-more:hover, .load-more:active {{
530 background: var(--surface-hover);
531 border-color: var(--text-light);
532 }}
533
534 @media (max-width: 768px) {{
535 .load-more {{
536 padding: 0.6rem 0.8rem;
537 font-size: 0.7rem;
538 }}
539 }}
540
541 .footer {{
542 position: fixed;
543 bottom: 1rem;
544 left: 50%;
545 transform: translateX(-50%);
546 font-size: 0.65rem;
547 color: var(--text-light);
548 z-index: 100;
549 }}
550
551 .footer a {{
552 color: var(--text-light);
553 text-decoration: none;
554 border-bottom: 1px solid transparent;
555 transition: border-color 0.2s ease;
556 }}
557
558 .footer a:hover {{
559 border-bottom-color: var(--text-light);
560 }}
561
562 .loading {{ color: var(--text-light); font-size: 0.75rem; }}
563 </style>
564</head>
565<body>
566 <div class="info" id="infoBtn">i</div>
567 <a href="javascript:void(0)" id="logoutBtn" class="logout">logout</a>
568
569 <div class="overlay" id="overlay"></div>
570 <div class="info-modal" id="infoModal">
571 <h2>@me - your at protocol identity</h2>
572 <p>in decentralized social networks, you own your identity and your data lives in your personal data server (pds).</p>
573 <p>third-party applications create records in your repository using different lexicons (data schemas). for example, bluesky creates posts, white wind stores blog entries, tangled.org hosts code repositories, and frontpage aggregates links - all in the same place.</p>
574 <p>this visualization shows your identity at the center, surrounded by the third-party apps that have created data for you. click an app to see what types of records it stores, then click a record type to see the actual data.</p>
575 <button id="closeInfo">got it</button>
576 </div>
577
578 <div class="canvas">
579 <div class="identity">
580 <div class="identity-label">@</div>
581 <div class="identity-value" id="handle">loading...</div>
582 <div class="identity-hint">tap for details</div>
583 </div>
584 <div id="field" class="loading">loading...</div>
585 </div>
586 <div id="detail" class="detail-panel"></div>
587
588 <div class="footer">
589 <a href="https://tangled.org/@zzstoatzz.io/at-me" target="_blank" rel="noopener noreferrer">view source</a>
590 </div>
591 <script>
592 const did = '{}';
593 localStorage.setItem('atme_did', did);
594
595 let globalPds = null;
596 let globalHandle = null;
597
598 // Logout handler
599 document.getElementById('logoutBtn').addEventListener('click', (e) => {{
600 e.preventDefault();
601 localStorage.removeItem('atme_did');
602 window.location.href = '/logout';
603 }});
604
605 // Info modal handlers
606 document.getElementById('infoBtn').addEventListener('click', () => {{
607 document.getElementById('infoModal').classList.add('visible');
608 document.getElementById('overlay').classList.add('visible');
609 }});
610
611 document.getElementById('closeInfo').addEventListener('click', () => {{
612 document.getElementById('infoModal').classList.remove('visible');
613 document.getElementById('overlay').classList.remove('visible');
614 }});
615
616 document.getElementById('overlay').addEventListener('click', () => {{
617 document.getElementById('infoModal').classList.remove('visible');
618 document.getElementById('overlay').classList.remove('visible');
619 const detail = document.getElementById('detail');
620 detail.classList.remove('visible');
621 }});
622
623 // First resolve DID to get PDS endpoint and handle
624 fetch('https://plc.directory/' + did)
625 .then(r => r.json())
626 .then(didDoc => {{
627 const pds = didDoc.service.find(s => s.type === 'AtprotoPersonalDataServer')?.serviceEndpoint;
628 const handle = didDoc.alsoKnownAs?.[0]?.replace('at://', '') || did;
629
630 globalPds = pds;
631 globalHandle = handle;
632
633 // Update identity display with handle
634 document.getElementById('handle').textContent = handle;
635
636 // Add identity click handler to show PDS info
637 document.querySelector('.identity').addEventListener('click', () => {{
638 const detail = document.getElementById('detail');
639 const pdsHost = pds.replace('https://', '').replace('http://', '');
640 detail.innerHTML = `
641 <button class="detail-close" id="detailClose">×</button>
642 <h3>your identity</h3>
643 <div class="subtitle">decentralized identifier & storage</div>
644 <div class="tree-item">
645 <div class="tree-item-header">
646 <span style="color: var(--text-light);">did</span>
647 <span style="font-size: 0.6rem; color: var(--text);">${{did}}</span>
648 </div>
649 </div>
650 <div class="tree-item">
651 <div class="tree-item-header">
652 <span style="color: var(--text-light);">handle</span>
653 <span style="font-size: 0.6rem; color: var(--text);">@${{handle}}</span>
654 </div>
655 </div>
656 <div class="tree-item">
657 <div class="tree-item-header">
658 <span style="color: var(--text-light);">personal data server</span>
659 <span style="font-size: 0.6rem; color: var(--text);">${{pds}}</span>
660 </div>
661 </div>
662 <div style="margin-top: 1rem; padding: 0.6rem; background: var(--bg); border-radius: 4px; font-size: 0.65rem; line-height: 1.5; color: var(--text-lighter);">
663 your data lives at <strong style="color: var(--text);">${{pdsHost}}</strong>. apps like bluesky write to and read from this server. you control @<strong style="color: var(--text);">${{handle}}</strong> and can move it to a different server anytime.
664 </div>
665 `;
666 detail.classList.add('visible');
667
668 // Add close button handler
669 document.getElementById('detailClose').addEventListener('click', (e) => {{
670 e.stopPropagation();
671 detail.classList.remove('visible');
672 }});
673 }});
674
675 // Get all collections from PDS
676 return fetch(`${{pds}}/xrpc/com.atproto.repo.describeRepo?repo=${{did}}`);
677 }})
678 .then(r => r.json())
679 .then(repo => {{
680 const collections = repo.collections || [];
681
682 // Group by app namespace (first two parts of lexicon)
683 const apps = {{}};
684 collections.forEach(collection => {{
685 const parts = collection.split('.');
686 if (parts.length >= 2) {{
687 const namespace = `${{parts[0]}}.${{parts[1]}}`;
688 if (!apps[namespace]) apps[namespace] = [];
689 apps[namespace].push(collection);
690 }}
691 }});
692
693 const field = document.getElementById('field');
694 field.innerHTML = '';
695 field.classList.remove('loading');
696
697 const appNames = Object.keys(apps).sort();
698 const radius = 240;
699 const centerX = window.innerWidth / 2;
700 const centerY = window.innerHeight / 2;
701
702 appNames.forEach((namespace, i) => {{
703 const angle = (i / appNames.length) * 2 * Math.PI - Math.PI / 2; // Start from top
704 const x = centerX + radius * Math.cos(angle) - 25;
705 const y = centerY + radius * Math.sin(angle) - 30;
706
707 const div = document.createElement('div');
708 div.className = 'app-view';
709 div.style.left = `${{x}}px`;
710 div.style.top = `${{y}}px`;
711
712 const firstLetter = namespace.split('.')[1]?.[0]?.toUpperCase() || namespace[0].toUpperCase();
713
714 div.innerHTML = `
715 <div class="app-circle">${{firstLetter}}</div>
716 <div class="app-name">${{namespace}}</div>
717 `;
718
719 div.addEventListener('click', () => {{
720 const detail = document.getElementById('detail');
721 const collections = apps[namespace];
722
723 let html = `
724 <button class="detail-close" id="detailClose">×</button>
725 <h3>${{namespace}}</h3>
726 <div class="subtitle">records stored in your pds:</div>
727 `;
728
729 if (collections && collections.length > 0) {{
730 collections.sort().forEach(lexicon => {{
731 const shortName = lexicon.split('.').slice(2).join('.') || lexicon;
732 html += `
733 <div class="tree-item" data-lexicon="${{lexicon}}">
734 <div class="tree-item-header">
735 <span>${{shortName}}</span>
736 <span class="tree-item-count">loading...</span>
737 </div>
738 </div>
739 `;
740 }});
741 }} else {{
742 html += `<div class="tree-item">no collections found</div>`;
743 }}
744
745 detail.innerHTML = html;
746 detail.classList.add('visible');
747
748 // Add close button handler
749 document.getElementById('detailClose').addEventListener('click', (e) => {{
750 e.stopPropagation();
751 detail.classList.remove('visible');
752 }});
753
754 // Fetch record counts for each collection
755 if (collections && collections.length > 0) {{
756 collections.forEach(lexicon => {{
757 fetch(`${{globalPds}}/xrpc/com.atproto.repo.listRecords?repo=${{did}}&collection=${{lexicon}}&limit=1`)
758 .then(r => r.json())
759 .then(data => {{
760 const item = detail.querySelector(`[data-lexicon="${{lexicon}}"]`);
761 if (item) {{
762 const countSpan = item.querySelector('.tree-item-count');
763 // The cursor field indicates there are more records
764 countSpan.textContent = data.records?.length > 0 ? 'has records' : 'empty';
765 }}
766 }})
767 .catch(e => {{
768 console.error('Error fetching count for', lexicon, e);
769 const item = detail.querySelector(`[data-lexicon="${{lexicon}}"]`);
770 if (item) {{
771 const countSpan = item.querySelector('.tree-item-count');
772 countSpan.textContent = 'error';
773 }}
774 }});
775 }});
776 }}
777
778 // Add click handlers to tree items to fetch actual records
779 detail.querySelectorAll('.tree-item[data-lexicon]').forEach(item => {{
780 item.addEventListener('click', (e) => {{
781 e.stopPropagation();
782 const lexicon = item.dataset.lexicon;
783 const existingRecords = item.querySelector('.record-list');
784
785 if (existingRecords) {{
786 existingRecords.remove();
787 return;
788 }}
789
790 const recordListDiv = document.createElement('div');
791 recordListDiv.className = 'record-list';
792 recordListDiv.innerHTML = '<div class="loading">loading records...</div>';
793 item.appendChild(recordListDiv);
794
795 fetch(`${{globalPds}}/xrpc/com.atproto.repo.listRecords?repo=${{did}}&collection=${{lexicon}}&limit=5`)
796 .then(r => r.json())
797 .then(data => {{
798 if (data.records && data.records.length > 0) {{
799 let recordsHtml = '';
800 data.records.forEach(record => {{
801 const json = JSON.stringify(record.value, null, 2);
802 recordsHtml += `<div class="record"><pre>${{json}}</pre></div>`;
803 }});
804
805 if (data.cursor) {{
806 recordsHtml += `<button class="load-more" data-cursor="${{data.cursor}}" data-lexicon="${{lexicon}}">load more</button>`;
807 }}
808
809 recordListDiv.innerHTML = recordsHtml;
810
811 // Use event delegation for load more buttons
812 recordListDiv.addEventListener('click', (e) => {{
813 if (e.target.classList.contains('load-more')) {{
814 e.stopPropagation();
815 const loadMoreBtn = e.target;
816 const cursor = loadMoreBtn.dataset.cursor;
817 const lexicon = loadMoreBtn.dataset.lexicon;
818
819 loadMoreBtn.textContent = 'loading...';
820
821 fetch(`${{globalPds}}/xrpc/com.atproto.repo.listRecords?repo=${{did}}&collection=${{lexicon}}&limit=5&cursor=${{cursor}}`)
822 .then(r => r.json())
823 .then(moreData => {{
824 let moreHtml = '';
825 moreData.records.forEach(record => {{
826 const json = JSON.stringify(record.value, null, 2);
827 moreHtml += `<div class="record"><pre>${{json}}</pre></div>`;
828 }});
829
830 loadMoreBtn.remove();
831 recordListDiv.insertAdjacentHTML('beforeend', moreHtml);
832
833 if (moreData.cursor) {{
834 recordListDiv.insertAdjacentHTML('beforeend',
835 `<button class="load-more" data-cursor="${{moreData.cursor}}" data-lexicon="${{lexicon}}">load more</button>`
836 );
837 }}
838 }});
839 }}
840 }});
841 }} else {{
842 recordListDiv.innerHTML = '<div class="record">no records found</div>';
843 }}
844 }})
845 .catch(e => {{
846 console.error('Error fetching records:', e);
847 recordListDiv.innerHTML = '<div class="record">error loading records</div>';
848 }});
849 }});
850 }});
851 }});
852
853 field.appendChild(div);
854 }});
855
856 // Close detail panel when clicking canvas
857 const canvas = document.querySelector('.canvas');
858 canvas.addEventListener('click', (e) => {{
859 if (e.target === canvas) {{
860 document.getElementById('detail').classList.remove('visible');
861 }}
862 }});
863 }})
864 .catch(e => {{
865 document.getElementById('field').innerHTML = 'error loading records';
866 console.error(e);
867 }});
868 </script>
869</body>
870</html>
871 "#, did)
872}