Personal blog finxol.io
blog

feat: show bsky posts on home page

finxol.io 90800401 5e653791

verified
+963 -94
+1 -1
.zed/settings.json
··· 6 6 }, 7 7 "Vue.js": { 8 8 "formatter": { "language_server": { "name": "biome" } }, 9 - "language_servers": ["!deno", "biome", "..."] 9 + "language_servers": ["!deno", "biome", "...", "vtsls"] 10 10 }, 11 11 12 12 "JavaScript": {
+20 -14
app/components/Country.vue
··· 1 1 <script setup lang="ts"> 2 - const data = await $fetch("https://hook.finxol.io/sensors/country") 3 - .then((res) => { 4 - return res as { 5 - country: string; 6 - country_code: string; 7 - country_flag: string; 8 - }; 9 - }) 10 - .catch((e) => { 11 - console.error(e); 12 - }); 2 + let country: string | undefined; 3 + let emoji: string | undefined; 13 4 14 - const country = data?.country; 15 - const emoji = data?.country_flag; 5 + try { 6 + const data = await $fetch("https://hook.finxol.io/sensors/country") 7 + .then((res) => { 8 + return res as { 9 + country: string; 10 + country_code: string; 11 + country_flag: string; 12 + }; 13 + }) 14 + .catch((e) => { 15 + console.error(e); 16 + }); 17 + country = data?.country; 18 + emoji = data?.country_flag; 19 + } catch (error) { 20 + console.error(error); 21 + } 16 22 </script> 17 23 18 24 <template> 19 - <div v-if="data" class="hidden sm:flex flex-row items-center gap-2"> 25 + <div v-if="country" class="hidden sm:flex flex-row items-center gap-2"> 20 26 <div class="tooltip-target flex flex-row items-center gap-2 bg-stone-200/50 dark:bg-stone-700/60 py-1 px-2 rounded-lg"> 21 27 I'm in 22 28 <span class="flex flex-row items-center gap-2 font-bold">
+547
app/components/page-elements/BskyFeedPost.vue
··· 1 + <script setup lang="ts"> 2 + import type { 3 + AppBskyEmbedImages, 4 + AppBskyEmbedRecord, 5 + AppBskyFeedDefs 6 + } from "@atcute/bluesky"; 7 + import { extractPostId } from "~/util/atproto"; 8 + 9 + const props = defineProps<{ 10 + post: AppBskyFeedDefs.FeedViewPost; 11 + }>(); 12 + 13 + const { post } = toRefs(props); 14 + 15 + // Get the actual post data 16 + const postView = computed(() => post.value.post); 17 + const author = computed(() => postView.value.author); 18 + const record = computed( 19 + () => postView.value.record as { text: string; createdAt: string } 20 + ); 21 + 22 + // Check for image embeds 23 + const images = computed((): AppBskyEmbedImages.ViewImage[] => { 24 + const embed = postView.value.embed; 25 + if (embed?.$type === "app.bsky.embed.images#view") { 26 + return (embed as AppBskyEmbedImages.View).images; 27 + } 28 + // Also check for images in recordWithMedia embeds (quote post with images) 29 + if (embed?.$type === "app.bsky.embed.recordWithMedia#view") { 30 + const media = ( 31 + embed as { 32 + media?: { 33 + $type: string; 34 + images?: AppBskyEmbedImages.ViewImage[]; 35 + }; 36 + } 37 + ).media; 38 + if (media?.$type === "app.bsky.embed.images#view" && media.images) { 39 + return media.images; 40 + } 41 + } 42 + return []; 43 + }); 44 + 45 + const hasImages = computed(() => images.value.length > 0); 46 + 47 + // Check for quote post embed 48 + const quotedPost = computed(() => { 49 + const embed = postView.value.embed; 50 + if (embed?.$type === "app.bsky.embed.record#view") { 51 + const recordEmbed = embed as AppBskyEmbedRecord.View; 52 + if (recordEmbed.record.$type === "app.bsky.embed.record#viewRecord") { 53 + return recordEmbed.record as AppBskyEmbedRecord.ViewRecord; 54 + } 55 + } 56 + // Also check for record in recordWithMedia embeds 57 + if (embed?.$type === "app.bsky.embed.recordWithMedia#view") { 58 + const record = (embed as { record?: AppBskyEmbedRecord.View }).record; 59 + if (record?.record.$type === "app.bsky.embed.record#viewRecord") { 60 + return record.record as AppBskyEmbedRecord.ViewRecord; 61 + } 62 + } 63 + return null; 64 + }); 65 + 66 + // Get quoted post text 67 + const quotedPostText = computed(() => { 68 + if (!quotedPost.value) return ""; 69 + const value = quotedPost.value.value as { text?: string }; 70 + return value.text || ""; 71 + }); 72 + 73 + // Format the date 74 + const formattedDate = computed(() => { 75 + const date = new Date(record.value.createdAt); 76 + return date.toLocaleDateString("en-US", { 77 + year: "numeric", 78 + month: "short", 79 + day: "numeric" 80 + }); 81 + }); 82 + 83 + // Build the Bluesky post URL 84 + const postUrl = computed(() => { 85 + const postId = extractPostId(postView.value.uri); 86 + return `https://bsky.app/profile/${author.value.handle}/post/${postId}`; 87 + }); 88 + 89 + // Build quoted post URL 90 + const quotedPostUrl = computed(() => { 91 + if (!quotedPost.value) return ""; 92 + const postId = extractPostId(quotedPost.value.uri); 93 + return `https://bsky.app/profile/${quotedPost.value.author.handle}/post/${postId}`; 94 + }); 95 + 96 + // Modal refs 97 + const quoteDialog = ref<HTMLDialogElement | null>(null); 98 + const imageDialog = ref<HTMLDialogElement | null>(null); 99 + const selectedImage = ref<AppBskyEmbedImages.ViewImage | null>(null); 100 + 101 + function openQuoteModal() { 102 + quoteDialog.value?.showModal(); 103 + } 104 + 105 + function openImageModal(image: AppBskyEmbedImages.ViewImage) { 106 + selectedImage.value = image; 107 + imageDialog.value?.showModal(); 108 + } 109 + 110 + function closeOnBackdropClick( 111 + event: MouseEvent, 112 + dialog: HTMLDialogElement | null 113 + ) { 114 + if (event.target === dialog) { 115 + dialog?.close(); 116 + } 117 + } 118 + </script> 119 + 120 + <template> 121 + <article class="bsky-feed-post" :class="{ 'has-images': hasImages }"> 122 + <div class="main"> 123 + <a :href="`https://bsky.app/profile/${author.handle}`" class="avatar-link"> 124 + <img 125 + :src="author.avatar" 126 + :alt="author.displayName || author.handle" 127 + class="size-8 rounded-full" 128 + /> 129 + </a> 130 + <div class="content"> 131 + <div class="header"> 132 + <a :href="`https://bsky.app/profile/${author.handle}`" class="author-name"> 133 + {{ author.displayName || author.handle }} 134 + </a> 135 + <span class="separator">·</span> 136 + <a :href="postUrl" class="date"> 137 + {{ formattedDate }} 138 + </a> 139 + </div> 140 + <div class="text" :class="{ 'has-quote': quotedPost }"> 141 + {{ record.text }} 142 + </div> 143 + 144 + <!-- Quote post button --> 145 + <button v-if="quotedPost" type="button" class="quote-button" @click="openQuoteModal"> 146 + <Icon name="ri:chat-quote-line" /> 147 + <img 148 + :src="quotedPost.author.avatar" 149 + :alt="quotedPost.author.displayName || quotedPost.author.handle" 150 + class="size-4 rounded-full" 151 + /> 152 + <span>{{ quotedPost.author.displayName || quotedPost.author.handle }}</span> 153 + </button> 154 + 155 + <div class="stats"> 156 + <span title="Replies"> 157 + <Icon name="ri:reply-line" /> 158 + {{ postView.replyCount }} 159 + </span> 160 + <span title="Reposts"> 161 + <Icon name="ri:repeat-line" /> 162 + {{ postView.repostCount }} 163 + </span> 164 + <span title="Likes"> 165 + <Icon name="ri:heart-3-line" /> 166 + {{ postView.likeCount }} 167 + </span> 168 + </div> 169 + </div> 170 + </div> 171 + 172 + <!-- Image embeds --> 173 + <div v-if="hasImages" class="images"> 174 + <button 175 + v-for="image in images" 176 + :key="image.thumb" 177 + type="button" 178 + class="image-button" 179 + @click="openImageModal(image)" 180 + > 181 + <img 182 + :src="image.thumb" 183 + :alt="image.alt" 184 + class="post-image" 185 + /> 186 + </button> 187 + </div> 188 + </article> 189 + 190 + <!-- Quote modal --> 191 + <dialog 192 + v-if="quotedPost" 193 + ref="quoteDialog" 194 + class="quote-dialog" 195 + @click="closeOnBackdropClick($event, quoteDialog)" 196 + > 197 + <div class="dialog-content"> 198 + <div class="dialog-header"> 199 + <a :href="`https://bsky.app/profile/${quotedPost.author.handle}`" class="quoted-author"> 200 + <img 201 + :src="quotedPost.author.avatar" 202 + :alt="quotedPost.author.displayName || quotedPost.author.handle" 203 + class="size-10 rounded-full" 204 + /> 205 + <div> 206 + <div class="quoted-author-name"> 207 + {{ quotedPost.author.displayName || quotedPost.author.handle }} 208 + </div> 209 + <div class="quoted-author-handle"> 210 + @{{ quotedPost.author.handle }} 211 + </div> 212 + </div> 213 + </a> 214 + <button type="button" class="close-button" @click="quoteDialog?.close()"> 215 + <Icon name="ri:close-line" /> 216 + </button> 217 + </div> 218 + <div class="dialog-body"> 219 + {{ quotedPostText }} 220 + </div> 221 + <a :href="quotedPostUrl" class="view-on-bsky"> 222 + View on Bluesky 223 + <Icon name="ri:external-link-line" /> 224 + </a> 225 + </div> 226 + </dialog> 227 + 228 + <!-- Image modal --> 229 + <dialog 230 + ref="imageDialog" 231 + class="image-dialog" 232 + @click="closeOnBackdropClick($event, imageDialog)" 233 + > 234 + <button type="button" class="close-button image-close" @click="imageDialog?.close()"> 235 + <Icon name="ri:close-line" /> 236 + </button> 237 + <img 238 + v-if="selectedImage" 239 + :src="selectedImage.fullsize" 240 + :alt="selectedImage.alt" 241 + class="fullsize-image" 242 + /> 243 + </dialog> 244 + </template> 245 + 246 + <style scoped> 247 + .bsky-feed-post { 248 + --post-z-index: 1; 249 + 250 + position: relative; 251 + display: flex; 252 + padding: 0.75rem; 253 + border: 1px solid #e5e7eb; 254 + border-radius: 0.5rem; 255 + width: 19rem; 256 + height: 13rem; 257 + flex-shrink: 0; 258 + scroll-snap-align: start; 259 + overflow: hidden; 260 + 261 + &.has-images { 262 + width: 28rem; 263 + } 264 + 265 + :where( 266 + .list-item-secondary-action, 267 + a, 268 + button, 269 + input, 270 + textarea, 271 + label, 272 + select, 273 + details, 274 + audio, 275 + video, 276 + object, 277 + [contenteditable], 278 + [tabindex] 279 + ) { 280 + /* Helps ensures secondary actions always float above the primary action's clickable area */ 281 + z-index: calc(var(--post-z-index) + 1); 282 + } 283 + } 284 + 285 + .main { 286 + display: flex; 287 + gap: 0.5rem; 288 + flex: 1; 289 + min-width: 0; 290 + } 291 + 292 + .avatar-link { 293 + flex-shrink: 0; 294 + opacity: 1; 295 + transition: opacity 200ms; 296 + height: fit-content; 297 + z-index: calc(var(--post-z-index) + 1); 298 + 299 + &:hover { 300 + opacity: 0.8; 301 + } 302 + } 303 + 304 + .content { 305 + display: flex; 306 + flex-direction: column; 307 + gap: 0.25rem; 308 + min-width: 0; 309 + flex: 1; 310 + } 311 + 312 + .header { 313 + display: flex; 314 + align-items: center; 315 + gap: 0.25rem; 316 + font-size: 0.75rem; 317 + } 318 + 319 + .author-name { 320 + font-weight: 600; 321 + white-space: nowrap; 322 + overflow: hidden; 323 + text-overflow: ellipsis; 324 + z-index: calc(var(--post-z-index) + 1); 325 + 326 + &:hover { 327 + text-decoration: underline; 328 + } 329 + 330 + } 331 + 332 + .separator { 333 + color: #6b7280; 334 + flex-shrink: 0; 335 + } 336 + 337 + .date { 338 + color: #6b7280; 339 + white-space: nowrap; 340 + flex-shrink: 0; 341 + 342 + &:hover { 343 + text-decoration: underline; 344 + } 345 + 346 + &::before { 347 + content: ''; 348 + display: block; 349 + position: absolute; 350 + inset: 0; 351 + z-index: var(--post-z-index); 352 + } 353 + } 354 + 355 + .text { 356 + font-size: 0.8125rem; 357 + white-space: pre-wrap; 358 + overflow: hidden; 359 + display: -webkit-box; 360 + line-clamp: 7; 361 + -webkit-line-clamp: 7; 362 + -webkit-box-orient: vertical; 363 + 364 + &.has-quote { 365 + line-clamp: 5; 366 + -webkit-line-clamp: 5; 367 + } 368 + } 369 + 370 + .quote-button { 371 + position: relative; 372 + display: flex; 373 + align-items: center; 374 + gap: 0.375rem; 375 + padding: 0.25rem 0.5rem; 376 + border: 1px solid #e5e7eb; 377 + border-radius: 0.375rem; 378 + background: none; 379 + font-size: 0.6875rem; 380 + color: #6b7280; 381 + cursor: pointer; 382 + transition: background-color 200ms; 383 + width: fit-content; 384 + 385 + &:hover { 386 + background-color: #f9fafb; 387 + } 388 + 389 + & span { 390 + max-width: 8rem; 391 + white-space: nowrap; 392 + overflow: hidden; 393 + text-overflow: ellipsis; 394 + } 395 + } 396 + 397 + .images { 398 + display: flex; 399 + gap: 0.25rem; 400 + margin-inline-start: 0.75rem; 401 + flex-shrink: 0; 402 + } 403 + 404 + .image-button { 405 + padding: 0; 406 + border: none; 407 + background: none; 408 + cursor: pointer; 409 + height: 100%; 410 + 411 + &:hover .post-image { 412 + opacity: 0.9; 413 + } 414 + } 415 + 416 + .post-image { 417 + height: 100%; 418 + width: auto; 419 + max-width: 8rem; 420 + object-fit: cover; 421 + border-radius: 0.375rem; 422 + transition: opacity 200ms; 423 + } 424 + 425 + /* Dialog styles */ 426 + .quote-dialog, 427 + .image-dialog { 428 + border: none; 429 + border-radius: 0.5rem; 430 + padding: 0; 431 + max-width: 90svw; 432 + max-height: 90svh; 433 + background: transparent; 434 + 435 + &::backdrop { 436 + background: rgba(0, 0, 0, 0.7); 437 + } 438 + } 439 + 440 + .quote-dialog { 441 + width: min(100%, 30rem); 442 + background: white; 443 + } 444 + 445 + .dialog-content { 446 + padding: 1rem; 447 + } 448 + 449 + .dialog-header { 450 + display: flex; 451 + justify-content: space-between; 452 + align-items: start; 453 + margin-bottom: 0.75rem; 454 + } 455 + 456 + .quoted-author { 457 + display: flex; 458 + align-items: center; 459 + gap: 0.5rem; 460 + 461 + &:hover .quoted-author-name { 462 + text-decoration: underline; 463 + } 464 + } 465 + 466 + .quoted-author-name { 467 + font-weight: 600; 468 + font-size: 0.875rem; 469 + } 470 + 471 + .quoted-author-handle { 472 + color: #6b7280; 473 + font-size: 0.75rem; 474 + } 475 + 476 + .close-button { 477 + padding: 0.25rem; 478 + border: none; 479 + background: none; 480 + cursor: pointer; 481 + color: #6b7280; 482 + font-size: 1.25rem; 483 + line-height: 1; 484 + border-radius: 0.25rem; 485 + transition: background-color 200ms; 486 + 487 + &:hover { 488 + background-color: #f3f4f6; 489 + } 490 + } 491 + 492 + .dialog-body { 493 + white-space: pre-wrap; 494 + font-size: 0.9375rem; 495 + line-height: 1.5; 496 + margin-bottom: 1rem; 497 + } 498 + 499 + .view-on-bsky { 500 + display: inline-flex; 501 + align-items: center; 502 + gap: 0.25rem; 503 + font-size: 0.8125rem; 504 + color: #6b7280; 505 + 506 + &:hover { 507 + text-decoration: underline; 508 + } 509 + } 510 + 511 + .image-dialog { 512 + background: transparent; 513 + overflow: visible; 514 + } 515 + 516 + .image-close { 517 + position: absolute; 518 + top: -2.5rem; 519 + right: 0; 520 + color: white; 521 + 522 + &:hover { 523 + background-color: rgba(255, 255, 255, 0.1); 524 + } 525 + } 526 + 527 + .fullsize-image { 528 + max-width: 90vw; 529 + max-height: 85vh; 530 + object-fit: contain; 531 + border-radius: 0.5rem; 532 + } 533 + 534 + .stats { 535 + display: flex; 536 + gap: 1rem; 537 + color: #6b7280; 538 + font-size: 0.6875rem; 539 + margin-top: auto; 540 + 541 + & > span { 542 + display: flex; 543 + align-items: center; 544 + gap: 0.25rem; 545 + } 546 + } 547 + </style>
+58
app/components/page-elements/BskyPosts.vue
··· 1 + <script setup lang="ts"> 2 + import config from "@/../blog.config"; 3 + import { getBskyPosts } from "~/util/atproto"; 4 + 5 + const posts = ref(await getBskyPosts()); 6 + const profileUrl = config.links?.bluesky || "https://bsky.app"; 7 + </script> 8 + 9 + <template> 10 + <div v-if="posts.length === 0" class="text-gray-500"> 11 + No posts found. 12 + </div> 13 + 14 + <div v-else class="posts-scroll"> 15 + <PageElementsBskyFeedPost 16 + v-for="feedPost in posts" 17 + :key="feedPost.post.uri" 18 + :post="feedPost" 19 + /> 20 + <a :href="profileUrl" class="view-more"> 21 + <span>View more on Bluesky</span> 22 + <Icon name="ri:arrow-right-line" /> 23 + </a> 24 + </div> 25 + </template> 26 + 27 + <style scoped> 28 + .posts-scroll { 29 + display: flex; 30 + gap: 1rem; 31 + overflow-x: auto; 32 + padding-bottom: 1rem; 33 + scroll-snap-type: x mandatory; 34 + align-items: start; 35 + } 36 + 37 + .view-more { 38 + display: flex; 39 + flex-direction: column; 40 + align-items: center; 41 + justify-content: center; 42 + gap: 0.5rem; 43 + min-width: 10rem; 44 + padding: 1rem; 45 + border: 1px solid #e5e7eb; 46 + border-radius: 0.5rem; 47 + color: #6b7280; 48 + flex-shrink: 0; 49 + scroll-snap-align: start; 50 + align-self: stretch; 51 + transition: background-color 200ms, color 200ms; 52 + 53 + &:hover { 54 + background-color: #f9fafb; 55 + color: #374151; 56 + } 57 + } 58 + </style>
+199
app/components/page-elements/BskyPostsSkeleton.vue
··· 1 + <script setup lang="ts"> 2 + // Show 2 skeleton cards by default 3 + const skeletonCount = 2; 4 + </script> 5 + 6 + <template> 7 + <div class="posts-scroll"> 8 + <div 9 + v-for="index in skeletonCount" 10 + :key="`skeleton-${index}`" 11 + class="bsky-feed-post skeleton" 12 + > 13 + <div class="main"> 14 + <!-- Avatar skeleton --> 15 + <div class="avatar-skeleton"></div> 16 + <div class="content"> 17 + <!-- Header skeleton --> 18 + <div class="header"> 19 + <div class="name-skeleton"></div> 20 + <span class="separator">·</span> 21 + <div class="date-skeleton"></div> 22 + </div> 23 + <!-- Text skeleton --> 24 + <div class="text-skeleton"> 25 + <div class="skeleton-line"></div> 26 + <div class="skeleton-line"></div> 27 + <div class="skeleton-line short"></div> 28 + </div> 29 + <!-- Stats skeleton --> 30 + <div class="stats-skeleton"> 31 + <div class="stat-skeleton"></div> 32 + <div class="stat-skeleton"></div> 33 + <div class="stat-skeleton"></div> 34 + </div> 35 + </div> 36 + </div> 37 + </div> 38 + <div class="view-more-skeleton"> 39 + <div class="skeleton-circle"></div> 40 + <div class="skeleton-text"></div> 41 + </div> 42 + </div> 43 + </template> 44 + 45 + <style scoped> 46 + .posts-scroll { 47 + display: flex; 48 + gap: 1rem; 49 + overflow-x: auto; 50 + padding-bottom: 1rem; 51 + scroll-snap-type: x mandatory; 52 + align-items: start; 53 + } 54 + 55 + .bsky-feed-post { 56 + position: relative; 57 + display: flex; 58 + padding: 0.75rem; 59 + border: 1px solid #e5e7eb; 60 + border-radius: 0.5rem; 61 + width: 19rem; 62 + height: 13rem; 63 + flex-shrink: 0; 64 + scroll-snap-align: start; 65 + } 66 + 67 + .main { 68 + display: flex; 69 + gap: 0.5rem; 70 + flex: 1; 71 + min-width: 0; 72 + } 73 + 74 + .avatar-skeleton { 75 + width: 2rem; 76 + height: 2rem; 77 + border-radius: 9999px; 78 + flex-shrink: 0; 79 + background: linear-gradient(90deg, #f3f4f6 25%, #e5e7eb 50%, #f3f4f6 75%); 80 + background-size: 200% 100%; 81 + animation: loading 1.5s infinite; 82 + } 83 + 84 + .content { 85 + display: flex; 86 + flex-direction: column; 87 + gap: 0.25rem; 88 + min-width: 0; 89 + flex: 1; 90 + } 91 + 92 + .header { 93 + display: flex; 94 + align-items: center; 95 + gap: 0.25rem; 96 + font-size: 0.75rem; 97 + } 98 + 99 + .name-skeleton { 100 + width: 6rem; 101 + height: 0.75rem; 102 + border-radius: 0.25rem; 103 + background: linear-gradient(90deg, #f3f4f6 25%, #e5e7eb 50%, #f3f4f6 75%); 104 + background-size: 200% 100%; 105 + animation: loading 1.5s infinite; 106 + } 107 + 108 + .separator { 109 + color: #d1d5db; 110 + flex-shrink: 0; 111 + } 112 + 113 + .date-skeleton { 114 + width: 4rem; 115 + height: 0.75rem; 116 + border-radius: 0.25rem; 117 + background: linear-gradient(90deg, #f3f4f6 25%, #e5e7eb 50%, #f3f4f6 75%); 118 + background-size: 200% 100%; 119 + animation: loading 1.5s infinite; 120 + } 121 + 122 + .text-skeleton { 123 + display: flex; 124 + flex-direction: column; 125 + gap: 0.375rem; 126 + margin-top: 0.5rem; 127 + flex: 1; 128 + } 129 + 130 + .skeleton-line { 131 + height: 0.625rem; 132 + border-radius: 0.25rem; 133 + background: linear-gradient(90deg, #f3f4f6 25%, #e5e7eb 50%, #f3f4f6 75%); 134 + background-size: 200% 100%; 135 + animation: loading 1.5s infinite; 136 + 137 + &.short { 138 + width: 60%; 139 + } 140 + } 141 + 142 + .stats-skeleton { 143 + display: flex; 144 + gap: 1rem; 145 + margin-top: auto; 146 + } 147 + 148 + .stat-skeleton { 149 + width: 3rem; 150 + height: 0.625rem; 151 + border-radius: 0.25rem; 152 + background: linear-gradient(90deg, #f3f4f6 25%, #e5e7eb 50%, #f3f4f6 75%); 153 + background-size: 200% 100%; 154 + animation: loading 1.5s infinite; 155 + } 156 + 157 + .view-more-skeleton { 158 + display: flex; 159 + flex-direction: column; 160 + align-items: center; 161 + justify-content: center; 162 + gap: 0.5rem; 163 + min-width: 10rem; 164 + padding: 1rem; 165 + border: 1px solid #e5e7eb; 166 + border-radius: 0.5rem; 167 + flex-shrink: 0; 168 + scroll-snap-align: start; 169 + align-self: stretch; 170 + } 171 + 172 + .skeleton-circle { 173 + width: 1.5rem; 174 + height: 1.5rem; 175 + border-radius: 9999px; 176 + background: linear-gradient(90deg, #f3f4f6 25%, #e5e7eb 50%, #f3f4f6 75%); 177 + background-size: 200% 100%; 178 + animation: loading 1.5s infinite; 179 + } 180 + 181 + .skeleton-text { 182 + width: 6rem; 183 + height: 0.75rem; 184 + border-radius: 0.25rem; 185 + background: linear-gradient(90deg, #f3f4f6 25%, #e5e7eb 50%, #f3f4f6 75%); 186 + background-size: 200% 100%; 187 + animation: loading 1.5s infinite; 188 + } 189 + 190 + @keyframes loading { 191 + 0% { 192 + background-position: 200% 0; 193 + } 194 + 195 + 100% { 196 + background-position: -200% 0; 197 + } 198 + } 199 + </style>
+80
app/components/page-elements/PostsList.vue
··· 1 + <script setup lang="ts"> 2 + const config = useRuntimeConfig().public; 3 + 4 + const { data } = await useAsyncData("postList", () => { 5 + return queryCollectionNavigation("posts", [ 6 + "path", 7 + "title", 8 + "date", 9 + "description", 10 + "authors", 11 + "tags" 12 + ]) 13 + .where("published", "<>", false) 14 + .order("date", "DESC"); 15 + }); 16 + 17 + const posts = data.value ? data.value[0]?.children : []; 18 + 19 + const tags = 20 + posts?.reduce((acc, post) => { 21 + for (const tag of post.tags as string[]) { 22 + if (!acc.includes(tag)) { 23 + acc.push(tag); 24 + } 25 + } 26 + return acc; 27 + }, [] as string[]) ?? []; 28 + 29 + const filter = ref<string | undefined>(undefined); 30 + 31 + const filteredPosts = computed(() => { 32 + if (!filter.value) return posts; 33 + return posts?.filter( 34 + (post) => 35 + post.tags && (post.tags as string[]).includes(filter.value ?? "") 36 + ); 37 + }); 38 + </script> 39 + 40 + <template> 41 + <section class=" overflow-x-scroll my-6 flex flex-row justify-between items-start gap-4"> 42 + <p class="text-sm text-gray-500 dark:text-gray-400 w-max text-nowrap mt-1"> 43 + Filter by tag: 44 + </p> 45 + <div class="flex flex-row items-center"> 46 + <button 47 + v-for="tag in tags" 48 + :key="tag" 49 + :class="[ 50 + 'flex px-3 py-1 mr-2 mb-2 w-max flex-row items-center gap-1', 51 + `${tag === filter ? 'bg-stone-500 dark:bg-stone-500 text-stone-100 dark:text-stone-100' : 'bg-stone-200 dark:bg-stone-700 text-stone-600 dark:text-stone-400'}`, 52 + 'rounded-full text-sm font-medium lowercase text-nowrap' 53 + ]" 54 + @click="filter = filter === tag ? undefined : tag" 55 + > 56 + <Icon 57 + v-if="tag === filter" 58 + name="ri:close-line" 59 + size="1rem" 60 + mode="svg" 61 + class="-ms-1" 62 + /> 63 + {{ tag }} 64 + </button> 65 + </div> 66 + </section> 67 + 68 + <PostPreviewAccent 69 + v-if="filteredPosts" 70 + :post="filteredPosts[0]" 71 + /> 72 + 73 + <div class="grid grid-cols-1 lg:grid-cols-2 gap-4 mt-4 mb-8"> 74 + <PostPreview 75 + v-for="post in filteredPosts?.slice(1)" 76 + :key="post.path" 77 + :post="post" 78 + /> 79 + </div> 80 + </template>
+26 -75
app/pages/index.vue
··· 5 5 queryCollection("pages").path("/pages").first() 6 6 ); 7 7 8 - const { data } = await useAsyncData("postList", () => { 9 - return queryCollectionNavigation("posts", [ 10 - "path", 11 - "title", 12 - "date", 13 - "description", 14 - "authors", 15 - "tags" 16 - ]) 17 - .where("published", "<>", false) 18 - .order("date", "DESC"); 19 - }); 20 - 21 - const posts = data.value ? data.value[0]?.children : []; 22 - 23 8 defineOgImageComponent("Page", { 24 - description: `This is ${config.title}. Read all ${posts?.length || 0} posts published so far, and stay tuned for more!` 25 - }); 26 - 27 - const tags = 28 - posts?.reduce((acc, post) => { 29 - for (const tag of post.tags as string[]) { 30 - if (!acc.includes(tag)) { 31 - acc.push(tag); 32 - } 33 - } 34 - return acc; 35 - }, [] as string[]) ?? []; 36 - 37 - const filter = ref<string | undefined>(undefined); 38 - 39 - const filteredPosts = computed(() => { 40 - if (!filter.value) return posts; 41 - return posts?.filter( 42 - (post) => 43 - post.tags && (post.tags as string[]).includes(filter.value ?? "") 44 - ); 9 + description: `This is ${config.title}.` 45 10 }); 46 11 </script> 47 12 ··· 55 20 </p> 56 21 </header> 57 22 58 - <h2 class="text-2xl font-bold my-6"> 59 - Posts 60 - </h2> 23 + <div class="flex flex-col my-6"> 24 + <h2 class="text-2xl font-bold"> 25 + Bluesky Posts 26 + </h2> 27 + <p class="text-gray-500"> 28 + My latest short-form 29 + <a href="https://bsky.app" class="underline">Bluesky</a> 30 + posts 31 + </p> 32 + </div> 61 33 62 - <section class=" overflow-x-scroll my-6 flex flex-row justify-between items-start gap-4"> 63 - <p class="text-sm text-gray-500 dark:text-gray-400 w-max text-nowrap mt-1"> 64 - Filter by tag: 65 - </p> 66 - <div class="flex flex-row items-center"> 67 - <button 68 - v-for="tag in tags" 69 - :key="tag" 70 - :class="[ 71 - 'flex px-3 py-1 mr-2 mb-2 w-max flex-row items-center gap-1', 72 - `${tag === filter ? 'bg-stone-500 dark:bg-stone-500 text-stone-100 dark:text-stone-100' : 'bg-stone-200 dark:bg-stone-700 text-stone-600 dark:text-stone-400'}`, 73 - 'rounded-full text-sm font-medium lowercase text-nowrap' 74 - ]" 75 - @click="filter = filter === tag ? undefined : tag" 76 - > 77 - <Icon 78 - v-if="tag === filter" 79 - name="ri:close-line" 80 - size="1rem" 81 - mode="svg" 82 - class="-ms-1" 83 - /> 84 - {{ tag }} 85 - </button> 86 - </div> 87 - </section> 34 + <Suspense> 35 + <PageElementsBskyPosts /> 88 36 89 - <PostPreviewAccent 90 - v-if="filteredPosts" 91 - :post="filteredPosts[0]" 92 - /> 37 + <template #fallback> 38 + <PageElementsBskyPostsSkeleton /> 39 + </template> 40 + </Suspense> 93 41 94 - <div class="grid grid-cols-1 lg:grid-cols-2 gap-4 mt-4 mb-8"> 95 - <PostPreview 96 - v-for="post in filteredPosts?.slice(1)" 97 - :key="post.path" 98 - :post="post" 99 - /> 42 + <div class="flex flex-col my-6"> 43 + <h2 class="text-2xl font-bold"> 44 + Blog Posts 45 + </h2> 46 + <p class="text-gray-500"> 47 + All my long-form blog posts about various, filterable topics 48 + </p> 100 49 </div> 50 + 51 + <PageElementsPostsList /> 101 52 </template>
+30 -2
app/util/atproto.ts
··· 1 - import { Client, simpleFetchHandler } from "@atcute/client"; 2 1 import type { AppBskyFeedDefs } from "@atcute/bluesky"; 2 + import { Client, simpleFetchHandler } from "@atcute/client"; 3 3 import type { ResourceUri } from "@atcute/lexicons"; 4 4 5 5 import config from "@/../blog.config"; 6 + import type { BlogConfig } from "~~/globals"; 7 + 8 + const authorDid = config.authorDid as BlogConfig["authorDid"]; 6 9 7 10 const handler = simpleFetchHandler({ 8 11 // Simply hit up the Bluesky API 9 - service: "https://public.api.bsky.app" 12 + service: "https://api.pop1.bsky.app" 10 13 }); 11 14 const rpc = new Client({ handler }); 12 15 ··· 56 59 } 57 60 return ""; 58 61 } 62 + 63 + /** 64 + * Fetch posts from the configured author's feed (excludes reposts) 65 + * @param limit Number of posts to fetch (default 50, max 100) 66 + * @param cursor Pagination cursor for fetching more posts 67 + * @returns Array of feed view posts with author info included 68 + */ 69 + export async function getBskyPosts(limit = 50, cursor?: string) { 70 + const { ok, data } = await rpc.get("app.bsky.feed.getAuthorFeed", { 71 + params: { 72 + actor: authorDid, 73 + limit, 74 + cursor, 75 + filter: "posts_and_author_threads" // Only get author's own posts, no reposts 76 + } 77 + }); 78 + 79 + if (!ok) { 80 + console.error("Error fetching author feed:", data.error); 81 + return []; 82 + } 83 + 84 + // Filter out any reposts just in case 85 + return data.feed.filter((item) => !item.reason); 86 + }
+2 -2
deno.jsonc
··· 1 1 { 2 2 "deploy": { 3 - "org": "finxol", 4 - "app": "blogging" 3 + "org": "finxol", 4 + "app": "blogging" 5 5 } 6 6 }