Add hand-rolled English support (/en/*) alongside the Italian site
PocketBase gains optional, manually-authored English fields on articles and categories (same record, same slug), and the frontend serves an /en counterpart of every dynamic route via thin page wrappers around shared view components — no i18n library, consistent with the project's existing minimalism stance for a two-locale site with ~20 UI strings. An article/category with no translation 404s cleanly on its own /en detail page and is filtered out of /en listings, and the header's language-switch link falls back to the English blog index rather than a dead link; resolving that requires a global route middleware, since the layout's header renders before the page content in document order and so can't react to state a page component sets during its own async setup.
This commit is contained in:
@@ -1,10 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import type { ArticleSummary } from '#shared/types/blog'
|
||||
import type { Locale } from '#shared/utils/locale'
|
||||
import { localePath } from '#shared/utils/locale'
|
||||
|
||||
const props = defineProps<{ article: ArticleSummary }>()
|
||||
const props = withDefaults(defineProps<{ article: ArticleSummary; locale?: Locale }>(), {
|
||||
locale: 'it',
|
||||
})
|
||||
|
||||
const mediaUrl = useMediaUrl()
|
||||
const cover = computed(() => mediaUrl(props.article.cover))
|
||||
const href = computed(() => localePath(props.locale, `/blog/${props.article.slug}`))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -14,7 +19,7 @@ const cover = computed(() => mediaUrl(props.article.cover))
|
||||
<NuxtLink
|
||||
v-if="cover"
|
||||
class="cover-link"
|
||||
:to="`/blog/${article.slug}`"
|
||||
:to="href"
|
||||
tabindex="-1"
|
||||
aria-hidden="true"
|
||||
>
|
||||
@@ -29,12 +34,12 @@ const cover = computed(() => mediaUrl(props.article.cover))
|
||||
<p v-if="article.category" class="kicker">{{ article.category.name }}</p>
|
||||
|
||||
<h2>
|
||||
<NuxtLink :to="`/blog/${article.slug}`">{{ article.title }}</NuxtLink>
|
||||
<NuxtLink :to="href">{{ article.title }}</NuxtLink>
|
||||
</h2>
|
||||
|
||||
<p class="kicker date">
|
||||
<time :datetime="isoDate(article.publishedAt)">
|
||||
{{ formatDate(article.publishedAt) }}
|
||||
{{ formatDate(article.publishedAt, locale) }}
|
||||
</time>
|
||||
</p>
|
||||
</article>
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
<script setup lang="ts">
|
||||
import { STRINGS } from '#shared/utils/i18n'
|
||||
import { bareLocalePath, localePath } from '#shared/utils/locale'
|
||||
|
||||
const route = useRoute()
|
||||
const locale = useLocale()
|
||||
const langSwitch = useLangSwitchState()
|
||||
|
||||
const target = computed(() => {
|
||||
if (!langSwitch.value.available) return langSwitch.value.fallback
|
||||
const otherLocale = locale.value === 'en' ? 'it' : 'en'
|
||||
return localePath(otherLocale, bareLocalePath(route.path))
|
||||
})
|
||||
|
||||
const label = computed(() => STRINGS[locale.value].langSwitchLabel)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<NuxtLink class="lang-switch" :to="target">{{ label }}</NuxtLink>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.lang-switch {
|
||||
display: inline-block;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
text-decoration: none;
|
||||
padding-block: 0.6rem;
|
||||
border-bottom: 1px solid transparent;
|
||||
}
|
||||
|
||||
.lang-switch:hover {
|
||||
border-bottom-color: var(--color-ink);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,157 @@
|
||||
<script setup lang="ts">
|
||||
import type { Article } from '#shared/types/blog'
|
||||
import type { Locale } from '#shared/utils/locale'
|
||||
import { STRINGS, interpolate } from '#shared/utils/i18n'
|
||||
import { localePath } from '#shared/utils/locale'
|
||||
|
||||
const props = defineProps<{ locale: Locale }>()
|
||||
|
||||
const strings = computed(() => STRINGS[props.locale])
|
||||
const route = useRoute()
|
||||
const slug = route.params.slug as string
|
||||
|
||||
const { data: article } = await useFetch<Article>(`/content/articles/${slug}`, {
|
||||
key: `article-${slug}-${props.locale}`,
|
||||
query: { lang: props.locale },
|
||||
})
|
||||
|
||||
if (!article.value) {
|
||||
throw createError({ statusCode: 404, statusMessage: 'Article not found', fatal: true })
|
||||
}
|
||||
|
||||
const mediaUrl = useMediaUrl()
|
||||
const cover = computed(() => mediaUrl(article.value?.cover) ?? undefined)
|
||||
const blogHref = computed(() => localePath(props.locale, '/blog'))
|
||||
const categoryHref = computed(() =>
|
||||
article.value?.category ? localePath(props.locale, `/category/${article.value.category.slug}`) : ''
|
||||
)
|
||||
|
||||
useSeo({
|
||||
locale: props.locale,
|
||||
path: `/blog/${slug}`,
|
||||
title: article.value.title,
|
||||
description: article.value.summary,
|
||||
type: 'article',
|
||||
image: cover.value,
|
||||
hasAlternate: props.locale === 'en' ? true : article.value.hasTranslation,
|
||||
jsonLd: {
|
||||
'@context': 'https://schema.org',
|
||||
'@type': 'BlogPosting',
|
||||
headline: article.value.title,
|
||||
description: article.value.summary,
|
||||
datePublished: isoDate(article.value.publishedAt),
|
||||
author: article.value.author
|
||||
? { '@type': 'Person', name: article.value.author }
|
||||
: undefined,
|
||||
image: cover.value ? [cover.value] : undefined,
|
||||
mainEntityOfPage: { '@type': 'WebPage', '@id': `${useRuntimeConfig().public.siteUrl}${localePath(props.locale, `/blog/${slug}`)}` },
|
||||
},
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<article v-if="article">
|
||||
<header class="article-header">
|
||||
<nav class="kicker" :aria-label="strings.article.breadcrumbAriaLabel">
|
||||
<NuxtLink :to="blogHref">{{ strings.article.articlesCrumb }}</NuxtLink>
|
||||
<template v-if="article.category">
|
||||
<span aria-hidden="true"> / </span>
|
||||
<NuxtLink :to="categoryHref">
|
||||
{{ article.category.name }}
|
||||
</NuxtLink>
|
||||
</template>
|
||||
</nav>
|
||||
|
||||
<h1>{{ article.title }}</h1>
|
||||
|
||||
<p class="kicker date">
|
||||
<time :datetime="isoDate(article.publishedAt)">
|
||||
{{ formatDateTime(article.publishedAt, locale) }}
|
||||
</time>
|
||||
<template v-if="article.author">
|
||||
<span aria-hidden="true"> · </span>
|
||||
<span>{{ interpolate(strings.article.byAuthor, { author: article.author }) }}</span>
|
||||
</template>
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<img
|
||||
v-if="cover"
|
||||
class="cover"
|
||||
:src="cover"
|
||||
:alt="article.cover?.alt ?? ''"
|
||||
>
|
||||
|
||||
<!-- eslint-disable-next-line vue/no-v-html -- rendered server-side from editor Markdown -->
|
||||
<div class="prose" v-html="article.html" />
|
||||
|
||||
<p class="back">
|
||||
<NuxtLink :to="blogHref">{{ strings.article.backToArticles }}</NuxtLink>
|
||||
</p>
|
||||
</article>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.article-header {
|
||||
max-width: 46rem;
|
||||
/* The gap below the header lives here, not on the cover: an article without
|
||||
a cover image would otherwise have its body butting against the byline. */
|
||||
margin: 0 auto 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@media (min-width: 48rem) {
|
||||
.article-header {
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
}
|
||||
|
||||
.article-header nav a {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.article-header nav a:hover {
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 0.2em;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 1rem 0 0;
|
||||
font-size: clamp(2rem, 5vw, 3.2rem);
|
||||
}
|
||||
|
||||
.date {
|
||||
margin-top: 1rem;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.prose {
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
.back {
|
||||
max-width: var(--width-prose);
|
||||
margin: 4rem auto 0;
|
||||
padding-top: 2rem;
|
||||
border-top: 1px solid var(--color-border);
|
||||
text-align: center;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.back a {
|
||||
text-decoration: none;
|
||||
border-bottom: 1px solid var(--color-ink);
|
||||
padding-bottom: 0.3rem;
|
||||
}
|
||||
|
||||
.cover {
|
||||
width: 100%;
|
||||
aspect-ratio: 3 / 2;
|
||||
object-fit: cover;
|
||||
margin-block: 0 3rem;
|
||||
background: var(--color-surface);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,67 @@
|
||||
<script setup lang="ts">
|
||||
import type { ArticleSummary, Paginated } from '#shared/types/blog'
|
||||
import type { Locale } from '#shared/utils/locale'
|
||||
import { STRINGS, interpolate } from '#shared/utils/i18n'
|
||||
|
||||
const props = defineProps<{ locale: Locale }>()
|
||||
|
||||
const strings = computed(() => STRINGS[props.locale])
|
||||
|
||||
const route = useRoute()
|
||||
|
||||
const page = computed(() => {
|
||||
const value = Number(route.query.page)
|
||||
return Number.isInteger(value) && value > 0 ? value : 1
|
||||
})
|
||||
|
||||
const { data, error, status } = await useFetch<Paginated<ArticleSummary>>('/content/articles', {
|
||||
key: () => `blog-${props.locale}-${page.value}`,
|
||||
query: { page, lang: props.locale },
|
||||
})
|
||||
|
||||
useSeo({
|
||||
locale: props.locale,
|
||||
path: '/blog',
|
||||
title: page.value > 1 ? interpolate(strings.value.blog.titlePage, { page: page.value }) : strings.value.blog.title,
|
||||
description: strings.value.blog.metaDescription,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<header class="page-header">
|
||||
<p class="kicker">{{ strings.blog.archiveKicker }}</p>
|
||||
<h1>{{ strings.blog.title }}</h1>
|
||||
</header>
|
||||
|
||||
<p v-if="error">{{ strings.blog.loadError }}</p>
|
||||
|
||||
<p v-else-if="status === 'pending'">{{ strings.blog.loading }}</p>
|
||||
|
||||
<p v-else-if="!data?.items.length">{{ strings.blog.empty }}</p>
|
||||
|
||||
<template v-else>
|
||||
<ul class="card-grid">
|
||||
<li v-for="article in data.items" :key="article.slug">
|
||||
<ArticleCard :article="article" :locale="locale" />
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<nav v-if="data.pageCount > 1" class="pagination" :aria-label="strings.blog.paginationAriaLabel">
|
||||
<NuxtLink v-if="data.page > 1" :to="{ query: { page: data.page - 1 } }" rel="prev">
|
||||
{{ strings.blog.prevPage }}
|
||||
</NuxtLink>
|
||||
<span class="muted">
|
||||
{{ interpolate(strings.blog.pageOf, { current: data.page, total: data.pageCount }) }}
|
||||
</span>
|
||||
<NuxtLink
|
||||
v-if="data.page < data.pageCount"
|
||||
:to="{ query: { page: data.page + 1 } }"
|
||||
rel="next"
|
||||
>
|
||||
{{ strings.blog.nextPage }}
|
||||
</NuxtLink>
|
||||
</nav>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,75 @@
|
||||
<script setup lang="ts">
|
||||
import type { ArticleSummary, Category, Paginated } from '#shared/types/blog'
|
||||
import type { Locale } from '#shared/utils/locale'
|
||||
import { STRINGS, interpolate } from '#shared/utils/i18n'
|
||||
|
||||
const props = defineProps<{ locale: Locale }>()
|
||||
|
||||
const strings = computed(() => STRINGS[props.locale])
|
||||
|
||||
const route = useRoute()
|
||||
const slug = route.params.slug as string
|
||||
|
||||
const page = computed(() => {
|
||||
const value = Number(route.query.page)
|
||||
return Number.isInteger(value) && value > 0 ? value : 1
|
||||
})
|
||||
|
||||
const { data: category } = await useFetch<Category>(`/content/categories/${slug}`, {
|
||||
key: `category-${slug}-${props.locale}`,
|
||||
query: { lang: props.locale },
|
||||
})
|
||||
|
||||
if (!category.value) {
|
||||
throw createError({ statusCode: 404, statusMessage: 'Category not found', fatal: true })
|
||||
}
|
||||
|
||||
const { data, error } = await useFetch<Paginated<ArticleSummary>>('/content/articles', {
|
||||
key: () => `category-articles-${slug}-${props.locale}-${page.value}`,
|
||||
query: { page, category: slug, lang: props.locale },
|
||||
})
|
||||
|
||||
useSeo({
|
||||
locale: props.locale,
|
||||
path: `/category/${slug}`,
|
||||
title: category.value.name,
|
||||
description: interpolate(strings.value.category.metaDescription, { name: category.value.name }),
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<header class="page-header">
|
||||
<p class="kicker">{{ strings.category.kicker }}</p>
|
||||
<h1>{{ category?.name }}</h1>
|
||||
</header>
|
||||
|
||||
<p v-if="error">{{ strings.category.loadError }}</p>
|
||||
|
||||
<p v-else-if="!data?.items.length">{{ strings.category.empty }}</p>
|
||||
|
||||
<template v-else>
|
||||
<ul class="card-grid">
|
||||
<li v-for="article in data.items" :key="article.slug">
|
||||
<ArticleCard :article="article" :locale="locale" />
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<nav v-if="data.pageCount > 1" class="pagination" :aria-label="strings.blog.paginationAriaLabel">
|
||||
<NuxtLink v-if="data.page > 1" :to="{ query: { page: data.page - 1 } }" rel="prev">
|
||||
{{ strings.blog.prevPage }}
|
||||
</NuxtLink>
|
||||
<span class="muted">
|
||||
{{ interpolate(strings.blog.pageOf, { current: data.page, total: data.pageCount }) }}
|
||||
</span>
|
||||
<NuxtLink
|
||||
v-if="data.page < data.pageCount"
|
||||
:to="{ query: { page: data.page + 1 } }"
|
||||
rel="next"
|
||||
>
|
||||
{{ strings.blog.nextPage }}
|
||||
</NuxtLink>
|
||||
</nav>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,200 @@
|
||||
<script setup lang="ts">
|
||||
import type { ArticleSummary, Paginated } from '#shared/types/blog'
|
||||
import type { Locale } from '#shared/utils/locale'
|
||||
import { STRINGS } from '#shared/utils/i18n'
|
||||
import { localePath } from '#shared/utils/locale'
|
||||
|
||||
const props = defineProps<{ locale: Locale }>()
|
||||
|
||||
const strings = computed(() => STRINGS[props.locale])
|
||||
|
||||
const { data, error } = await useFetch<Paginated<ArticleSummary>>('/content/articles', {
|
||||
key: `home-articles-${props.locale}`,
|
||||
query: { page: 1, lang: props.locale },
|
||||
})
|
||||
|
||||
const featured = computed(() => data.value?.items[0] ?? null)
|
||||
|
||||
const mediaUrl = useMediaUrl()
|
||||
const featuredCover = computed(() => mediaUrl(featured.value?.cover))
|
||||
const featuredHref = computed(() => (featured.value ? localePath(props.locale, `/blog/${featured.value.slug}`) : '/'))
|
||||
|
||||
useSeo({
|
||||
locale: props.locale,
|
||||
path: '/',
|
||||
title: SITE_NAME,
|
||||
description: strings.value.home.metaDescription,
|
||||
image: `${useRuntimeConfig().public.siteUrl}/logo.png`,
|
||||
})
|
||||
useHead({ titleTemplate: '%s' })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<section class="landing">
|
||||
<img
|
||||
class="landing-image"
|
||||
src="/protesta-bancaria.jpg"
|
||||
:alt="strings.home.img1Alt"
|
||||
width="770"
|
||||
height="420"
|
||||
>
|
||||
|
||||
<h1>{{ strings.home.heading }}</h1>
|
||||
|
||||
<img
|
||||
class="landing-image"
|
||||
src="/eurozona-crisi.jpg"
|
||||
:alt="strings.home.img2Alt"
|
||||
width="1000"
|
||||
height="736"
|
||||
>
|
||||
|
||||
<div class="landing-text">
|
||||
<p>{{ strings.home.p1 }}</p>
|
||||
<p>{{ strings.home.p2 }}</p>
|
||||
<p>{{ strings.home.p3 }}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<p v-if="error">{{ strings.blog.loadError }}</p>
|
||||
|
||||
<p v-else-if="!featured">{{ strings.blog.empty }}</p>
|
||||
|
||||
<template v-else>
|
||||
<article class="featured" :class="{ 'has-cover': featuredCover }">
|
||||
<NuxtLink
|
||||
v-if="featuredCover"
|
||||
class="featured-cover"
|
||||
:to="featuredHref"
|
||||
tabindex="-1"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<img
|
||||
:src="featuredCover"
|
||||
:alt="featured.cover?.alt ?? ''"
|
||||
>
|
||||
</NuxtLink>
|
||||
|
||||
<div class="featured-text">
|
||||
<p class="kicker">
|
||||
{{ featured.category ? featured.category.name : strings.home.fallbackKicker }}
|
||||
</p>
|
||||
<h2>
|
||||
<NuxtLink :to="featuredHref">{{ featured.title }}</NuxtLink>
|
||||
</h2>
|
||||
<p class="kicker date">
|
||||
<time :datetime="isoDate(featured.publishedAt)">
|
||||
{{ formatDate(featured.publishedAt, locale) }}
|
||||
</time>
|
||||
</p>
|
||||
<p class="read-more">
|
||||
<NuxtLink :to="featuredHref">
|
||||
{{ strings.home.readLatest }}
|
||||
</NuxtLink>
|
||||
</p>
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.landing {
|
||||
max-width: var(--width-prose);
|
||||
margin-inline: auto;
|
||||
margin-bottom: var(--gap-section);
|
||||
padding-bottom: var(--gap-section);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.landing-image {
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
object-fit: cover;
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.landing h1 {
|
||||
margin-block: 1.75rem;
|
||||
font-size: clamp(2rem, 4.5vw, 3.1rem);
|
||||
}
|
||||
|
||||
.landing-text {
|
||||
margin-top: 1.75rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.landing-text p {
|
||||
margin-top: 1.1rem;
|
||||
}
|
||||
|
||||
.featured {
|
||||
display: grid;
|
||||
gap: 1.5rem;
|
||||
align-items: center;
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
/* Stacked on phones and portrait tablets — a side-by-side split below this
|
||||
width leaves both the image and the headline too cramped to read. The split
|
||||
also needs a cover to sit in it, or one column would just be empty. */
|
||||
@media (min-width: 64rem) {
|
||||
.featured.has-cover {
|
||||
grid-template-columns: 1.35fr 1fr;
|
||||
gap: 3.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Without a cover the headline is the whole lead, so it gets the full width
|
||||
but stays inside a readable measure. */
|
||||
.featured:not(.has-cover) .featured-text {
|
||||
max-width: 46rem;
|
||||
}
|
||||
|
||||
.featured-cover {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.featured-cover img {
|
||||
width: 100%;
|
||||
aspect-ratio: 3 / 2;
|
||||
object-fit: cover;
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.featured h2 {
|
||||
margin-top: 0.6rem;
|
||||
font-size: clamp(2rem, 4.5vw, 3.1rem);
|
||||
}
|
||||
|
||||
.featured h2 a {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.featured h2 a:hover {
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 0.12em;
|
||||
}
|
||||
|
||||
.date {
|
||||
margin-top: 1rem;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.read-more {
|
||||
margin-top: 1.75rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.read-more a {
|
||||
padding-bottom: 0.35rem;
|
||||
text-decoration: none;
|
||||
border-bottom: 1px solid var(--color-ink);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,16 @@
|
||||
/**
|
||||
* Shared per-navigation state for the language-switch button in the layout.
|
||||
* Each page sets this on setup: `available` is false when there is no
|
||||
* equivalent page in the other language for the current content (e.g. an
|
||||
* untranslated article, or the Italian-only corralito page) — in that case
|
||||
* `fallback` is where the switch should go instead (typically the other
|
||||
* language's blog index).
|
||||
*/
|
||||
interface LangSwitchState {
|
||||
available: boolean
|
||||
fallback: string
|
||||
}
|
||||
|
||||
export function useLangSwitchState() {
|
||||
return useState<LangSwitchState>('lang-switch', () => ({ available: true, fallback: '/en' }))
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import type { Locale } from '#shared/utils/locale'
|
||||
|
||||
/** Derives the current locale from the URL path — `/en` or `/en/...` is English, everything else Italian. */
|
||||
export function useLocale() {
|
||||
const route = useRoute()
|
||||
return computed<Locale>(() => (route.path === '/en' || route.path.startsWith('/en/') ? 'en' : 'it'))
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import type { Locale } from '#shared/utils/locale'
|
||||
|
||||
interface SeoOptions {
|
||||
locale: Locale
|
||||
/** The bare, unprefixed path, e.g. `/blog/mio-slug` — never `/en/...`. */
|
||||
path: string
|
||||
title: string
|
||||
description: string
|
||||
type?: 'website' | 'article'
|
||||
image?: string
|
||||
/** False when no translation exists at all for this content — omits the alternate link. */
|
||||
hasAlternate?: boolean
|
||||
jsonLd?: Record<string, unknown>
|
||||
}
|
||||
|
||||
/**
|
||||
* Centralises canonical/hreflang/OG/JSON-LD head tags so IT and EN pages for
|
||||
* the same content always agree on each other's URL — duplicating this per
|
||||
* page, doubled for two locales, is exactly how hreflang correctness drifts.
|
||||
*/
|
||||
export function useSeo(options: SeoOptions) {
|
||||
const { public: config } = useRuntimeConfig()
|
||||
const bare = options.path === '/' ? '' : options.path
|
||||
const itUrl = `${config.siteUrl}${bare}`
|
||||
const enUrl = `${config.siteUrl}/en${bare}`
|
||||
const canonical = options.locale === 'en' ? enUrl : itUrl
|
||||
|
||||
useSeoMeta({
|
||||
title: options.title,
|
||||
description: options.description,
|
||||
ogTitle: options.title,
|
||||
ogDescription: options.description,
|
||||
ogType: options.type ?? 'website',
|
||||
ogUrl: canonical,
|
||||
ogImage: options.image,
|
||||
twitterCard: options.image ? 'summary_large_image' : undefined,
|
||||
})
|
||||
|
||||
const link: Array<{ rel: 'canonical' | 'alternate'; href: string; hreflang?: string }> = [
|
||||
{ rel: 'canonical', href: canonical },
|
||||
]
|
||||
if (options.hasAlternate !== false) {
|
||||
link.push(
|
||||
{ rel: 'alternate', hreflang: 'it', href: itUrl },
|
||||
{ rel: 'alternate', hreflang: 'en', href: enUrl },
|
||||
{ rel: 'alternate', hreflang: 'x-default', href: itUrl }
|
||||
)
|
||||
}
|
||||
|
||||
useHead({
|
||||
htmlAttrs: { lang: options.locale },
|
||||
// unhead's Link type narrows required keys per literal `rel` value in a
|
||||
// way a plain canonical/alternate/hreflang link doesn't fit — the shape
|
||||
// itself is valid HTML, so this is a type-only escape hatch.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
link: link as any,
|
||||
script: options.jsonLd
|
||||
? [{ type: 'application/ld+json', innerHTML: JSON.stringify(options.jsonLd) }]
|
||||
: undefined,
|
||||
})
|
||||
}
|
||||
@@ -1,26 +1,36 @@
|
||||
<script setup lang="ts">
|
||||
import { STRINGS } from '#shared/utils/i18n'
|
||||
import { localePath } from '#shared/utils/locale'
|
||||
|
||||
const locale = useLocale()
|
||||
const strings = computed(() => STRINGS[locale.value])
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page">
|
||||
<a class="skip-link" href="#main">Vai al contenuto</a>
|
||||
<a class="skip-link" href="#main">{{ strings.skipLink }}</a>
|
||||
|
||||
<header class="site-header">
|
||||
<div class="container">
|
||||
<p class="wordmark">
|
||||
<NuxtLink to="/">
|
||||
<NuxtLink :to="localePath(locale, '/')">
|
||||
<img src="/logo.png" :alt="SITE_NAME" width="400" height="400">
|
||||
</NuxtLink>
|
||||
</p>
|
||||
<p class="tagline kicker">Articoli, guide e analisi.</p>
|
||||
<p class="tagline kicker">{{ strings.tagline }}</p>
|
||||
<p class="lang-switch-header">
|
||||
<LanguageSwitch />
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<nav class="site-nav" aria-label="Navigazione principale">
|
||||
<nav class="site-nav" :aria-label="strings.navAriaLabel">
|
||||
<div class="container nav-inner">
|
||||
<ul>
|
||||
<li><NuxtLink to="/">Home</NuxtLink></li>
|
||||
<li><NuxtLink to="/blog">Tutti gli articoli</NuxtLink></li>
|
||||
<li><NuxtLink to="/come-difendersi-dal-corralito">Come difendersi dal corralito</NuxtLink></li>
|
||||
<li><NuxtLink :to="localePath(locale, '/')">{{ strings.navHome }}</NuxtLink></li>
|
||||
<li><NuxtLink :to="localePath(locale, '/blog')">{{ strings.navBlog }}</NuxtLink></li>
|
||||
<li v-if="locale === 'it'">
|
||||
<NuxtLink to="/come-difendersi-dal-corralito">{{ strings.navCorralito }}</NuxtLink>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</nav>
|
||||
@@ -34,24 +44,26 @@
|
||||
<div class="container footer-inner">
|
||||
<div class="footer-block">
|
||||
<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">{{ strings.tagline }}</p>
|
||||
<address class="contact">
|
||||
Contatti:
|
||||
{{ strings.contactLabel }}
|
||||
<a :href="`mailto:${SITE_EMAIL}`">{{ SITE_EMAIL }}</a>
|
||||
</address>
|
||||
</div>
|
||||
|
||||
<nav class="footer-block" aria-label="Naviga">
|
||||
<p class="kicker">Naviga</p>
|
||||
<nav class="footer-block" :aria-label="strings.footerNavAriaLabel">
|
||||
<p class="kicker">{{ strings.footerNavigaKicker }}</p>
|
||||
<ul>
|
||||
<li><NuxtLink to="/">Home</NuxtLink></li>
|
||||
<li><NuxtLink to="/blog">Tutti gli articoli</NuxtLink></li>
|
||||
<li><NuxtLink to="/come-difendersi-dal-corralito">Come difendersi dal corralito</NuxtLink></li>
|
||||
<li><NuxtLink :to="localePath(locale, '/')">{{ strings.navHome }}</NuxtLink></li>
|
||||
<li><NuxtLink :to="localePath(locale, '/blog')">{{ strings.navBlog }}</NuxtLink></li>
|
||||
<li v-if="locale === 'it'">
|
||||
<NuxtLink to="/come-difendersi-dal-corralito">{{ strings.navCorralito }}</NuxtLink>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
<nav class="footer-block" aria-label="Seguici">
|
||||
<p class="kicker">Seguici</p>
|
||||
<nav class="footer-block" :aria-label="strings.footerFollowAriaLabel">
|
||||
<p class="kicker">{{ strings.footerSeguiciKicker }}</p>
|
||||
<ul>
|
||||
<li v-for="social in SOCIAL_LINKS" :key="social.name">
|
||||
<a class="social" :href="social.url" target="_blank" rel="noopener me">
|
||||
@@ -64,7 +76,7 @@
|
||||
</div>
|
||||
|
||||
<p class="container copyright kicker">
|
||||
© {{ new Date().getFullYear() }} {{ SITE_NAME }}. Tutti i diritti riservati.
|
||||
© {{ new Date().getFullYear() }} {{ SITE_NAME }}. {{ strings.copyright }}
|
||||
</p>
|
||||
</footer>
|
||||
</div>
|
||||
@@ -109,6 +121,10 @@
|
||||
margin-top: 0.6rem;
|
||||
}
|
||||
|
||||
.lang-switch-header {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.site-nav {
|
||||
margin-top: 1.25rem;
|
||||
border-top: 1px solid var(--color-border);
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { Article, Category } from '#shared/types/blog'
|
||||
|
||||
/**
|
||||
* Resolves the language-switch target *before* the layout (and its header,
|
||||
* rendered ahead of the page in document order) mounts — setting this from
|
||||
* inside the page component itself is too late: a parent renders its
|
||||
* children in document order, so the header's LanguageSwitch would already
|
||||
* be resolved before the page's own setup ever runs, translated content or
|
||||
* not. Running as global route middleware guarantees this resolves first.
|
||||
*/
|
||||
export default defineNuxtRouteMiddleware(async (to) => {
|
||||
const langSwitch = useLangSwitchState()
|
||||
const locale = to.path === '/en' || to.path.startsWith('/en/') ? 'en' : 'it'
|
||||
const bare = locale === 'en' ? to.path.slice(3) || '/' : to.path
|
||||
|
||||
const articleMatch = bare.match(/^\/blog\/([^/]+)$/)
|
||||
const categoryMatch = bare.match(/^\/category\/([^/]+)$/)
|
||||
|
||||
if (articleMatch) {
|
||||
const slug = articleMatch[1]
|
||||
const { data } = await useFetch<Article>(`/content/articles/${slug}`, {
|
||||
key: `article-${slug}-${locale}`,
|
||||
query: { lang: locale },
|
||||
})
|
||||
langSwitch.value = { available: Boolean(data.value?.hasTranslation), fallback: '/en/blog' }
|
||||
return
|
||||
}
|
||||
|
||||
if (categoryMatch) {
|
||||
// A category's own translation only gates that specific label — the
|
||||
// switch always lands somewhere useful (a filtered, possibly empty,
|
||||
// article list), so it's always shown.
|
||||
void useFetch<Category>(`/content/categories/${categoryMatch[1]}`, {
|
||||
key: `category-${categoryMatch[1]}-${locale}`,
|
||||
query: { lang: locale },
|
||||
})
|
||||
langSwitch.value = { available: true, fallback: '/en/blog' }
|
||||
return
|
||||
}
|
||||
|
||||
if (bare === '/come-difendersi-dal-corralito') {
|
||||
langSwitch.value = { available: false, fallback: '/en' }
|
||||
return
|
||||
}
|
||||
|
||||
langSwitch.value = { available: true, fallback: '/en' }
|
||||
})
|
||||
@@ -1,157 +1,3 @@
|
||||
<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>(`/content/articles/${slug}`)
|
||||
|
||||
if (!article.value) {
|
||||
throw createError({ statusCode: 404, statusMessage: 'Article not found', fatal: true })
|
||||
}
|
||||
|
||||
const { public: config } = useRuntimeConfig()
|
||||
const mediaUrl = useMediaUrl()
|
||||
|
||||
const canonical = `${config.siteUrl}${route.path}`
|
||||
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),
|
||||
author: article.value.author
|
||||
? { '@type': 'Person', name: article.value.author }
|
||||
: undefined,
|
||||
image: cover.value ? [cover.value] : undefined,
|
||||
mainEntityOfPage: { '@type': 'WebPage', '@id': canonical },
|
||||
}),
|
||||
},
|
||||
],
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<article v-if="article">
|
||||
<header class="article-header">
|
||||
<nav class="kicker" aria-label="Percorso di navigazione">
|
||||
<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="kicker date">
|
||||
<time :datetime="isoDate(article.publishedAt)">
|
||||
{{ formatDateTime(article.publishedAt) }}
|
||||
</time>
|
||||
<template v-if="article.author">
|
||||
<span aria-hidden="true"> · </span>
|
||||
<span>Di {{ article.author }}</span>
|
||||
</template>
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<img
|
||||
v-if="cover"
|
||||
class="cover"
|
||||
:src="cover"
|
||||
:alt="article.cover?.alt ?? ''"
|
||||
>
|
||||
|
||||
<!-- eslint-disable-next-line vue/no-v-html -- rendered server-side from editor Markdown -->
|
||||
<div class="prose" v-html="article.html" />
|
||||
|
||||
<p class="back">
|
||||
<NuxtLink to="/blog">Torna a tutti gli articoli</NuxtLink>
|
||||
</p>
|
||||
</article>
|
||||
<ArticleView locale="it" />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.article-header {
|
||||
max-width: 46rem;
|
||||
/* The gap below the header lives here, not on the cover: an article without
|
||||
a cover image would otherwise have its body butting against the byline. */
|
||||
margin: 0 auto 2rem;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
@media (min-width: 48rem) {
|
||||
.article-header {
|
||||
margin-bottom: 3rem;
|
||||
}
|
||||
}
|
||||
|
||||
.article-header nav a {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.article-header nav a:hover {
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 0.2em;
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin: 1rem 0 0;
|
||||
font-size: clamp(2rem, 5vw, 3.2rem);
|
||||
}
|
||||
|
||||
.date {
|
||||
margin-top: 1rem;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.prose {
|
||||
margin-inline: auto;
|
||||
}
|
||||
|
||||
.back {
|
||||
max-width: var(--width-prose);
|
||||
margin: 4rem auto 0;
|
||||
padding-top: 2rem;
|
||||
border-top: 1px solid var(--color-border);
|
||||
text-align: center;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.back a {
|
||||
text-decoration: none;
|
||||
border-bottom: 1px solid var(--color-ink);
|
||||
padding-bottom: 0.3rem;
|
||||
}
|
||||
|
||||
.cover {
|
||||
width: 100%;
|
||||
aspect-ratio: 3 / 2;
|
||||
object-fit: cover;
|
||||
margin-block: 0 3rem;
|
||||
background: var(--color-surface);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -1,66 +1,3 @@
|
||||
<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>>('/content/articles', {
|
||||
query: { page },
|
||||
})
|
||||
|
||||
const { public: config } = useRuntimeConfig()
|
||||
const canonical = computed(
|
||||
() => `${config.siteUrl}${route.path}${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>
|
||||
<header class="page-header">
|
||||
<p class="kicker">L'archivio</p>
|
||||
<h1>Articoli</h1>
|
||||
</header>
|
||||
|
||||
<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 è stato ancora pubblicato nulla.</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>
|
||||
<BlogIndexView locale="it" />
|
||||
</template>
|
||||
|
||||
@@ -1,71 +1,3 @@
|
||||
<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>(`/content/categories/${slug}`)
|
||||
|
||||
if (!category.value) {
|
||||
throw createError({ statusCode: 404, statusMessage: 'Category not found', fatal: true })
|
||||
}
|
||||
|
||||
const { data, error } = await useFetch<Paginated<ArticleSummary>>('/content/articles', {
|
||||
query: { page, category: slug },
|
||||
})
|
||||
|
||||
const { public: config } = useRuntimeConfig()
|
||||
const canonical = computed(
|
||||
() => `${config.siteUrl}${route.path}${page.value > 1 ? `?page=${page.value}` : ''}`
|
||||
)
|
||||
|
||||
useSeoMeta({
|
||||
title: category.value.name,
|
||||
description: () => `Tutti gli articoli nella categoria ${category.value?.name ?? ''}.`,
|
||||
ogType: 'website',
|
||||
})
|
||||
|
||||
useHead({ link: [{ rel: 'canonical', href: canonical }] })
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<header class="page-header">
|
||||
<p class="kicker">Categoria</p>
|
||||
<h1>{{ category?.name }}</h1>
|
||||
</header>
|
||||
|
||||
<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>
|
||||
<CategoryView locale="it" />
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<ArticleView locale="en" />
|
||||
</template>
|
||||
@@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<BlogIndexView locale="en" />
|
||||
</template>
|
||||
@@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<CategoryView locale="en" />
|
||||
</template>
|
||||
@@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<HomeView locale="en" />
|
||||
</template>
|
||||
@@ -1,210 +1,3 @@
|
||||
<script setup lang="ts">
|
||||
import type { ArticleSummary, Paginated } from '#shared/types/blog'
|
||||
|
||||
const { data, error } = await useFetch<Paginated<ArticleSummary>>('/content/articles', {
|
||||
key: 'home-articles',
|
||||
query: { page: 1 },
|
||||
})
|
||||
|
||||
const featured = computed(() => data.value?.items[0] ?? null)
|
||||
|
||||
const mediaUrl = useMediaUrl()
|
||||
const featuredCover = computed(() => mediaUrl(featured.value?.cover))
|
||||
|
||||
const { public: config } = useRuntimeConfig()
|
||||
const route = useRoute()
|
||||
const canonical = computed(() => `${config.siteUrl}${route.path}`)
|
||||
|
||||
// The home page is the site itself, so it carries the bare name as its title.
|
||||
useHead({ titleTemplate: '%s', link: [{ rel: 'canonical', href: canonical }] })
|
||||
|
||||
useSeoMeta({
|
||||
title: SITE_NAME,
|
||||
description: 'Articoli, guide e analisi.',
|
||||
ogTitle: SITE_NAME,
|
||||
ogDescription: 'Articoli, guide e analisi.',
|
||||
ogType: 'website',
|
||||
ogUrl: canonical,
|
||||
ogImage: `${config.siteUrl}/logo.png`,
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<section class="landing">
|
||||
<img
|
||||
class="landing-image"
|
||||
src="/protesta-bancaria.jpg"
|
||||
alt="Manifesti e scritte contro le banche durante una crisi bancaria"
|
||||
width="770"
|
||||
height="420"
|
||||
>
|
||||
|
||||
<h1>La fine dell'Unione Europea per come l'abbiamo conosciuta.</h1>
|
||||
|
||||
<img
|
||||
class="landing-image"
|
||||
src="/eurozona-crisi.jpg"
|
||||
alt="Bandiera dell'Unione Europea incrinata"
|
||||
width="1000"
|
||||
height="736"
|
||||
>
|
||||
|
||||
<div class="landing-text">
|
||||
<p>
|
||||
La fine della nostra moneta come moneta forte: la fine di quella moneta che ci ha
|
||||
dato la vera libertà di viaggiare, consumare e fare.
|
||||
</p>
|
||||
<p>
|
||||
Una fine che avverrà a seguito di un "incidente" nucleare, al quale farà seguito un
|
||||
corralito bancario, ossia l'impossibilità, da parte dei cittadini europei, di
|
||||
disporre liberamente dei propri depositi.
|
||||
</p>
|
||||
<p>
|
||||
Le monete fiduciarie sono per loro natura cannibalizzanti. Oggi tocca all'euro.
|
||||
Anche perché le altre hanno praticamente perso tutte tutto.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<p v-if="error">Non è stato possibile caricare gli articoli. Riprova più tardi.</p>
|
||||
|
||||
<p v-else-if="!featured">Non è stato ancora pubblicato nulla.</p>
|
||||
|
||||
<template v-else>
|
||||
<article class="featured" :class="{ 'has-cover': featuredCover }">
|
||||
<NuxtLink
|
||||
v-if="featuredCover"
|
||||
class="featured-cover"
|
||||
:to="`/blog/${featured.slug}`"
|
||||
tabindex="-1"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<img
|
||||
:src="featuredCover"
|
||||
:alt="featured.cover?.alt ?? ''"
|
||||
>
|
||||
</NuxtLink>
|
||||
|
||||
<div class="featured-text">
|
||||
<p class="kicker">
|
||||
{{ featured.category ? featured.category.name : 'Ultimo' }}
|
||||
</p>
|
||||
<h2>
|
||||
<NuxtLink :to="`/blog/${featured.slug}`">{{ featured.title }}</NuxtLink>
|
||||
</h2>
|
||||
<p class="kicker date">
|
||||
<time :datetime="isoDate(featured.publishedAt)">
|
||||
{{ formatDate(featured.publishedAt) }}
|
||||
</time>
|
||||
</p>
|
||||
<p class="read-more">
|
||||
<NuxtLink :to="`/blog/${featured.slug}`">
|
||||
Leggi l'ultimo articolo
|
||||
</NuxtLink>
|
||||
</p>
|
||||
</div>
|
||||
</article>
|
||||
</template>
|
||||
</div>
|
||||
<HomeView locale="it" />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.landing {
|
||||
max-width: var(--width-prose);
|
||||
margin-inline: auto;
|
||||
margin-bottom: var(--gap-section);
|
||||
padding-bottom: var(--gap-section);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.landing-image {
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 9;
|
||||
object-fit: cover;
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.landing h1 {
|
||||
margin-block: 1.75rem;
|
||||
font-size: clamp(2rem, 4.5vw, 3.1rem);
|
||||
}
|
||||
|
||||
.landing-text {
|
||||
margin-top: 1.75rem;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.landing-text p {
|
||||
margin-top: 1.1rem;
|
||||
}
|
||||
|
||||
.featured {
|
||||
display: grid;
|
||||
gap: 1.5rem;
|
||||
align-items: center;
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
/* Stacked on phones and portrait tablets — a side-by-side split below this
|
||||
width leaves both the image and the headline too cramped to read. The split
|
||||
also needs a cover to sit in it, or one column would just be empty. */
|
||||
@media (min-width: 64rem) {
|
||||
.featured.has-cover {
|
||||
grid-template-columns: 1.35fr 1fr;
|
||||
gap: 3.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
/* Without a cover the headline is the whole lead, so it gets the full width
|
||||
but stays inside a readable measure. */
|
||||
.featured:not(.has-cover) .featured-text {
|
||||
max-width: 46rem;
|
||||
}
|
||||
|
||||
.featured-cover {
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.featured-cover img {
|
||||
width: 100%;
|
||||
aspect-ratio: 3 / 2;
|
||||
object-fit: cover;
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
.featured h2 {
|
||||
margin-top: 0.6rem;
|
||||
font-size: clamp(2rem, 4.5vw, 3.1rem);
|
||||
}
|
||||
|
||||
.featured h2 a {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.featured h2 a:hover {
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 0.12em;
|
||||
}
|
||||
|
||||
.date {
|
||||
margin-top: 1rem;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.read-more {
|
||||
margin-top: 1.75rem;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.14em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.read-more a {
|
||||
padding-bottom: 0.35rem;
|
||||
text-decoration: none;
|
||||
border-bottom: 1px solid var(--color-ink);
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user