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:
2026-09-11 13:53:17 +02:00
parent 847edc7397
commit 33b49c5dbb
40 changed files with 1285 additions and 576 deletions
+9 -4
View File
@@ -1,10 +1,15 @@
<script setup lang="ts">
import type { ArticleSummary } from '#shared/types/blog'
import type { Locale } from '#shared/utils/locale'
import { localePath } from '#shared/utils/locale'
const props = defineProps<{ article: ArticleSummary }>()
const props = withDefaults(defineProps<{ article: ArticleSummary; locale?: Locale }>(), {
locale: 'it',
})
const mediaUrl = useMediaUrl()
const cover = computed(() => mediaUrl(props.article.cover))
const href = computed(() => localePath(props.locale, `/blog/${props.article.slug}`))
</script>
<template>
@@ -14,7 +19,7 @@ const cover = computed(() => mediaUrl(props.article.cover))
<NuxtLink
v-if="cover"
class="cover-link"
:to="`/blog/${article.slug}`"
:to="href"
tabindex="-1"
aria-hidden="true"
>
@@ -29,12 +34,12 @@ const cover = computed(() => mediaUrl(props.article.cover))
<p v-if="article.category" class="kicker">{{ article.category.name }}</p>
<h2>
<NuxtLink :to="`/blog/${article.slug}`">{{ article.title }}</NuxtLink>
<NuxtLink :to="href">{{ article.title }}</NuxtLink>
</h2>
<p class="kicker date">
<time :datetime="isoDate(article.publishedAt)">
{{ formatDate(article.publishedAt) }}
{{ formatDate(article.publishedAt, locale) }}
</time>
</p>
</article>
@@ -0,0 +1,37 @@
<script setup lang="ts">
import { STRINGS } from '#shared/utils/i18n'
import { bareLocalePath, localePath } from '#shared/utils/locale'
const route = useRoute()
const locale = useLocale()
const langSwitch = useLangSwitchState()
const target = computed(() => {
if (!langSwitch.value.available) return langSwitch.value.fallback
const otherLocale = locale.value === 'en' ? 'it' : 'en'
return localePath(otherLocale, bareLocalePath(route.path))
})
const label = computed(() => STRINGS[locale.value].langSwitchLabel)
</script>
<template>
<NuxtLink class="lang-switch" :to="target">{{ label }}</NuxtLink>
</template>
<style scoped>
.lang-switch {
display: inline-block;
font-size: 0.75rem;
font-weight: 600;
letter-spacing: 0.14em;
text-transform: uppercase;
text-decoration: none;
padding-block: 0.6rem;
border-bottom: 1px solid transparent;
}
.lang-switch:hover {
border-bottom-color: var(--color-ink);
}
</style>
@@ -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>
@@ -0,0 +1,67 @@
<script setup lang="ts">
import type { ArticleSummary, Paginated } from '#shared/types/blog'
import type { Locale } from '#shared/utils/locale'
import { STRINGS, interpolate } from '#shared/utils/i18n'
const props = defineProps<{ locale: Locale }>()
const strings = computed(() => STRINGS[props.locale])
const route = useRoute()
const page = computed(() => {
const value = Number(route.query.page)
return Number.isInteger(value) && value > 0 ? value : 1
})
const { data, error, status } = await useFetch<Paginated<ArticleSummary>>('/content/articles', {
key: () => `blog-${props.locale}-${page.value}`,
query: { page, lang: props.locale },
})
useSeo({
locale: props.locale,
path: '/blog',
title: page.value > 1 ? interpolate(strings.value.blog.titlePage, { page: page.value }) : strings.value.blog.title,
description: strings.value.blog.metaDescription,
})
</script>
<template>
<div>
<header class="page-header">
<p class="kicker">{{ strings.blog.archiveKicker }}</p>
<h1>{{ strings.blog.title }}</h1>
</header>
<p v-if="error">{{ strings.blog.loadError }}</p>
<p v-else-if="status === 'pending'">{{ strings.blog.loading }}</p>
<p v-else-if="!data?.items.length">{{ strings.blog.empty }}</p>
<template v-else>
<ul class="card-grid">
<li v-for="article in data.items" :key="article.slug">
<ArticleCard :article="article" :locale="locale" />
</li>
</ul>
<nav v-if="data.pageCount > 1" class="pagination" :aria-label="strings.blog.paginationAriaLabel">
<NuxtLink v-if="data.page > 1" :to="{ query: { page: data.page - 1 } }" rel="prev">
{{ strings.blog.prevPage }}
</NuxtLink>
<span class="muted">
{{ interpolate(strings.blog.pageOf, { current: data.page, total: data.pageCount }) }}
</span>
<NuxtLink
v-if="data.page < data.pageCount"
:to="{ query: { page: data.page + 1 } }"
rel="next"
>
{{ strings.blog.nextPage }}
</NuxtLink>
</nav>
</template>
</div>
</template>
@@ -0,0 +1,75 @@
<script setup lang="ts">
import type { ArticleSummary, Category, Paginated } from '#shared/types/blog'
import type { Locale } from '#shared/utils/locale'
import { STRINGS, interpolate } from '#shared/utils/i18n'
const props = defineProps<{ locale: Locale }>()
const strings = computed(() => STRINGS[props.locale])
const route = useRoute()
const slug = route.params.slug as string
const page = computed(() => {
const value = Number(route.query.page)
return Number.isInteger(value) && value > 0 ? value : 1
})
const { data: category } = await useFetch<Category>(`/content/categories/${slug}`, {
key: `category-${slug}-${props.locale}`,
query: { lang: props.locale },
})
if (!category.value) {
throw createError({ statusCode: 404, statusMessage: 'Category not found', fatal: true })
}
const { data, error } = await useFetch<Paginated<ArticleSummary>>('/content/articles', {
key: () => `category-articles-${slug}-${props.locale}-${page.value}`,
query: { page, category: slug, lang: props.locale },
})
useSeo({
locale: props.locale,
path: `/category/${slug}`,
title: category.value.name,
description: interpolate(strings.value.category.metaDescription, { name: category.value.name }),
})
</script>
<template>
<div>
<header class="page-header">
<p class="kicker">{{ strings.category.kicker }}</p>
<h1>{{ category?.name }}</h1>
</header>
<p v-if="error">{{ strings.category.loadError }}</p>
<p v-else-if="!data?.items.length">{{ strings.category.empty }}</p>
<template v-else>
<ul class="card-grid">
<li v-for="article in data.items" :key="article.slug">
<ArticleCard :article="article" :locale="locale" />
</li>
</ul>
<nav v-if="data.pageCount > 1" class="pagination" :aria-label="strings.blog.paginationAriaLabel">
<NuxtLink v-if="data.page > 1" :to="{ query: { page: data.page - 1 } }" rel="prev">
{{ strings.blog.prevPage }}
</NuxtLink>
<span class="muted">
{{ interpolate(strings.blog.pageOf, { current: data.page, total: data.pageCount }) }}
</span>
<NuxtLink
v-if="data.page < data.pageCount"
:to="{ query: { page: data.page + 1 } }"
rel="next"
>
{{ strings.blog.nextPage }}
</NuxtLink>
</nav>
</template>
</div>
</template>
+200
View File
@@ -0,0 +1,200 @@
<script setup lang="ts">
import type { ArticleSummary, Paginated } from '#shared/types/blog'
import type { Locale } from '#shared/utils/locale'
import { STRINGS } from '#shared/utils/i18n'
import { localePath } from '#shared/utils/locale'
const props = defineProps<{ locale: Locale }>()
const strings = computed(() => STRINGS[props.locale])
const { data, error } = await useFetch<Paginated<ArticleSummary>>('/content/articles', {
key: `home-articles-${props.locale}`,
query: { page: 1, lang: props.locale },
})
const featured = computed(() => data.value?.items[0] ?? null)
const mediaUrl = useMediaUrl()
const featuredCover = computed(() => mediaUrl(featured.value?.cover))
const featuredHref = computed(() => (featured.value ? localePath(props.locale, `/blog/${featured.value.slug}`) : '/'))
useSeo({
locale: props.locale,
path: '/',
title: SITE_NAME,
description: strings.value.home.metaDescription,
image: `${useRuntimeConfig().public.siteUrl}/logo.png`,
})
useHead({ titleTemplate: '%s' })
</script>
<template>
<div>
<section class="landing">
<img
class="landing-image"
src="/protesta-bancaria.jpg"
:alt="strings.home.img1Alt"
width="770"
height="420"
>
<h1>{{ strings.home.heading }}</h1>
<img
class="landing-image"
src="/eurozona-crisi.jpg"
:alt="strings.home.img2Alt"
width="1000"
height="736"
>
<div class="landing-text">
<p>{{ strings.home.p1 }}</p>
<p>{{ strings.home.p2 }}</p>
<p>{{ strings.home.p3 }}</p>
</div>
</section>
<p v-if="error">{{ strings.blog.loadError }}</p>
<p v-else-if="!featured">{{ strings.blog.empty }}</p>
<template v-else>
<article class="featured" :class="{ 'has-cover': featuredCover }">
<NuxtLink
v-if="featuredCover"
class="featured-cover"
:to="featuredHref"
tabindex="-1"
aria-hidden="true"
>
<img
:src="featuredCover"
:alt="featured.cover?.alt ?? ''"
>
</NuxtLink>
<div class="featured-text">
<p class="kicker">
{{ featured.category ? featured.category.name : strings.home.fallbackKicker }}
</p>
<h2>
<NuxtLink :to="featuredHref">{{ featured.title }}</NuxtLink>
</h2>
<p class="kicker date">
<time :datetime="isoDate(featured.publishedAt)">
{{ formatDate(featured.publishedAt, locale) }}
</time>
</p>
<p class="read-more">
<NuxtLink :to="featuredHref">
{{ strings.home.readLatest }}
</NuxtLink>
</p>
</div>
</article>
</template>
</div>
</template>
<style scoped>
.landing {
max-width: var(--width-prose);
margin-inline: auto;
margin-bottom: var(--gap-section);
padding-bottom: var(--gap-section);
border-bottom: 1px solid var(--color-border);
text-align: center;
}
.landing-image {
width: 100%;
aspect-ratio: 16 / 9;
object-fit: cover;
background: var(--color-surface);
}
.landing h1 {
margin-block: 1.75rem;
font-size: clamp(2rem, 4.5vw, 3.1rem);
}
.landing-text {
margin-top: 1.75rem;
text-align: left;
}
.landing-text p {
margin-top: 1.1rem;
}
.featured {
display: grid;
gap: 1.5rem;
align-items: center;
grid-template-columns: 1fr;
}
/* Stacked on phones and portrait tablets — a side-by-side split below this
width leaves both the image and the headline too cramped to read. The split
also needs a cover to sit in it, or one column would just be empty. */
@media (min-width: 64rem) {
.featured.has-cover {
grid-template-columns: 1.35fr 1fr;
gap: 3.5rem;
}
}
/* Without a cover the headline is the whole lead, so it gets the full width
but stays inside a readable measure. */
.featured:not(.has-cover) .featured-text {
max-width: 46rem;
}
.featured-cover {
display: block;
overflow: hidden;
}
.featured-cover img {
width: 100%;
aspect-ratio: 3 / 2;
object-fit: cover;
background: var(--color-surface);
}
.featured h2 {
margin-top: 0.6rem;
font-size: clamp(2rem, 4.5vw, 3.1rem);
}
.featured h2 a {
text-decoration: none;
}
.featured h2 a:hover {
text-decoration: underline;
text-underline-offset: 0.12em;
}
.date {
margin-top: 1rem;
font-weight: 400;
}
.read-more {
margin-top: 1.75rem;
font-size: 0.75rem;
font-weight: 600;
letter-spacing: 0.14em;
text-transform: uppercase;
}
.read-more a {
padding-bottom: 0.35rem;
text-decoration: none;
border-bottom: 1px solid var(--color-ink);
}
</style>