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.
This commit is contained in:
@@ -0,0 +1,157 @@
|
||||
<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>
|
||||
Reference in New Issue
Block a user