Portfolio/app/pages/blog/[slug].vue
2026-09-08 19:54:06 +02:00

196 lines
4.1 KiB
Vue

<script setup lang="ts">
import type { Post, PostMeta } from '#shared/types/blog'
const route = useRoute()
const slug = computed(() => String(route.params.slug))
const { data, error } = await useFetch<{
post: Post
previous: PostMeta | null
next: PostMeta | null
}>(() => `/api/blog/${slug.value}`, { key: () => `post-${slug.value}` })
if (error.value) {
throw createError({
statusCode: error.value.statusCode ?? 500,
message: error.value.statusCode === 404 ? 'no such post' : 'could not load this post',
fatal: true,
})
}
const post = computed(() => data.value!.post)
const published = computed(() =>
new Intl.DateTimeFormat('en-GB', { dateStyle: 'long', timeZone: 'UTC' }).format(
new Date(post.value.date),
),
)
const updated = computed(() =>
post.value.updated
? new Intl.DateTimeFormat('en-GB', { dateStyle: 'long', timeZone: 'UTC' }).format(
new Date(post.value.updated),
)
: null,
)
useSeoMeta({
title: () => post.value.title,
description: () => post.value.description,
ogTitle: () => post.value.title,
ogDescription: () => post.value.description,
ogType: 'article',
articlePublishedTime: () => post.value.date,
articleTag: () => [...post.value.tags],
})
</script>
<template>
<article v-if="post">
<header class="head">
<p class="small muted crumbs">
<NuxtLink to="/blog">../blog</NuxtLink> / {{ post.slug }}.md
</p>
<h1>{{ post.title }}</h1>
<p class="muted small lede">{{ post.description }}</p>
<div class="row meta small faint">
<span>{{ published }}</span>
<span v-if="updated">· updated {{ updated }}</span>
<span>· {{ post.readingTime }} min</span>
<span v-if="post.tags.length">
·
<NuxtLink
v-for="tag in post.tags"
:key="tag"
:to="{ path: '/blog', query: { tag } }"
class="tag"
>#{{ tag }}</NuxtLink>
</span>
<span v-if="post.draft" class="accent">· draft</span>
</div>
</header>
<AsciiRule />
<div class="body">
<aside v-if="post.toc.length > 2" class="toc">
<div class="frame toc-frame">
<span class="frame-tag">contents</span>
<ul>
<li v-for="entry in post.toc" :key="entry.id" :class="`d${entry.depth}`">
<a :href="`#${entry.id}`">{{ entry.text }}</a>
</li>
</ul>
</div>
</aside>
<!-- rendered server-side from the repo's markdown -->
<!-- eslint-disable-next-line vue/no-v-html -->
<div class="prose" v-html="post.html" />
</div>
<AsciiRule />
<nav class="neighbours grid-2">
<FrameBox v-if="data?.previous" :to="`/blog/${data.previous.slug}`" hover tag="older">
<span class="small">← {{ data.previous.title }}</span>
</FrameBox>
<span v-else />
<FrameBox v-if="data?.next" :to="`/blog/${data.next.slug}`" hover tag="newer" class="right">
<span class="small">{{ data.next.title }} →</span>
</FrameBox>
</nav>
<p v-if="post.sourceUrl" class="small faint source">
<a :href="post.sourceUrl" target="_blank" rel="noopener noreferrer">→ view the raw markdown</a>
</p>
</article>
</template>
<style scoped>
.head {
margin: 2rem 0 0;
}
.crumbs {
margin-bottom: 0.75rem;
}
h1 {
font-size: 1.6rem;
letter-spacing: -0.02em;
}
.lede {
margin-top: 0.5rem;
}
.meta {
margin-top: 0.75rem;
gap: 0.35rem;
}
.tag {
margin-left: 0.35rem;
}
.body {
display: grid;
grid-template-columns: 1fr;
gap: 2rem;
}
@media (min-width: 940px) {
.body {
grid-template-columns: minmax(0, 1fr) 15rem;
}
.toc {
order: 2;
}
.toc-frame {
position: sticky;
top: 1.5rem;
}
}
.toc ul {
list-style: none;
margin: 0;
padding: 0;
font-size: 0.85rem;
}
.toc li + li {
margin-top: 0.3rem;
}
.toc .d3 {
padding-left: 1rem;
}
.toc a {
text-decoration: none;
color: var(--ink-mute);
}
.toc a:hover {
color: var(--paper);
}
.neighbours {
align-items: stretch;
}
.right {
text-align: right;
}
.source {
margin-top: 1.5rem;
text-align: right;
}
</style>