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:
@@ -0,0 +1,6 @@
|
||||
node_modules
|
||||
.nuxt
|
||||
.output
|
||||
.data
|
||||
.env
|
||||
.git
|
||||
@@ -0,0 +1,24 @@
|
||||
# Nuxt dev/build outputs
|
||||
.output
|
||||
.data
|
||||
.nuxt
|
||||
.nitro
|
||||
.cache
|
||||
dist
|
||||
|
||||
# Node dependencies
|
||||
node_modules
|
||||
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
|
||||
# Misc
|
||||
.DS_Store
|
||||
.fleet
|
||||
.idea
|
||||
|
||||
# Local env files
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
@@ -0,0 +1,14 @@
|
||||
FROM node:22-alpine AS build
|
||||
WORKDIR /app
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
COPY . .
|
||||
RUN npm run build
|
||||
|
||||
FROM node:22-alpine AS runtime
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
COPY --from=build /app/.output /app/.output
|
||||
USER node
|
||||
EXPOSE 3000
|
||||
CMD ["node", ".output/server/index.mjs"]
|
||||
@@ -0,0 +1,75 @@
|
||||
# Nuxt Minimal Starter
|
||||
|
||||
Look at the [Nuxt documentation](https://nuxt.com/docs/getting-started/introduction) to learn more.
|
||||
|
||||
## Setup
|
||||
|
||||
Make sure to install dependencies:
|
||||
|
||||
```bash
|
||||
# npm
|
||||
npm install
|
||||
|
||||
# pnpm
|
||||
pnpm install
|
||||
|
||||
# yarn
|
||||
yarn install
|
||||
|
||||
# bun
|
||||
bun install
|
||||
```
|
||||
|
||||
## Development Server
|
||||
|
||||
Start the development server on `http://localhost:3000`:
|
||||
|
||||
```bash
|
||||
# npm
|
||||
npm run dev
|
||||
|
||||
# pnpm
|
||||
pnpm dev
|
||||
|
||||
# yarn
|
||||
yarn dev
|
||||
|
||||
# bun
|
||||
bun run dev
|
||||
```
|
||||
|
||||
## Production
|
||||
|
||||
Build the application for production:
|
||||
|
||||
```bash
|
||||
# npm
|
||||
npm run build
|
||||
|
||||
# pnpm
|
||||
pnpm build
|
||||
|
||||
# yarn
|
||||
yarn build
|
||||
|
||||
# bun
|
||||
bun run build
|
||||
```
|
||||
|
||||
Locally preview production build:
|
||||
|
||||
```bash
|
||||
# npm
|
||||
npm run preview
|
||||
|
||||
# pnpm
|
||||
pnpm preview
|
||||
|
||||
# yarn
|
||||
yarn preview
|
||||
|
||||
# bun
|
||||
bun run preview
|
||||
```
|
||||
|
||||
Check out the [deployment documentation](https://nuxt.com/docs/getting-started/deployment) for more information.
|
||||
@@ -0,0 +1,8 @@
|
||||
<template>
|
||||
<div>
|
||||
<NuxtRouteAnnouncer />
|
||||
<NuxtLayout>
|
||||
<NuxtPage />
|
||||
</NuxtLayout>
|
||||
</div>
|
||||
</template>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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}`
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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">© {{ 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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -0,0 +1,30 @@
|
||||
// https://nuxt.com/docs/api/configuration/nuxt-config
|
||||
export default defineNuxtConfig({
|
||||
compatibilityDate: '2025-07-15',
|
||||
devtools: { enabled: true },
|
||||
|
||||
css: ['~/assets/css/main.css'],
|
||||
|
||||
app: {
|
||||
head: {
|
||||
htmlAttrs: { lang: 'it' },
|
||||
meta: [{ name: 'viewport', content: 'width=device-width, initial-scale=1' }],
|
||||
},
|
||||
},
|
||||
|
||||
runtimeConfig: {
|
||||
// Server-only: internal Strapi address, never exposed to the browser.
|
||||
strapiUrl: 'http://localhost:1337',
|
||||
strapiToken: '',
|
||||
public: {
|
||||
// Canonical origin of the public website.
|
||||
siteUrl: 'http://localhost:3000',
|
||||
// Browser-reachable Strapi origin, used to build absolute media URLs.
|
||||
strapiUrl: 'http://localhost:1337',
|
||||
},
|
||||
},
|
||||
|
||||
typescript: {
|
||||
strict: true,
|
||||
},
|
||||
})
|
||||
Generated
+9342
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,23 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "nuxt build",
|
||||
"dev": "nuxt dev",
|
||||
"generate": "nuxt generate",
|
||||
"preview": "nuxt preview",
|
||||
"postinstall": "nuxt prepare",
|
||||
"typecheck": "nuxt typecheck"
|
||||
},
|
||||
"dependencies": {
|
||||
"marked": "^18.0.11",
|
||||
"nuxt": "^4.5.2",
|
||||
"vue": "^3.5.41",
|
||||
"vue-router": "^5.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"typescript": "^5.9.3",
|
||||
"vue-tsc": "^3.3.11"
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
@@ -0,0 +1,2 @@
|
||||
User-Agent: *
|
||||
Disallow:
|
||||
@@ -0,0 +1,24 @@
|
||||
import type { Article } from '#shared/types/blog'
|
||||
|
||||
type RawArticle = Omit<Article, 'html' | 'summary'> & { content: string | null }
|
||||
|
||||
export default defineEventHandler(async (event): Promise<Article> => {
|
||||
const slug = getRouterParam(event, 'slug')
|
||||
|
||||
if (!slug) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Missing article slug' })
|
||||
}
|
||||
|
||||
const response = await strapiFetch<StrapiList<RawArticle>>(
|
||||
`/api/articles?${ARTICLE_DETAIL_QUERY}&filters[slug][$eq]=${encodeURIComponent(slug)}&pagination[pageSize]=1`
|
||||
)
|
||||
|
||||
const article = response.data[0]
|
||||
|
||||
if (!article) {
|
||||
throw createError({ statusCode: 404, statusMessage: 'Article not found' })
|
||||
}
|
||||
|
||||
const { content, ...rest } = article
|
||||
return { ...rest, html: renderMarkdown(content), summary: summarise(content) }
|
||||
})
|
||||
@@ -0,0 +1,22 @@
|
||||
import type { ArticleSummary, Paginated } from '#shared/types/blog'
|
||||
|
||||
export default defineEventHandler(async (event): Promise<Paginated<ArticleSummary>> => {
|
||||
const query = getQuery(event)
|
||||
const page = pageParam(query.page)
|
||||
const category = typeof query.category === 'string' ? query.category : null
|
||||
|
||||
const filter = category
|
||||
? `&filters[category][slug][$eq]=${encodeURIComponent(category)}`
|
||||
: ''
|
||||
|
||||
const response = await strapiFetch<StrapiList<ArticleSummary>>(
|
||||
`/api/articles?${ARTICLE_SUMMARY_QUERY}&${pagination(page)}${filter}`
|
||||
)
|
||||
|
||||
return {
|
||||
items: response.data,
|
||||
page: response.meta.pagination.page,
|
||||
pageCount: response.meta.pagination.pageCount,
|
||||
total: response.meta.pagination.total,
|
||||
}
|
||||
})
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { Category } from '#shared/types/blog'
|
||||
|
||||
export default defineEventHandler(async (event): Promise<Category> => {
|
||||
const slug = getRouterParam(event, 'slug')
|
||||
|
||||
if (!slug) {
|
||||
throw createError({ statusCode: 400, statusMessage: 'Missing category slug' })
|
||||
}
|
||||
|
||||
const response = await strapiFetch<StrapiList<Category>>(
|
||||
`/api/categories?fields[0]=name&fields[1]=slug&filters[slug][$eq]=${encodeURIComponent(slug)}&pagination[pageSize]=1`
|
||||
)
|
||||
|
||||
const category = response.data[0]
|
||||
|
||||
if (!category) {
|
||||
throw createError({ statusCode: 404, statusMessage: 'Category not found' })
|
||||
}
|
||||
|
||||
return category
|
||||
})
|
||||
@@ -0,0 +1,9 @@
|
||||
import type { Category } from '#shared/types/blog'
|
||||
|
||||
export default defineEventHandler(async (): Promise<Category[]> => {
|
||||
const response = await strapiFetch<StrapiList<Category>>(
|
||||
'/api/categories?fields[0]=name&fields[1]=slug&sort[0]=name:asc&pagination[pageSize]=100'
|
||||
)
|
||||
|
||||
return response.data
|
||||
})
|
||||
@@ -0,0 +1,30 @@
|
||||
/** Strapi query fragments. Only the fields the pages actually render are requested. */
|
||||
|
||||
const list = (prefix: string, names: readonly string[]) =>
|
||||
names.map((name, i) => `${prefix}[${i}]=${name}`).join('&')
|
||||
|
||||
const IMAGE_FIELDS = ['url', 'alternativeText', 'width', 'height'] as const
|
||||
|
||||
export const ARTICLE_SUMMARY_QUERY = [
|
||||
list('fields', ['title', 'slug', 'publishedAt']),
|
||||
list('populate[cover][fields]', IMAGE_FIELDS),
|
||||
list('populate[category][fields]', ['name', 'slug']),
|
||||
'sort[0]=publishedAt:desc',
|
||||
].join('&')
|
||||
|
||||
export const ARTICLE_DETAIL_QUERY = [
|
||||
list('fields', ['title', 'slug', 'content', 'publishedAt']),
|
||||
list('populate[cover][fields]', IMAGE_FIELDS),
|
||||
list('populate[category][fields]', ['name', 'slug']),
|
||||
].join('&')
|
||||
|
||||
export const PAGE_SIZE = 12
|
||||
|
||||
export const pagination = (page: number, pageSize = PAGE_SIZE) =>
|
||||
`pagination[page]=${page}&pagination[pageSize]=${pageSize}`
|
||||
|
||||
/** Reads a positive integer query param, falling back to 1. */
|
||||
export const pageParam = (value: unknown): number => {
|
||||
const page = Number(value)
|
||||
return Number.isInteger(page) && page > 0 ? page : 1
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { marked } from 'marked'
|
||||
|
||||
/**
|
||||
* Calls the Strapi REST API from the server only, so the internal URL and any
|
||||
* future API token never reach the browser.
|
||||
*/
|
||||
export async function strapiFetch<T>(path: string): Promise<T> {
|
||||
const { strapiUrl, strapiToken } = useRuntimeConfig()
|
||||
|
||||
try {
|
||||
return (await $fetch(path, {
|
||||
baseURL: strapiUrl,
|
||||
headers: strapiToken ? { Authorization: `Bearer ${strapiToken}` } : undefined,
|
||||
})) as T
|
||||
} catch (error) {
|
||||
console.error(`Strapi request failed: ${path}`, error)
|
||||
throw createError({ statusCode: 502, statusMessage: 'Content service unavailable' })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders the Markdown body server-side, so the HTML is crawlable and `marked`
|
||||
* stays out of the client bundle.
|
||||
*
|
||||
* ponytail: no HTML sanitisation — article bodies come from authenticated
|
||||
* editors only. Add DOMPurify here if authoring is ever opened up further.
|
||||
*/
|
||||
export function renderMarkdown(source: string | null | undefined): string {
|
||||
return source ? (marked.parse(source, { async: false }) as string) : ''
|
||||
}
|
||||
|
||||
/**
|
||||
* There is no excerpt field: the meta description is the opening of the body,
|
||||
* stripped of Markdown syntax and clipped on a word boundary.
|
||||
*/
|
||||
export function summarise(source: string | null | undefined, maxLength = 155): string {
|
||||
const text = (source ?? '')
|
||||
.replace(/```[\s\S]*?```/g, ' ')
|
||||
.replace(/!\[[^\]]*\]\([^)]*\)/g, ' ')
|
||||
.replace(/\[([^\]]*)\]\([^)]*\)/g, '$1')
|
||||
.replace(/[#>*_`~-]/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
|
||||
if (text.length <= maxLength) return text
|
||||
|
||||
const clipped = text.slice(0, maxLength)
|
||||
return `${clipped.slice(0, clipped.lastIndexOf(' ')).trimEnd()}…`
|
||||
}
|
||||
|
||||
export interface StrapiList<T> {
|
||||
data: T[]
|
||||
meta: { pagination: { page: number; pageCount: number; total: number } }
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
/** Shape of the Strapi payloads, narrowed to what the site actually renders. */
|
||||
|
||||
export interface StrapiImage {
|
||||
url: string
|
||||
alternativeText: string | null
|
||||
width: number | null
|
||||
height: number | null
|
||||
}
|
||||
|
||||
export interface Category {
|
||||
name: string
|
||||
slug: string
|
||||
}
|
||||
|
||||
/** An article as returned by the listing endpoints. */
|
||||
export interface ArticleSummary {
|
||||
title: string
|
||||
slug: string
|
||||
publishedAt: string
|
||||
cover: StrapiImage | null
|
||||
category: Category | null
|
||||
}
|
||||
|
||||
/** A single article, with the body already rendered to HTML server-side. */
|
||||
export interface Article extends ArticleSummary {
|
||||
html: string
|
||||
/** Plain-text opening of the body, used as the meta description. */
|
||||
summary: string
|
||||
}
|
||||
|
||||
export interface Paginated<T> {
|
||||
items: T[]
|
||||
page: number
|
||||
pageCount: number
|
||||
total: number
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
/** Human-readable date for display; returns an empty string when unset. */
|
||||
export function formatDate(value: string | null | undefined): string {
|
||||
if (!value) return ''
|
||||
return new Intl.DateTimeFormat('it-IT', { dateStyle: 'long' }).format(new Date(value))
|
||||
}
|
||||
|
||||
/** Machine-readable date for the `datetime` attribute and structured data. */
|
||||
export function isoDate(value: string | null | undefined): string {
|
||||
return value ? new Date(value).toISOString() : ''
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
// https://nuxt.com/docs/guide/concepts/typescript
|
||||
"files": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./.nuxt/tsconfig.app.json"
|
||||
},
|
||||
{
|
||||
"path": "./.nuxt/tsconfig.server.json"
|
||||
},
|
||||
{
|
||||
"path": "./.nuxt/tsconfig.shared.json"
|
||||
},
|
||||
{
|
||||
"path": "./.nuxt/tsconfig.node.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user