Skip to content

Commit 0b57b04

Browse files
committed
feat: add tag pages and API for dynamic tag handling
1 parent 9c19a39 commit 0b57b04

6 files changed

Lines changed: 249 additions & 0 deletions

File tree

app/pages/tags/[tag].vue

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
<script setup lang="ts">
2+
const route = useRoute()
3+
4+
const tagSlug = route.params.tag as string
5+
6+
const { data: tagsBySlug } = await useFetch<[string, string][]>('/api/tags.json')
7+
const tag = new Map<string, string>(tagsBySlug.value)?.get(tagSlug) ?? ''
8+
9+
const { data: posts } = await useAsyncData(`tags-${tag}`, () =>
10+
queryCollection('posts')
11+
.where('tags', 'LIKE', `%${tag}%`)
12+
.order('date', 'DESC')
13+
.all()
14+
)
15+
16+
const title = computed(() => `${tag.charAt(0).toUpperCase()}${tag.slice(1)}`)
17+
const description = computed(() => `List of articles about ${tag}`)
18+
19+
useSeoMeta({
20+
title,
21+
ogTitle: title,
22+
description,
23+
ogDescription: description
24+
})
25+
26+
defineOgImageComponent('Saas')
27+
28+
const activePost = useState<number | null>('activePost', () => null)
29+
</script>
30+
31+
<template>
32+
<UContainer>
33+
<UPageHeader :title="title" :description="description" />
34+
35+
<UPageBody>
36+
<UBlogPosts>
37+
<UBlogPost
38+
v-for="(post, index) in posts"
39+
:key="index"
40+
:to="post.path"
41+
:title="post.title"
42+
:description="post.description"
43+
:image="post.image"
44+
:date="new Date(post.date).toLocaleDateString('en', { year: 'numeric', month: 'short', day: 'numeric' })"
45+
:authors="post.authors"
46+
:badge="post.badge"
47+
:orientation="index === 0 ? 'horizontal' : 'vertical'"
48+
:class="[index === 0 && 'col-span-full', activePost === index && 'active']"
49+
variant="naked"
50+
:ui="{
51+
description: 'line-clamp-2'
52+
}"
53+
@click="activePost = index"
54+
/>
55+
</UBlogPosts>
56+
</UPageBody>
57+
</UContainer>
58+
</template>
59+
60+
<style scoped>
61+
.active {
62+
view-transition-name: selected-post;
63+
contain: layout;
64+
}
65+
</style>

app/pages/tags/index.vue

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
<script setup lang="ts">
2+
const { data: posts } = await useAsyncData('tags', () =>
3+
queryCollection('posts')
4+
.select('tags')
5+
.all()
6+
)
7+
8+
if (!posts.value) {
9+
throw createError({ statusCode: 404, statusMessage: 'Tags not found', fatal: true })
10+
}
11+
12+
const tagOccurrences = computed(() => {
13+
// count each tag occurrence and sort them descending
14+
const occurrences = posts.value
15+
?.flatMap(t => t.tags ?? [])
16+
.reduce((acc, tag) => {
17+
acc.set(tag, (acc.get(tag) || 0) + 1)
18+
return acc
19+
}, new Map<string, number>()) ?? new Map<string, number>()
20+
return [...occurrences.entries()]
21+
.sort(([_tagA, occurrenceA], [_tagB, occurrenceB]) => occurrenceB - occurrenceA)
22+
})
23+
24+
const title = 'Tags'
25+
const description = 'All tags used in this blog'
26+
27+
useSeoMeta({
28+
title,
29+
ogTitle: title,
30+
description,
31+
ogDescription: description
32+
})
33+
34+
defineOgImageComponent('Saas')
35+
</script>
36+
37+
<template>
38+
<UContainer>
39+
<UPage>
40+
<UPageHeader :title="title" :description="description" />
41+
<UPageBody>
42+
<div class="flex flex-wrap place-content-evenly gap-5 mt-4">
43+
<UChip v-for="[tag, occurrence] in tagOccurrences" :key="tag" size="2xl" :text="occurrence">
44+
<UButton color="neutral" variant="subtle" :to="getTagRoute(tag)">
45+
{{ tag }}
46+
</UButton>
47+
</UChip>
48+
</div>
49+
</UPageBody>
50+
</UPage>
51+
</UContainer>
52+
</template>

app/utils/tag.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import kebabCase from 'just-kebab-case'
2+
3+
export const getTagSlug = (tag: string) => kebabCase(tag.toLowerCase().replace('/', ''))
4+
5+
export const getTagRoute = (tag: string) => `/tags/${getTagSlug(tag)}`

package.json

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
"typecheck": "nuxt typecheck"
1212
},
1313
"dependencies": {
14+
"@giscus/vue": "^3.1.1",
1415
"@iconify-json/lucide": "^1.2.75",
1516
"@iconify-json/simple-icons": "^1.2.60",
1617
"@nuxt/content": "^3.8.2",
@@ -22,8 +23,10 @@
2223
"@unhead/vue": "^2.0.19",
2324
"@vueuse/nuxt": "^13.9.0",
2425
"better-sqlite3": "^12.4.6",
26+
"just-kebab-case": "^4.2.0",
2527
"nuxt": "^4.2.2",
2628
"nuxt-og-image": "^5.1.12",
29+
"posthog-js": "^1.310.1",
2730
"zod": "^4.1.13"
2831
},
2932
"devDependencies": {

0 commit comments

Comments
 (0)