Something went wrong. Try again.
[READ-ONLY] Mirror of https://github.com/danielroe/roe.dev. This is the code and content for my personal website, built in Nuxt. roe.dev
Something went wrong. Try again.
10 kB · 346 lines
Vue
at feat/nuxt-v5
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347<script setup lang="ts">import type { app } from '@bsky/sdk/lexicons'import type { Facet, BlueskyFacetFeature } from '#shared/utils/bluesky'import { segmentize, atUriToWebUrl } from '#shared/utils/bluesky'
interface CommentEmbed { type: 'images' | 'external' images?: app.bsky.embed.images.ViewImage[] external?: app.bsky.embed.external.ViewExternal}
interface Comment { uri: string cid: string author: { did: string handle: string displayName?: string avatar?: string } text: string facets?: Facet<BlueskyFacetFeature>[] embed?: CommentEmbed createdAt: string likeCount: number replyCount: number repostCount: number replies: Comment[]}
function getCommentUrl (comment: Comment): string { return atUriToWebUrl(comment.uri) ?? '#'}
function getFeatureUrl (feature: BlueskyFacetFeature): string | undefined { if (feature.$type === 'app.bsky.richtext.facet#link') return feature.uri if (feature.$type === 'app.bsky.richtext.facet#mention') return `https://bsky.app/profile/${feature.did}` if (feature.$type === 'app.bsky.richtext.facet#tag') return `https://bsky.app/hashtag/${feature.tag}` return undefined}
const props = defineProps<{ comment: Comment depth: number}>()
const commentUrl = computed(() => getCommentUrl(props.comment))const segments = computed(() => segmentize(props.comment.text, props.comment.facets))const maxDepth = 4</script>
<template> <div v-if="depth === 0" class="flex gap-3" > <a :href="`https://bsky.app/profile/${comment.author.handle}`" target="_blank" rel="noopener noreferrer" class="flex-shrink-0 rounded-full f-ring" :aria-label="`View ${comment.author.displayName || comment.author.handle}'s profile on Bluesky`" > <img v-if="comment.author.avatar" :src="comment.author.avatar" alt="" class="rounded-full h-10 w-10" width="40" height="40" loading="lazy" > <div v-else class="rounded-full h-10 w-10 bg-gray-300 dark:bg-gray-700 flex items-center justify-center text-sm" aria-hidden="true" > {{ (comment.author.displayName || comment.author.handle).charAt(0).toUpperCase() }} </div> </a>
<div class="flex-1 min-w-0"> <div class="flex items-baseline gap-2 flex-wrap"> <a :href="`https://bsky.app/profile/${comment.author.handle}`" target="_blank" rel="noopener" class="font-medium hover:underline truncate rounded f-ring" > {{ comment.author.displayName || `@${comment.author.handle}` }} </a> <span v-if="comment.author.displayName" class="text-muted text-sm truncate" > @{{ comment.author.handle }} </span> <a :href="commentUrl" target="_blank" rel="noopener" class="text-muted text-sm hover:underline rounded f-ring" > <NuxtTime relative :datetime="comment.createdAt" /> </a> </div>
<p class="mt-1 whitespace-pre-wrap break-words"> <template v-for="(segment, i) in segments" :key="i" > <a v-if="segment.features?.[0] && getFeatureUrl(segment.features[0])" :href="getFeatureUrl(segment.features[0])" target="_blank" rel="noopener" class="text-blue-600 dark:text-blue-400 hover:underline rounded f-ring" >{{ segment.text }}</a> <template v-else> {{ segment.text }} </template> </template> </p>
<div v-if="comment.embed?.type === 'images' && comment.embed.images" class="mt-2 flex gap-2 flex-wrap" > <a v-for="(img, i) in comment.embed.images" :key="i" :href="img.fullsize" target="_blank" rel="noopener" class="block rounded-lg f-ring" > <img :src="img.thumb" :alt="img.alt || 'Image'" class="rounded-lg max-h-48 object-cover" loading="lazy" > </a> </div>
<a v-if="comment.embed?.type === 'external' && comment.embed.external" :href="comment.embed.external.uri" target="_blank" rel="noopener" class="mt-2 block border border-gray-300 dark:border-gray-700 rounded-lg overflow-hidden hover:border-gray-400 dark:hover:border-gray-600 transition-colors f-ring-inset" > <div class="flex"> <img v-if="comment.embed.external.thumb" :src="comment.embed.external.thumb" :alt="comment.embed.external.title" class="w-24 h-24 object-cover flex-shrink-0" loading="lazy" > <div class="p-3 min-w-0"> <p class="text-sm font-medium truncate"> {{ comment.embed.external.title }} </p> <p class="text-xs text-muted line-clamp-2 mt-1"> {{ comment.embed.external.description }} </p> </div> </div> </a>
<div v-if="comment.likeCount > 0 || comment.repostCount > 0" class="flex items-center gap-4 mt-2 text-sm text-muted" > <span v-if="comment.likeCount > 0"> {{ comment.likeCount }} {{ comment.likeCount === 1 ? 'like' : 'likes' }} </span> <span v-if="comment.repostCount > 0"> {{ comment.repostCount }} {{ comment.repostCount === 1 ? 'repost' : 'reposts' }} </span> </div>
<div v-if="comment.replies.length > 0" class="mt-4 flex flex-col gap-4 border-l border-gray-300 dark:border-gray-800" > <BlueskyComment v-for="reply in comment.replies" :key="reply.uri" :comment="reply" :depth="depth + 1" /> </div> </div> </div>
<div v-else class="pt-3 pl-1" > <div class="flex items-center gap-2 flex-wrap"> <a :href="`https://bsky.app/profile/${comment.author.handle}`" target="_blank" rel="noopener noreferrer" class="flex-shrink-0 rounded-full f-ring p-1" :aria-label="`View ${comment.author.displayName || comment.author.handle}'s profile on Bluesky`" > <img v-if="comment.author.avatar" :src="comment.author.avatar" alt="" class="rounded-full h-6 w-6" width="24" height="24" loading="lazy" > <div v-else class="rounded-full h-6 w-6 bg-gray-300 dark:bg-gray-700 flex items-center justify-center text-xs" aria-hidden="true" > {{ (comment.author.displayName || comment.author.handle).charAt(0).toUpperCase() }} </div> </a> <a :href="`https://bsky.app/profile/${comment.author.handle}`" target="_blank" rel="noopener" class="font-medium hover:underline truncate rounded f-ring" > {{ comment.author.displayName || `@${comment.author.handle}` }} </a> <span v-if="comment.author.displayName" class="text-muted text-sm truncate" > @{{ comment.author.handle }} </span> <a :href="commentUrl" target="_blank" rel="noopener" class="text-muted text-sm hover:underline rounded f-ring" > <NuxtTime relative :datetime="comment.createdAt" /> </a> </div>
<p class="mt-1 whitespace-pre-wrap break-words"> <template v-for="(segment, i) in segments" :key="i" > <a v-if="segment.features?.[0] && getFeatureUrl(segment.features[0])" :href="getFeatureUrl(segment.features[0])" target="_blank" rel="noopener" class="text-blue-600 dark:text-blue-400 hover:underline rounded f-ring" >{{ segment.text }}</a> <template v-else> {{ segment.text }} </template> </template> </p>
<div v-if="comment.embed?.type === 'images' && comment.embed.images" class="mt-2 flex gap-2 flex-wrap" > <a v-for="(img, i) in comment.embed.images" :key="i" :href="img.fullsize" target="_blank" rel="noopener" class="block rounded-lg f-ring" > <img :src="img.thumb" :alt="img.alt || 'Image'" class="rounded-lg max-h-32 object-cover" loading="lazy" > </a> </div>
<a v-if="comment.embed?.type === 'external' && comment.embed.external" :href="comment.embed.external.uri" target="_blank" rel="noopener" class="mt-2 block border border-gray-300 dark:border-gray-700 rounded-lg overflow-hidden hover:border-gray-400 dark:hover:border-gray-600 transition-colors f-ring-inset" > <div class="flex"> <img v-if="comment.embed.external.thumb" :src="comment.embed.external.thumb" :alt="comment.embed.external.title" class="w-20 h-20 object-cover flex-shrink-0" loading="lazy" > <div class="p-2 min-w-0"> <p class="text-sm font-medium truncate"> {{ comment.embed.external.title }} </p> <p class="text-xs text-muted line-clamp-2 mt-1"> {{ comment.embed.external.description }} </p> </div> </div> </a>
<template v-if="comment.replies.length > 0"> <div v-if="depth < maxDepth" class="flex flex-col border-l border-gray-300 dark:border-gray-800" > <BlueskyComment v-for="reply in comment.replies" :key="reply.uri" :comment="reply" :depth="depth + 1" /> </div> <a v-else :href="getCommentUrl(comment.replies[0]!)" target="_blank" rel="noopener" class="text-sm text-muted hover:underline mt-2 block rounded f-ring" > {{ comment.replies.length }} more {{ comment.replies.length === 1 ? 'reply' : 'replies' }}... </a> </template> </div></template>