Skip to content

Commit 476a798

Browse files
whitep4nth3rKai-rosjonathanyeongautofix-ci[bot]abbeyperini
authored
feat: blog (#1094)
Co-authored-by: Brandon Hurrington <brandon.o.hurrington@gmail.com> Co-authored-by: Jonathan Yeong <hey@jonathanyeong.com> Co-authored-by: autofix-ci[bot] <114827586+autofix-ci[bot]@users.noreply.github.com> Co-authored-by: AbbeyPerini <abbeyperini@gmail.com> Co-authored-by: Daniel Roe <daniel@roe.dev> Co-authored-by: Dominik K. <dominik@rivo.gg> Co-authored-by: Luke Warlow <luke@warlow.dev> Co-authored-by: Victoria <vicmdo@gmail.com> Co-authored-by: Yevhen Husak <yev.husak@gmail.com> Co-authored-by: Yevhen Husak <gusa4grr@users.noreply.github.com> Co-authored-by: Jialong Lu <48578099+kalu5@users.noreply.github.com> Co-authored-by: Alexander Schmid <120512287+alexschmd@users.noreply.github.com> Co-authored-by: James Garbutt <43081j@users.noreply.github.com> Co-authored-by: Vincent Taverna <vinnymac@gmail.com> Co-authored-by: Philippe Serhal <philippe.serhal@gmail.com> Co-authored-by: Garth de Wet <garthofhearts@gmail.com> Co-authored-by: Alex Savelyev <91429106+alexdln@users.noreply.github.com> Co-authored-by: Marcus Blättermann <marcus@essenmitsosse.de> Co-authored-by: Wojciech Maj <kontakt@wojtekmaj.pl> Co-authored-by: Chase Naples <cnaples79@gmail.com> Co-authored-by: Felix Schneider <99918022+trueberryless@users.noreply.github.com> Co-authored-by: btea <2356281422@qq.com> Co-authored-by: Santosh Yadav <santosh.yadav198613@gmail.com> Co-authored-by: Vida Xie <vida_2020@163.com> Co-authored-by: Okinea Dev <hi@okinea.dev> Co-authored-by: Maher <50132270+abaza738@users.noreply.github.com> Co-authored-by: Nandkishor Jadoun <183695114+NandkishorJadoun@users.noreply.github.com> Co-authored-by: abeer0 <47961062+iiio2@users.noreply.github.com> Co-authored-by: Alec Lloyd Probert <55991794+graphieros@users.noreply.github.com> Co-authored-by: Stephen Zhou <38493346+hyoban@users.noreply.github.com> Co-authored-by: Matteo Gabriele <m.gabriele.dev@gmail.com> Co-authored-by: Mihkel Eidast <mihkel@eida.st> Co-authored-by: Craig Hart <i@craigary.net> Co-authored-by: Houssam Elbadissi <skaldebane@gmail.com> Co-authored-by: Cassidy Williams <1454517+cassidoo@users.noreply.github.com> Co-authored-by: Cassidy Williams <cassidoo@users.noreply.github.com> Co-authored-by: Bailey Townsend <baileytownsend2323@gmail.com> Co-authored-by: Denys <40246260+DDeenis@users.noreply.github.com> Co-authored-by: SHAMIL <64640025+shamilkotta@users.noreply.github.com> Co-authored-by: Jiří Michel <admin@venty.cz> Co-authored-by: Lars Kappert <oss@webpro.nl> Co-authored-by: penjj <32674989+penjj@users.noreply.github.com> Co-authored-by: Nicolas DUBIEN <github@dubien.org> Co-authored-by: Chris <hizyyv@gmail.com> Co-authored-by: Josh King <59017897+kn0wn@users.noreply.github.com> Co-authored-by: Jens Rømer Hesselbjerg <jh.roemer@gmail.com> Co-authored-by: Nathan Knowler <nathan@knowler.dev> Co-authored-by: Stanyslas Bres <15731884+sybers@users.noreply.github.com> Co-authored-by: Trivikram Kamat <16024985+trivikr@users.noreply.github.com> Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: Bobbie Goede <bobbiegoede@gmail.com> Co-authored-by: Joaquín Sánchez <userquin@gmail.com> Co-authored-by: coderabbitai[bot] <136622811+coderabbitai[bot]@users.noreply.github.com> Co-authored-by: TAKAHASHI Shuuji <id@shuuji3.xyz> Co-authored-by: Tierney Cyren <accounts@bnb.im> Co-authored-by: NullVoxPopuli <199018+NullVoxPopuli@users.noreply.github.com> Co-authored-by: Abderrahmen Mhemed <33667872+MhemedAbderrahmen@users.noreply.github.com> Co-authored-by: abderrahmen.mhemed <abderrahmen.mhemed@wevioo.com> Co-authored-by: Julien Déramond <juderamond@gmail.com> Co-authored-by: Roman <dev@rman.dev> Co-authored-by: shamilkotta <shamilkotta99@gmail.com> Co-authored-by: rygrit <62373365+RYGRIT@users.noreply.github.com> Co-authored-by: Vrajpal Jhala <43914725+vrajpal-jhala@users.noreply.github.com> Co-authored-by: Lars Kappert <lars@webpro.nl> Co-authored-by: Jaydip Sanghani <91427591+jellydeck@users.noreply.github.com> Co-authored-by: wr <58406705+onebyte8bits@users.noreply.github.com> Co-authored-by: wr <wr@wrtekiMacBook-Pro.local> Co-authored-by: Emanuele Orfanelli <emanueleorfanelli@gmail.com> Co-authored-by: Robin <robin.kehl@singular-it.de> Co-authored-by: rzzf <cszhjh@gmail.com> Co-authored-by: Florian Heuberger <10813063+Flo0806@users.noreply.github.com> Co-authored-by: Mikołaj Misztal <46728995+mikouaji@users.noreply.github.com> Co-authored-by: Nico <98180436+nitodeco@users.noreply.github.com> Co-authored-by: Benjamín Vicente <62021328+benjavicente@users.noreply.github.com> Co-authored-by: Nico Kempe <50241630+nicokempe@users.noreply.github.com> Co-authored-by: SerKo <serko.dev@gmail.com> Co-authored-by: Scott Wu <sw@scottwu.ca> Co-authored-by: Pascal Küsgen <pascalkuesgen@gmail.com> Co-authored-by: Luke Warlow <lwarlow@igalia.com> Co-authored-by: Idris Gadi <85882535+IdrisGit@users.noreply.github.com> Co-authored-by: Colin S. <19342760+colinscz@users.noreply.github.com> Co-authored-by: Bjorn Lu <bjornlu.dev@gmail.com> Co-authored-by: Bonsak Schiledrop <1322824+bonsak@users.noreply.github.com> Co-authored-by: 山吹色御守 <85992002+KazariEX@users.noreply.github.com> Co-authored-by: Kirankumar Ambati <kiran.chinna12520@gmail.com> Co-authored-by: Sybren W <sybren.willemot@gmail.com> Co-authored-by: Willow (GHOST) <git@willow.sh> Co-authored-by: Sarah Rainsberger <5098874+sarah11918@users.noreply.github.com> Co-authored-by: Willow (GHOST) <ghostdevbusiness@gmail.com> Co-authored-by: Sukiu <17344148+Sukiiu@users.noreply.github.com> Co-authored-by: Vordgi <sasha2822222@gmail.com>
1 parent 3d0ae04 commit 476a798

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

56 files changed

+2965
-136
lines changed

.storybook/main.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,16 @@ const config = {
88
backgrounds: false,
99
},
1010
async viteFinal(config) {
11+
config.plugins ??= []
12+
13+
config.plugins.push({
14+
name: 'ignore-internals',
15+
transform(_, id) {
16+
if (id.includes('/app/pages/blog/') && id.endsWith('.md')) {
17+
return 'export default {}'
18+
}
19+
},
20+
})
1121
// Replace the built-in vue-docgen plugin with a fault-tolerant version.
1222
// vue-docgen-api can crash on components that import types from other
1323
// .vue files (it tries to parse the SFC with @babel/parser as plain TS).

app/components/AppFooter.vue

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,9 @@ const closeModal = () => modalRef.value?.close?.()
2525
<LinkBase :to="{ name: 'about' }">
2626
{{ $t('footer.about') }}
2727
</LinkBase>
28+
<LinkBase :to="{ name: 'blog' }">
29+
{{ $t('footer.blog') }}
30+
</LinkBase>
2831
<LinkBase :to="{ name: 'privacy' }">
2932
{{ $t('privacy_policy.title') }}
3033
</LinkBase>

app/components/AppHeader.vue

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,14 @@ const mobileLinks = computed<NavigationConfigWithGroups>(() => [
5959
external: false,
6060
iconClass: 'i-lucide:info',
6161
},
62+
{
63+
name: 'Blog',
64+
label: $t('footer.blog'),
65+
to: { name: 'blog' },
66+
type: 'link',
67+
external: false,
68+
iconClass: 'i-lucide:notebook-pen',
69+
},
6270
{
6371
name: 'Privacy Policy',
6472
label: $t('privacy_policy.title'),

app/components/AuthorAvatar.vue

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
<script setup lang="ts">
2+
import type { ResolvedAuthor } from '#shared/schemas/blog'
3+
4+
const props = defineProps<{
5+
author: Partial<ResolvedAuthor> & Pick<ResolvedAuthor, 'name' | 'avatar'>
6+
size?: 'sm' | 'md' | 'lg'
7+
}>()
8+
9+
const sizeClasses = computed(() => {
10+
switch (props.size ?? 'md') {
11+
case 'sm':
12+
return 'w-8 h-8 text-sm'
13+
case 'lg':
14+
return 'w-12 h-12 text-xl'
15+
default:
16+
return 'w-10 h-10 text-lg'
17+
}
18+
})
19+
20+
const initials = computed(() =>
21+
props.author.name
22+
.split(' ')
23+
.map(n => n[0])
24+
.join('')
25+
.toUpperCase()
26+
.slice(0, 2),
27+
)
28+
</script>
29+
30+
<template>
31+
<div
32+
class="shrink-0 flex items-center justify-center border border-border rounded-full bg-bg-muted overflow-hidden"
33+
:class="[sizeClasses]"
34+
>
35+
<img
36+
v-if="author.avatar"
37+
:src="author.avatar"
38+
:alt="author.name"
39+
class="w-full h-full object-cover"
40+
/>
41+
<span v-else class="text-fg-subtle font-mono" aria-hidden="true">
42+
{{ initials }}
43+
</span>
44+
</div>
45+
</template>

app/components/AuthorList.vue

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
<script setup lang="ts">
2+
import type { Author } from '#shared/schemas/blog'
3+
4+
const props = defineProps<{
5+
authors: Author[]
6+
variant?: 'compact' | 'expanded'
7+
}>()
8+
9+
const { resolvedAuthors } = useBlueskyAuthorProfiles(props.authors)
10+
</script>
11+
12+
<template>
13+
<!-- Expanded variant: vertical list with larger avatars -->
14+
<div v-if="variant === 'expanded'" class="flex flex-wrap items-center gap-4">
15+
<div v-for="author in resolvedAuthors" :key="author.name" class="flex items-center gap-2">
16+
<AuthorAvatar :author="author" size="md" disable-link />
17+
<div class="flex flex-col">
18+
<span class="text-sm font-medium text-fg">{{ author.name }}</span>
19+
<a
20+
v-if="author.blueskyHandle && author.profileUrl"
21+
:href="author.profileUrl"
22+
target="_blank"
23+
rel="noopener noreferrer"
24+
:aria-label="$t('blog.author.view_profile', { name: author.name })"
25+
class="text-xs text-fg-muted hover:text-primary transition-colors"
26+
>
27+
@{{ author.blueskyHandle }}
28+
</a>
29+
</div>
30+
</div>
31+
</div>
32+
33+
<!-- Compact variant: no avatars -->
34+
<div v-else class="flex items-center gap-2 min-w-0">
35+
<div class="flex items-center">
36+
<AuthorAvatar
37+
v-for="(author, index) in resolvedAuthors"
38+
:key="author.name"
39+
:author="author"
40+
size="md"
41+
class="ring-2 ring-bg"
42+
:class="index > 0 ? '-ms-3' : ''"
43+
/>
44+
</div>
45+
<span class="text-xs text-fg-muted font-mono truncate">
46+
{{ resolvedAuthors.map(a => a.name).join(', ') }}
47+
</span>
48+
</div>
49+
</template>
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
<script setup lang="ts">
2+
import type { Author } from '#shared/schemas/blog'
3+
4+
defineProps<{
5+
/** Authors of the blog post */
6+
authors: Author[]
7+
/** Blog Title */
8+
title: string
9+
/** Tags such as OpenSource, Architecture, Community, etc. */
10+
topics: string[]
11+
/** Brief line from the text. */
12+
excerpt: string
13+
/** The datetime value (ISO string or Date) */
14+
published: string
15+
/** Path/Slug of the post */
16+
path: string
17+
/** For keyboard nav scaffold */
18+
index: number
19+
}>()
20+
</script>
21+
22+
<template>
23+
<article
24+
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"
25+
>
26+
<NuxtLink
27+
:to="`/blog/${path}`"
28+
:data-suggestion-index="index"
29+
class="flex items-center gap-4 focus-visible:outline-none after:content-[''] after:absolute after:inset-0"
30+
>
31+
<!-- Text Content -->
32+
<div class="flex-1 min-w-0 text-start gap-2">
33+
<span class="text-xs text-fg-muted font-mono">{{ published }}</span>
34+
<h2
35+
class="font-mono text-xl font-medium text-fg group-hover:text-primary transition-colors hover:underline"
36+
>
37+
{{ title }}
38+
</h2>
39+
<p v-if="excerpt" class="text-fg-muted leading-relaxed line-clamp-2 no-underline">
40+
{{ excerpt }}
41+
</p>
42+
<div class="flex flex-wrap items-center gap-2 text-xs text-fg-muted font-mono mt-4">
43+
<AuthorList :authors="authors" />
44+
</div>
45+
</div>
46+
47+
<span
48+
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"
49+
aria-hidden="true"
50+
/>
51+
</NuxtLink>
52+
</article>
53+
</template>

app/components/BlueskyComment.vue

Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,203 @@
1+
<script setup lang="ts">
2+
import type { AppBskyRichtextFacet } from '@atproto/api'
3+
import { segmentize } from '@atcute/bluesky-richtext-segmenter'
4+
import type { Comment } from '#shared/types/blog-post'
5+
6+
type RichtextFeature =
7+
| AppBskyRichtextFacet.Link
8+
| AppBskyRichtextFacet.Mention
9+
| AppBskyRichtextFacet.Tag
10+
11+
function getCommentUrl(comment: Comment): string {
12+
return atUriToWebUrl(comment.uri) ?? '#'
13+
}
14+
const props = defineProps<{
15+
comment: Comment
16+
depth: number
17+
}>()
18+
19+
const MaxDepth = 4
20+
21+
function getFeatureUrl(feature: RichtextFeature): string | undefined {
22+
if (feature.$type === 'app.bsky.richtext.facet#link') return feature.uri
23+
if (feature.$type === 'app.bsky.richtext.facet#mention')
24+
return `https://bsky.app/profile/${feature.did}`
25+
if (feature.$type === 'app.bsky.richtext.facet#tag')
26+
return `https://bsky.app/hashtag/${feature.tag}`
27+
}
28+
29+
const processedSegments = segmentize(props.comment.text, props.comment.facets).map(segment => ({
30+
text: segment.text,
31+
url: segment.features?.[0] ? getFeatureUrl(segment.features[0] as RichtextFeature) : undefined,
32+
}))
33+
34+
function getHostname(uri: string): string {
35+
try {
36+
return new URL(uri).hostname
37+
} catch {
38+
return uri
39+
}
40+
}
41+
</script>
42+
43+
<template>
44+
<div :class="depth === 0 ? 'flex gap-3' : 'flex gap-3 mt-3'">
45+
<!-- Avatar -->
46+
<a
47+
:href="`https://bsky.app/profile/${comment.author.handle}`"
48+
target="_blank"
49+
rel="noopener noreferrer"
50+
class="shrink-0"
51+
>
52+
<img
53+
v-if="comment.author.avatar"
54+
:src="comment.author.avatar"
55+
:alt="comment.author.displayName || comment.author.handle"
56+
:class="['rounded-full', depth === 0 ? 'w-10 h-10' : 'w-8 h-8']"
57+
width="40"
58+
height="40"
59+
loading="lazy"
60+
/>
61+
<div
62+
v-else
63+
:class="[
64+
'rounded-full bg-border flex items-center justify-center text-fg-muted',
65+
depth === 0 ? 'w-10 h-10' : 'w-8 h-8 text-sm',
66+
]"
67+
>
68+
{{ (comment.author.displayName || comment.author.handle).charAt(0).toUpperCase() }}
69+
</div>
70+
</a>
71+
72+
<div class="flex-1 min-w-0">
73+
<!-- Author info + timestamp -->
74+
<div class="flex flex-wrap items-baseline gap-x-2 gap-y-0">
75+
<a
76+
:href="`https://bsky.app/profile/${comment.author.handle}`"
77+
target="_blank"
78+
rel="noopener noreferrer"
79+
class="font-medium text-fg hover:underline"
80+
>
81+
{{ comment.author.displayName || comment.author.handle }}
82+
</a>
83+
<span class="text-fg-subtle text-sm">@{{ comment.author.handle }}</span>
84+
<span class="text-fg-subtle text-sm">·</span>
85+
<a
86+
:href="getCommentUrl(props.comment)"
87+
target="_blank"
88+
rel="noopener noreferrer"
89+
class="text-fg-subtle text-sm hover:underline"
90+
>
91+
<NuxtTime relative :datetime="comment.createdAt" />
92+
</a>
93+
</div>
94+
95+
<!-- Comment text with rich segments -->
96+
<p class="text-fg-muted whitespace-pre-wrap">
97+
<template v-for="(segment, i) in processedSegments" :key="i">
98+
<a
99+
v-if="segment.url"
100+
:href="segment.url"
101+
target="_blank"
102+
rel="noopener noreferrer"
103+
class="link"
104+
>{{ segment.text }}</a
105+
>
106+
<template v-else>{{ segment.text }}</template>
107+
</template>
108+
</p>
109+
110+
<!-- Embedded images -->
111+
<div
112+
v-if="comment.embed?.type === 'images' && comment.embed.images"
113+
class="flex flex-wrap gap-2"
114+
>
115+
<a
116+
v-for="(img, i) in comment.embed.images"
117+
:key="i"
118+
:href="img.fullsize"
119+
target="_blank"
120+
rel="noopener noreferrer"
121+
class="block"
122+
>
123+
<img
124+
:src="img.thumb"
125+
:alt="img.alt || 'Embedded image'"
126+
class="rounded-lg max-w-48 max-h-36 object-cover"
127+
loading="lazy"
128+
/>
129+
</a>
130+
</div>
131+
132+
<!-- Embedded external link card -->
133+
<a
134+
v-if="comment.embed?.type === 'external' && comment.embed.external"
135+
:href="comment.embed.external.uri"
136+
target="_blank"
137+
rel="noopener noreferrer"
138+
class="flex gap-3 p-3 border border-border rounded-lg bg-bg-subtle hover:bg-bg-subtle/80 transition-colors no-underline"
139+
>
140+
<img
141+
v-if="comment.embed.external.thumb"
142+
:src="comment.embed.external.thumb"
143+
:alt="comment.embed.external.title"
144+
width="20"
145+
height="20"
146+
class="w-20 h-20 rounded object-cover shrink-0"
147+
loading="lazy"
148+
/>
149+
<div class="min-w-0">
150+
<div class="font-medium text-fg truncate">
151+
{{ comment.embed.external.title }}
152+
</div>
153+
<div class="text-sm text-fg-muted line-clamp-2">
154+
{{ comment.embed.external.description }}
155+
</div>
156+
<div class="text-xs text-fg-subtle mt-1 truncate">
157+
{{ getHostname(comment.embed.external.uri) }}
158+
</div>
159+
</div>
160+
</a>
161+
162+
<!-- Like/repost counts -->
163+
<div
164+
v-if="comment.likeCount > 0 || comment.repostCount > 0"
165+
class="mt-2 flex gap-4 text-sm text-fg-subtle"
166+
>
167+
<span v-if="comment.likeCount > 0">
168+
{{ $t('blog.atproto.like_count', { count: comment.likeCount }, comment.likeCount) }}
169+
</span>
170+
<span v-if="comment.repostCount > 0">
171+
{{ $t('blog.atproto.repost_count', { count: comment.repostCount }, comment.repostCount) }}
172+
</span>
173+
</div>
174+
175+
<!-- Nested replies -->
176+
<template v-if="comment.replies.length > 0">
177+
<div v-if="depth < MaxDepth" class="mt-2 ps-2 border-is-2 border-border flex flex-col">
178+
<BlueskyComment
179+
v-for="reply in comment.replies"
180+
:key="reply.uri"
181+
:comment="reply"
182+
:depth="depth + 1"
183+
/>
184+
</div>
185+
<a
186+
v-else
187+
:href="getCommentUrl(comment.replies[0]!)"
188+
target="_blank"
189+
rel="noopener noreferrer"
190+
class="mt-2 block text-sm link"
191+
>
192+
{{
193+
$t(
194+
'blog.atproto.more_replies',
195+
{ count: comment.replies.length },
196+
comment.replies.length,
197+
)
198+
}}
199+
</a>
200+
</template>
201+
</div>
202+
</div>
203+
</template>

0 commit comments

Comments
 (0)