Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
754b84e5f6 |
@@ -11,31 +11,41 @@
|
|||||||
"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,22 +10,30 @@
|
|||||||
"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 } }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -45,11 +45,25 @@ 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);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
const { locale } = useLocale()
|
||||||
|
useHead({ htmlAttrs: { lang: locale } })
|
||||||
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<NuxtRouteAnnouncer />
|
<NuxtRouteAnnouncer />
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ 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>
|
||||||
@@ -36,7 +37,7 @@ const cover = computed(() => mediaUrl(props.article.cover))
|
|||||||
|
|
||||||
<p class="kicker date">
|
<p class="kicker date">
|
||||||
<time :datetime="isoDate(article.publishedAt)">
|
<time :datetime="isoDate(article.publishedAt)">
|
||||||
{{ formatDate(article.publishedAt) }}
|
{{ formatDate(article.publishedAt, locale) }}
|
||||||
</time>
|
</time>
|
||||||
</p>
|
</p>
|
||||||
</article>
|
</article>
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
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 }
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
/** 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.',
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -1,9 +1,22 @@
|
|||||||
<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">Vai al contenuto</a>
|
<a class="skip-link" href="#main">{{ t('skipToContent') }}</a>
|
||||||
|
|
||||||
<header class="site-header">
|
<header class="site-header">
|
||||||
<div class="container">
|
<div class="container">
|
||||||
@@ -12,16 +25,19 @@
|
|||||||
<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">Articoli, guide e analisi.</p>
|
<p class="tagline kicker">{{ t('tagline') }}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav class="site-nav" aria-label="Navigazione principale">
|
<nav class="site-nav" :aria-label="t('navPrimary')">
|
||||||
<div class="container nav-inner">
|
<div class="container nav-inner">
|
||||||
<ul>
|
<ul>
|
||||||
<li><NuxtLink to="/">Home</NuxtLink></li>
|
<li><NuxtLink to="/">{{ t('navHome') }}</NuxtLink></li>
|
||||||
<li><NuxtLink to="/blog">Tutti gli articoli</NuxtLink></li>
|
<li><NuxtLink to="/blog">{{ t('navAllArticles') }}</NuxtLink></li>
|
||||||
<li><NuxtLink to="/come-difendersi-dal-corralito">Come difendersi dal corralito</NuxtLink></li>
|
<li><NuxtLink to="/come-difendersi-dal-corralito">{{ t('navCorralito') }}</NuxtLink></li>
|
||||||
</ul>
|
</ul>
|
||||||
|
<button type="button" class="lang-switch" @click="switchLanguage">
|
||||||
|
{{ t('switchLanguage') }}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
@@ -34,24 +50,24 @@
|
|||||||
<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">Articoli, guide e analisi.</p>
|
<p class="muted">{{ t('tagline') }}</p>
|
||||||
<address class="contact">
|
<address class="contact">
|
||||||
Contatti:
|
{{ t('footerContact') }}
|
||||||
<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="Naviga">
|
<nav class="footer-block" :aria-label="t('footerNavigate')">
|
||||||
<p class="kicker">Naviga</p>
|
<p class="kicker">{{ t('footerNavigate') }}</p>
|
||||||
<ul>
|
<ul>
|
||||||
<li><NuxtLink to="/">Home</NuxtLink></li>
|
<li><NuxtLink to="/">{{ t('navHome') }}</NuxtLink></li>
|
||||||
<li><NuxtLink to="/blog">Tutti gli articoli</NuxtLink></li>
|
<li><NuxtLink to="/blog">{{ t('navAllArticles') }}</NuxtLink></li>
|
||||||
<li><NuxtLink to="/come-difendersi-dal-corralito">Come difendersi dal corralito</NuxtLink></li>
|
<li><NuxtLink to="/come-difendersi-dal-corralito">{{ t('navCorralito') }}</NuxtLink></li>
|
||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<nav class="footer-block" aria-label="Seguici">
|
<nav class="footer-block" :aria-label="t('footerFollow')">
|
||||||
<p class="kicker">Seguici</p>
|
<p class="kicker">{{ t('footerFollow') }}</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">
|
||||||
@@ -64,7 +80,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p class="container copyright kicker">
|
<p class="container copyright kicker">
|
||||||
© {{ new Date().getFullYear() }} {{ SITE_NAME }}. Tutti i diritti riservati.
|
© {{ new Date().getFullYear() }} {{ SITE_NAME }}. {{ t('footerRights') }}
|
||||||
</p>
|
</p>
|
||||||
</footer>
|
</footer>
|
||||||
</div>
|
</div>
|
||||||
@@ -116,7 +132,10 @@
|
|||||||
|
|
||||||
.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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -134,6 +153,24 @@
|
|||||||
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;
|
||||||
|
|||||||
@@ -4,7 +4,12 @@ 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 { data: article } = await useFetch<Article>(`/api/articles/${slug}`)
|
const { locale, t } = useLocale()
|
||||||
|
|
||||||
|
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 })
|
||||||
@@ -38,6 +43,7 @@ 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,
|
||||||
@@ -53,7 +59,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">Articoli</NuxtLink>
|
<NuxtLink to="/blog">{{ t('breadcrumbArticles') }}</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}`">
|
||||||
@@ -66,11 +72,11 @@ useHead({
|
|||||||
|
|
||||||
<p class="kicker date">
|
<p class="kicker date">
|
||||||
<time :datetime="isoDate(article.publishedAt)">
|
<time :datetime="isoDate(article.publishedAt)">
|
||||||
{{ formatDateTime(article.publishedAt) }}
|
{{ formatDateTime(article.publishedAt, locale) }}
|
||||||
</time>
|
</time>
|
||||||
<template v-if="article.author">
|
<template v-if="article.author">
|
||||||
<span aria-hidden="true"> · </span>
|
<span aria-hidden="true"> · </span>
|
||||||
<span>Di {{ article.author }}</span>
|
<span>{{ t('byAuthor')(article.author) }}</span>
|
||||||
</template>
|
</template>
|
||||||
</p>
|
</p>
|
||||||
</header>
|
</header>
|
||||||
@@ -88,7 +94,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">Torna a tutti gli articoli</NuxtLink>
|
<NuxtLink to="/blog">{{ t('backToArticles') }}</NuxtLink>
|
||||||
</p>
|
</p>
|
||||||
</article>
|
</article>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -8,8 +8,11 @@ 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', {
|
||||||
query: { page },
|
key: () => `blog-articles-${locale.value}-${page.value}`,
|
||||||
|
query: { page, lang: locale },
|
||||||
})
|
})
|
||||||
|
|
||||||
const { public: config } = useRuntimeConfig()
|
const { public: config } = useRuntimeConfig()
|
||||||
@@ -18,8 +21,8 @@ const canonical = computed(
|
|||||||
)
|
)
|
||||||
|
|
||||||
useSeoMeta({
|
useSeoMeta({
|
||||||
title: () => (page.value > 1 ? `Articoli — pagina ${page.value}` : 'Articoli'),
|
title: () => (page.value > 1 ? `${t('articlesTitle')} — ${t('pagePrefix')} ${page.value}` : t('articlesTitle')),
|
||||||
description: 'Tutti gli articoli pubblicati sul blog.',
|
description: () => t('allArticlesDescription'),
|
||||||
ogType: 'website',
|
ogType: 'website',
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -29,15 +32,15 @@ useHead({ link: [{ rel: 'canonical', href: canonical }] })
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<header class="page-header">
|
<header class="page-header">
|
||||||
<p class="kicker">L'archivio</p>
|
<p class="kicker">{{ t('archiveKicker') }}</p>
|
||||||
<h1>Articoli</h1>
|
<h1>{{ t('articlesTitle') }}</h1>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<p v-if="error">Non è stato possibile caricare gli articoli. Riprova più tardi.</p>
|
<p v-if="error">{{ t('loadError') }}</p>
|
||||||
|
|
||||||
<p v-else-if="status === 'pending'">Caricamento…</p>
|
<p v-else-if="status === 'pending'">{{ t('loading') }}</p>
|
||||||
|
|
||||||
<p v-else-if="!data?.items.length">Non è stato ancora pubblicato nulla.</p>
|
<p v-else-if="!data?.items.length">{{ t('noArticlesYet') }}</p>
|
||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<ul class="card-grid">
|
<ul class="card-grid">
|
||||||
@@ -46,19 +49,19 @@ useHead({ link: [{ rel: 'canonical', href: canonical }] })
|
|||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<nav v-if="data.pageCount > 1" class="pagination" aria-label="Paginazione">
|
<nav v-if="data.pageCount > 1" class="pagination" :aria-label="t('pagination')">
|
||||||
<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">
|
||||||
Pagina precedente
|
{{ t('prevPage') }}
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<span class="muted">
|
<span class="muted">
|
||||||
Pagina {{ data.page }} di {{ data.pageCount }}
|
{{ t('pageOf')(data.page, 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"
|
||||||
>
|
>
|
||||||
Pagina successiva
|
{{ t('nextPage') }}
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</nav>
|
</nav>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -9,14 +9,20 @@ const page = computed(() => {
|
|||||||
return Number.isInteger(value) && value > 0 ? value : 1
|
return Number.isInteger(value) && value > 0 ? value : 1
|
||||||
})
|
})
|
||||||
|
|
||||||
const { data: category } = await useFetch<Category>(`/api/categories/${slug}`)
|
const { locale, t } = useLocale()
|
||||||
|
|
||||||
|
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', {
|
||||||
query: { page, category: slug },
|
key: () => `category-articles-${slug}-${locale.value}-${page.value}`,
|
||||||
|
query: { page, category: slug, lang: locale },
|
||||||
})
|
})
|
||||||
|
|
||||||
const { public: config } = useRuntimeConfig()
|
const { public: config } = useRuntimeConfig()
|
||||||
@@ -25,8 +31,8 @@ const canonical = computed(
|
|||||||
)
|
)
|
||||||
|
|
||||||
useSeoMeta({
|
useSeoMeta({
|
||||||
title: category.value.name,
|
title: () => category.value?.name ?? '',
|
||||||
description: () => `Tutti gli articoli nella categoria ${category.value?.name ?? ''}.`,
|
description: () => t('categoryDescription')(category.value?.name ?? ''),
|
||||||
ogType: 'website',
|
ogType: 'website',
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -36,13 +42,13 @@ useHead({ link: [{ rel: 'canonical', href: canonical }] })
|
|||||||
<template>
|
<template>
|
||||||
<div>
|
<div>
|
||||||
<header class="page-header">
|
<header class="page-header">
|
||||||
<p class="kicker">Categoria</p>
|
<p class="kicker">{{ t('categoryKicker') }}</p>
|
||||||
<h1>{{ category?.name }}</h1>
|
<h1>{{ category?.name }}</h1>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<p v-if="error">Non è stato possibile caricare gli articoli. Riprova più tardi.</p>
|
<p v-if="error">{{ t('loadError') }}</p>
|
||||||
|
|
||||||
<p v-else-if="!data?.items.length">Non ci sono articoli in questa categoria.</p>
|
<p v-else-if="!data?.items.length">{{ t('noArticlesInCategory') }}</p>
|
||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<ul class="card-grid">
|
<ul class="card-grid">
|
||||||
@@ -51,19 +57,19 @@ useHead({ link: [{ rel: 'canonical', href: canonical }] })
|
|||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<nav v-if="data.pageCount > 1" class="pagination" aria-label="Paginazione">
|
<nav v-if="data.pageCount > 1" class="pagination" :aria-label="t('pagination')">
|
||||||
<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">
|
||||||
Pagina precedente
|
{{ t('prevPage') }}
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
<span class="muted">
|
<span class="muted">
|
||||||
Pagina {{ data.page }} di {{ data.pageCount }}
|
{{ t('pageOf')(data.page, 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"
|
||||||
>
|
>
|
||||||
Pagina successiva
|
{{ t('nextPage') }}
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</nav>
|
</nav>
|
||||||
</template>
|
</template>
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
<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',
|
key: () => `home-articles-${locale.value}`,
|
||||||
query: { page: 1 },
|
query: { page: 1, lang: locale },
|
||||||
})
|
})
|
||||||
|
|
||||||
const featured = computed(() => data.value?.items[0] ?? null)
|
const featured = computed(() => data.value?.items[0] ?? null)
|
||||||
@@ -20,9 +22,9 @@ useHead({ titleTemplate: '%s', link: [{ rel: 'canonical', href: canonical }] })
|
|||||||
|
|
||||||
useSeoMeta({
|
useSeoMeta({
|
||||||
title: SITE_NAME,
|
title: SITE_NAME,
|
||||||
description: 'Articoli, guide e analisi.',
|
description: () => t('tagline'),
|
||||||
ogTitle: SITE_NAME,
|
ogTitle: SITE_NAME,
|
||||||
ogDescription: 'Articoli, guide e analisi.',
|
ogDescription: () => t('tagline'),
|
||||||
ogType: 'website',
|
ogType: 'website',
|
||||||
ogUrl: canonical,
|
ogUrl: canonical,
|
||||||
ogImage: `${config.siteUrl}/logo.png`,
|
ogImage: `${config.siteUrl}/logo.png`,
|
||||||
@@ -67,9 +69,9 @@ useSeoMeta({
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
<p v-if="error">Non è stato possibile caricare gli articoli. Riprova più tardi.</p>
|
<p v-if="error">{{ t('loadError') }}</p>
|
||||||
|
|
||||||
<p v-else-if="!featured">Non è stato ancora pubblicato nulla.</p>
|
<p v-else-if="!featured">{{ t('noArticlesYet') }}</p>
|
||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<article class="featured" :class="{ 'has-cover': featuredCover }">
|
<article class="featured" :class="{ 'has-cover': featuredCover }">
|
||||||
@@ -90,26 +92,26 @@ useSeoMeta({
|
|||||||
|
|
||||||
<div class="featured-text">
|
<div class="featured-text">
|
||||||
<p class="kicker">
|
<p class="kicker">
|
||||||
{{ featured.category ? featured.category.name : 'Ultimo' }}
|
{{ featured.category ? featured.category.name : t('latestFallback') }}
|
||||||
</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) }}
|
{{ formatDate(featured.publishedAt, locale) }}
|
||||||
</time>
|
</time>
|
||||||
</p>
|
</p>
|
||||||
<p class="read-more">
|
<p class="read-more">
|
||||||
<NuxtLink :to="`/blog/${featured.slug}`">
|
<NuxtLink :to="`/blog/${featured.slug}`">
|
||||||
Leggi l'articolo
|
{{ t('readArticle') }}
|
||||||
</NuxtLink>
|
</NuxtLink>
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
|
|
||||||
<p class="more">
|
<p class="more">
|
||||||
<NuxtLink to="/blog">Vedi tutti gli articoli</NuxtLink>
|
<NuxtLink to="/blog">{{ t('seeAllArticles') }}</NuxtLink>
|
||||||
</p>
|
</p>
|
||||||
</template>
|
</template>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -18,13 +18,14 @@ 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}&filters[slug][$eq]=${encodeURIComponent(slug)}&pagination[pageSize]=1`
|
`/api/articles?${ARTICLE_DETAIL_QUERY}&locale=${locale}&filters[slug][$eq]=${encodeURIComponent(slug)}&pagination[pageSize]=1`
|
||||||
)
|
)
|
||||||
|
|
||||||
const article = response.data[0]
|
const article = response.data[0]
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ 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
|
||||||
@@ -10,7 +11,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}&${pagination(page)}${filter}`
|
`/api/articles?${ARTICLE_SUMMARY_QUERY}&locale=${locale}&${pagination(page)}${filter}`
|
||||||
)
|
)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|||||||
@@ -2,13 +2,14 @@ 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&filters[slug][$eq]=${encodeURIComponent(slug)}&pagination[pageSize]=1`
|
`/api/categories?fields[0]=name&fields[1]=slug&locale=${locale}&filters[slug][$eq]=${encodeURIComponent(slug)}&pagination[pageSize]=1`
|
||||||
)
|
)
|
||||||
|
|
||||||
const category = response.data[0]
|
const category = response.data[0]
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import type { Category } from '#shared/types/blog'
|
import type { Category } from '#shared/types/blog'
|
||||||
|
|
||||||
export default defineEventHandler(async (): Promise<Category[]> => {
|
export default defineEventHandler(async (event): 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&sort[0]=name:asc&pagination[pageSize]=100'
|
`/api/categories?fields[0]=name&fields[1]=slug&locale=${locale}&sort[0]=name:asc&pagination[pageSize]=100`
|
||||||
)
|
)
|
||||||
|
|
||||||
return response.data
|
return response.data
|
||||||
|
|||||||
@@ -31,3 +31,8 @@ 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')
|
||||||
|
|||||||
@@ -1,13 +1,18 @@
|
|||||||
|
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): string {
|
export function formatDate(value: string | null | undefined, locale: 'it' | 'en' = 'it'): string {
|
||||||
if (!value) return ''
|
if (!value) return ''
|
||||||
return new Intl.DateTimeFormat('it-IT', { dateStyle: 'long' }).format(new Date(value))
|
return new Intl.DateTimeFormat(INTL_LOCALE[locale], { 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(value: string | null | undefined): string {
|
export function formatDateTime(
|
||||||
|
value: string | null | undefined,
|
||||||
|
locale: 'it' | 'en' = 'it'
|
||||||
|
): string {
|
||||||
if (!value) return ''
|
if (!value) return ''
|
||||||
return new Intl.DateTimeFormat('it-IT', { dateStyle: 'long', timeStyle: 'short' })
|
return new Intl.DateTimeFormat(INTL_LOCALE[locale], { dateStyle: 'long', timeStyle: 'short' })
|
||||||
.format(new Date(value))
|
.format(new Date(value))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user