See the best posts from any Bluesky account
0
fork

Configure Feed

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

Add sign-in/out UI to header and like/repost buttons to post cards

- Add CSRF meta tag to layout head for API calls from Alpine.js
- Add conditional auth buttons in header: "Sign in" link when logged out,
"My profile" link and "Sign out" form when logged in
- Create postEngagement Alpine.data() component with optimistic like/repost
toggling via fetch to /api/like and /api/repost endpoints
- Update profile show.edge to render interactive engagement buttons when
viewer data is present, falling back to static counts for anonymous users

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

+268 -9
+210
resources/js/app.js
··· 49 49 }) 50 50 ) 51 51 52 + Alpine.data('postEngagement', function () { 53 + return { 54 + liked: false, 55 + likeUri: null, 56 + likeCount: 0, 57 + reposted: false, 58 + repostUri: null, 59 + repostCount: 0, 60 + postUri: '', 61 + postCid: '', 62 + csrfToken: '', 63 + busy: false, 64 + 65 + init() { 66 + this.liked = this.$el.dataset.liked === 'true' 67 + this.likeUri = this.$el.dataset.likeUri || null 68 + this.likeCount = parseInt(this.$el.dataset.likeCount, 10) || 0 69 + this.reposted = this.$el.dataset.reposted === 'true' 70 + this.repostUri = this.$el.dataset.repostUri || null 71 + this.repostCount = parseInt(this.$el.dataset.repostCount, 10) || 0 72 + this.postUri = this.$el.dataset.postUri || '' 73 + this.postCid = this.$el.dataset.postCid || '' 74 + var meta = document.querySelector('meta[name="csrf-token"]') 75 + this.csrfToken = meta ? meta.getAttribute('content') : '' 76 + }, 77 + 78 + toggleLike() { 79 + if (this.busy) return 80 + this.busy = true 81 + 82 + if (this.liked) { 83 + // Optimistic update 84 + this.liked = false 85 + this.likeCount = this.likeCount - 1 86 + var uri = this.likeUri 87 + this.likeUri = null 88 + 89 + fetch('/api/like', { 90 + method: 'DELETE', 91 + headers: { 92 + 'Content-Type': 'application/json', 93 + 'Accept': 'application/json', 94 + 'x-csrf-token': this.csrfToken, 95 + }, 96 + body: JSON.stringify({ uri: uri }), 97 + }) 98 + .then( 99 + function (resp) { 100 + if (!resp.ok) { 101 + // Revert 102 + this.liked = true 103 + this.likeCount = this.likeCount + 1 104 + this.likeUri = uri 105 + } 106 + }.bind(this) 107 + ) 108 + .catch( 109 + function () { 110 + this.liked = true 111 + this.likeCount = this.likeCount + 1 112 + this.likeUri = uri 113 + }.bind(this) 114 + ) 115 + .finally( 116 + function () { 117 + this.busy = false 118 + }.bind(this) 119 + ) 120 + } else { 121 + // Optimistic update 122 + this.liked = true 123 + this.likeCount = this.likeCount + 1 124 + 125 + fetch('/api/like', { 126 + method: 'POST', 127 + headers: { 128 + 'Content-Type': 'application/json', 129 + 'Accept': 'application/json', 130 + 'x-csrf-token': this.csrfToken, 131 + }, 132 + body: JSON.stringify({ uri: this.postUri, cid: this.postCid }), 133 + }) 134 + .then( 135 + function (resp) { 136 + if (resp.ok) { 137 + return resp.json() 138 + } 139 + // Revert 140 + this.liked = false 141 + this.likeCount = this.likeCount - 1 142 + return null 143 + }.bind(this) 144 + ) 145 + .then( 146 + function (data) { 147 + if (data && data.uri) { 148 + this.likeUri = data.uri 149 + } 150 + }.bind(this) 151 + ) 152 + .catch( 153 + function () { 154 + this.liked = false 155 + this.likeCount = this.likeCount - 1 156 + }.bind(this) 157 + ) 158 + .finally( 159 + function () { 160 + this.busy = false 161 + }.bind(this) 162 + ) 163 + } 164 + }, 165 + 166 + toggleRepost() { 167 + if (this.busy) return 168 + this.busy = true 169 + 170 + if (this.reposted) { 171 + // Optimistic update 172 + this.reposted = false 173 + this.repostCount = this.repostCount - 1 174 + var uri = this.repostUri 175 + this.repostUri = null 176 + 177 + fetch('/api/repost', { 178 + method: 'DELETE', 179 + headers: { 180 + 'Content-Type': 'application/json', 181 + 'Accept': 'application/json', 182 + 'x-csrf-token': this.csrfToken, 183 + }, 184 + body: JSON.stringify({ uri: uri }), 185 + }) 186 + .then( 187 + function (resp) { 188 + if (!resp.ok) { 189 + this.reposted = true 190 + this.repostCount = this.repostCount + 1 191 + this.repostUri = uri 192 + } 193 + }.bind(this) 194 + ) 195 + .catch( 196 + function () { 197 + this.reposted = true 198 + this.repostCount = this.repostCount + 1 199 + this.repostUri = uri 200 + }.bind(this) 201 + ) 202 + .finally( 203 + function () { 204 + this.busy = false 205 + }.bind(this) 206 + ) 207 + } else { 208 + // Optimistic update 209 + this.reposted = true 210 + this.repostCount = this.repostCount + 1 211 + 212 + fetch('/api/repost', { 213 + method: 'POST', 214 + headers: { 215 + 'Content-Type': 'application/json', 216 + 'Accept': 'application/json', 217 + 'x-csrf-token': this.csrfToken, 218 + }, 219 + body: JSON.stringify({ uri: this.postUri, cid: this.postCid }), 220 + }) 221 + .then( 222 + function (resp) { 223 + if (resp.ok) { 224 + return resp.json() 225 + } 226 + this.reposted = false 227 + this.repostCount = this.repostCount - 1 228 + return null 229 + }.bind(this) 230 + ) 231 + .then( 232 + function (data) { 233 + if (data && data.uri) { 234 + this.repostUri = data.uri 235 + } 236 + }.bind(this) 237 + ) 238 + .catch( 239 + function () { 240 + this.reposted = false 241 + this.repostCount = this.repostCount - 1 242 + }.bind(this) 243 + ) 244 + .finally( 245 + function () { 246 + this.busy = false 247 + }.bind(this) 248 + ) 249 + } 250 + }, 251 + 252 + get likedClass() { 253 + return this.liked ? 'text-red-500' : 'text-gray-400 hover:text-red-400' 254 + }, 255 + 256 + get repostedClass() { 257 + return this.reposted ? 'text-blue-600' : 'text-gray-400 hover:text-blue-500' 258 + }, 259 + } 260 + }) 261 + 52 262 Alpine.start()
+16
resources/views/components/layout.edge
··· 12 12 @vite(['resources/css/app.css', 'resources/js/app.js'], { nonce: cspNonce }) 13 13 <link rel="stylesheet" type="text/css" href="https://cdn.jsdelivr.net/npm/@phosphor-icons/web@2.1.2/src/fill/style.css" /> 14 14 <link rel="stylesheet" type="text/css" href="https://cdn.jsdelivr.net/npm/@phosphor-icons/web@2.1.2/src/bold/style.css" /> 15 + <meta name="csrf-token" content="{{ csrfToken }}"> 15 16 @if($slots.head) 16 17 {{{ await $slots.head() }}} 17 18 @endif ··· 48 49 <i x-show="!dark" class="ph-fill ph-moon text-base"></i> 49 50 <i x-show="dark" class="ph-fill ph-sun text-base"></i> 50 51 </button> 52 + @if(auth.isAuthenticated) 53 + <a 54 + href="/profile/{{ auth.user.handle }}/likes" 55 + class="text-sm text-gray-600 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 transition-colors duration-200" 56 + >My profile</a> 57 + <form action="/oauth/logout" method="POST" class="inline"> 58 + {{ csrfField() }} 59 + <button type="submit" class="text-sm text-gray-600 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 transition-colors duration-200 cursor-pointer">Sign out</button> 60 + </form> 61 + @else 62 + <a 63 + href="/oauth/login" 64 + class="text-sm text-gray-600 dark:text-gray-300 hover:text-blue-600 dark:hover:text-blue-400 transition-colors duration-200" 65 + >Sign in</a> 66 + @endif 51 67 </div> 52 68 </div> 53 69 @endif
+42 -9
resources/views/pages/profile/show.edge
··· 181 181 @endif 182 182 183 183 <div class="flex items-baseline justify-between flex-wrap gap-4 mt-3"> 184 - <div class="flex items-baseline gap-4"> 185 - @if(kind === 'likes') 186 - <span class="text-sm text-red-500 tabular-nums"><i class="ph-fill ph-heart"></i> {{ post.likes.toLocaleString() }}</span> 187 - <span class="text-sm text-gray-400"><i class="ph-bold ph-repeat"></i> {{ post.reposts.toLocaleString() }}</span> 188 - @else 189 - <span class="text-sm text-gray-400"><i class="ph-fill ph-heart"></i> {{ post.likes.toLocaleString() }}</span> 190 - <span class="text-sm text-blue-600 tabular-nums"><i class="ph-bold ph-repeat"></i> {{ post.reposts.toLocaleString() }}</span> 191 - @endif 192 - </div> 184 + @if(viewer) 185 + @set('viewerState', viewer[post.postUri]) 186 + <div 187 + x-data="postEngagement" 188 + data-post-uri="{{ post.postUri }}" 189 + data-post-cid="{{ post.postCid }}" 190 + data-liked="{{ viewerState && viewerState.likeUri ? 'true' : 'false' }}" 191 + data-like-uri="{{ viewerState && viewerState.likeUri ? viewerState.likeUri : '' }}" 192 + data-like-count="{{ post.likes }}" 193 + data-reposted="{{ viewerState && viewerState.repostUri ? 'true' : 'false' }}" 194 + data-repost-uri="{{ viewerState && viewerState.repostUri ? viewerState.repostUri : '' }}" 195 + data-repost-count="{{ post.reposts }}" 196 + class="flex items-baseline gap-4" 197 + > 198 + <button 199 + x-on:click="toggleLike" 200 + x-bind:class="likedClass" 201 + class="text-sm tabular-nums cursor-pointer transition-colors duration-150" 202 + aria-label="Like" 203 + > 204 + <i class="ph-fill ph-heart"></i> <span x-text="likeCount"></span> 205 + </button> 206 + <button 207 + x-on:click="toggleRepost" 208 + x-bind:class="repostedClass" 209 + class="text-sm tabular-nums cursor-pointer transition-colors duration-150" 210 + aria-label="Repost" 211 + > 212 + <i class="ph-bold ph-repeat"></i> <span x-text="repostCount"></span> 213 + </button> 214 + </div> 215 + @else 216 + <div class="flex items-baseline gap-4"> 217 + @if(kind === 'likes') 218 + <span class="text-sm text-red-500 tabular-nums"><i class="ph-fill ph-heart"></i> {{ post.likes.toLocaleString() }}</span> 219 + <span class="text-sm text-gray-400"><i class="ph-bold ph-repeat"></i> {{ post.reposts.toLocaleString() }}</span> 220 + @else 221 + <span class="text-sm text-gray-400"><i class="ph-fill ph-heart"></i> {{ post.likes.toLocaleString() }}</span> 222 + <span class="text-sm text-blue-600 tabular-nums"><i class="ph-bold ph-repeat"></i> {{ post.reposts.toLocaleString() }}</span> 223 + @endif 224 + </div> 225 + @endif 193 226 <a href="{{ post.bskyUrl }}" target="_blank" rel="noopener" class="text-sm text-gray-400 hover:underline">{{ post.postCreatedAt.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' }) }}</a> 194 227 </div> 195 228 </div>