1 Commits
18 changed files with 71 additions and 330 deletions
@@ -11,41 +11,31 @@
"draftAndPublish": true, "draftAndPublish": true,
"populateCreatorFields": true "populateCreatorFields": true
}, },
"pluginOptions": {
"i18n": {
"localized": true
}
},
"attributes": { "attributes": {
"title": { "title": {
"type": "string", "type": "string",
"required": true, "required": true,
"maxLength": 160, "maxLength": 160
"pluginOptions": { "i18n": { "localized": true } }
}, },
"slug": { "slug": {
"type": "uid", "type": "uid",
"targetField": "title", "targetField": "title",
"required": true, "required": true
"pluginOptions": { "i18n": { "localized": false } }
}, },
"content": { "content": {
"type": "richtext", "type": "richtext",
"required": true, "required": true
"pluginOptions": { "i18n": { "localized": true } }
}, },
"cover": { "cover": {
"type": "media", "type": "media",
"multiple": false, "multiple": false,
"allowedTypes": ["images"], "allowedTypes": ["images"]
"pluginOptions": { "i18n": { "localized": false } }
}, },
"category": { "category": {
"type": "relation", "type": "relation",
"relation": "manyToOne", "relation": "manyToOne",
"target": "api::category.category", "target": "api::category.category",
"inversedBy": "articles", "inversedBy": "articles"
"pluginOptions": { "i18n": { "localized": false } }
} }
} }
} }
@@ -10,30 +10,22 @@
"options": { "options": {
"draftAndPublish": false "draftAndPublish": false
}, },
"pluginOptions": {
"i18n": {
"localized": true
}
},
"attributes": { "attributes": {
"name": { "name": {
"type": "string", "type": "string",
"required": true, "required": true,
"unique": true, "unique": true
"pluginOptions": { "i18n": { "localized": true } }
}, },
"slug": { "slug": {
"type": "uid", "type": "uid",
"targetField": "name", "targetField": "name",
"required": true, "required": true
"pluginOptions": { "i18n": { "localized": false } }
}, },
"articles": { "articles": {
"type": "relation", "type": "relation",
"relation": "oneToMany", "relation": "oneToMany",
"target": "api::article.article", "target": "api::article.article",
"mappedBy": "category", "mappedBy": "category"
"pluginOptions": { "i18n": { "localized": false } }
} }
} }
} }
-14
View File
@@ -45,25 +45,11 @@ async function disablePublicSignUp(strapi: Core.Strapi) {
} }
} }
/**
* The site content is authored in Italian and English. Strapi ships with only
* the default `it` locale, so the `en` locale is created here if missing.
*/
async function ensureEnglishLocale(strapi: Core.Strapi) {
const locales = strapi.plugin('i18n').service('locales');
const existing = await locales.find({ where: { code: 'en' } });
if (existing.length === 0) {
await locales.create({ code: 'en', name: 'English (en)', isDefault: false });
}
}
export default { export default {
register() {}, register() {},
async bootstrap({ strapi }: { strapi: Core.Strapi }) { async bootstrap({ strapi }: { strapi: Core.Strapi }) {
await grantPublicReadAccess(strapi); await grantPublicReadAccess(strapi);
await disablePublicSignUp(strapi); await disablePublicSignUp(strapi);
await ensureEnglishLocale(strapi);
}, },
}; };
-5
View File
@@ -1,8 +1,3 @@
<script setup lang="ts">
const { locale } = useLocale()
useHead({ htmlAttrs: { lang: locale } })
</script>
<template> <template>
<div> <div>
<NuxtRouteAnnouncer /> <NuxtRouteAnnouncer />
+1 -2
View File
@@ -5,7 +5,6 @@ const props = defineProps<{ article: ArticleSummary }>()
const mediaUrl = useMediaUrl() const mediaUrl = useMediaUrl()
const cover = computed(() => mediaUrl(props.article.cover)) const cover = computed(() => mediaUrl(props.article.cover))
const { locale } = useLocale()
</script> </script>
<template> <template>
@@ -37,7 +36,7 @@ const { locale } = useLocale()
<p class="kicker date"> <p class="kicker date">
<time :datetime="isoDate(article.publishedAt)"> <time :datetime="isoDate(article.publishedAt)">
{{ formatDate(article.publishedAt, locale) }} {{ formatDate(article.publishedAt) }}
</time> </time>
</p> </p>
</article> </article>
-21
View File
@@ -1,21 +0,0 @@
import { STRINGS, type Strings } from '../i18n/strings'
export type Locale = 'it' | 'en'
/**
* Site language, persisted in a cookie so SSR renders the right language on
* the first request. `t(key)` reads the current locale's UI string.
*/
export function useLocale() {
const locale = useCookie<Locale>('lang', { default: () => 'it', sameSite: 'lax' })
function setLocale(value: Locale) {
locale.value = value
}
function t<K extends keyof Strings>(key: K): Strings[K] {
return STRINGS[locale.value][key]
}
return { locale, setLocale, t }
}
-104
View File
@@ -1,104 +0,0 @@
/** Static UI strings, keyed once and translated for each supported locale. */
export interface Strings {
skipToContent: string
tagline: string
navHome: string
navAllArticles: string
navCorralito: string
navPrimary: string
footerNavigate: string
footerFollow: string
footerContact: string
footerRights: string
switchLanguage: string
loadError: string
noArticlesYet: string
latestFallback: string
readArticle: string
seeAllArticles: string
archiveKicker: string
articlesTitle: string
allArticlesDescription: string
pagePrefix: string
loading: string
pagination: string
prevPage: string
nextPage: string
pageOf: (page: number, pageCount: number) => string
breadcrumbArticles: string
byAuthor: (name: string) => string
backToArticles: string
categoryKicker: string
categoryDescription: (name: string) => string
noArticlesInCategory: string
}
export const STRINGS: Record<'it' | 'en', Strings> = {
it: {
skipToContent: 'Vai al contenuto',
tagline: 'Articoli, guide e analisi.',
navHome: 'Home',
navAllArticles: 'Tutti gli articoli',
navCorralito: 'Come difendersi dal corralito',
navPrimary: 'Navigazione principale',
footerNavigate: 'Naviga',
footerFollow: 'Seguici',
footerContact: 'Contatti:',
footerRights: 'Tutti i diritti riservati.',
switchLanguage: 'English',
loadError: 'Non è stato possibile caricare gli articoli. Riprova più tardi.',
noArticlesYet: 'Non è stato ancora pubblicato nulla.',
latestFallback: 'Ultimo',
readArticle: "Leggi l'articolo",
seeAllArticles: 'Vedi tutti gli articoli',
archiveKicker: "L'archivio",
articlesTitle: 'Articoli',
allArticlesDescription: 'Tutti gli articoli pubblicati sul blog.',
pagePrefix: 'pagina',
loading: 'Caricamento…',
pagination: 'Paginazione',
prevPage: 'Pagina precedente',
nextPage: 'Pagina successiva',
pageOf: (page: number, pageCount: number) => `Pagina ${page} di ${pageCount}`,
breadcrumbArticles: 'Articoli',
byAuthor: (name: string) => `Di ${name}`,
backToArticles: 'Torna a tutti gli articoli',
categoryKicker: 'Categoria',
categoryDescription: (name: string) => `Tutti gli articoli nella categoria ${name}.`,
noArticlesInCategory: 'Non ci sono articoli in questa categoria.',
},
en: {
skipToContent: 'Skip to content',
tagline: 'Articles, guides and analysis.',
navHome: 'Home',
navAllArticles: 'All articles',
navCorralito: 'Come difendersi dal corralito',
navPrimary: 'Main navigation',
footerNavigate: 'Navigate',
footerFollow: 'Follow',
footerContact: 'Contact:',
footerRights: 'All rights reserved.',
switchLanguage: 'Italiano',
loadError: 'The articles could not be loaded. Please try again later.',
noArticlesYet: 'Nothing has been published yet.',
latestFallback: 'Latest',
readArticle: 'Read the article',
seeAllArticles: 'See all articles',
archiveKicker: 'The archive',
articlesTitle: 'Articles',
allArticlesDescription: 'All articles published on the blog.',
pagePrefix: 'page',
loading: 'Loading…',
pagination: 'Pagination',
prevPage: 'Previous page',
nextPage: 'Next page',
pageOf: (page: number, pageCount: number) => `Page ${page} of ${pageCount}`,
breadcrumbArticles: 'Articles',
byAuthor: (name: string) => `By ${name}`,
backToArticles: 'Back to all articles',
categoryKicker: 'Category',
categoryDescription: (name: string) => `All articles in the ${name} category.`,
noArticlesInCategory: 'There are no articles in this category.',
},
}
+16 -53
View File
@@ -1,22 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
const { locale, setLocale, t } = useLocale()
const otherLocale = computed(() => (locale.value === 'it' ? 'en' : 'it'))
/**
* Switches the site language. On an article/category page, jumps to the
* translated counterpart's slug if one exists; elsewhere reloads the current
* route so it refetches its content in the new language.
*/
function switchLanguage() {
setLocale(otherLocale.value)
reloadNuxtApp({ path: useRoute().fullPath })
}
</script> </script>
<template> <template>
<div class="page"> <div class="page">
<a class="skip-link" href="#main">{{ t('skipToContent') }}</a> <a class="skip-link" href="#main">Vai al contenuto</a>
<header class="site-header"> <header class="site-header">
<div class="container"> <div class="container">
@@ -25,19 +12,16 @@ function switchLanguage() {
<img src="/logo.png" :alt="SITE_NAME" width="400" height="400"> <img src="/logo.png" :alt="SITE_NAME" width="400" height="400">
</NuxtLink> </NuxtLink>
</p> </p>
<p class="tagline kicker">{{ t('tagline') }}</p> <p class="tagline kicker">Articoli, guide e analisi.</p>
</div> </div>
<nav class="site-nav" :aria-label="t('navPrimary')"> <nav class="site-nav" aria-label="Navigazione principale">
<div class="container nav-inner"> <div class="container nav-inner">
<ul> <ul>
<li><NuxtLink to="/">{{ t('navHome') }}</NuxtLink></li> <li><NuxtLink to="/">Home</NuxtLink></li>
<li><NuxtLink to="/blog">{{ t('navAllArticles') }}</NuxtLink></li> <li><NuxtLink to="/blog">Tutti gli articoli</NuxtLink></li>
<li><NuxtLink to="/come-difendersi-dal-corralito">{{ t('navCorralito') }}</NuxtLink></li> <li><NuxtLink to="/come-difendersi-dal-corralito">Come difendersi dal corralito</NuxtLink></li>
</ul> </ul>
<button type="button" class="lang-switch" @click="switchLanguage">
{{ t('switchLanguage') }}
</button>
</div> </div>
</nav> </nav>
</header> </header>
@@ -50,24 +34,24 @@ function switchLanguage() {
<div class="container footer-inner"> <div class="container footer-inner">
<div class="footer-block"> <div class="footer-block">
<img class="footer-logo" src="/logo.png" :alt="SITE_NAME" width="400" height="400"> <img class="footer-logo" src="/logo.png" :alt="SITE_NAME" width="400" height="400">
<p class="muted">{{ t('tagline') }}</p> <p class="muted">Articoli, guide e analisi.</p>
<address class="contact"> <address class="contact">
{{ t('footerContact') }} Contatti:
<a :href="`mailto:${SITE_EMAIL}`">{{ SITE_EMAIL }}</a> <a :href="`mailto:${SITE_EMAIL}`">{{ SITE_EMAIL }}</a>
</address> </address>
</div> </div>
<nav class="footer-block" :aria-label="t('footerNavigate')"> <nav class="footer-block" aria-label="Naviga">
<p class="kicker">{{ t('footerNavigate') }}</p> <p class="kicker">Naviga</p>
<ul> <ul>
<li><NuxtLink to="/">{{ t('navHome') }}</NuxtLink></li> <li><NuxtLink to="/">Home</NuxtLink></li>
<li><NuxtLink to="/blog">{{ t('navAllArticles') }}</NuxtLink></li> <li><NuxtLink to="/blog">Tutti gli articoli</NuxtLink></li>
<li><NuxtLink to="/come-difendersi-dal-corralito">{{ t('navCorralito') }}</NuxtLink></li> <li><NuxtLink to="/come-difendersi-dal-corralito">Come difendersi dal corralito</NuxtLink></li>
</ul> </ul>
</nav> </nav>
<nav class="footer-block" :aria-label="t('footerFollow')"> <nav class="footer-block" aria-label="Seguici">
<p class="kicker">{{ t('footerFollow') }}</p> <p class="kicker">Seguici</p>
<ul> <ul>
<li v-for="social in SOCIAL_LINKS" :key="social.name"> <li v-for="social in SOCIAL_LINKS" :key="social.name">
<a class="social" :href="social.url" target="_blank" rel="noopener me"> <a class="social" :href="social.url" target="_blank" rel="noopener me">
@@ -80,7 +64,7 @@ function switchLanguage() {
</div> </div>
<p class="container copyright kicker"> <p class="container copyright kicker">
&copy; {{ new Date().getFullYear() }} {{ SITE_NAME }}. {{ t('footerRights') }} &copy; {{ new Date().getFullYear() }} {{ SITE_NAME }}. Tutti i diritti riservati.
</p> </p>
</footer> </footer>
</div> </div>
@@ -132,10 +116,7 @@ function switchLanguage() {
.nav-inner { .nav-inner {
display: flex; display: flex;
flex-wrap: wrap;
justify-content: center; justify-content: center;
align-items: center;
gap: 0.75rem 1.5rem;
padding-block: 0.6rem; padding-block: 0.6rem;
} }
@@ -153,24 +134,6 @@ function switchLanguage() {
text-transform: uppercase; text-transform: uppercase;
} }
.lang-switch {
font: inherit;
font-size: 0.75rem;
font-weight: 600;
letter-spacing: 0.14em;
text-transform: uppercase;
padding: 0.35rem 0.75rem;
color: var(--color-body);
background: none;
border: 1px solid var(--color-border);
cursor: pointer;
}
.lang-switch:hover {
color: var(--color-ink);
border-color: var(--color-ink);
}
@media (min-width: 48rem) { @media (min-width: 48rem) {
.site-nav { .site-nav {
margin-top: 2rem; margin-top: 2rem;
+5 -11
View File
@@ -4,12 +4,7 @@ import type { Article } from '#shared/types/blog'
const route = useRoute() const route = useRoute()
const slug = route.params.slug as string const slug = route.params.slug as string
const { locale, t } = useLocale() const { data: article } = await useFetch<Article>(`/api/articles/${slug}`)
const { data: article } = await useFetch<Article>(`/api/articles/${slug}`, {
key: () => `article-${slug}-${locale.value}`,
query: { lang: locale },
})
if (!article.value) { if (!article.value) {
throw createError({ statusCode: 404, statusMessage: 'Article not found', fatal: true }) throw createError({ statusCode: 404, statusMessage: 'Article not found', fatal: true })
@@ -43,7 +38,6 @@ useHead({
headline: article.value.title, headline: article.value.title,
description: article.value.summary, description: article.value.summary,
datePublished: isoDate(article.value.publishedAt), datePublished: isoDate(article.value.publishedAt),
inLanguage: locale.value,
author: article.value.author author: article.value.author
? { '@type': 'Person', name: article.value.author } ? { '@type': 'Person', name: article.value.author }
: undefined, : undefined,
@@ -59,7 +53,7 @@ useHead({
<article v-if="article"> <article v-if="article">
<header class="article-header"> <header class="article-header">
<nav class="kicker" aria-label="Percorso di navigazione"> <nav class="kicker" aria-label="Percorso di navigazione">
<NuxtLink to="/blog">{{ t('breadcrumbArticles') }}</NuxtLink> <NuxtLink to="/blog">Articoli</NuxtLink>
<template v-if="article.category"> <template v-if="article.category">
<span aria-hidden="true"> / </span> <span aria-hidden="true"> / </span>
<NuxtLink :to="`/category/${article.category.slug}`"> <NuxtLink :to="`/category/${article.category.slug}`">
@@ -72,11 +66,11 @@ useHead({
<p class="kicker date"> <p class="kicker date">
<time :datetime="isoDate(article.publishedAt)"> <time :datetime="isoDate(article.publishedAt)">
{{ formatDateTime(article.publishedAt, locale) }} {{ formatDateTime(article.publishedAt) }}
</time> </time>
<template v-if="article.author"> <template v-if="article.author">
<span aria-hidden="true"> · </span> <span aria-hidden="true"> · </span>
<span>{{ t('byAuthor')(article.author) }}</span> <span>Di {{ article.author }}</span>
</template> </template>
</p> </p>
</header> </header>
@@ -94,7 +88,7 @@ useHead({
<div class="prose" v-html="article.html" /> <div class="prose" v-html="article.html" />
<p class="back"> <p class="back">
<NuxtLink to="/blog">{{ t('backToArticles') }}</NuxtLink> <NuxtLink to="/blog">Torna a tutti gli articoli</NuxtLink>
</p> </p>
</article> </article>
</template> </template>
+12 -15
View File
@@ -8,11 +8,8 @@ const page = computed(() => {
return Number.isInteger(value) && value > 0 ? value : 1 return Number.isInteger(value) && value > 0 ? value : 1
}) })
const { locale, t } = useLocale()
const { data, error, status } = await useFetch<Paginated<ArticleSummary>>('/api/articles', { const { data, error, status } = await useFetch<Paginated<ArticleSummary>>('/api/articles', {
key: () => `blog-articles-${locale.value}-${page.value}`, query: { page },
query: { page, lang: locale },
}) })
const { public: config } = useRuntimeConfig() const { public: config } = useRuntimeConfig()
@@ -21,8 +18,8 @@ const canonical = computed(
) )
useSeoMeta({ useSeoMeta({
title: () => (page.value > 1 ? `${t('articlesTitle')}${t('pagePrefix')} ${page.value}` : t('articlesTitle')), title: () => (page.value > 1 ? `Articoli — pagina ${page.value}` : 'Articoli'),
description: () => t('allArticlesDescription'), description: 'Tutti gli articoli pubblicati sul blog.',
ogType: 'website', ogType: 'website',
}) })
@@ -32,15 +29,15 @@ useHead({ link: [{ rel: 'canonical', href: canonical }] })
<template> <template>
<div> <div>
<header class="page-header"> <header class="page-header">
<p class="kicker">{{ t('archiveKicker') }}</p> <p class="kicker">L'archivio</p>
<h1>{{ t('articlesTitle') }}</h1> <h1>Articoli</h1>
</header> </header>
<p v-if="error">{{ t('loadError') }}</p> <p v-if="error">Non è stato possibile caricare gli articoli. Riprova più tardi.</p>
<p v-else-if="status === 'pending'">{{ t('loading') }}</p> <p v-else-if="status === 'pending'">Caricamento…</p>
<p v-else-if="!data?.items.length">{{ t('noArticlesYet') }}</p> <p v-else-if="!data?.items.length">Non è stato ancora pubblicato nulla.</p>
<template v-else> <template v-else>
<ul class="card-grid"> <ul class="card-grid">
@@ -49,19 +46,19 @@ useHead({ link: [{ rel: 'canonical', href: canonical }] })
</li> </li>
</ul> </ul>
<nav v-if="data.pageCount > 1" class="pagination" :aria-label="t('pagination')"> <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"> <NuxtLink v-if="data.page > 1" :to="{ query: { page: data.page - 1 } }" rel="prev">
{{ t('prevPage') }} Pagina precedente
</NuxtLink> </NuxtLink>
<span class="muted"> <span class="muted">
{{ t('pageOf')(data.page, data.pageCount) }} Pagina {{ data.page }} di {{ data.pageCount }}
</span> </span>
<NuxtLink <NuxtLink
v-if="data.page < data.pageCount" v-if="data.page < data.pageCount"
:to="{ query: { page: data.page + 1 } }" :to="{ query: { page: data.page + 1 } }"
rel="next" rel="next"
> >
{{ t('nextPage') }} Pagina successiva
</NuxtLink> </NuxtLink>
</nav> </nav>
</template> </template>
+11 -17
View File
@@ -9,20 +9,14 @@ const page = computed(() => {
return Number.isInteger(value) && value > 0 ? value : 1 return Number.isInteger(value) && value > 0 ? value : 1
}) })
const { locale, t } = useLocale() const { data: category } = await useFetch<Category>(`/api/categories/${slug}`)
const { data: category } = await useFetch<Category>(`/api/categories/${slug}`, {
key: () => `category-${slug}-${locale.value}`,
query: { lang: locale },
})
if (!category.value) { if (!category.value) {
throw createError({ statusCode: 404, statusMessage: 'Category not found', fatal: true }) throw createError({ statusCode: 404, statusMessage: 'Category not found', fatal: true })
} }
const { data, error } = await useFetch<Paginated<ArticleSummary>>('/api/articles', { const { data, error } = await useFetch<Paginated<ArticleSummary>>('/api/articles', {
key: () => `category-articles-${slug}-${locale.value}-${page.value}`, query: { page, category: slug },
query: { page, category: slug, lang: locale },
}) })
const { public: config } = useRuntimeConfig() const { public: config } = useRuntimeConfig()
@@ -31,8 +25,8 @@ const canonical = computed(
) )
useSeoMeta({ useSeoMeta({
title: () => category.value?.name ?? '', title: category.value.name,
description: () => t('categoryDescription')(category.value?.name ?? ''), description: () => `Tutti gli articoli nella categoria ${category.value?.name ?? ''}.`,
ogType: 'website', ogType: 'website',
}) })
@@ -42,13 +36,13 @@ useHead({ link: [{ rel: 'canonical', href: canonical }] })
<template> <template>
<div> <div>
<header class="page-header"> <header class="page-header">
<p class="kicker">{{ t('categoryKicker') }}</p> <p class="kicker">Categoria</p>
<h1>{{ category?.name }}</h1> <h1>{{ category?.name }}</h1>
</header> </header>
<p v-if="error">{{ t('loadError') }}</p> <p v-if="error">Non è stato possibile caricare gli articoli. Riprova più tardi.</p>
<p v-else-if="!data?.items.length">{{ t('noArticlesInCategory') }}</p> <p v-else-if="!data?.items.length">Non ci sono articoli in questa categoria.</p>
<template v-else> <template v-else>
<ul class="card-grid"> <ul class="card-grid">
@@ -57,19 +51,19 @@ useHead({ link: [{ rel: 'canonical', href: canonical }] })
</li> </li>
</ul> </ul>
<nav v-if="data.pageCount > 1" class="pagination" :aria-label="t('pagination')"> <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"> <NuxtLink v-if="data.page > 1" :to="{ query: { page: data.page - 1 } }" rel="prev">
{{ t('prevPage') }} Pagina precedente
</NuxtLink> </NuxtLink>
<span class="muted"> <span class="muted">
{{ t('pageOf')(data.page, data.pageCount) }} Pagina {{ data.page }} di {{ data.pageCount }}
</span> </span>
<NuxtLink <NuxtLink
v-if="data.page < data.pageCount" v-if="data.page < data.pageCount"
:to="{ query: { page: data.page + 1 } }" :to="{ query: { page: data.page + 1 } }"
rel="next" rel="next"
> >
{{ t('nextPage') }} Pagina successiva
</NuxtLink> </NuxtLink>
</nav> </nav>
</template> </template>
+9 -38
View File
@@ -1,11 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
import type { ArticleSummary, Paginated } from '#shared/types/blog' import type { ArticleSummary, Paginated } from '#shared/types/blog'
const { locale, t } = useLocale()
const { data, error } = await useFetch<Paginated<ArticleSummary>>('/api/articles', { const { data, error } = await useFetch<Paginated<ArticleSummary>>('/api/articles', {
key: () => `home-articles-${locale.value}`, key: 'home-articles',
query: { page: 1, lang: locale }, query: { page: 1 },
}) })
const featured = computed(() => data.value?.items[0] ?? null) const featured = computed(() => data.value?.items[0] ?? null)
@@ -22,9 +20,9 @@ useHead({ titleTemplate: '%s', link: [{ rel: 'canonical', href: canonical }] })
useSeoMeta({ useSeoMeta({
title: SITE_NAME, title: SITE_NAME,
description: () => t('tagline'), description: 'Articoli, guide e analisi.',
ogTitle: SITE_NAME, ogTitle: SITE_NAME,
ogDescription: () => t('tagline'), ogDescription: 'Articoli, guide e analisi.',
ogType: 'website', ogType: 'website',
ogUrl: canonical, ogUrl: canonical,
ogImage: `${config.siteUrl}/logo.png`, ogImage: `${config.siteUrl}/logo.png`,
@@ -69,9 +67,9 @@ useSeoMeta({
</div> </div>
</section> </section>
<p v-if="error">{{ t('loadError') }}</p> <p v-if="error">Non è stato possibile caricare gli articoli. Riprova più tardi.</p>
<p v-else-if="!featured">{{ t('noArticlesYet') }}</p> <p v-else-if="!featured">Non è stato ancora pubblicato nulla.</p>
<template v-else> <template v-else>
<article class="featured" :class="{ 'has-cover': featuredCover }"> <article class="featured" :class="{ 'has-cover': featuredCover }">
@@ -92,27 +90,23 @@ useSeoMeta({
<div class="featured-text"> <div class="featured-text">
<p class="kicker"> <p class="kicker">
{{ featured.category ? featured.category.name : t('latestFallback') }} {{ featured.category ? featured.category.name : 'Ultimo' }}
</p> </p>
<h2> <h2>
<NuxtLink :to="`/blog/${featured.slug}`">{{ featured.title }}</NuxtLink> <NuxtLink :to="`/blog/${featured.slug}`">{{ featured.title }}</NuxtLink>
</h2> </h2>
<p class="kicker date"> <p class="kicker date">
<time :datetime="isoDate(featured.publishedAt)"> <time :datetime="isoDate(featured.publishedAt)">
{{ formatDate(featured.publishedAt, locale) }} {{ formatDate(featured.publishedAt) }}
</time> </time>
</p> </p>
<p class="read-more"> <p class="read-more">
<NuxtLink :to="`/blog/${featured.slug}`"> <NuxtLink :to="`/blog/${featured.slug}`">
{{ t('readArticle') }} Leggi l'ultimo articolo
</NuxtLink> </NuxtLink>
</p> </p>
</div> </div>
</article> </article>
<p class="more">
<NuxtLink to="/blog">{{ t('seeAllArticles') }}</NuxtLink>
</p>
</template> </template>
</div> </div>
</template> </template>
@@ -215,27 +209,4 @@ useSeoMeta({
text-decoration: none; text-decoration: none;
border-bottom: 1px solid var(--color-ink); border-bottom: 1px solid var(--color-ink);
} }
.more {
margin-top: var(--gap-section);
text-align: center;
font-size: 0.75rem;
font-weight: 600;
letter-spacing: 0.14em;
text-transform: uppercase;
}
.more a {
padding: 0.85rem 2.5rem;
text-decoration: none;
border: 1px solid var(--color-ink);
color: var(--color-ink);
display: inline-block;
transition: background 0.2s ease, color 0.2s ease;
}
.more a:hover {
background: var(--color-ink);
color: var(--color-bg);
}
</style> </style>
+1 -2
View File
@@ -18,14 +18,13 @@ function byline(user: AdminUser | null | undefined): string | null {
export default defineEventHandler(async (event): Promise<Article> => { export default defineEventHandler(async (event): Promise<Article> => {
const slug = getRouterParam(event, 'slug') const slug = getRouterParam(event, 'slug')
const locale = localeParam(getQuery(event).lang)
if (!slug) { if (!slug) {
throw createError({ statusCode: 400, statusMessage: 'Missing article slug' }) throw createError({ statusCode: 400, statusMessage: 'Missing article slug' })
} }
const response = await strapiFetch<StrapiList<RawArticle>>( const response = await strapiFetch<StrapiList<RawArticle>>(
`/api/articles?${ARTICLE_DETAIL_QUERY}&locale=${locale}&filters[slug][$eq]=${encodeURIComponent(slug)}&pagination[pageSize]=1` `/api/articles?${ARTICLE_DETAIL_QUERY}&filters[slug][$eq]=${encodeURIComponent(slug)}&pagination[pageSize]=1`
) )
const article = response.data[0] const article = response.data[0]
+1 -2
View File
@@ -3,7 +3,6 @@ import type { ArticleSummary, Paginated } from '#shared/types/blog'
export default defineEventHandler(async (event): Promise<Paginated<ArticleSummary>> => { export default defineEventHandler(async (event): Promise<Paginated<ArticleSummary>> => {
const query = getQuery(event) const query = getQuery(event)
const page = pageParam(query.page) const page = pageParam(query.page)
const locale = localeParam(query.lang)
const category = typeof query.category === 'string' ? query.category : null const category = typeof query.category === 'string' ? query.category : null
const filter = category const filter = category
@@ -11,7 +10,7 @@ export default defineEventHandler(async (event): Promise<Paginated<ArticleSummar
: '' : ''
const response = await strapiFetch<StrapiList<ArticleSummary>>( const response = await strapiFetch<StrapiList<ArticleSummary>>(
`/api/articles?${ARTICLE_SUMMARY_QUERY}&locale=${locale}&${pagination(page)}${filter}` `/api/articles?${ARTICLE_SUMMARY_QUERY}&${pagination(page)}${filter}`
) )
return { return {
+1 -2
View File
@@ -2,14 +2,13 @@ import type { Category } from '#shared/types/blog'
export default defineEventHandler(async (event): Promise<Category> => { export default defineEventHandler(async (event): Promise<Category> => {
const slug = getRouterParam(event, 'slug') const slug = getRouterParam(event, 'slug')
const locale = localeParam(getQuery(event).lang)
if (!slug) { if (!slug) {
throw createError({ statusCode: 400, statusMessage: 'Missing category slug' }) throw createError({ statusCode: 400, statusMessage: 'Missing category slug' })
} }
const response = await strapiFetch<StrapiList<Category>>( const response = await strapiFetch<StrapiList<Category>>(
`/api/categories?fields[0]=name&fields[1]=slug&locale=${locale}&filters[slug][$eq]=${encodeURIComponent(slug)}&pagination[pageSize]=1` `/api/categories?fields[0]=name&fields[1]=slug&filters[slug][$eq]=${encodeURIComponent(slug)}&pagination[pageSize]=1`
) )
const category = response.data[0] const category = response.data[0]
+2 -4
View File
@@ -1,10 +1,8 @@
import type { Category } from '#shared/types/blog' import type { Category } from '#shared/types/blog'
export default defineEventHandler(async (event): Promise<Category[]> => { export default defineEventHandler(async (): Promise<Category[]> => {
const locale = localeParam(getQuery(event).lang)
const response = await strapiFetch<StrapiList<Category>>( const response = await strapiFetch<StrapiList<Category>>(
`/api/categories?fields[0]=name&fields[1]=slug&locale=${locale}&sort[0]=name:asc&pagination[pageSize]=100` '/api/categories?fields[0]=name&fields[1]=slug&sort[0]=name:asc&pagination[pageSize]=100'
) )
return response.data return response.data
-5
View File
@@ -31,8 +31,3 @@ export const pageParam = (value: unknown): number => {
const page = Number(value) const page = Number(value)
return Number.isInteger(page) && page > 0 ? page : 1 return Number.isInteger(page) && page > 0 ? page : 1
} }
export type Locale = 'it' | 'en'
/** Reads the `lang` query param, falling back to the site default `it`. */
export const localeParam = (value: unknown): Locale => (value === 'en' ? 'en' : 'it')
+4 -9
View File
@@ -1,18 +1,13 @@
const INTL_LOCALE = { it: 'it-IT', en: 'en-GB' } as const
/** Human-readable date for display; returns an empty string when unset. */ /** Human-readable date for display; returns an empty string when unset. */
export function formatDate(value: string | null | undefined, locale: 'it' | 'en' = 'it'): string { export function formatDate(value: string | null | undefined): string {
if (!value) return '' if (!value) return ''
return new Intl.DateTimeFormat(INTL_LOCALE[locale], { dateStyle: 'long' }).format(new Date(value)) return new Intl.DateTimeFormat('it-IT', { dateStyle: 'long' }).format(new Date(value))
} }
/** Date and time of publication, shown in the article byline. */ /** Date and time of publication, shown in the article byline. */
export function formatDateTime( export function formatDateTime(value: string | null | undefined): string {
value: string | null | undefined,
locale: 'it' | 'en' = 'it'
): string {
if (!value) return '' if (!value) return ''
return new Intl.DateTimeFormat(INTL_LOCALE[locale], { dateStyle: 'long', timeStyle: 'short' }) return new Intl.DateTimeFormat('it-IT', { dateStyle: 'long', timeStyle: 'short' })
.format(new Date(value)) .format(new Date(value))
} }