this repo has no description
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 · 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, '&').replace(/</g, '<').replace(/>/g, '>')
782 .replace(/"/g, '"').replace(/'/g, ''');
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"> · ${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"> · ${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>