Files
blog/frontend/app/components/views/ArticleView.vue
T
davide 33b49c5dbb Add hand-rolled English support (/en/*) alongside the Italian site
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.
2026-09-11 13:53:17 +02:00

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>