interactive intro to open social at-me.zzstoatzz.io
at ci-setup 872 lines 33 kB view raw
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}