this repo has no description
0
fork

Configure Feed

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

at main 1278 lines 38 kB view raw
1<!DOCTYPE html> 2<html lang="en"> 3<head> 4 <meta charset="UTF-8" /> 5 <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 6 <title>Nate Spilman</title> 7 <link rel="preconnect" href="https://fonts.googleapis.com" /> 8 <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> 9 <link href="https://fonts.googleapis.com/css2?family=Playfair+Display:ital,wght@0,400;0,700;1,400&family=Inter:wght@300;400;500&display=swap" rel="stylesheet" /> 10 <style> 11 :root { 12 --lavender: #b8a9c9; 13 --soft-blue: #89a4c7; 14 --warm-peach: #e8c4a0; 15 --sage: #a8b89c; 16 --rose: #d4a0a0; 17 --cream: #faf5ef; 18 --warm-white: #fdfbf7; 19 --text: #3d3535; 20 --text-light: #6b5e5e; 21 --text-muted: #9a8c8c; 22 --stroke: rgba(61, 53, 53, 0.08); 23 } 24 25 * { margin: 0; padding: 0; box-sizing: border-box; } 26 27 body { 28 font-family: 'Inter', sans-serif; 29 font-weight: 300; 30 color: var(--text); 31 background: var(--warm-white); 32 line-height: 1.7; 33 min-height: 100vh; 34 } 35 36 /* Impressionist background texture */ 37 body::before { 38 content: ''; 39 position: fixed; 40 inset: 0; 41 background: 42 radial-gradient(ellipse at 20% 50%, rgba(184, 169, 201, 0.15) 0%, transparent 50%), 43 radial-gradient(ellipse at 80% 20%, rgba(137, 164, 199, 0.12) 0%, transparent 50%), 44 radial-gradient(ellipse at 60% 80%, rgba(232, 196, 160, 0.1) 0%, transparent 50%), 45 radial-gradient(ellipse at 10% 90%, rgba(168, 184, 156, 0.08) 0%, transparent 40%); 46 pointer-events: none; 47 z-index: 0; 48 } 49 50 .page { position: relative; z-index: 1; } 51 52 /* Banner */ 53 .banner { 54 width: 100%; 55 height: 280px; 56 position: relative; 57 overflow: hidden; 58 } 59 60 .banner img { 61 width: 100%; 62 height: 100%; 63 object-fit: cover; 64 filter: saturate(0.8) contrast(0.9) brightness(1.05); 65 } 66 67 .banner::after { 68 content: ''; 69 position: absolute; 70 inset: 0; 71 background: linear-gradient( 72 to bottom, 73 transparent 40%, 74 rgba(253, 251, 247, 0.3) 70%, 75 var(--warm-white) 100% 76 ); 77 } 78 79 /* Profile Header */ 80 .profile-header { 81 max-width: 720px; 82 margin: -60px auto 0; 83 padding: 0 24px; 84 position: relative; 85 } 86 87 .avatar-wrap { 88 width: 120px; 89 height: 120px; 90 border-radius: 50%; 91 overflow: hidden; 92 border: 4px solid var(--warm-white); 93 box-shadow: 0 4px 20px rgba(61, 53, 53, 0.1); 94 margin-bottom: 16px; 95 } 96 97 .avatar-wrap img { 98 width: 100%; 99 height: 100%; 100 object-fit: cover; 101 } 102 103 .display-name { 104 font-family: 'Playfair Display', serif; 105 font-size: 2.4rem; 106 font-weight: 700; 107 letter-spacing: -0.02em; 108 color: var(--text); 109 margin-bottom: 4px; 110 } 111 112 .handle { 113 font-size: 0.9rem; 114 color: var(--text-muted); 115 margin-bottom: 12px; 116 } 117 118 .handle a { 119 color: var(--soft-blue); 120 text-decoration: none; 121 } 122 123 .bio { 124 font-size: 1.05rem; 125 color: var(--text-light); 126 margin-bottom: 16px; 127 white-space: pre-line; 128 } 129 130 .stats { 131 display: flex; 132 gap: 24px; 133 margin-bottom: 32px; 134 } 135 136 .stat { 137 font-size: 0.85rem; 138 color: var(--text-muted); 139 } 140 141 .stat strong { 142 font-weight: 500; 143 color: var(--text); 144 margin-right: 3px; 145 } 146 147 /* Navigation Tabs */ 148 .tabs { 149 max-width: 720px; 150 margin: 0 auto; 151 padding: 0 24px; 152 display: flex; 153 gap: 8px; 154 border-bottom: 1px solid var(--stroke); 155 margin-bottom: 24px; 156 } 157 158 .tab { 159 padding: 10px 20px; 160 font-size: 0.85rem; 161 font-weight: 400; 162 color: var(--text-muted); 163 cursor: pointer; 164 border: none; 165 background: none; 166 font-family: 'Inter', sans-serif; 167 position: relative; 168 transition: color 0.3s; 169 } 170 171 .tab:hover { color: var(--text-light); } 172 173 .tab.active { 174 color: var(--text); 175 font-weight: 500; 176 } 177 178 .tab.active::after { 179 content: ''; 180 position: absolute; 181 bottom: -1px; 182 left: 20px; 183 right: 20px; 184 height: 2px; 185 background: linear-gradient(90deg, var(--lavender), var(--soft-blue)); 186 border-radius: 1px; 187 } 188 189 .tab .count { 190 font-size: 0.75rem; 191 color: var(--text-muted); 192 margin-left: 4px; 193 opacity: 0.7; 194 } 195 196 /* Content Area */ 197 .content { 198 max-width: 720px; 199 margin: 0 auto; 200 padding: 0 24px 60px; 201 } 202 203 .tab-panel { display: none; } 204 .tab-panel.active { display: block; } 205 206 /* Posts */ 207 .post-card { 208 padding: 24px 0; 209 border-bottom: 1px solid var(--stroke); 210 animation: fadeIn 0.4s ease; 211 } 212 213 .post-card:last-child { border-bottom: none; } 214 215 @keyframes fadeIn { 216 from { opacity: 0; transform: translateY(8px); } 217 to { opacity: 1; transform: translateY(0); } 218 } 219 220 .post-meta { 221 display: flex; 222 align-items: center; 223 gap: 8px; 224 margin-bottom: 8px; 225 } 226 227 .post-meta-avatar { 228 width: 36px; 229 height: 36px; 230 border-radius: 50%; 231 object-fit: cover; 232 } 233 234 .post-meta-info { 235 flex: 1; 236 } 237 238 .post-author { 239 font-weight: 500; 240 font-size: 0.9rem; 241 color: var(--text); 242 } 243 244 .post-time { 245 font-size: 0.78rem; 246 color: var(--text-muted); 247 } 248 249 .post-text { 250 font-size: 0.95rem; 251 line-height: 1.7; 252 color: var(--text); 253 margin-bottom: 12px; 254 word-wrap: break-word; 255 } 256 257 .post-text a { 258 color: var(--soft-blue); 259 text-decoration: none; 260 border-bottom: 1px solid rgba(137, 164, 199, 0.3); 261 transition: border-color 0.2s; 262 } 263 264 .post-text a:hover { border-color: var(--soft-blue); } 265 266 /* Embedded images */ 267 .post-images { 268 display: grid; 269 gap: 8px; 270 margin-bottom: 12px; 271 border-radius: 12px; 272 overflow: hidden; 273 } 274 275 .post-images.grid-1 { grid-template-columns: 1fr; } 276 .post-images.grid-2 { grid-template-columns: 1fr 1fr; } 277 .post-images.grid-3 { grid-template-columns: 1fr 1fr; } 278 .post-images.grid-4 { grid-template-columns: 1fr 1fr; } 279 280 .post-images img { 281 width: 100%; 282 height: 240px; 283 object-fit: cover; 284 border-radius: 8px; 285 cursor: pointer; 286 transition: filter 0.3s; 287 } 288 289 .post-images img:hover { 290 filter: brightness(1.05) saturate(1.1); 291 } 292 293 /* External embeds */ 294 .embed-external { 295 border: 1px solid var(--stroke); 296 border-radius: 12px; 297 overflow: hidden; 298 margin-bottom: 12px; 299 text-decoration: none; 300 display: block; 301 transition: box-shadow 0.3s; 302 } 303 304 .embed-external:hover { 305 box-shadow: 0 2px 12px rgba(61, 53, 53, 0.06); 306 } 307 308 .embed-external-thumb { 309 width: 100%; 310 height: 180px; 311 object-fit: cover; 312 } 313 314 .embed-external-info { 315 padding: 12px 16px; 316 } 317 318 .embed-external-domain { 319 font-size: 0.75rem; 320 color: var(--text-muted); 321 text-transform: uppercase; 322 letter-spacing: 0.05em; 323 margin-bottom: 4px; 324 } 325 326 .embed-external-title { 327 font-family: 'Playfair Display', serif; 328 font-size: 1rem; 329 color: var(--text); 330 margin-bottom: 4px; 331 } 332 333 .embed-external-desc { 334 font-size: 0.82rem; 335 color: var(--text-light); 336 display: -webkit-box; 337 -webkit-line-clamp: 2; 338 -webkit-box-orient: vertical; 339 overflow: hidden; 340 } 341 342 /* Quote posts */ 343 .embed-quote { 344 border: 1px solid var(--stroke); 345 border-radius: 12px; 346 padding: 16px; 347 margin-bottom: 12px; 348 background: rgba(250, 245, 239, 0.5); 349 } 350 351 .embed-quote .post-meta { margin-bottom: 6px; } 352 .embed-quote .post-text { font-size: 0.88rem; margin-bottom: 0; } 353 354 /* Post engagement */ 355 .post-engagement { 356 display: flex; 357 gap: 20px; 358 font-size: 0.78rem; 359 color: var(--text-muted); 360 } 361 362 .post-engagement span { 363 display: flex; 364 align-items: center; 365 gap: 4px; 366 } 367 368 /* Follows */ 369 .follows-grid { 370 display: grid; 371 grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); 372 gap: 16px; 373 } 374 375 .follow-card { 376 display: flex; 377 align-items: center; 378 gap: 12px; 379 padding: 16px; 380 border: 1px solid var(--stroke); 381 border-radius: 12px; 382 text-decoration: none; 383 color: var(--text); 384 transition: box-shadow 0.3s, transform 0.2s; 385 animation: fadeIn 0.4s ease; 386 } 387 388 .follow-card:hover { 389 box-shadow: 0 4px 16px rgba(61, 53, 53, 0.06); 390 transform: translateY(-1px); 391 } 392 393 .follow-avatar { 394 width: 44px; 395 height: 44px; 396 border-radius: 50%; 397 object-fit: cover; 398 flex-shrink: 0; 399 } 400 401 .follow-info { overflow: hidden; } 402 403 .follow-name { 404 font-weight: 500; 405 font-size: 0.88rem; 406 white-space: nowrap; 407 overflow: hidden; 408 text-overflow: ellipsis; 409 } 410 411 .follow-handle { 412 font-size: 0.78rem; 413 color: var(--text-muted); 414 white-space: nowrap; 415 overflow: hidden; 416 text-overflow: ellipsis; 417 } 418 419 /* Feeds */ 420 .feed-card { 421 padding: 20px; 422 border: 1px solid var(--stroke); 423 border-radius: 12px; 424 margin-bottom: 12px; 425 animation: fadeIn 0.4s ease; 426 } 427 428 .feed-name { 429 font-family: 'Playfair Display', serif; 430 font-size: 1.1rem; 431 margin-bottom: 4px; 432 } 433 434 .feed-desc { 435 font-size: 0.85rem; 436 color: var(--text-light); 437 margin-bottom: 8px; 438 } 439 440 .feed-likes { 441 font-size: 0.78rem; 442 color: var(--text-muted); 443 } 444 445 /* Load more */ 446 .load-more { 447 display: block; 448 margin: 24px auto; 449 padding: 10px 32px; 450 border: 1px solid var(--stroke); 451 border-radius: 24px; 452 background: transparent; 453 font-family: 'Inter', sans-serif; 454 font-size: 0.85rem; 455 color: var(--text-muted); 456 cursor: pointer; 457 transition: all 0.3s; 458 } 459 460 .load-more:hover { 461 border-color: var(--lavender); 462 color: var(--text); 463 box-shadow: 0 2px 8px rgba(184, 169, 201, 0.15); 464 } 465 466 .load-more:disabled { 467 opacity: 0.4; 468 cursor: default; 469 } 470 471 /* Loading states */ 472 .loading { 473 text-align: center; 474 padding: 40px 0; 475 color: var(--text-muted); 476 font-size: 0.9rem; 477 } 478 479 .loading-dot { 480 display: inline-block; 481 width: 6px; 482 height: 6px; 483 border-radius: 50%; 484 background: var(--lavender); 485 margin: 0 3px; 486 animation: pulse 1.4s infinite ease-in-out; 487 } 488 489 .loading-dot:nth-child(2) { animation-delay: 0.2s; } 490 .loading-dot:nth-child(3) { animation-delay: 0.4s; } 491 492 @keyframes pulse { 493 0%, 80%, 100% { transform: scale(0.6); opacity: 0.4; } 494 40% { transform: scale(1); opacity: 1; } 495 } 496 497 /* Empty state */ 498 .empty-state { 499 text-align: center; 500 padding: 60px 20px; 501 color: var(--text-muted); 502 font-style: italic; 503 } 504 505 /* Error */ 506 .error-state { 507 text-align: center; 508 padding: 40px 20px; 509 color: var(--rose); 510 } 511 512 /* Footer */ 513 .footer { 514 max-width: 720px; 515 margin: 0 auto; 516 padding: 24px 24px 40px; 517 text-align: center; 518 font-size: 0.75rem; 519 color: var(--text-muted); 520 border-top: 1px solid var(--stroke); 521 } 522 523 .footer a { 524 color: var(--soft-blue); 525 text-decoration: none; 526 } 527 528 /* Repost indicator */ 529 .repost-indicator { 530 font-size: 0.78rem; 531 color: var(--text-muted); 532 margin-bottom: 8px; 533 display: flex; 534 align-items: center; 535 gap: 4px; 536 } 537 538 /* Thread toggle */ 539 .thread-toggle { 540 display: inline-flex; 541 align-items: center; 542 gap: 6px; 543 margin-top: 10px; 544 padding: 6px 14px; 545 border: 1px solid var(--stroke); 546 border-radius: 20px; 547 background: transparent; 548 font-family: 'Inter', sans-serif; 549 font-size: 0.78rem; 550 color: var(--soft-blue); 551 cursor: pointer; 552 transition: all 0.25s; 553 } 554 555 .thread-toggle:hover { 556 background: rgba(137, 164, 199, 0.08); 557 border-color: rgba(137, 164, 199, 0.3); 558 } 559 560 .thread-toggle .arrow { 561 display: inline-block; 562 transition: transform 0.25s; 563 font-size: 0.65rem; 564 } 565 566 .thread-toggle.open .arrow { 567 transform: rotate(90deg); 568 } 569 570 /* Thread replies container */ 571 .thread-replies { 572 display: none; 573 margin-top: 12px; 574 padding-left: 20px; 575 border-left: 2px solid var(--lavender); 576 opacity: 0; 577 transition: opacity 0.3s; 578 } 579 580 .thread-replies.open { 581 display: block; 582 opacity: 1; 583 } 584 585 .thread-replies .thread-reply { 586 padding: 14px 0; 587 border-bottom: 1px solid var(--stroke); 588 animation: fadeIn 0.3s ease; 589 } 590 591 .thread-replies .thread-reply:last-child { 592 border-bottom: none; 593 } 594 595 .thread-replies .post-text { 596 font-size: 0.9rem; 597 } 598 599 .thread-replies .post-meta-avatar { 600 width: 28px; 601 height: 28px; 602 } 603 604 .thread-replies .post-engagement { 605 font-size: 0.75rem; 606 } 607 608 .thread-loading { 609 padding: 16px 0; 610 font-size: 0.8rem; 611 color: var(--text-muted); 612 display: flex; 613 align-items: center; 614 gap: 8px; 615 } 616 617 /* Lightbox */ 618 .lightbox { 619 position: fixed; 620 inset: 0; 621 background: rgba(0, 0, 0, 0.85); 622 z-index: 1000; 623 display: none; 624 align-items: center; 625 justify-content: center; 626 cursor: pointer; 627 } 628 629 .lightbox.open { display: flex; } 630 631 .lightbox img { 632 max-width: 90vw; 633 max-height: 90vh; 634 object-fit: contain; 635 border-radius: 4px; 636 } 637 638 /* Responsive */ 639 @media (max-width: 600px) { 640 .banner { height: 180px; } 641 .profile-header { margin-top: -40px; } 642 .avatar-wrap { width: 88px; height: 88px; } 643 .display-name { font-size: 1.8rem; } 644 .stats { gap: 16px; flex-wrap: wrap; } 645 .tabs { overflow-x: auto; -webkit-overflow-scrolling: touch; } 646 .follows-grid { grid-template-columns: 1fr; } 647 .post-images img { height: 180px; } 648 } 649 </style> 650</head> 651<body> 652 <div class="page"> 653 <!-- Banner --> 654 <div class="banner" id="banner"></div> 655 656 <!-- Profile Header --> 657 <div class="profile-header" id="profileHeader"> 658 <div class="loading"> 659 <span class="loading-dot"></span> 660 <span class="loading-dot"></span> 661 <span class="loading-dot"></span> 662 </div> 663 </div> 664 665 <!-- Tabs --> 666 <nav class="tabs" id="tabs" style="display:none"> 667 <button class="tab active" data-tab="posts">Posts</button> 668 <button class="tab" data-tab="follows">Following</button> 669 <button class="tab" data-tab="feeds">Feeds</button> 670 </nav> 671 672 <!-- Content --> 673 <div class="content"> 674 <div id="postsPanel" class="tab-panel active"> 675 <div class="loading"> 676 <span class="loading-dot"></span> 677 <span class="loading-dot"></span> 678 <span class="loading-dot"></span> 679 </div> 680 </div> 681 <div id="followsPanel" class="tab-panel"></div> 682 <div id="feedsPanel" class="tab-panel"></div> 683 </div> 684 685 <!-- Footer --> 686 <div class="footer"> 687 Powered by the <a href="https://atproto.com" target="_blank" rel="noopener">AT Protocol</a> 688 &middot; Hosted on <a href="https://tangled.org" target="_blank" rel="noopener">Tangled</a> 689 </div> 690 </div> 691 692 <!-- Lightbox --> 693 <div class="lightbox" id="lightbox" onclick="this.classList.remove('open')"> 694 <img id="lightboxImg" src="" alt="" /> 695 </div> 696 697 <script> 698 // ── Config ────────────────────────────────────────────── 699 const HANDLE = 'natespilman.com'; 700 const API = 'https://public.api.bsky.app/xrpc'; 701 const POSTS_PER_PAGE = 25; 702 const FOLLOWS_PER_PAGE = 50; 703 704 // ── State ─────────────────────────────────────────────── 705 let postsCursor = null; 706 let followsCursor = null; 707 let postsLoaded = false; 708 let followsLoaded = false; 709 let feedsLoaded = false; 710 let profile = null; 711 712 // ── API helpers ───────────────────────────────────────── 713 async function api(method, params = {}) { 714 const url = new URL(`${API}/${method}`); 715 Object.entries(params).forEach(([k, v]) => { 716 if (v !== undefined && v !== null) url.searchParams.set(k, v); 717 }); 718 const res = await fetch(url); 719 if (!res.ok) throw new Error(`API error: ${res.status}`); 720 return res.json(); 721 } 722 723 // ── Time formatting ───────────────────────────────────── 724 function timeAgo(dateStr) { 725 const now = Date.now(); 726 const then = new Date(dateStr).getTime(); 727 const diff = now - then; 728 const mins = Math.floor(diff / 60000); 729 const hours = Math.floor(diff / 3600000); 730 const days = Math.floor(diff / 86400000); 731 if (mins < 1) return 'just now'; 732 if (mins < 60) return `${mins}m`; 733 if (hours < 24) return `${hours}h`; 734 if (days < 7) return `${days}d`; 735 return new Date(dateStr).toLocaleDateString('en-US', { 736 month: 'short', day: 'numeric', 737 ...(days > 365 ? { year: 'numeric' } : {}) 738 }); 739 } 740 741 // ── Text rendering (facets) ───────────────────────────── 742 function renderText(text, facets) { 743 if (!facets || !facets.length) return escapeHtml(text); 744 745 const encoder = new TextEncoder(); 746 const decoder = new TextDecoder(); 747 const bytes = encoder.encode(text); 748 749 // Sort facets by start index 750 const sorted = [...facets].sort((a, b) => a.index.byteStart - b.index.byteStart); 751 752 let result = ''; 753 let lastEnd = 0; 754 755 for (const facet of sorted) { 756 const { byteStart, byteEnd } = facet.index; 757 // Text before this facet 758 result += escapeHtml(decoder.decode(bytes.slice(lastEnd, byteStart))); 759 760 const facetText = escapeHtml(decoder.decode(bytes.slice(byteStart, byteEnd))); 761 const feature = facet.features?.[0]; 762 763 if (feature?.$type === 'app.bsky.richtext.facet#link') { 764 result += `<a href="${escapeHtml(feature.uri)}" target="_blank" rel="noopener">${facetText}</a>`; 765 } else if (feature?.$type === 'app.bsky.richtext.facet#mention') { 766 result += `<a href="https://bsky.app/profile/${feature.did}" target="_blank" rel="noopener">${facetText}</a>`; 767 } else if (feature?.$type === 'app.bsky.richtext.facet#tag') { 768 result += `<a href="https://bsky.app/hashtag/${feature.tag}" target="_blank" rel="noopener">${facetText}</a>`; 769 } else { 770 result += facetText; 771 } 772 773 lastEnd = byteEnd; 774 } 775 776 result += escapeHtml(decoder.decode(bytes.slice(lastEnd))); 777 return result; 778 } 779 780 function escapeHtml(str) { 781 return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;') 782 .replace(/"/g, '&quot;').replace(/'/g, '&#39;'); 783 } 784 785 // ── Render Profile ────────────────────────────────────── 786 async function loadProfile() { 787 try { 788 profile = await api('app.bsky.actor.getProfile', { actor: HANDLE }); 789 790 // Banner 791 const banner = document.getElementById('banner'); 792 if (profile.banner) { 793 banner.innerHTML = `<img src="${profile.banner}" alt="Banner" />`; 794 } else { 795 banner.style.height = '120px'; 796 banner.style.background = 'linear-gradient(135deg, var(--lavender) 0%, var(--soft-blue) 50%, var(--sage) 100%)'; 797 } 798 799 // Profile header 800 const header = document.getElementById('profileHeader'); 801 header.innerHTML = ` 802 <div class="avatar-wrap"> 803 <img src="${profile.avatar || ''}" alt="${escapeHtml(profile.displayName || HANDLE)}" /> 804 </div> 805 <h1 class="display-name">${escapeHtml(profile.displayName || HANDLE)}</h1> 806 <p class="handle"> 807 <a href="https://bsky.app/profile/${HANDLE}" target="_blank" rel="noopener">@${escapeHtml(profile.handle)}</a> 808 </p> 809 ${profile.description ? `<p class="bio">${escapeHtml(profile.description)}</p>` : ''} 810 <div class="stats"> 811 <span class="stat"><strong>${formatCount(profile.postsCount)}</strong> posts</span> 812 <span class="stat"><strong>${formatCount(profile.followingCount || profile.followsCount)}</strong> following</span> 813 <span class="stat"><strong>${formatCount(profile.followersCount)}</strong> followers</span> 814 </div> 815 `; 816 817 // Show tabs 818 document.getElementById('tabs').style.display = 'flex'; 819 } catch (err) { 820 document.getElementById('profileHeader').innerHTML = 821 `<div class="error-state">Could not load profile: ${escapeHtml(err.message)}</div>`; 822 } 823 } 824 825 function formatCount(n) { 826 if (n >= 1000000) return (n / 1000000).toFixed(1) + 'M'; 827 if (n >= 10000) return (n / 1000).toFixed(0) + 'K'; 828 if (n >= 1000) return (n / 1000).toFixed(1) + 'K'; 829 return String(n || 0); 830 } 831 832 // ── Render Posts ──────────────────────────────────────── 833 async function loadPosts(append = false) { 834 const panel = document.getElementById('postsPanel'); 835 if (!append) { 836 panel.innerHTML = '<div class="loading"><span class="loading-dot"></span><span class="loading-dot"></span><span class="loading-dot"></span></div>'; 837 } 838 839 try { 840 const data = await api('app.bsky.feed.getAuthorFeed', { 841 actor: HANDLE, 842 limit: POSTS_PER_PAGE, 843 cursor: postsCursor, 844 filter: 'posts_no_replies', 845 }); 846 847 postsCursor = data.cursor || null; 848 849 if (!append) panel.innerHTML = ''; 850 851 // Remove existing load-more button if appending 852 const existingBtn = panel.querySelector('.load-more'); 853 if (existingBtn) existingBtn.remove(); 854 855 if (!data.feed?.length && !append) { 856 panel.innerHTML = '<div class="empty-state">No posts yet</div>'; 857 return; 858 } 859 860 for (const item of data.feed) { 861 panel.appendChild(renderPostCard(item)); 862 } 863 864 if (postsCursor) { 865 const btn = document.createElement('button'); 866 btn.className = 'load-more'; 867 btn.textContent = 'Load more'; 868 btn.onclick = () => { 869 btn.disabled = true; 870 btn.textContent = 'Loading...'; 871 loadPosts(true); 872 }; 873 panel.appendChild(btn); 874 } 875 876 postsLoaded = true; 877 } catch (err) { 878 if (!append) { 879 panel.innerHTML = `<div class="error-state">Could not load posts: ${escapeHtml(err.message)}</div>`; 880 } 881 } 882 } 883 884 function renderPostCard(item) { 885 const post = item.post; 886 const record = post.record; 887 const author = post.author; 888 const isRepost = item.reason?.$type === 'app.bsky.feed.defs#reasonRepost'; 889 890 const card = document.createElement('div'); 891 card.className = 'post-card'; 892 893 let html = ''; 894 895 if (isRepost) { 896 html += `<div class="repost-indicator">\u21BB Reposted by ${escapeHtml(profile?.displayName || HANDLE)}</div>`; 897 } 898 899 // Post meta 900 html += ` 901 <div class="post-meta"> 902 <img class="post-meta-avatar" src="${author.avatar || ''}" alt="" /> 903 <div class="post-meta-info"> 904 <span class="post-author">${escapeHtml(author.displayName || author.handle)}</span> 905 <span class="post-time"> &middot; ${timeAgo(record.createdAt)}</span> 906 </div> 907 </div> 908 `; 909 910 // Post text 911 if (record.text) { 912 html += `<div class="post-text">${renderText(record.text, record.facets)}</div>`; 913 } 914 915 // Embeds 916 html += renderEmbed(post.embed); 917 918 // Engagement 919 html += ` 920 <div class="post-engagement"> 921 <span>\u2661 ${post.likeCount || 0}</span> 922 <span>\u21BB ${post.repostCount || 0}</span> 923 <span>\u2709 ${post.replyCount || 0}</span> 924 </div> 925 `; 926 927 // Thread toggle — show if the post has replies (potential thread) 928 if (post.replyCount > 0) { 929 const threadId = post.uri.split('/').pop(); 930 html += ` 931 <button class="thread-toggle" onclick="toggleThread(this, '${post.uri}', '${author.did}')"> 932 <span class="arrow">\u25B6</span> 933 Show thread 934 </button> 935 <div class="thread-replies" id="thread-${threadId}"></div> 936 `; 937 } 938 939 card.innerHTML = html; 940 return card; 941 } 942 943 // ── Thread expansion ──────────────────────────────────── 944 async function toggleThread(btn, postUri, authorDid) { 945 const repliesContainer = btn.nextElementSibling; 946 const isOpen = repliesContainer.classList.contains('open'); 947 948 if (isOpen) { 949 repliesContainer.classList.remove('open'); 950 btn.classList.remove('open'); 951 const count = parseInt(repliesContainer.dataset.count || '0'); 952 btn.childNodes[btn.childNodes.length - 1].textContent = 953 count > 0 ? ` Thread (${count})` : ' Show thread'; 954 return; 955 } 956 957 // If already loaded, just re-open 958 if (repliesContainer.dataset.loaded) { 959 const count = parseInt(repliesContainer.dataset.count || '0'); 960 if (count === 0) return; // No self-thread, button is disabled 961 repliesContainer.classList.add('open'); 962 btn.classList.add('open'); 963 btn.childNodes[btn.childNodes.length - 1].textContent = 964 ` Hide thread (${count})`; 965 return; 966 } 967 968 // Fetch the thread 969 btn.disabled = true; 970 repliesContainer.innerHTML = '<div class="thread-loading"><span class="loading-dot"></span><span class="loading-dot"></span><span class="loading-dot"></span> Loading thread\u2026</div>'; 971 repliesContainer.classList.add('open'); 972 973 try { 974 const data = await api('app.bsky.feed.getPostThread', { 975 uri: postUri, 976 depth: 10, 977 parentHeight: 0, 978 }); 979 980 // Walk the thread tree and collect only the author's own replies (the thread chain) 981 const threadReplies = collectAuthorThread(data.thread, authorDid); 982 983 repliesContainer.innerHTML = ''; 984 985 if (threadReplies.length === 0) { 986 repliesContainer.dataset.loaded = 'true'; 987 repliesContainer.dataset.count = '0'; 988 // No self-thread — collapse and disable the button 989 repliesContainer.classList.remove('open'); 990 btn.classList.remove('open'); 991 btn.disabled = true; 992 btn.style.opacity = '0.4'; 993 btn.style.cursor = 'default'; 994 btn.childNodes[btn.childNodes.length - 1].textContent = ' No thread'; 995 return; 996 } 997 998 for (const reply of threadReplies) { 999 repliesContainer.appendChild(renderThreadReply(reply)); 1000 } 1001 1002 repliesContainer.dataset.loaded = 'true'; 1003 repliesContainer.dataset.count = String(threadReplies.length); 1004 btn.classList.add('open'); 1005 btn.disabled = false; 1006 btn.childNodes[btn.childNodes.length - 1].textContent = 1007 ` Hide thread (${threadReplies.length})`; 1008 } catch (err) { 1009 repliesContainer.innerHTML = `<div class="thread-loading" style="color:var(--rose);">Could not load thread</div>`; 1010 btn.disabled = false; 1011 } 1012 } 1013 1014 // Walk the reply tree depth-first, collecting only replies by the same author 1015 function collectAuthorThread(thread, authorDid) { 1016 const results = []; 1017 if (!thread.replies) return results; 1018 1019 for (const reply of thread.replies) { 1020 if (reply.$type === 'app.bsky.feed.defs#threadViewPost' && 1021 reply.post?.author?.did === authorDid) { 1022 results.push(reply.post); 1023 // Recurse into this reply's replies for continued thread 1024 results.push(...collectAuthorThread(reply, authorDid)); 1025 } 1026 } 1027 return results; 1028 } 1029 1030 function renderThreadReply(post) { 1031 const record = post.record; 1032 const author = post.author; 1033 1034 const el = document.createElement('div'); 1035 el.className = 'thread-reply'; 1036 1037 let html = ` 1038 <div class="post-meta"> 1039 <img class="post-meta-avatar" src="${author.avatar || ''}" alt="" /> 1040 <div class="post-meta-info"> 1041 <span class="post-author">${escapeHtml(author.displayName || author.handle)}</span> 1042 <span class="post-time"> &middot; ${timeAgo(record.createdAt)}</span> 1043 </div> 1044 </div> 1045 `; 1046 1047 if (record.text) { 1048 html += `<div class="post-text">${renderText(record.text, record.facets)}</div>`; 1049 } 1050 1051 html += renderEmbed(post.embed); 1052 1053 html += ` 1054 <div class="post-engagement"> 1055 <span>\u2661 ${post.likeCount || 0}</span> 1056 <span>\u21BB ${post.repostCount || 0}</span> 1057 <span>\u2709 ${post.replyCount || 0}</span> 1058 </div> 1059 `; 1060 1061 el.innerHTML = html; 1062 return el; 1063 } 1064 1065 function renderEmbed(embed) { 1066 if (!embed) return ''; 1067 1068 // Images 1069 if (embed.$type === 'app.bsky.embed.images#view') { 1070 const count = embed.images.length; 1071 const gridClass = count <= 4 ? `grid-${count}` : 'grid-4'; 1072 let html = `<div class="post-images ${gridClass}">`; 1073 for (const img of embed.images) { 1074 html += `<img src="${img.thumb}" alt="${escapeHtml(img.alt || '')}" onclick="openLightbox('${img.fullsize}')" />`; 1075 } 1076 html += '</div>'; 1077 return html; 1078 } 1079 1080 // External link 1081 if (embed.$type === 'app.bsky.embed.external#view') { 1082 const ext = embed.external; 1083 const domain = getDomain(ext.uri); 1084 let html = `<a class="embed-external" href="${escapeHtml(ext.uri)}" target="_blank" rel="noopener">`; 1085 if (ext.thumb) { 1086 html += `<img class="embed-external-thumb" src="${ext.thumb}" alt="" />`; 1087 } 1088 html += ` 1089 <div class="embed-external-info"> 1090 <div class="embed-external-domain">${escapeHtml(domain)}</div> 1091 <div class="embed-external-title">${escapeHtml(ext.title || ext.uri)}</div> 1092 ${ext.description ? `<div class="embed-external-desc">${escapeHtml(ext.description)}</div>` : ''} 1093 </div> 1094 </a>`; 1095 return html; 1096 } 1097 1098 // Quote post 1099 if (embed.$type === 'app.bsky.embed.record#view') { 1100 const rec = embed.record; 1101 if (rec.$type === 'app.bsky.embed.record#viewRecord') { 1102 let html = `<div class="embed-quote">`; 1103 html += ` 1104 <div class="post-meta"> 1105 <img class="post-meta-avatar" src="${rec.author?.avatar || ''}" alt="" style="width:28px;height:28px;" /> 1106 <div class="post-meta-info"> 1107 <span class="post-author" style="font-size:0.84rem;">${escapeHtml(rec.author?.displayName || rec.author?.handle || '')}</span> 1108 </div> 1109 </div> 1110 `; 1111 if (rec.value?.text) { 1112 html += `<div class="post-text">${renderText(rec.value.text, rec.value.facets)}</div>`; 1113 } 1114 html += '</div>'; 1115 return html; 1116 } 1117 } 1118 1119 // Record with media (quote + images) 1120 if (embed.$type === 'app.bsky.embed.recordWithMedia#view') { 1121 let html = ''; 1122 if (embed.media) html += renderEmbed(embed.media); 1123 if (embed.record) html += renderEmbed(embed.record); 1124 return html; 1125 } 1126 1127 // Video 1128 if (embed.$type === 'app.bsky.embed.video#view') { 1129 if (embed.thumbnail) { 1130 return `<div class="post-images grid-1"> 1131 <img src="${embed.thumbnail}" alt="Video thumbnail" style="cursor:default;" /> 1132 </div>`; 1133 } 1134 } 1135 1136 return ''; 1137 } 1138 1139 function getDomain(url) { 1140 try { return new URL(url).hostname.replace('www.', ''); } catch { return url; } 1141 } 1142 1143 function openLightbox(src) { 1144 const lb = document.getElementById('lightbox'); 1145 document.getElementById('lightboxImg').src = src; 1146 lb.classList.add('open'); 1147 } 1148 1149 // ── Render Follows ────────────────────────────────────── 1150 async function loadFollows(append = false) { 1151 const panel = document.getElementById('followsPanel'); 1152 if (!append) { 1153 panel.innerHTML = '<div class="loading"><span class="loading-dot"></span><span class="loading-dot"></span><span class="loading-dot"></span></div>'; 1154 } 1155 1156 try { 1157 const data = await api('app.bsky.graph.getFollows', { 1158 actor: HANDLE, 1159 limit: FOLLOWS_PER_PAGE, 1160 cursor: followsCursor, 1161 }); 1162 1163 followsCursor = data.cursor || null; 1164 1165 if (!append) { 1166 panel.innerHTML = '<div class="follows-grid" id="followsGrid"></div>'; 1167 } 1168 1169 const existingBtn = panel.querySelector('.load-more'); 1170 if (existingBtn) existingBtn.remove(); 1171 1172 const grid = document.getElementById('followsGrid'); 1173 1174 if (!data.follows?.length && !append) { 1175 panel.innerHTML = '<div class="empty-state">Not following anyone yet</div>'; 1176 return; 1177 } 1178 1179 for (const f of data.follows) { 1180 const card = document.createElement('a'); 1181 card.className = 'follow-card'; 1182 card.href = `https://bsky.app/profile/${f.handle}`; 1183 card.target = '_blank'; 1184 card.rel = 'noopener'; 1185 card.innerHTML = ` 1186 <img class="follow-avatar" src="${f.avatar || ''}" alt="" /> 1187 <div class="follow-info"> 1188 <div class="follow-name">${escapeHtml(f.displayName || f.handle)}</div> 1189 <div class="follow-handle">@${escapeHtml(f.handle)}</div> 1190 </div> 1191 `; 1192 grid.appendChild(card); 1193 } 1194 1195 if (followsCursor) { 1196 const btn = document.createElement('button'); 1197 btn.className = 'load-more'; 1198 btn.textContent = 'Load more'; 1199 btn.onclick = () => { 1200 btn.disabled = true; 1201 btn.textContent = 'Loading...'; 1202 loadFollows(true); 1203 }; 1204 panel.appendChild(btn); 1205 } 1206 1207 followsLoaded = true; 1208 } catch (err) { 1209 if (!append) { 1210 panel.innerHTML = `<div class="error-state">Could not load follows: ${escapeHtml(err.message)}</div>`; 1211 } 1212 } 1213 } 1214 1215 // ── Render Feeds ──────────────────────────────────────── 1216 async function loadFeeds() { 1217 const panel = document.getElementById('feedsPanel'); 1218 panel.innerHTML = '<div class="loading"><span class="loading-dot"></span><span class="loading-dot"></span><span class="loading-dot"></span></div>'; 1219 1220 try { 1221 const data = await api('app.bsky.feed.getActorFeeds', { actor: HANDLE }); 1222 1223 if (!data.feeds?.length) { 1224 panel.innerHTML = '<div class="empty-state">No custom feeds</div>'; 1225 return; 1226 } 1227 1228 panel.innerHTML = ''; 1229 for (const feed of data.feeds) { 1230 const card = document.createElement('div'); 1231 card.className = 'feed-card'; 1232 card.innerHTML = ` 1233 <div class="feed-name">${escapeHtml(feed.displayName || 'Untitled Feed')}</div> 1234 ${feed.description ? `<div class="feed-desc">${escapeHtml(feed.description)}</div>` : ''} 1235 <div class="feed-likes">\u2661 ${feed.likeCount || 0} likes</div> 1236 `; 1237 panel.appendChild(card); 1238 } 1239 1240 feedsLoaded = true; 1241 } catch (err) { 1242 panel.innerHTML = `<div class="error-state">Could not load feeds: ${escapeHtml(err.message)}</div>`; 1243 } 1244 } 1245 1246 // ── Tab switching ─────────────────────────────────────── 1247 document.getElementById('tabs').addEventListener('click', (e) => { 1248 const tab = e.target.closest('.tab'); 1249 if (!tab) return; 1250 1251 const tabName = tab.dataset.tab; 1252 1253 // Update active tab 1254 document.querySelectorAll('.tab').forEach(t => t.classList.remove('active')); 1255 tab.classList.add('active'); 1256 1257 // Update panels 1258 document.querySelectorAll('.tab-panel').forEach(p => p.classList.remove('active')); 1259 document.getElementById(`${tabName}Panel`).classList.add('active'); 1260 1261 // Lazy load 1262 if (tabName === 'follows' && !followsLoaded) loadFollows(); 1263 if (tabName === 'feeds' && !feedsLoaded) loadFeeds(); 1264 }); 1265 1266 // ── Keyboard: close lightbox on Escape ────────────────── 1267 document.addEventListener('keydown', (e) => { 1268 if (e.key === 'Escape') { 1269 document.getElementById('lightbox').classList.remove('open'); 1270 } 1271 }); 1272 1273 // ── Init ──────────────────────────────────────────────── 1274 loadProfile(); 1275 loadPosts(); 1276 </script> 1277</body> 1278</html>