|
| 1 | +--- |
| 2 | +interface Props { |
| 3 | + postId: string; |
| 4 | +} |
| 5 | +
|
| 6 | +const { postId } = Astro.props; |
| 7 | +const handle = "codingwithcalvin.net"; |
| 8 | +--- |
| 9 | + |
| 10 | +<div |
| 11 | + class="bluesky-engagement mt-8 p-6 bg-background-2 rounded-lg" |
| 12 | + data-post-id={postId} |
| 13 | + data-handle={handle} |
| 14 | +> |
| 15 | + <div class="flex items-center justify-between mb-4"> |
| 16 | + <div class="flex items-center gap-2"> |
| 17 | + <svg class="w-5 h-5 text-[#0085ff]" viewBox="0 0 568 501" fill="currentColor" xmlns="http://www.w3.org/2000/svg"> |
| 18 | + <path d="M123.121 33.6637C188.241 82.5526 258.281 181.681 284 234.873C309.719 181.681 379.759 82.5526 444.879 33.6637C491.866 -1.61183 568 -28.9064 568 57.9464C568 75.2916 558.055 189.086 552.071 210.685C534.135 274.363 494.427 288.674 458.331 284.323C347.591 270.773 378.291 346.014 455.423 366.672C543.113 390.192 558.377 455.107 454.018 464.097C375.969 470.64 336.053 439.194 307.553 411.717C290.099 394.915 283.99 394.875 284 394.915C284.008 394.875 277.896 394.915 260.447 411.717C231.944 439.194 192.031 470.64 113.982 464.097C9.61951 455.107 24.8869 390.192 112.577 366.672C189.709 346.014 220.409 270.773 109.669 284.323C73.5731 288.674 33.8647 274.363 15.9289 210.685C9.94525 189.086 0 75.2916 0 57.9464C0 -28.9064 76.1339 -1.61183 123.121 33.6637Z"/> |
| 19 | + </svg> |
| 20 | + <span class="text-text-muted text-sm">Engagement on Bluesky</span> |
| 21 | + </div> |
| 22 | + <a |
| 23 | + class="bluesky-link inline-flex items-center gap-2 text-[#0085ff] hover:underline text-sm" |
| 24 | + href="#" |
| 25 | + target="_blank" |
| 26 | + rel="noopener noreferrer" |
| 27 | + > |
| 28 | + Join the conversation |
| 29 | + <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> |
| 30 | + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/> |
| 31 | + </svg> |
| 32 | + </a> |
| 33 | + </div> |
| 34 | + |
| 35 | + <div class="engagement-content hidden"> |
| 36 | + <div class="flex gap-4"> |
| 37 | + <div class="likers-section w-1/2"> |
| 38 | + <div class="flex items-center gap-2 mb-2"> |
| 39 | + <svg class="w-5 h-5 text-red-400" fill="currentColor" viewBox="0 0 24 24"> |
| 40 | + <path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z"/> |
| 41 | + </svg> |
| 42 | + <span class="likes-count font-medium">0</span> |
| 43 | + <span class="likes-label text-text-muted text-sm">likes from</span> |
| 44 | + </div> |
| 45 | + <div class="likers-avatars flex flex-wrap gap-1"> |
| 46 | + <!-- Populated by JS --> |
| 47 | + </div> |
| 48 | + </div> |
| 49 | + |
| 50 | + <div class="reposters-section w-1/2"> |
| 51 | + <div class="flex items-center gap-2 mb-2"> |
| 52 | + <svg class="w-5 h-5 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24"> |
| 53 | + <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"/> |
| 54 | + </svg> |
| 55 | + <span class="reposts-count font-medium">0</span> |
| 56 | + <span class="reposts-label text-text-muted text-sm">reposts from</span> |
| 57 | + </div> |
| 58 | + <div class="reposters-avatars flex flex-wrap gap-1"> |
| 59 | + <!-- Populated by JS --> |
| 60 | + </div> |
| 61 | + </div> |
| 62 | + </div> |
| 63 | + </div> |
| 64 | + |
| 65 | + <div class="engagement-loading text-text-muted text-sm"> |
| 66 | + Loading engagement data... |
| 67 | + </div> |
| 68 | + |
| 69 | + <div class="engagement-error hidden text-text-muted text-sm"> |
| 70 | + <!-- Hidden on error - component just disappears gracefully --> |
| 71 | + </div> |
| 72 | +</div> |
| 73 | + |
| 74 | +<script> |
| 75 | + async function loadBlueskyEngagement() { |
| 76 | + const containers = document.querySelectorAll('.bluesky-engagement'); |
| 77 | + |
| 78 | + for (const container of containers) { |
| 79 | + const postId = container.dataset.postId; |
| 80 | + const handle = container.dataset.handle; |
| 81 | + |
| 82 | + if (!postId || !handle) { |
| 83 | + container.classList.add('hidden'); |
| 84 | + continue; |
| 85 | + } |
| 86 | + |
| 87 | + const content = container.querySelector('.engagement-content'); |
| 88 | + const loading = container.querySelector('.engagement-loading'); |
| 89 | + const errorEl = container.querySelector('.engagement-error'); |
| 90 | + const likesCount = container.querySelector('.likes-count'); |
| 91 | + const repostsCount = container.querySelector('.reposts-count'); |
| 92 | + const likersAvatars = container.querySelector('.likers-avatars'); |
| 93 | + const blueskyLink = container.querySelector('.bluesky-link'); |
| 94 | + |
| 95 | + try { |
| 96 | + // Resolve handle to DID |
| 97 | + const resolveRes = await fetch( |
| 98 | + `https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(handle)}` |
| 99 | + ); |
| 100 | + |
| 101 | + if (!resolveRes.ok) throw new Error('Failed to resolve handle'); |
| 102 | + |
| 103 | + const { did } = await resolveRes.json(); |
| 104 | + const atUri = `at://${did}/app.bsky.feed.post/${postId}`; |
| 105 | + |
| 106 | + // Get post details (includes like/repost counts) |
| 107 | + const postRes = await fetch( |
| 108 | + `https://public.api.bsky.app/xrpc/app.bsky.feed.getPosts?uris=${encodeURIComponent(atUri)}` |
| 109 | + ); |
| 110 | + |
| 111 | + if (!postRes.ok) throw new Error('Failed to fetch post'); |
| 112 | + |
| 113 | + const postData = await postRes.json(); |
| 114 | + |
| 115 | + if (!postData.posts || postData.posts.length === 0) { |
| 116 | + throw new Error('Post not found'); |
| 117 | + } |
| 118 | + |
| 119 | + const post = postData.posts[0]; |
| 120 | + const likes = post.likeCount || 0; |
| 121 | + const reposts = post.repostCount || 0; |
| 122 | + |
| 123 | + // Update counts and labels with proper pluralization |
| 124 | + if (likesCount) likesCount.textContent = likes.toString(); |
| 125 | + if (repostsCount) repostsCount.textContent = reposts.toString(); |
| 126 | + |
| 127 | + const likesLabel = container.querySelector('.likes-label'); |
| 128 | + const repostsLabel = container.querySelector('.reposts-label'); |
| 129 | + if (likesLabel) likesLabel.textContent = likes === 1 ? 'like from' : 'likes from'; |
| 130 | + if (repostsLabel) repostsLabel.textContent = reposts === 1 ? 'repost from' : 'reposts from'; |
| 131 | + |
| 132 | + // Hide likers section if no likes |
| 133 | + const likersSection = container.querySelector('.likers-section'); |
| 134 | + if (likes === 0 && likersSection) { |
| 135 | + likersSection.classList.add('hidden'); |
| 136 | + } |
| 137 | + |
| 138 | + // Hide reposters section if no reposts |
| 139 | + const repostersSection = container.querySelector('.reposters-section'); |
| 140 | + if (reposts === 0 && repostersSection) { |
| 141 | + repostersSection.classList.add('hidden'); |
| 142 | + } |
| 143 | + |
| 144 | + // Update Bluesky link |
| 145 | + if (blueskyLink) { |
| 146 | + blueskyLink.setAttribute( |
| 147 | + 'href', |
| 148 | + `https://bsky.app/profile/${handle}/post/${postId}` |
| 149 | + ); |
| 150 | + } |
| 151 | + |
| 152 | + // Fetch likers for avatars (limit to 50) |
| 153 | + if (likes > 0 && likersAvatars) { |
| 154 | + const likesRes = await fetch( |
| 155 | + `https://public.api.bsky.app/xrpc/app.bsky.feed.getLikes?uri=${encodeURIComponent(atUri)}&limit=50` |
| 156 | + ); |
| 157 | + |
| 158 | + if (likesRes.ok) { |
| 159 | + const likesData = await likesRes.json(); |
| 160 | + |
| 161 | + if (likesData.likes && likesData.likes.length > 0) { |
| 162 | + likersAvatars.innerHTML = likesData.likes |
| 163 | + .map((like: { actor: { avatar?: string; displayName?: string; handle: string } }) => { |
| 164 | + const avatar = like.actor.avatar; |
| 165 | + const name = like.actor.displayName || like.actor.handle; |
| 166 | + |
| 167 | + if (avatar) { |
| 168 | + // Use thumbnail size for performance |
| 169 | + const thumbUrl = avatar.replace('/img/avatar/', '/img/avatar_thumbnail/'); |
| 170 | + return `<img |
| 171 | + src="${thumbUrl}" |
| 172 | + alt="${name}" |
| 173 | + title="${name}" |
| 174 | + class="w-8 h-8 rounded-full border-2 border-background" |
| 175 | + loading="lazy" |
| 176 | + />`; |
| 177 | + } |
| 178 | + // Default avatar for users without one |
| 179 | + return `<div |
| 180 | + class="w-8 h-8 rounded-full bg-primary/20 flex items-center justify-center text-xs font-medium" |
| 181 | + title="${name}" |
| 182 | + >${name.charAt(0).toUpperCase()}</div>`; |
| 183 | + }) |
| 184 | + .join(''); |
| 185 | + } |
| 186 | + } |
| 187 | + } |
| 188 | + |
| 189 | + // Fetch reposters for avatars (limit to 50) |
| 190 | + const repostersAvatars = container.querySelector('.reposters-avatars'); |
| 191 | + if (reposts > 0 && repostersAvatars) { |
| 192 | + const repostsRes = await fetch( |
| 193 | + `https://public.api.bsky.app/xrpc/app.bsky.feed.getRepostedBy?uri=${encodeURIComponent(atUri)}&limit=50` |
| 194 | + ); |
| 195 | + |
| 196 | + if (repostsRes.ok) { |
| 197 | + const repostsData = await repostsRes.json(); |
| 198 | + |
| 199 | + if (repostsData.repostedBy && repostsData.repostedBy.length > 0) { |
| 200 | + repostersAvatars.innerHTML = repostsData.repostedBy |
| 201 | + .map((user: { avatar?: string; displayName?: string; handle: string }) => { |
| 202 | + const avatar = user.avatar; |
| 203 | + const name = user.displayName || user.handle; |
| 204 | + |
| 205 | + if (avatar) { |
| 206 | + // Use thumbnail size for performance |
| 207 | + const thumbUrl = avatar.replace('/img/avatar/', '/img/avatar_thumbnail/'); |
| 208 | + return `<img |
| 209 | + src="${thumbUrl}" |
| 210 | + alt="${name}" |
| 211 | + title="${name}" |
| 212 | + class="w-8 h-8 rounded-full border-2 border-background" |
| 213 | + loading="lazy" |
| 214 | + />`; |
| 215 | + } |
| 216 | + // Default avatar for users without one |
| 217 | + return `<div |
| 218 | + class="w-8 h-8 rounded-full bg-primary/20 flex items-center justify-center text-xs font-medium" |
| 219 | + title="${name}" |
| 220 | + >${name.charAt(0).toUpperCase()}</div>`; |
| 221 | + }) |
| 222 | + .join(''); |
| 223 | + } |
| 224 | + } |
| 225 | + } |
| 226 | + |
| 227 | + // Show content, hide loading |
| 228 | + loading?.classList.add('hidden'); |
| 229 | + content?.classList.remove('hidden'); |
| 230 | + |
| 231 | + } catch (error) { |
| 232 | + // On any error, just hide the entire component gracefully |
| 233 | + container.classList.add('hidden'); |
| 234 | + } |
| 235 | + } |
| 236 | + } |
| 237 | + |
| 238 | + // Run on page load |
| 239 | + document.addEventListener('DOMContentLoaded', loadBlueskyEngagement); |
| 240 | + |
| 241 | + // Also run on Astro page transitions (View Transitions API) |
| 242 | + document.addEventListener('astro:page-load', loadBlueskyEngagement); |
| 243 | +</script> |
0 commit comments