Add Nuxt frontend with blog index, article and category pages

The browser never talks to Strapi: pages call Nitro endpoints under
server/api/, which are the only place Strapi queries are built. That keeps the
Strapi URL internal to the Docker network and avoids CORS entirely.

Article bodies are rendered from Markdown to HTML in the endpoint, so the
content is server-rendered and crawlable, marked stays out of the client
bundle, and the meta description is derived from the opening of the body.

Pages carry canonical URLs, Open Graph tags and BlogPosting structured data,
and handle empty, missing and failed states.
This commit is contained in:
2026-08-25 11:42:17 +02:00
parent ec5976135f
commit a808f1010f
28 changed files with 10387 additions and 0 deletions
+8
View File
@@ -0,0 +1,8 @@
<template>
<div>
<NuxtRouteAnnouncer />
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
</div>
</template>
+115
View File
@@ -0,0 +1,115 @@
:root {
--color-text: #16181d;
--color-muted: #5b6270;
--color-bg: #ffffff;
--color-surface: #f5f6f8;
--color-border: #e2e5ea;
--color-accent: #2f4bd8;
--radius: 10px;
--width: 68rem;
--space: 1.25rem;
}
*,
*::before,
*::after {
box-sizing: border-box;
}
body {
margin: 0;
color: var(--color-text);
background: var(--color-bg);
font-family: system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
font-size: 1.05rem;
line-height: 1.65;
}
img {
max-width: 100%;
height: auto;
}
a {
color: var(--color-accent);
}
h1,
h2,
h3 {
line-height: 1.25;
text-wrap: balance;
}
:focus-visible {
outline: 3px solid var(--color-accent);
outline-offset: 2px;
}
.container {
width: 100%;
max-width: var(--width);
margin-inline: auto;
padding-inline: var(--space);
}
.skip-link {
position: absolute;
left: -9999px;
}
.skip-link:focus {
left: var(--space);
top: var(--space);
z-index: 10;
padding: 0.5rem 0.75rem;
background: var(--color-bg);
border: 1px solid var(--color-border);
border-radius: var(--radius);
}
.muted {
color: var(--color-muted);
}
.card-grid {
display: grid;
gap: 1.75rem;
grid-template-columns: repeat(auto-fill, minmax(17rem, 1fr));
padding: 0;
margin: 0;
list-style: none;
}
.prose {
max-width: 44rem;
}
.prose img {
border-radius: var(--radius);
}
.prose pre {
overflow-x: auto;
padding: 1rem;
background: var(--color-surface);
border-radius: var(--radius);
}
.tag-list {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
padding: 0;
margin: 2rem 0 0;
list-style: none;
}
.tag-list a {
display: inline-block;
padding: 0.15rem 0.6rem;
font-size: 0.9rem;
background: var(--color-surface);
border-radius: 999px;
text-decoration: none;
}
+66
View File
@@ -0,0 +1,66 @@
<script setup lang="ts">
import type { ArticleSummary } from '#shared/types/blog'
const props = defineProps<{ article: ArticleSummary }>()
const mediaUrl = useMediaUrl()
const cover = computed(() => mediaUrl(props.article.cover))
</script>
<template>
<article class="card">
<img
v-if="cover"
class="cover"
:src="cover"
:alt="article.cover?.alternativeText ?? ''"
:width="article.cover?.width ?? undefined"
:height="article.cover?.height ?? undefined"
loading="lazy"
>
<p v-if="article.category" class="muted meta">
{{ article.category.name }}
</p>
<h2>
<NuxtLink :to="`/blog/${article.slug}`">{{ article.title }}</NuxtLink>
</h2>
<p class="muted meta">
<time :datetime="isoDate(article.publishedAt)">{{ formatDate(article.publishedAt) }}</time>
</p>
</article>
</template>
<style scoped>
.card h2 {
margin: 0.35rem 0 0.5rem;
font-size: 1.2rem;
}
.card h2 a {
color: inherit;
text-decoration: none;
}
.card h2 a:hover {
text-decoration: underline;
}
.card p {
margin: 0 0 0.5rem;
}
.cover {
width: 100%;
aspect-ratio: 16 / 9;
object-fit: cover;
border-radius: var(--radius);
background: var(--color-surface);
}
.meta {
font-size: 0.9rem;
}
</style>
+14
View File
@@ -0,0 +1,14 @@
import type { StrapiImage } from '#shared/types/blog'
/**
* Strapi returns media paths relative to its own origin; the browser needs
* absolute ones. Remote providers (S3) already return absolute URLs.
*/
export function useMediaUrl() {
const { public: config } = useRuntimeConfig()
return (image: StrapiImage | null | undefined): string | null => {
if (!image?.url) return null
return image.url.startsWith('http') ? image.url : `${config.strapiUrl}${image.url}`
}
}
+27
View File
@@ -0,0 +1,27 @@
<script setup lang="ts">
import type { NuxtError } from '#app'
const props = defineProps<{ error: NuxtError }>()
const isNotFound = computed(() => props.error.statusCode === 404)
useSeoMeta({ robots: 'noindex' })
</script>
<template>
<NuxtLayout>
<h1>{{ isNotFound ? 'Pagina non trovata' : 'Qualcosa è andato storto' }}</h1>
<p class="muted">
{{
isNotFound
? 'Il contenuto che cerchi non esiste o è stato spostato.'
: 'Riprova tra qualche istante.'
}}
</p>
<p>
<NuxtLink to="/">Torna alla home</NuxtLink>
</p>
</NuxtLayout>
</template>
+94
View File
@@ -0,0 +1,94 @@
<script setup lang="ts">
import type { Category } from '#shared/types/blog'
const { data: categories } = await useFetch<Category[]>('/api/categories', {
key: 'nav-categories',
default: () => [],
})
</script>
<template>
<div class="page">
<a class="skip-link" href="#main">Salta al contenuto</a>
<header class="site-header">
<div class="container header-inner">
<NuxtLink to="/" class="brand">Blog</NuxtLink>
<nav aria-label="Navigazione principale">
<ul>
<li><NuxtLink to="/blog">Articoli</NuxtLink></li>
<li v-for="category in categories" :key="category.slug">
<NuxtLink :to="`/category/${category.slug}`">{{ category.name }}</NuxtLink>
</li>
</ul>
</nav>
</div>
</header>
<main id="main" class="container site-main">
<slot />
</main>
<footer class="site-footer">
<div class="container">
<p class="muted">&copy; {{ new Date().getFullYear() }} Blog</p>
</div>
</footer>
</div>
</template>
<style scoped>
.page {
display: flex;
flex-direction: column;
min-height: 100vh;
}
.site-header {
border-bottom: 1px solid var(--color-border);
}
.header-inner {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 1rem 2rem;
padding-block: 1rem;
}
.brand {
font-size: 1.25rem;
font-weight: 700;
color: inherit;
text-decoration: none;
}
nav ul {
display: flex;
flex-wrap: wrap;
gap: 1rem;
padding: 0;
margin: 0;
list-style: none;
}
nav a {
color: inherit;
text-decoration: none;
}
nav a:hover,
nav a.router-link-active {
text-decoration: underline;
}
.site-main {
flex: 1;
padding-block: 2.5rem 4rem;
}
.site-footer {
padding-block: 1.5rem;
border-top: 1px solid var(--color-border);
}
</style>
+102
View File
@@ -0,0 +1,102 @@
<script setup lang="ts">
import type { Article } from '#shared/types/blog'
const route = useRoute()
const slug = route.params.slug as string
const { data: article } = await useFetch<Article>(`/api/articles/${slug}`)
if (!article.value) {
throw createError({ statusCode: 404, statusMessage: 'Articolo non trovato', fatal: true })
}
const { public: config } = useRuntimeConfig()
const mediaUrl = useMediaUrl()
const canonical = `${config.siteUrl}/blog/${article.value.slug}`
const cover = computed(() => mediaUrl(article.value?.cover) ?? undefined)
useSeoMeta({
title: article.value.title,
description: article.value.summary,
ogTitle: article.value.title,
ogDescription: article.value.summary,
ogType: 'article',
ogUrl: canonical,
ogImage: cover,
twitterCard: 'summary_large_image',
})
useHead({
link: [{ rel: 'canonical', href: canonical }],
script: [
{
type: 'application/ld+json',
innerHTML: JSON.stringify({
'@context': 'https://schema.org',
'@type': 'BlogPosting',
headline: article.value.title,
description: article.value.summary,
datePublished: isoDate(article.value.publishedAt),
image: cover.value ? [cover.value] : undefined,
mainEntityOfPage: { '@type': 'WebPage', '@id': canonical },
}),
},
],
})
</script>
<template>
<article v-if="article">
<nav class="breadcrumb muted" aria-label="Percorso">
<NuxtLink to="/blog">Articoli</NuxtLink>
<template v-if="article.category">
<span aria-hidden="true"> / </span>
<NuxtLink :to="`/category/${article.category.slug}`">{{ article.category.name }}</NuxtLink>
</template>
</nav>
<h1>{{ article.title }}</h1>
<p class="muted meta">
<time :datetime="isoDate(article.publishedAt)">{{ formatDate(article.publishedAt) }}</time>
</p>
<img
v-if="cover"
class="cover"
:src="cover"
:alt="article.cover?.alternativeText ?? ''"
:width="article.cover?.width ?? undefined"
:height="article.cover?.height ?? undefined"
>
<!-- eslint-disable-next-line vue/no-v-html -- rendered server-side from editor Markdown -->
<div class="prose" v-html="article.html" />
</article>
</template>
<style scoped>
article {
max-width: 44rem;
}
.breadcrumb {
font-size: 0.9rem;
}
h1 {
margin: 0.5rem 0 0.25rem;
font-size: clamp(1.8rem, 4vw, 2.6rem);
}
.meta {
margin-top: 0;
}
.cover {
width: 100%;
margin-block: 1.5rem;
border-radius: var(--radius);
}
</style>
+70
View File
@@ -0,0 +1,70 @@
<script setup lang="ts">
import type { ArticleSummary, Paginated } from '#shared/types/blog'
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>>('/api/articles', {
query: { page },
})
const { public: config } = useRuntimeConfig()
const canonical = computed(
() => `${config.siteUrl}/blog${page.value > 1 ? `?page=${page.value}` : ''}`
)
useSeoMeta({
title: page.value > 1 ? `Articoli — pagina ${page.value}` : 'Articoli',
description: 'Tutti gli articoli pubblicati sul blog.',
ogType: 'website',
})
useHead({ link: [{ rel: 'canonical', href: canonical }] })
</script>
<template>
<div>
<h1>Articoli</h1>
<p v-if="error">Non è stato possibile caricare gli articoli. Riprova più tardi.</p>
<p v-else-if="status === 'pending'">Caricamento</p>
<p v-else-if="!data?.items.length">Non ci sono ancora articoli pubblicati.</p>
<template v-else>
<ul class="card-grid">
<li v-for="article in data.items" :key="article.slug">
<ArticleCard :article="article" />
</li>
</ul>
<nav v-if="data.pageCount > 1" class="pagination" aria-label="Paginazione">
<NuxtLink v-if="data.page > 1" :to="{ query: { page: data.page - 1 } }" rel="prev">
Pagina precedente
</NuxtLink>
<span class="muted">Pagina {{ data.page }} di {{ data.pageCount }}</span>
<NuxtLink
v-if="data.page < data.pageCount"
:to="{ query: { page: data.page + 1 } }"
rel="next"
>
Pagina successiva
</NuxtLink>
</nav>
</template>
</div>
</template>
<style scoped>
.pagination {
display: flex;
flex-wrap: wrap;
gap: 1.5rem;
align-items: center;
margin-top: 3rem;
}
</style>
+84
View File
@@ -0,0 +1,84 @@
<script setup lang="ts">
import type { ArticleSummary, Paginated, Category } from '#shared/types/blog'
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>(`/api/categories/${slug}`)
if (!category.value) {
throw createError({ statusCode: 404, statusMessage: 'Categoria non trovata', fatal: true })
}
const { data, error } = await useFetch<Paginated<ArticleSummary>>('/api/articles', {
query: { page, category: slug },
})
const { public: config } = useRuntimeConfig()
const canonical = computed(
() => `${config.siteUrl}/category/${slug}${page.value > 1 ? `?page=${page.value}` : ''}`
)
useSeoMeta({
title: `${category.value.name} — Articoli`,
description: `Tutti gli articoli nella categoria ${category.value.name}.`,
ogType: 'website',
})
useHead({ link: [{ rel: 'canonical', href: canonical }] })
</script>
<template>
<div>
<p class="breadcrumb muted">
<NuxtLink to="/blog">Articoli</NuxtLink>
</p>
<h1>{{ category?.name }}</h1>
<p v-if="error">Non è stato possibile caricare gli articoli. Riprova più tardi.</p>
<p v-else-if="!data?.items.length">Non ci sono articoli in questa categoria.</p>
<template v-else>
<ul class="card-grid">
<li v-for="article in data.items" :key="article.slug">
<ArticleCard :article="article" />
</li>
</ul>
<nav v-if="data.pageCount > 1" class="pagination" aria-label="Paginazione">
<NuxtLink v-if="data.page > 1" :to="{ query: { page: data.page - 1 } }" rel="prev">
Pagina precedente
</NuxtLink>
<span class="muted">Pagina {{ data.page }} di {{ data.pageCount }}</span>
<NuxtLink
v-if="data.page < data.pageCount"
:to="{ query: { page: data.page + 1 } }"
rel="next"
>
Pagina successiva
</NuxtLink>
</nav>
</template>
</div>
</template>
<style scoped>
.breadcrumb {
font-size: 0.9rem;
}
.pagination {
display: flex;
flex-wrap: wrap;
gap: 1.5rem;
align-items: center;
margin-top: 3rem;
}
</style>
+67
View File
@@ -0,0 +1,67 @@
<script setup lang="ts">
import type { ArticleSummary, Paginated } from '#shared/types/blog'
const { data, error } = await useFetch<Paginated<ArticleSummary>>('/api/articles', {
key: 'home-articles',
query: { page: 1 },
})
const articles = computed(() => data.value?.items.slice(0, 6) ?? [])
const { public: config } = useRuntimeConfig()
useSeoMeta({
title: 'Blog',
description: 'Articoli, guide e approfondimenti.',
ogTitle: 'Blog',
ogDescription: 'Articoli, guide e approfondimenti.',
ogType: 'website',
ogUrl: config.siteUrl,
})
useHead({ link: [{ rel: 'canonical', href: config.siteUrl }] })
</script>
<template>
<div>
<section class="hero">
<h1>Blog</h1>
<p class="muted">Articoli, guide e approfondimenti.</p>
</section>
<h2>Ultimi articoli</h2>
<p v-if="error">
Non è stato possibile caricare gli articoli. Riprova più tardi.
</p>
<p v-else-if="!articles.length">
Non ci sono ancora articoli pubblicati.
</p>
<ul v-else class="card-grid">
<li v-for="article in articles" :key="article.slug">
<ArticleCard :article="article" />
</li>
</ul>
<p class="more">
<NuxtLink to="/blog">Vedi tutti gli articoli</NuxtLink>
</p>
</div>
</template>
<style scoped>
.hero {
margin-bottom: 3rem;
}
.hero h1 {
margin-bottom: 0.25rem;
font-size: clamp(2rem, 5vw, 3rem);
}
.more {
margin-top: 2.5rem;
}
</style>