-
-
Notifications
You must be signed in to change notification settings - Fork 366
feat: blog #1094
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
feat: blog #1094
Changes from all commits
651fe07
c5fcd2b
0878f16
9d077fd
81868e0
1b253b2
6ec8568
79e933e
012472e
c1b4ffc
6fc3a8f
7e87ee1
9d3714a
1f74b4b
030df0f
a4039c2
7835ef2
127ad59
9f6bc48
ed96391
6774771
391c883
b76ba2a
3ad742b
669063c
71f5cca
45f9bc6
2052c21
1ca8d10
1d112b4
cfe9d55
d58ae02
33ad33d
8424fa4
2a46555
2a17a53
6cb0feb
1cb8dbd
87353fe
1ec98d4
3d8f7df
3748d51
31fafbd
8beb8e6
38158f3
73ea341
56f1223
6f4e63e
129d94b
e588f5b
c96a5f8
5440302
b9d217f
9085d4a
4b0b850
c506c73
0d1f837
ac1baf9
d70b4a8
5077c69
277d83b
a27cea0
9a70159
9da9597
6b5d438
21c8b0b
0ec67fb
233f6a5
86c91ad
99ecd5b
9f2858d
499868a
c02fd9c
4a9f6a0
815de27
ae47eb9
18d1bf0
824ac6f
9db3aca
35d0a85
78beae7
04acd6d
f8662ca
84dbf26
fa40a99
03f237c
7722280
bddcd7b
2725f18
05b887f
0cad588
684feb4
ba705b6
df1f1ac
b47a1b2
d3aa695
ca08677
52f8ea0
d55c67f
83a2ab5
6ad2bc5
1099966
849e178
8d31bbd
364e3ed
5020b4a
2f6c3b6
0bcbf1e
3433046
eaf5097
f08c10b
848cd43
36dd357
c8e4aa2
bbe7b97
67402e0
e8e5a1e
94b408d
0f51b12
eedbbe7
3698abc
1f84e78
4e1293b
445e81a
3062937
4d01829
f52c91a
3c10cd9
2bffb28
073d631
e002d17
5bca78d
ee9922b
e53efc0
928b41c
2b82383
a0318d0
739a8f0
315f381
f64aa74
32f705a
38dfe4d
3aac6dd
109839e
a5acfe5
130c5d5
6ac567b
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,45 @@ | ||
| <script setup lang="ts"> | ||
| import type { ResolvedAuthor } from '#shared/schemas/blog' | ||
|
|
||
| const props = defineProps<{ | ||
| author: Partial<ResolvedAuthor> & Pick<ResolvedAuthor, 'name' | 'avatar'> | ||
| size?: 'sm' | 'md' | 'lg' | ||
| }>() | ||
|
|
||
| const sizeClasses = computed(() => { | ||
| switch (props.size ?? 'md') { | ||
| case 'sm': | ||
| return 'w-8 h-8 text-sm' | ||
| case 'lg': | ||
| return 'w-12 h-12 text-xl' | ||
| default: | ||
| return 'w-10 h-10 text-lg' | ||
| } | ||
| }) | ||
|
|
||
| const initials = computed(() => | ||
| props.author.name | ||
| .split(' ') | ||
| .map(n => n[0]) | ||
| .join('') | ||
| .toUpperCase() | ||
| .slice(0, 2), | ||
| ) | ||
| </script> | ||
|
|
||
| <template> | ||
| <div | ||
| class="shrink-0 flex items-center justify-center border border-border rounded-full bg-bg-muted overflow-hidden" | ||
| :class="[sizeClasses]" | ||
| > | ||
| <img | ||
| v-if="author.avatar" | ||
| :src="author.avatar" | ||
| :alt="author.name" | ||
| class="w-full h-full object-cover" | ||
| /> | ||
| <span v-else class="text-fg-subtle font-mono" aria-hidden="true"> | ||
| {{ initials }} | ||
| </span> | ||
| </div> | ||
| </template> |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,49 @@ | ||
| <script setup lang="ts"> | ||
| import type { Author } from '#shared/schemas/blog' | ||
|
|
||
| const props = defineProps<{ | ||
| authors: Author[] | ||
| variant?: 'compact' | 'expanded' | ||
| }>() | ||
|
|
||
| const { resolvedAuthors } = useBlueskyAuthorProfiles(props.authors) | ||
| </script> | ||
|
|
||
| <template> | ||
| <!-- Expanded variant: vertical list with larger avatars --> | ||
| <div v-if="variant === 'expanded'" class="flex flex-wrap items-center gap-4"> | ||
| <div v-for="author in resolvedAuthors" :key="author.name" class="flex items-center gap-2"> | ||
| <AuthorAvatar :author="author" size="md" disable-link /> | ||
| <div class="flex flex-col"> | ||
| <span class="text-sm font-medium text-fg">{{ author.name }}</span> | ||
| <a | ||
| v-if="author.blueskyHandle && author.profileUrl" | ||
| :href="author.profileUrl" | ||
| target="_blank" | ||
| rel="noopener noreferrer" | ||
| :aria-label="$t('blog.author.view_profile', { name: author.name })" | ||
| class="text-xs text-fg-muted hover:text-primary transition-colors" | ||
| > | ||
| @{{ author.blueskyHandle }} | ||
| </a> | ||
| </div> | ||
| </div> | ||
| </div> | ||
|
|
||
| <!-- Compact variant: no avatars --> | ||
| <div v-else class="flex items-center gap-2 min-w-0"> | ||
| <div class="flex items-center"> | ||
| <AuthorAvatar | ||
| v-for="(author, index) in resolvedAuthors" | ||
| :key="author.name" | ||
| :author="author" | ||
| size="md" | ||
| class="ring-2 ring-bg" | ||
| :class="index > 0 ? '-ms-3' : ''" | ||
| /> | ||
| </div> | ||
| <span class="text-xs text-fg-muted font-mono truncate"> | ||
| {{ resolvedAuthors.map(a => a.name).join(', ') }} | ||
| </span> | ||
| </div> | ||
| </template> |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,53 @@ | ||
| <script setup lang="ts"> | ||
| import type { Author } from '#shared/schemas/blog' | ||
|
|
||
| defineProps<{ | ||
| /** Authors of the blog post */ | ||
| authors: Author[] | ||
| /** Blog Title */ | ||
| title: string | ||
| /** Tags such as OpenSource, Architecture, Community, etc. */ | ||
| topics: string[] | ||
| /** Brief line from the text. */ | ||
| excerpt: string | ||
| /** The datetime value (ISO string or Date) */ | ||
| published: string | ||
| /** Path/Slug of the post */ | ||
| path: string | ||
| /** For keyboard nav scaffold */ | ||
| index: number | ||
| }>() | ||
| </script> | ||
|
|
||
| <template> | ||
| <article | ||
| class="group relative hover:bg-bg-subtle transition-colors duration-150 focus-within:outline-none focus-within:ring-2 focus-within:ring-offset-bg focus-within:ring-offset-2 focus-within:ring-fg/50 -mx-4 px-4 -my-2 py-2 sm:-mx-6 sm:px-6 sm:-my-3 sm:py-3 sm:rounded-md" | ||
| > | ||
| <NuxtLink | ||
| :to="`/blog/${path}`" | ||
| :data-suggestion-index="index" | ||
| class="flex items-center gap-4 focus-visible:outline-none after:content-[''] after:absolute after:inset-0" | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This trick with |
||
| > | ||
| <!-- Text Content --> | ||
| <div class="flex-1 min-w-0 text-start gap-2"> | ||
| <span class="text-xs text-fg-muted font-mono">{{ published }}</span> | ||
| <h2 | ||
| class="font-mono text-xl font-medium text-fg group-hover:text-primary transition-colors hover:underline" | ||
| > | ||
| {{ title }} | ||
| </h2> | ||
| <p v-if="excerpt" class="text-fg-muted leading-relaxed line-clamp-2 no-underline"> | ||
| {{ excerpt }} | ||
| </p> | ||
| <div class="flex flex-wrap items-center gap-2 text-xs text-fg-muted font-mono mt-4"> | ||
| <AuthorList :authors="authors" /> | ||
| </div> | ||
| </div> | ||
|
|
||
| <span | ||
| class="i-lucide:arrow-right w-4 h-4 text-fg-subtle group-hover:text-fg relative inset-is-0 group-hover:inset-is-1 transition-all duration-200 shrink-0" | ||
| aria-hidden="true" | ||
| /> | ||
| </NuxtLink> | ||
| </article> | ||
| </template> | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,203 @@ | ||
| <script setup lang="ts"> | ||
| import type { AppBskyRichtextFacet } from '@atproto/api' | ||
| import { segmentize } from '@atcute/bluesky-richtext-segmenter' | ||
| import type { Comment } from '#shared/types/blog-post' | ||
|
|
||
| type RichtextFeature = | ||
| | AppBskyRichtextFacet.Link | ||
| | AppBskyRichtextFacet.Mention | ||
| | AppBskyRichtextFacet.Tag | ||
|
|
||
| function getCommentUrl(comment: Comment): string { | ||
| return atUriToWebUrl(comment.uri) ?? '#' | ||
| } | ||
| const props = defineProps<{ | ||
| comment: Comment | ||
| depth: number | ||
| }>() | ||
|
|
||
| const MaxDepth = 4 | ||
|
|
||
| function getFeatureUrl(feature: RichtextFeature): 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}` | ||
| } | ||
|
|
||
| const processedSegments = segmentize(props.comment.text, props.comment.facets).map(segment => ({ | ||
| text: segment.text, | ||
| url: segment.features?.[0] ? getFeatureUrl(segment.features[0] as RichtextFeature) : undefined, | ||
| })) | ||
|
|
||
| function getHostname(uri: string): string { | ||
| try { | ||
| return new URL(uri).hostname | ||
| } catch { | ||
| return uri | ||
| } | ||
| } | ||
| </script> | ||
|
|
||
| <template> | ||
| <div :class="depth === 0 ? 'flex gap-3' : 'flex gap-3 mt-3'"> | ||
| <!-- Avatar --> | ||
| <a | ||
| :href="`https://bsky.app/profile/${comment.author.handle}`" | ||
| target="_blank" | ||
| rel="noopener noreferrer" | ||
| class="shrink-0" | ||
| > | ||
| <img | ||
| v-if="comment.author.avatar" | ||
| :src="comment.author.avatar" | ||
| :alt="comment.author.displayName || comment.author.handle" | ||
| :class="['rounded-full', depth === 0 ? 'w-10 h-10' : 'w-8 h-8']" | ||
| width="40" | ||
| height="40" | ||
| loading="lazy" | ||
| /> | ||
| <div | ||
| v-else | ||
| :class="[ | ||
| 'rounded-full bg-border flex items-center justify-center text-fg-muted', | ||
| depth === 0 ? 'w-10 h-10' : 'w-8 h-8 text-sm', | ||
| ]" | ||
| > | ||
| {{ (comment.author.displayName || comment.author.handle).charAt(0).toUpperCase() }} | ||
| </div> | ||
| </a> | ||
|
|
||
| <div class="flex-1 min-w-0"> | ||
| <!-- Author info + timestamp --> | ||
| <div class="flex flex-wrap items-baseline gap-x-2 gap-y-0"> | ||
| <a | ||
| :href="`https://bsky.app/profile/${comment.author.handle}`" | ||
| target="_blank" | ||
| rel="noopener noreferrer" | ||
| class="font-medium text-fg hover:underline" | ||
| > | ||
| {{ comment.author.displayName || comment.author.handle }} | ||
| </a> | ||
| <span class="text-fg-subtle text-sm">@{{ comment.author.handle }}</span> | ||
| <span class="text-fg-subtle text-sm">·</span> | ||
| <a | ||
| :href="getCommentUrl(props.comment)" | ||
| target="_blank" | ||
| rel="noopener noreferrer" | ||
| class="text-fg-subtle text-sm hover:underline" | ||
| > | ||
| <NuxtTime relative :datetime="comment.createdAt" /> | ||
| </a> | ||
| </div> | ||
|
|
||
| <!-- Comment text with rich segments --> | ||
| <p class="text-fg-muted whitespace-pre-wrap"> | ||
| <template v-for="(segment, i) in processedSegments" :key="i"> | ||
| <a | ||
| v-if="segment.url" | ||
| :href="segment.url" | ||
| target="_blank" | ||
| rel="noopener noreferrer" | ||
| class="link" | ||
| >{{ segment.text }}</a | ||
| > | ||
| <template v-else>{{ segment.text }}</template> | ||
| </template> | ||
| </p> | ||
|
|
||
| <!-- Embedded images --> | ||
| <div | ||
| v-if="comment.embed?.type === 'images' && comment.embed.images" | ||
| class="flex flex-wrap gap-2" | ||
| > | ||
| <a | ||
| v-for="(img, i) in comment.embed.images" | ||
| :key="i" | ||
| :href="img.fullsize" | ||
| target="_blank" | ||
| rel="noopener noreferrer" | ||
| class="block" | ||
| > | ||
| <img | ||
| :src="img.thumb" | ||
| :alt="img.alt || 'Embedded image'" | ||
| class="rounded-lg max-w-48 max-h-36 object-cover" | ||
| loading="lazy" | ||
| /> | ||
| </a> | ||
| </div> | ||
|
|
||
| <!-- Embedded external link card --> | ||
| <a | ||
| v-if="comment.embed?.type === 'external' && comment.embed.external" | ||
| :href="comment.embed.external.uri" | ||
| target="_blank" | ||
| rel="noopener noreferrer" | ||
| class="flex gap-3 p-3 border border-border rounded-lg bg-bg-subtle hover:bg-bg-subtle/80 transition-colors no-underline" | ||
| > | ||
| <img | ||
| v-if="comment.embed.external.thumb" | ||
| :src="comment.embed.external.thumb" | ||
| :alt="comment.embed.external.title" | ||
| width="20" | ||
| height="20" | ||
| class="w-20 h-20 rounded object-cover shrink-0" | ||
| loading="lazy" | ||
whitep4nth3r marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| /> | ||
| <div class="min-w-0"> | ||
| <div class="font-medium text-fg truncate"> | ||
| {{ comment.embed.external.title }} | ||
| </div> | ||
| <div class="text-sm text-fg-muted line-clamp-2"> | ||
| {{ comment.embed.external.description }} | ||
| </div> | ||
| <div class="text-xs text-fg-subtle mt-1 truncate"> | ||
| {{ getHostname(comment.embed.external.uri) }} | ||
| </div> | ||
| </div> | ||
| </a> | ||
|
|
||
| <!-- Like/repost counts --> | ||
| <div | ||
| v-if="comment.likeCount > 0 || comment.repostCount > 0" | ||
| class="mt-2 flex gap-4 text-sm text-fg-subtle" | ||
| > | ||
| <span v-if="comment.likeCount > 0"> | ||
| {{ $t('blog.atproto.like_count', { count: comment.likeCount }, comment.likeCount) }} | ||
| </span> | ||
| <span v-if="comment.repostCount > 0"> | ||
| {{ $t('blog.atproto.repost_count', { count: comment.repostCount }, comment.repostCount) }} | ||
| </span> | ||
| </div> | ||
|
|
||
| <!-- Nested replies --> | ||
| <template v-if="comment.replies.length > 0"> | ||
| <div v-if="depth < MaxDepth" class="mt-2 ps-2 border-is-2 border-border flex flex-col"> | ||
| <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 noreferrer" | ||
| class="mt-2 block text-sm link" | ||
| > | ||
| {{ | ||
| $t( | ||
| 'blog.atproto.more_replies', | ||
| { count: comment.replies.length }, | ||
| comment.replies.length, | ||
| ) | ||
| }} | ||
| </a> | ||
| </template> | ||
| </div> | ||
| </div> | ||
| </template> | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@danielroe should we repeat the logic with typed paths here? I'm a bit confused about what the params should be in this case