Skip to content

Commit 6f3e8ec

Browse files
committed
feat: add support for authors in blog posts
1 parent ee45d2f commit 6f3e8ec

File tree

17 files changed

+311
-45
lines changed

17 files changed

+311
-45
lines changed

app/components/AuthorAvatar.vue

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
<script setup lang="ts">
2+
import type { ResolvedAuthor } from '#shared/schemas/blog'
3+
4+
const props = defineProps<{
5+
author: ResolvedAuthor
6+
size?: 'sm' | 'md' | 'lg'
7+
disableLink?: boolean
8+
}>()
9+
10+
const { t } = useI18n()
11+
12+
const sizeClasses = computed(() => {
13+
switch (props.size ?? 'md') {
14+
case 'sm':
15+
return 'w-8 h-8 text-sm'
16+
case 'lg':
17+
return 'w-12 h-12 text-xl'
18+
default:
19+
return 'w-10 h-10 text-lg'
20+
}
21+
})
22+
23+
const initials = computed(() =>
24+
props.author.name
25+
.split(' ')
26+
.map(n => n[0])
27+
.join('')
28+
.toUpperCase()
29+
.slice(0, 2),
30+
)
31+
32+
const isLink = computed(() => !props.disableLink && props.author.profileUrl)
33+
const ariaLabel = computed(() => t('blog.author.view_profile', { name: props.author.name }))
34+
</script>
35+
36+
<template>
37+
<component
38+
:is="isLink ? 'a' : 'div'"
39+
:href="isLink ? author.profileUrl : undefined"
40+
:target="isLink ? '_blank' : undefined"
41+
:rel="isLink ? 'noopener noreferrer' : undefined"
42+
:aria-label="isLink ? ariaLabel : undefined"
43+
class="shrink-0 flex items-center justify-center border border-border rounded-full bg-bg-muted overflow-hidden"
44+
:class="[sizeClasses, { 'hover:border-primary transition-colors': isLink }]"
45+
:title="author.name"
46+
>
47+
<span v-if="!isLink" class="sr-only">{{ author.name }}</span>
48+
<img
49+
v-if="author.avatar"
50+
:src="author.avatar"
51+
:alt="author.name"
52+
class="w-full h-full object-cover"
53+
/>
54+
<span v-else class="text-fg-subtle font-mono" aria-hidden="true">
55+
{{ initials }}
56+
</span>
57+
</component>
58+
</template>

app/components/AuthorList.vue

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
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 } = useAuthorProfiles(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+
class="text-xs text-fg-muted hover:text-primary transition-colors"
25+
>
26+
@{{ author.blueskyHandle }}
27+
</a>
28+
</div>
29+
</div>
30+
</div>
31+
32+
<!-- Compact variant: stacked avatars -->
33+
<div v-else class="flex items-center gap-2">
34+
<div class="flex items-center gap-1">
35+
<AuthorAvatar
36+
v-for="author in resolvedAuthors"
37+
:key="author.name"
38+
:author="author"
39+
size="md"
40+
class="ring-2 ring-bg"
41+
/>
42+
</div>
43+
<span class="text-xs text-fg-muted font-mono truncate">
44+
{{ resolvedAuthors.map(a => a.name).join(', ') }}
45+
</span>
46+
</div>
47+
</template>

app/components/BlogPostListCard.vue

Lines changed: 7 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
<script setup lang="ts">
2+
import type { Author } from '#shared/schemas/blog'
3+
24
defineProps<{
3-
/** First and last name - Potentially Multiple? i.e. co-authors */
4-
author: string
5+
/** Authors of the blog post */
6+
authors: Author[]
57
/** Blog Title */
68
title: string
79
/** Tags such as OpenSource, Architecture, Community, etc. */
@@ -32,22 +34,6 @@ const emit = defineEmits<{
3234
@focus="index != null && emit('focus', index)"
3335
@mouseenter="index != null && emit('focus', index)"
3436
>
35-
<!-- Avatar placeholder -->
36-
<div
37-
class="w-10 h-10 shrink-0 flex items-center justify-center border border-border rounded-full bg-bg-muted"
38-
aria-hidden="true"
39-
>
40-
<span class="text-lg text-fg-subtle font-mono">
41-
{{
42-
author
43-
.split(' ')
44-
.map(n => n[0])
45-
.join('')
46-
.toUpperCase()
47-
}}
48-
</span>
49-
</div>
50-
5137
<!-- Text Content -->
5238
<div class="flex-1 min-w-0 text-left">
5339
<h2
@@ -56,8 +42,8 @@ const emit = defineEmits<{
5642
{{ title }}
5743
</h2>
5844

59-
<div class="flex items-center gap-2 text-xs text-fg-muted font-mono">
60-
<span>{{ author }}</span>
45+
<div class="flex items-center gap-2 text-xs text-fg-muted font-mono mt-1">
46+
<AuthorList :authors="authors" />
6147
<span>•</span>
6248
<span>{{ published }}</span>
6349
</div>
@@ -68,13 +54,9 @@ const emit = defineEmits<{
6854
</div>
6955

7056
<span
71-
class="i-carbon-arrow-right w-4 h-4 text-fg-subtle group-hover:text-fg transition-colors shrink-0"
57+
class="i-carbon:arrow-right w-4 h-4 text-fg-subtle group-hover:text-fg transition-colors shrink-0"
7258
aria-hidden="true"
7359
/>
7460
</NuxtLink>
7561
</article>
7662
</template>
77-
<!-- :class="{
78-
'bg-bg-muted border-border-hover': selected,
79-
'border-accent/30 bg-accent/5': isExactMatch,
80-
}" -->

app/components/BlogPostWrapper.vue

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,18 +2,18 @@
22
import type { BlogPostFrontmatter } from '#shared/schemas/blog'
33
44
const props = defineProps<{
5-
frontmatter?: BlogPostFrontmatter
5+
frontmatter: BlogPostFrontmatter
66
}>()
77
88
useSeoMeta({
9-
title: props.frontmatter?.title,
10-
description: props.frontmatter?.description || props.frontmatter?.excerpt,
11-
ogTitle: props.frontmatter?.title,
12-
ogDescription: props.frontmatter?.description || props.frontmatter?.excerpt,
9+
title: props.frontmatter.title,
10+
description: props.frontmatter.description || props.frontmatter.excerpt,
11+
ogTitle: props.frontmatter.title,
12+
ogDescription: props.frontmatter.description || props.frontmatter.excerpt,
1313
ogType: 'article',
1414
})
1515
16-
const slug = computed(() => props.frontmatter?.slug)
16+
const slug = computed(() => props.frontmatter.slug)
1717
1818
// Use Constellation to find the Bluesky post linking to this blog post
1919
const { data: blueskyLink } = await useBlogPostBlueskyLink(slug)
@@ -22,9 +22,14 @@ const blueskyPostUri = computed(() => blueskyLink.value?.postUri ?? null)
2222

2323
<template>
2424
<main class="container w-full py-8">
25-
<article class="prose dark:prose-invert mx-auto">
25+
<article class="prose dark:prose-invert mx-auto border-b border-border">
2626
<slot />
2727
</article>
28+
<div v-if="frontmatter.authors" class="mt-12 max-w-prose mx-auto">
29+
<div class="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
30+
<AuthorList :authors="frontmatter.authors" variant="expanded" />
31+
</div>
32+
</div>
2833

2934
<!--
3035
- Only renders if Constellation found a Bluesky post linking to this slug
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
import type { Author, ResolvedAuthor } from '#shared/schemas/blog'
2+
3+
/**
4+
* @public
5+
*/
6+
export function useAuthorProfiles(authors: Author[]) {
7+
const authorsJson = JSON.stringify(authors)
8+
9+
const { data } = useFetch('/api/atproto/author-profiles', {
10+
query: {
11+
authors: authorsJson,
12+
},
13+
})
14+
15+
const resolvedAuthors = computed<ResolvedAuthor[]>(
16+
() =>
17+
data.value?.authors ??
18+
authors.map(author => ({
19+
...author,
20+
avatar: null,
21+
profileUrl: author.blueskyHandle
22+
? `https://bsky.app/profile/${author.blueskyHandle}`
23+
: null,
24+
})),
25+
)
26+
27+
return {
28+
resolvedAuthors,
29+
}
30+
}

app/pages/blog/atproto.md

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
---
2-
author: 'Daniel Roe'
2+
authors:
3+
- name: Daniel Roe
4+
blueskyHandle: danielroe.dev
5+
- name: Salma Alam-Naylor
6+
blueskyHandle: whitep4nth3r.com
7+
- name: Matias Capeletto
8+
blueskyHandle: patak.dev
39
title: 'ATProto'
410
tags: ['OpenSource', 'Nuxt']
511
excerpt: 'ATProto is very cool'
@@ -12,3 +18,11 @@ draft: false
1218
# Atmosphere Apps
1319

1420
All the cool kids are doing Software Decentralization.
21+
22+
This post is all about atmosphere. How it's something that we need to live. Without atmosphere we may end up like Arnie in Total Recall. We don't want that.
23+
But thankfully, we have atmosphere. This beautiful concept is used for other things as well. Atmos is a Fellow product that is a vacuum canister used to store coffee.
24+
This keeps your coffee fresh. But arguably, if you drink a lot of coffee you don't need to store it in a vacuum canister. But if you like to be super fancy. There is an
25+
automated vacuum canister that sucks out the air for you. You don't need to twist and turn like a human machine. You press a button and it sucks the air out. Automation.
26+
It's a wonderful thing. We use automation on this blog post. One automation is getting the Bluesky comments. These are fetched during build time and also run time. This means
27+
posts will always have bluesky comments. Whether you like it or not. Under the hood we do fancy ATProto stuff. And that brings us back to Atmosphere. Because it's something
28+
we need to live.

app/pages/blog/first-post.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,9 @@
11
---
2-
author: 'Daniel Roe'
2+
authors:
3+
- name: Daniel Roe
4+
blueskyHandle: danielroe.dev
5+
- name: Salma Alam-Naylor
6+
blueskyHandle: whitep4nth3r.com
37
title: 'Hello World'
48
tags: ['OpenSource', 'Nuxt']
59
excerpt: 'My first post'

app/pages/blog/index.vue

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ const blogModules = import.meta.glob<BlogPostFrontmatter>('./*.md', { eager: tru
55
66
const posts: BlogPostFrontmatter[] = []
77
8-
for (const [path, module] of Object.entries(blogModules)) {
8+
for (const [_, module] of Object.entries(blogModules)) {
99
if (module.draft) continue
1010
1111
posts.push({ ...module })
@@ -35,8 +35,8 @@ useSeoMeta({
3535
<article v-if="posts && posts.length > 0" class="mx-30 space-y-4">
3636
<BlogPostListCard
3737
v-for="(post, idx) in posts"
38-
:key="`${post.author}-${post.title}`"
39-
:author="post.author || 'Roe'"
38+
:key="`${post.authors.map(a => a.name).join('-')}-${post.title}`"
39+
:authors="post.authors"
4040
:title="post.title"
4141
:path="post.slug"
4242
:excerpt="post.excerpt || post.description || 'No Excerpt Available'"

app/pages/blog/nuxt.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
---
2-
author: 'Daniel Roe'
2+
authors:
3+
- name: Daniel Roe
4+
blueskyHandle: danielroe.dev
35
title: 'Nuxted'
46
tags: ['OpenSource', 'Nuxt']
57
excerpt: 'Nuxting'

app/pages/blog/open-source.md

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
---
2-
author: 'Daniel Roe'
2+
authors:
3+
- name: Daniel Roe
4+
blueskyHandle: danielroe.dev
35
title: 'OSS'
46
tags: ['OpenSource', 'Nuxt']
57
excerpt: 'OSS Things'

0 commit comments

Comments
 (0)