PocketBase gains optional, manually-authored English fields on articles and categories (same record, same slug), and the frontend serves an /en counterpart of every dynamic route via thin page wrappers around shared view components — no i18n library, consistent with the project's existing minimalism stance for a two-locale site with ~20 UI strings. An article/category with no translation 404s cleanly on its own /en detail page and is filtered out of /en listings, and the header's language-switch link falls back to the English blog index rather than a dead link; resolving that requires a global route middleware, since the layout's header renders before the page content in document order and so can't react to state a page component sets during its own async setup.
158 lines
4.1 KiB
Vue
158 lines
4.1 KiB
Vue
<script setup lang="ts">
|
|
import type { Article } from '#shared/types/blog'
|
|
import type { Locale } from '#shared/utils/locale'
|
|
import { STRINGS, interpolate } from '#shared/utils/i18n'
|
|
import { localePath } from '#shared/utils/locale'
|
|
|
|
const props = defineProps<{ locale: Locale }>()
|
|
|
|
const strings = computed(() => STRINGS[props.locale])
|
|
const route = useRoute()
|
|
const slug = route.params.slug as string
|
|
|
|
const { data: article } = await useFetch<Article>(`/content/articles/${slug}`, {
|
|
key: `article-${slug}-${props.locale}`,
|
|
query: { lang: props.locale },
|
|
})
|
|
|
|
if (!article.value) {
|
|
throw createError({ statusCode: 404, statusMessage: 'Article not found', fatal: true })
|
|
}
|
|
|
|
const mediaUrl = useMediaUrl()
|
|
const cover = computed(() => mediaUrl(article.value?.cover) ?? undefined)
|
|
const blogHref = computed(() => localePath(props.locale, '/blog'))
|
|
const categoryHref = computed(() =>
|
|
article.value?.category ? localePath(props.locale, `/category/${article.value.category.slug}`) : ''
|
|
)
|
|
|
|
useSeo({
|
|
locale: props.locale,
|
|
path: `/blog/${slug}`,
|
|
title: article.value.title,
|
|
description: article.value.summary,
|
|
type: 'article',
|
|
image: cover.value,
|
|
hasAlternate: props.locale === 'en' ? true : article.value.hasTranslation,
|
|
jsonLd: {
|
|
'@context': 'https://schema.org',
|
|
'@type': 'BlogPosting',
|
|
headline: article.value.title,
|
|
description: article.value.summary,
|
|
datePublished: isoDate(article.value.publishedAt),
|
|
author: article.value.author
|
|
? { '@type': 'Person', name: article.value.author }
|
|
: undefined,
|
|
image: cover.value ? [cover.value] : undefined,
|
|
mainEntityOfPage: { '@type': 'WebPage', '@id': `${useRuntimeConfig().public.siteUrl}${localePath(props.locale, `/blog/${slug}`)}` },
|
|
},
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<article v-if="article">
|
|
<header class="article-header">
|
|
<nav class="kicker" :aria-label="strings.article.breadcrumbAriaLabel">
|
|
<NuxtLink :to="blogHref">{{ strings.article.articlesCrumb }}</NuxtLink>
|
|
<template v-if="article.category">
|
|
<span aria-hidden="true"> / </span>
|
|
<NuxtLink :to="categoryHref">
|
|
{{ article.category.name }}
|
|
</NuxtLink>
|
|
</template>
|
|
</nav>
|
|
|
|
<h1>{{ article.title }}</h1>
|
|
|
|
<p class="kicker date">
|
|
<time :datetime="isoDate(article.publishedAt)">
|
|
{{ formatDateTime(article.publishedAt, locale) }}
|
|
</time>
|
|
<template v-if="article.author">
|
|
<span aria-hidden="true"> · </span>
|
|
<span>{{ interpolate(strings.article.byAuthor, { author: article.author }) }}</span>
|
|
</template>
|
|
</p>
|
|
</header>
|
|
|
|
<img
|
|
v-if="cover"
|
|
class="cover"
|
|
:src="cover"
|
|
:alt="article.cover?.alt ?? ''"
|
|
>
|
|
|
|
<!-- eslint-disable-next-line vue/no-v-html -- rendered server-side from editor Markdown -->
|
|
<div class="prose" v-html="article.html" />
|
|
|
|
<p class="back">
|
|
<NuxtLink :to="blogHref">{{ strings.article.backToArticles }}</NuxtLink>
|
|
</p>
|
|
</article>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.article-header {
|
|
max-width: 46rem;
|
|
/* The gap below the header lives here, not on the cover: an article without
|
|
a cover image would otherwise have its body butting against the byline. */
|
|
margin: 0 auto 2rem;
|
|
text-align: center;
|
|
}
|
|
|
|
@media (min-width: 48rem) {
|
|
.article-header {
|
|
margin-bottom: 3rem;
|
|
}
|
|
}
|
|
|
|
.article-header nav a {
|
|
text-decoration: none;
|
|
}
|
|
|
|
.article-header nav a:hover {
|
|
text-decoration: underline;
|
|
text-underline-offset: 0.2em;
|
|
}
|
|
|
|
h1 {
|
|
margin: 1rem 0 0;
|
|
font-size: clamp(2rem, 5vw, 3.2rem);
|
|
}
|
|
|
|
.date {
|
|
margin-top: 1rem;
|
|
font-weight: 400;
|
|
}
|
|
|
|
.prose {
|
|
margin-inline: auto;
|
|
}
|
|
|
|
.back {
|
|
max-width: var(--width-prose);
|
|
margin: 4rem auto 0;
|
|
padding-top: 2rem;
|
|
border-top: 1px solid var(--color-border);
|
|
text-align: center;
|
|
font-size: 0.75rem;
|
|
font-weight: 600;
|
|
letter-spacing: 0.14em;
|
|
text-transform: uppercase;
|
|
}
|
|
|
|
.back a {
|
|
text-decoration: none;
|
|
border-bottom: 1px solid var(--color-ink);
|
|
padding-bottom: 0.3rem;
|
|
}
|
|
|
|
.cover {
|
|
width: 100%;
|
|
aspect-ratio: 3 / 2;
|
|
object-fit: cover;
|
|
margin-block: 0 3rem;
|
|
background: var(--color-surface);
|
|
}
|
|
</style>
|