Skip to content

Commit ba705b6

Browse files
authored
feat: blog posts og image (#1336)
1 parent 684feb4 commit ba705b6

File tree

2 files changed

+148
-0
lines changed

2 files changed

+148
-0
lines changed

app/components/BlogPostWrapper.vue

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,12 @@ useSeoMeta({
1313
ogType: 'article',
1414
})
1515
16+
defineOgImageComponent('BlogPost', {
17+
title: props.frontmatter.title,
18+
authors: props.frontmatter.authors,
19+
date: props.frontmatter.date,
20+
})
21+
1622
const slug = computed(() => props.frontmatter.slug)
1723
1824
// Use Constellation to find the Bluesky post linking to this blog post
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
<script setup lang="ts">
2+
import type { Author } from '#shared/schemas/blog'
3+
4+
const props = withDefaults(
5+
defineProps<{
6+
title: string
7+
authors?: Author[]
8+
date?: string
9+
primaryColor?: string
10+
}>(),
11+
{
12+
authors: () => [],
13+
date: '',
14+
primaryColor: '#60a5fa',
15+
},
16+
)
17+
18+
const { resolvedAuthors } = useAuthorProfiles(props.authors)
19+
20+
const formattedDate = computed(() => {
21+
if (!props.date) return ''
22+
try {
23+
return new Date(props.date).toLocaleDateString('en-US', {
24+
year: 'numeric',
25+
month: 'short',
26+
day: 'numeric',
27+
})
28+
} catch {
29+
return props.date
30+
}
31+
})
32+
33+
const MAX_VISIBLE_AUTHORS = 2
34+
35+
const getInitials = (name: string) =>
36+
name
37+
.split(' ')
38+
.map(n => n[0])
39+
.join('')
40+
.toUpperCase()
41+
.slice(0, 2)
42+
43+
const visibleAuthors = computed(() => {
44+
if (resolvedAuthors.value.length <= 3) return resolvedAuthors.value
45+
return resolvedAuthors.value.slice(0, MAX_VISIBLE_AUTHORS)
46+
})
47+
48+
const extraCount = computed(() => {
49+
if (resolvedAuthors.value.length <= 3) return 0
50+
return resolvedAuthors.value.length - MAX_VISIBLE_AUTHORS
51+
})
52+
53+
const formattedAuthorNames = computed(() => {
54+
const allNames = resolvedAuthors.value.map(a => a.name)
55+
if (allNames.length === 0) return ''
56+
if (allNames.length === 1) return allNames[0]
57+
if (allNames.length === 2) return `${allNames[0]} and ${allNames[1]}`
58+
if (allNames.length === 3) return `${allNames[0]}, ${allNames[1]}, and ${allNames[2]}`
59+
// More than 3: show first 2 + others
60+
const shown = allNames.slice(0, MAX_VISIBLE_AUTHORS)
61+
const remaining = allNames.length - MAX_VISIBLE_AUTHORS
62+
return `${shown.join(', ')} and ${remaining} others`
63+
})
64+
</script>
65+
66+
<template>
67+
<div
68+
class="h-full w-full flex flex-col justify-center px-20 bg-[#050505] text-[#fafafa] relative overflow-hidden"
69+
>
70+
<!-- npmx logo - top right -->
71+
<div
72+
class="absolute top-12 z-10 flex items-center gap-1 text-5xl font-bold tracking-tight"
73+
style="font-family: 'Geist Sans', sans-serif; right: 4rem"
74+
>
75+
<span :style="{ color: primaryColor }" class="opacity-80">./</span>
76+
<span class="text-white">npmx</span>
77+
</div>
78+
79+
<div class="relative z-10 flex flex-col gap-2">
80+
<!-- Date -->
81+
<span
82+
v-if="formattedDate"
83+
class="text-3xl text-[#a3a3a3] font-light"
84+
style="font-family: 'Geist Sans', sans-serif"
85+
>
86+
{{ formattedDate }}
87+
</span>
88+
89+
<!-- Blog title -->
90+
<h1
91+
class="text-6xl font-semibold tracking-tight leading-snug w-9/10"
92+
style="font-family: 'Geist Sans', sans-serif; letter-spacing: -0.03em"
93+
>
94+
{{ title }}
95+
</h1>
96+
97+
<!-- Authors -->
98+
<div
99+
v-if="resolvedAuthors.length"
100+
class="flex items-center gap-4 self-start justify-start flex-nowrap"
101+
style="font-family: 'Geist Sans', sans-serif"
102+
>
103+
<!-- Stacked avatars -->
104+
<span>
105+
<span
106+
v-for="(author, index) in visibleAuthors"
107+
:key="author.name"
108+
class="flex items-center justify-center rounded-full border border-[#050505] bg-[#1a1a1a] overflow-hidden w-12 h-12"
109+
:style="{ marginLeft: index > 0 ? '-20px' : '0' }"
110+
>
111+
<img
112+
v-if="author.avatar"
113+
:src="author.avatar"
114+
:alt="author.name"
115+
class="w-full h-full object-cover"
116+
/>
117+
<span v-else style="font-size: 20px; color: #666; font-weight: 500">
118+
{{ getInitials(author.name) }}
119+
</span>
120+
</span>
121+
<!-- +N badge -->
122+
<span
123+
v-if="extraCount > 0"
124+
class="flex items-center justify-center text-lg font-medium text-[#a3a3a3] rounded-full border border-[#050505] bg-[#262626] overflow-hidden w-12 h-12"
125+
:style="{ marginLeft: '-20px' }"
126+
>
127+
+{{ extraCount }}
128+
</span>
129+
</span>
130+
<!-- Names -->
131+
<span style="font-size: 24px; color: #a3a3a3; font-weight: 300">{{
132+
formattedAuthorNames
133+
}}</span>
134+
</div>
135+
</div>
136+
137+
<div
138+
class="absolute -top-32 -inset-ie-32 w-[550px] h-[550px] rounded-full blur-3xl"
139+
:style="{ backgroundColor: primaryColor + '10' }"
140+
/>
141+
</div>
142+
</template>

0 commit comments

Comments
 (0)