Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f18b0bff2c | |||
| 0ff36784e9 | |||
| b72b564b69 | |||
| d8130bba70 | |||
| eca09e0c32 |
@@ -0,0 +1,78 @@
|
||||
---
|
||||
phase: 06-blog-pages
|
||||
plan: "03"
|
||||
subsystem: blog-listing-page
|
||||
tags: [blog, listing, page, ssr, i18n]
|
||||
dependency_graph:
|
||||
requires: ['01', '02']
|
||||
provides: [blog-listing-page]
|
||||
affects:
|
||||
- app/pages/blog/index.vue
|
||||
key_files:
|
||||
created:
|
||||
- app/pages/blog/index.vue
|
||||
modified: []
|
||||
decisions:
|
||||
- "queryCollection literal branches (D-03 Phase 5 gotcha): jamais queryCollection(variable) — branches if/else isFr obligatoires pour le Vite extractor"
|
||||
- "{ watch: [locale] } dans useAsyncData: sans ça, switch FR/EN garde l'ancienne langue (Pitfall 3 RESEARCH)"
|
||||
- "key useAsyncData = `blog-list-${locale.value}`: cache invalidé proprement au switch"
|
||||
- "Empty state conscious à ce stade: tous les articles ont draft:true (Wave 1 T1.5) — comportement voulu, blog ship-ready avec CTA contact"
|
||||
- "SEO minimal (title/description/ogType) — JSON-LD Article + og:image par page sera Phase 7"
|
||||
- "Pas de routeRules /blog/** ajouté: la redirection sans préfixe reste gérée par detectBrowserLanguage (Phase 5)"
|
||||
metrics:
|
||||
duration: "~5 min (exécution inline après rollback subagent Task freeze)"
|
||||
completed: "2026-04-22"
|
||||
tasks_completed: 1
|
||||
tasks_total: 1
|
||||
files_created: 1
|
||||
files_modified: 0
|
||||
checkpoint: "none (autonomous)"
|
||||
---
|
||||
|
||||
# Phase 06 Plan 03: Blog Listing Page Summary
|
||||
|
||||
Création de la page listing `/blog` en SSR bilingue — hero avec stats, grille responsive 1/2/3 cols de BlogCard (variant default), empty state avec CTA vers `/contact`. Query bilingue @nuxt/content v3 avec branches littérales (Phase 5 gotcha respecté), filtre `draft`, tri par date descendant.
|
||||
|
||||
## Tasks Completed
|
||||
|
||||
| Task | Name | Commit | Files |
|
||||
|------|------|--------|-------|
|
||||
| 3.1 | Créer `app/pages/blog/index.vue` (hero + query bilingue + grille + empty state) | `eca09e0` | app/pages/blog/index.vue |
|
||||
|
||||
## Decisions Made
|
||||
|
||||
1. **queryCollection littéral** — branches `if isFr.value ? queryCollection('blog_fr') : queryCollection('blog_en')`. Le Vite extractor de @nuxt/content analyse seulement les littéraux — toute variable casse l'extraction et retourne `{}` silencieusement (Phase 5 gotcha Pitfall 1 RESEARCH).
|
||||
|
||||
2. **`{ watch: [locale] }` sur useAsyncData** — sans cette option, le switch FR↔EN ne re-fetch pas et affiche l'ancienne langue. Indispensable pour le comportement réactif de la locale (Pitfall 3 RESEARCH).
|
||||
|
||||
3. **Stats : articles + tags uniques + langues fixes (2)** — computed sur `articles.value` côté client après fetch. `Set<string>` pour dédupliquer les tags. La valeur `2` pour les langues est fixée (FR + EN) — à dériver si une 3ème langue apparaît (note UI-SPEC checker).
|
||||
|
||||
4. **Empty state intentionnel** — à la sortie de Phase 6, tous les articles ont `draft: true` (article test marqué Wave 1). Le listing affiche donc l'empty state "Bientôt des articles Hytale" avec CTA contact — comportement voulu et professionnel, le blog est prêt pour Phase 8 (articles seed réels).
|
||||
|
||||
5. **useSeoMeta minimal** — seulement title + description + ogType. Phase 7 ajoutera JSON-LD Article, og:image par page, BreadcrumbList, canonical avec variants i18n.
|
||||
|
||||
## Deviations from Plan
|
||||
|
||||
Aucune — plan exécuté exactement selon spec. Le fichier avait déjà été créé par un subagent précédent (interrompu avant commit) avec exactement le même contenu que le plan. Vérification intégrale faite ; commit et SUMMARY ajoutés.
|
||||
|
||||
## Acceptance Criteria Check
|
||||
|
||||
- [x] `test -f app/pages/blog/index.vue` → file exists
|
||||
- [x] `grep -c "queryCollection('blog_fr')" app/pages/blog/index.vue` = 1
|
||||
- [x] `grep -c "queryCollection('blog_en')" app/pages/blog/index.vue` = 1
|
||||
- [x] `grep "queryCollection(locale" ...` → nothing (literal-only)
|
||||
- [x] `grep -c "\.where('draft'" app/pages/blog/index.vue` = 2 (une par branche)
|
||||
- [x] `grep -c "\.order('date', 'DESC')" app/pages/blog/index.vue` = 2
|
||||
- [x] `grep -c "watch: \[locale\]" app/pages/blog/index.vue` = 1
|
||||
- [x] `grep "<BlogCard" app/pages/blog/index.vue` matches
|
||||
- [x] `grep "variant=\"default\"" app/pages/blog/index.vue` matches
|
||||
- [x] `grep "localePath('/contact')" app/pages/blog/index.vue` matches
|
||||
- [x] `grep "// blog" app/pages/blog/index.vue` matches
|
||||
- [x] `grep "grid-cols-1 md:grid-cols-2 lg:grid-cols-3" app/pages/blog/index.vue` matches
|
||||
- [x] `pnpm typecheck` → exit 0
|
||||
- [ ] `pnpm build` → non exécuté à ce stade (déléguée à l'étape de vérification phase)
|
||||
- [ ] Tests runtime curl /fr/blog + /en/blog → non exécutés (pnpm dev pas lancé ici, à valider par gsd-verifier ou via /gsd-verify-work)
|
||||
|
||||
## Self-Check: PASSED
|
||||
|
||||
Tous les critères statiques (fichiers, grep, typecheck) passent. Les critères runtime (curl, switch locale) sont reportés à l'étape de vérification phase (post Wave 3 complète).
|
||||
@@ -0,0 +1,39 @@
|
||||
<script setup lang="ts">
|
||||
interface SurroundArticle {
|
||||
path: string
|
||||
title: string
|
||||
description?: string
|
||||
date: string
|
||||
tags?: string[]
|
||||
image?: string
|
||||
minutes?: number
|
||||
}
|
||||
|
||||
interface Props {
|
||||
prev: SurroundArticle | null
|
||||
next: SurroundArticle | null
|
||||
}
|
||||
|
||||
defineProps<Props>()
|
||||
const { t } = useI18n()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<nav
|
||||
v-if="prev || next"
|
||||
class="mt-16 grid md:grid-cols-2 gap-5"
|
||||
:aria-label="t('blog.prevArticle') + ' / ' + t('blog.nextArticle')"
|
||||
>
|
||||
<!-- Prev (older article in DESC order) -->
|
||||
<div v-if="prev">
|
||||
<BlogCard :article="prev" variant="compact" direction="prev" />
|
||||
</div>
|
||||
<div v-else aria-hidden="true" />
|
||||
|
||||
<!-- Next (newer article in DESC order) -->
|
||||
<div v-if="next">
|
||||
<BlogCard :article="next" variant="compact" direction="next" />
|
||||
</div>
|
||||
<div v-else aria-hidden="true" />
|
||||
</nav>
|
||||
</template>
|
||||
@@ -0,0 +1,158 @@
|
||||
<script setup lang="ts">
|
||||
interface TocLink {
|
||||
id: string
|
||||
depth: number
|
||||
text: string
|
||||
children?: TocLink[]
|
||||
}
|
||||
|
||||
interface Props {
|
||||
links: TocLink[]
|
||||
}
|
||||
|
||||
const props = defineProps<Props>()
|
||||
const { t } = useI18n()
|
||||
|
||||
const drawerOpen = ref(false)
|
||||
const activeId = ref<string | null>(null)
|
||||
let observer: IntersectionObserver | null = null
|
||||
|
||||
const flatIds = computed(() => {
|
||||
const ids: string[] = []
|
||||
const collect = (nodes: TocLink[]) => {
|
||||
for (const node of nodes) {
|
||||
ids.push(node.id)
|
||||
if (node.children?.length) collect(node.children)
|
||||
}
|
||||
}
|
||||
collect(props.links)
|
||||
return ids
|
||||
})
|
||||
|
||||
onMounted(() => {
|
||||
if (typeof window === 'undefined') return
|
||||
|
||||
activeId.value = flatIds.value[0] ?? null
|
||||
|
||||
observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
const visible = entries
|
||||
.filter((e) => e.isIntersecting)
|
||||
.sort((a, b) => a.target.getBoundingClientRect().top - b.target.getBoundingClientRect().top)
|
||||
if (visible.length > 0) {
|
||||
activeId.value = visible[0]!.target.id
|
||||
}
|
||||
},
|
||||
{ rootMargin: '-20% 0px -70% 0px', threshold: 0 },
|
||||
)
|
||||
|
||||
for (const id of flatIds.value) {
|
||||
const el = document.getElementById(id)
|
||||
if (el) observer.observe(el)
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
observer?.disconnect()
|
||||
observer = null
|
||||
})
|
||||
|
||||
function handleItemClick() {
|
||||
drawerOpen.value = false
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- Desktop sticky sidebar -->
|
||||
<aside class="hidden lg:block sticky top-24 w-64 self-start">
|
||||
<p class="text-sm font-bold uppercase tracking-wider text-gray-500 dark:text-gray-400 mb-4">
|
||||
{{ t('blog.toc.title') }}
|
||||
</p>
|
||||
<ol class="space-y-2 text-sm">
|
||||
<li v-for="link in links" :key="link.id">
|
||||
<a
|
||||
:href="`#${link.id}`"
|
||||
:class="[
|
||||
activeId === link.id
|
||||
? 'text-brand-500 dark:text-brand-400 font-medium'
|
||||
: 'text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white',
|
||||
'block transition-colors',
|
||||
]"
|
||||
>
|
||||
{{ link.text }}
|
||||
</a>
|
||||
<ol v-if="link.children?.length" class="mt-1 ml-4 space-y-1">
|
||||
<li v-for="child in link.children" :key="child.id">
|
||||
<a
|
||||
:href="`#${child.id}`"
|
||||
:class="[
|
||||
activeId === child.id
|
||||
? 'text-brand-500 dark:text-brand-400 font-medium'
|
||||
: 'text-gray-500 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white',
|
||||
'block transition-colors',
|
||||
]"
|
||||
>
|
||||
{{ child.text }}
|
||||
</a>
|
||||
</li>
|
||||
</ol>
|
||||
</li>
|
||||
</ol>
|
||||
</aside>
|
||||
|
||||
<!-- Mobile trigger + drawer -->
|
||||
<div class="lg:hidden inline-block">
|
||||
<UButton
|
||||
variant="ghost"
|
||||
color="neutral"
|
||||
size="sm"
|
||||
icon="i-lucide-list"
|
||||
:aria-label="t('a11y.blogTocToggle')"
|
||||
@click="drawerOpen = true"
|
||||
>
|
||||
{{ t('blog.toc.title') }}
|
||||
</UButton>
|
||||
|
||||
<UDrawer v-model:open="drawerOpen" direction="right">
|
||||
<template #header>
|
||||
<p class="text-sm font-bold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||
{{ t('blog.toc.title') }}
|
||||
</p>
|
||||
</template>
|
||||
<template #body>
|
||||
<ol class="space-y-3 text-sm p-4">
|
||||
<li v-for="link in links" :key="link.id">
|
||||
<a
|
||||
:href="`#${link.id}`"
|
||||
:class="[
|
||||
activeId === link.id
|
||||
? 'text-brand-500 dark:text-brand-400 font-medium'
|
||||
: 'text-gray-600 dark:text-gray-300',
|
||||
'block transition-colors',
|
||||
]"
|
||||
@click="handleItemClick"
|
||||
>
|
||||
{{ link.text }}
|
||||
</a>
|
||||
<ol v-if="link.children?.length" class="mt-2 ml-4 space-y-2">
|
||||
<li v-for="child in link.children" :key="child.id">
|
||||
<a
|
||||
:href="`#${child.id}`"
|
||||
:class="[
|
||||
activeId === child.id
|
||||
? 'text-brand-500 dark:text-brand-400 font-medium'
|
||||
: 'text-gray-500 dark:text-gray-400',
|
||||
'block transition-colors',
|
||||
]"
|
||||
@click="handleItemClick"
|
||||
>
|
||||
{{ child.text }}
|
||||
</a>
|
||||
</li>
|
||||
</ol>
|
||||
</li>
|
||||
</ol>
|
||||
</template>
|
||||
</UDrawer>
|
||||
</div>
|
||||
</template>
|
||||
+140
-16
@@ -1,33 +1,157 @@
|
||||
<script setup lang="ts">
|
||||
const { locale } = useI18n()
|
||||
const { t, locale } = useI18n()
|
||||
const localePath = useLocalePath()
|
||||
const route = useRoute()
|
||||
|
||||
const isFr = computed(() => locale.value === 'fr')
|
||||
const slug = route.params.slug as string
|
||||
const isFr = locale.value === 'fr'
|
||||
const path = isFr ? `/fr/blog/${slug}` : `/en/blog/${slug}`
|
||||
const path = computed(() => (isFr.value ? `/fr/blog/${slug}` : `/en/blog/${slug}`))
|
||||
|
||||
const { data: page } = await useAsyncData(`blog-${locale.value}-${slug}`, () =>
|
||||
isFr
|
||||
? queryCollection('blog_fr').path(path).first()
|
||||
: queryCollection('blog_en').path(path).first()
|
||||
// 1) Main article (NO draft filter — direct URL access allowed for drafts, D-14)
|
||||
const { data: page } = await useAsyncData(
|
||||
`blog-${locale.value}-${slug}`,
|
||||
() =>
|
||||
isFr.value
|
||||
? queryCollection('blog_fr').path(path.value).first()
|
||||
: queryCollection('blog_en').path(path.value).first(),
|
||||
{ watch: [locale] },
|
||||
)
|
||||
|
||||
if (!page.value) {
|
||||
throw createError({ statusCode: 404, statusMessage: 'Article introuvable' })
|
||||
}
|
||||
|
||||
// 2) Surroundings (prev/next) WITH draft filter + order DESC
|
||||
const { data: surround } = await useAsyncData(
|
||||
`blog-surround-${locale.value}-${slug}`,
|
||||
() =>
|
||||
isFr.value
|
||||
? queryCollectionItemSurroundings('blog_fr', path.value, {
|
||||
fields: ['title', 'description', 'date', 'image', 'path', 'minutes'],
|
||||
})
|
||||
.where('draft', '=', false)
|
||||
.order('date', 'DESC')
|
||||
: queryCollectionItemSurroundings('blog_en', path.value, {
|
||||
fields: ['title', 'description', 'date', 'image', 'path', 'minutes'],
|
||||
})
|
||||
.where('draft', '=', false)
|
||||
.order('date', 'DESC'),
|
||||
{ watch: [locale] },
|
||||
)
|
||||
|
||||
interface SurroundArticle {
|
||||
path: string
|
||||
title: string
|
||||
description?: string
|
||||
date: string
|
||||
tags?: string[]
|
||||
image?: string
|
||||
minutes?: number
|
||||
}
|
||||
|
||||
// D-12 + Pitfall 4: order DESC → surround[0] = newer (next UI), surround[1] = older (prev UI)
|
||||
// @nuxt/content v3 surround return type is ContentNavigationItem (minimal) but with fields[] option
|
||||
// the runtime object carries the requested fields. Cast to SurroundArticle for BlogPrevNext props.
|
||||
const nextArticle = computed(() => (surround.value?.[0] as SurroundArticle | undefined) ?? null)
|
||||
const prevArticle = computed(() => (surround.value?.[1] as SurroundArticle | undefined) ?? null)
|
||||
|
||||
const breadcrumbItems = computed(() => [
|
||||
{ label: t('blog.breadcrumb.home'), to: localePath('/'), icon: 'i-lucide-home' },
|
||||
{ label: t('blog.breadcrumb.blog'), to: localePath('/blog') },
|
||||
{ label: page.value?.title ?? '' },
|
||||
])
|
||||
|
||||
const formattedDate = computed(() => {
|
||||
if (!page.value?.date) return ''
|
||||
try {
|
||||
return new Intl.DateTimeFormat(isFr.value ? 'fr-FR' : 'en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
}).format(new Date(page.value.date))
|
||||
} catch {
|
||||
return page.value.date
|
||||
}
|
||||
})
|
||||
|
||||
const readingMinutes = computed(() => {
|
||||
if (typeof page.value?.minutes === 'number') return page.value.minutes
|
||||
return useReadingTime(page.value?.description ?? '')
|
||||
})
|
||||
|
||||
interface TocLink {
|
||||
id: string
|
||||
depth: number
|
||||
text: string
|
||||
children?: TocLink[]
|
||||
}
|
||||
|
||||
const tocLinks = computed<TocLink[]>(() => {
|
||||
const body = page.value?.body as { toc?: { links?: TocLink[] } } | undefined
|
||||
return body?.toc?.links ?? []
|
||||
})
|
||||
|
||||
useSeoMeta({
|
||||
title: page.value.title,
|
||||
description: page.value.description,
|
||||
ogTitle: page.value.title,
|
||||
ogDescription: page.value.description,
|
||||
title: () => page.value?.title,
|
||||
description: () => page.value?.description,
|
||||
ogTitle: () => page.value?.title,
|
||||
ogDescription: () => page.value?.description,
|
||||
ogType: 'article',
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mx-auto max-w-3xl px-4 py-12">
|
||||
<article class="prose dark:prose-invert max-w-none">
|
||||
<ContentRenderer v-if="page" :value="page" />
|
||||
</article>
|
||||
<div class="max-w-7xl mx-auto px-4 py-12">
|
||||
<UBreadcrumb :items="breadcrumbItems" class="mb-6 text-sm" />
|
||||
|
||||
<div class="lg:grid lg:grid-cols-[1fr_16rem] lg:gap-12">
|
||||
<!-- Main column -->
|
||||
<div class="max-w-3xl mx-auto lg:mx-0 w-full">
|
||||
<header class="mb-8">
|
||||
<h1 class="text-3xl sm:text-4xl font-bold text-gray-900 dark:text-white mb-4">
|
||||
{{ page?.title }}
|
||||
</h1>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-2 text-sm text-gray-500 dark:text-gray-400 mb-4">
|
||||
<time :datetime="page?.date" class="font-mono inline-flex items-center gap-1.5">
|
||||
<UIcon name="i-lucide-calendar" class="w-3.5 h-3.5" />
|
||||
{{ formattedDate }}
|
||||
</time>
|
||||
<span aria-hidden="true">·</span>
|
||||
<span class="inline-flex items-center gap-1.5">
|
||||
<UIcon name="i-lucide-clock" class="w-3.5 h-3.5" />
|
||||
{{ t('blog.readingTime', { minutes: readingMinutes }) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="page?.tags?.length" class="flex flex-wrap gap-2 mb-6">
|
||||
<UBadge
|
||||
v-for="tag in page.tags"
|
||||
:key="tag"
|
||||
color="primary"
|
||||
variant="subtle"
|
||||
>
|
||||
{{ tag }}
|
||||
</UBadge>
|
||||
</div>
|
||||
|
||||
<NuxtImg
|
||||
v-if="page?.image"
|
||||
:src="page.image"
|
||||
:alt="page.title"
|
||||
loading="eager"
|
||||
format="webp"
|
||||
class="w-full aspect-[21/9] object-cover rounded-2xl mt-2 mb-4"
|
||||
/>
|
||||
</header>
|
||||
|
||||
<article class="prose dark:prose-invert max-w-none">
|
||||
<ContentRenderer v-if="page" :value="page" />
|
||||
</article>
|
||||
|
||||
<BlogPrevNext :prev="prevArticle" :next="nextArticle" />
|
||||
</div>
|
||||
|
||||
<BlogToc v-if="tocLinks.length > 0" :links="tocLinks" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
<script setup lang="ts">
|
||||
const { t, locale } = useI18n()
|
||||
const localePath = useLocalePath()
|
||||
const isFr = computed(() => locale.value === 'fr')
|
||||
|
||||
// Query bilingue avec branches littérales (Phase 5 gotcha — Pattern 1 RESEARCH)
|
||||
// Option watch sur locale pour re-fetch au switch FR/EN (Pitfall 3)
|
||||
const { data: articles } = await useAsyncData(
|
||||
`blog-list-${locale.value}`,
|
||||
() =>
|
||||
isFr.value
|
||||
? queryCollection('blog_fr')
|
||||
.where('draft', '=', false)
|
||||
.order('date', 'DESC')
|
||||
.all()
|
||||
: queryCollection('blog_en')
|
||||
.where('draft', '=', false)
|
||||
.order('date', 'DESC')
|
||||
.all(),
|
||||
{ watch: [locale] },
|
||||
)
|
||||
|
||||
// Stats computed (UI-SPEC §Hero contract exact — 3 items)
|
||||
const totalArticles = computed(() => articles.value?.length ?? 0)
|
||||
|
||||
const uniqueTags = computed(() => {
|
||||
const set = new Set<string>()
|
||||
for (const a of articles.value ?? []) {
|
||||
for (const tag of a.tags ?? []) set.add(tag)
|
||||
}
|
||||
return set.size
|
||||
})
|
||||
|
||||
const totalLanguages = 2 // FR + EN — valeur fixe (UI-SPEC)
|
||||
|
||||
// SEO minimal Phase 6 — Phase 7 enrichira avec JSON-LD + og:image par article
|
||||
useSeoMeta({
|
||||
title: () => t('blog.title'),
|
||||
description: () => t('blog.subtitle'),
|
||||
ogTitle: () => t('blog.title'),
|
||||
ogDescription: () => t('blog.subtitle'),
|
||||
ogType: 'website',
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div>
|
||||
<!-- Hero (pattern /projects.vue lignes 56-83) -->
|
||||
<section class="relative pt-20 pb-16 px-4 sm:px-6 lg:px-8 overflow-hidden">
|
||||
<div class="absolute inset-0 bg-gray-50/80 dark:bg-gray-900/40" aria-hidden="true" />
|
||||
<div
|
||||
class="absolute top-0 left-1/2 -translate-x-1/2 w-[800px] h-[400px] bg-brand-500/5 dark:bg-brand-500/8 rounded-full blur-3xl pointer-events-none"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
|
||||
<div class="relative z-10 max-w-7xl mx-auto text-center">
|
||||
<span class="font-mono text-sm text-brand-500 dark:text-brand-400 tracking-wider">// blog</span>
|
||||
<h1
|
||||
class="text-4xl sm:text-5xl lg:text-6xl font-bold mt-3 mb-5 bg-gradient-to-r from-gray-900 via-gray-800 to-gray-600 dark:from-white dark:via-gray-200 dark:to-gray-500 bg-clip-text text-transparent"
|
||||
>
|
||||
{{ t('blog.title') }}
|
||||
</h1>
|
||||
<p class="text-lg sm:text-xl text-gray-500 dark:text-gray-400 max-w-2xl mx-auto leading-relaxed">
|
||||
{{ t('blog.subtitle') }}
|
||||
</p>
|
||||
|
||||
<!-- Stats: articles / tags / languages (3 items + 2 dividers) -->
|
||||
<div class="flex justify-center gap-8 sm:gap-12 mt-12">
|
||||
<div class="text-center">
|
||||
<p
|
||||
class="text-3xl sm:text-4xl font-black bg-gradient-to-b from-brand-400 to-brand-600 bg-clip-text text-transparent"
|
||||
>
|
||||
{{ totalArticles }}
|
||||
</p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mt-2 font-medium">
|
||||
{{ t('blog.stats.articles') }}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
class="w-px bg-gradient-to-b from-transparent via-gray-300 dark:via-gray-700 to-transparent"
|
||||
/>
|
||||
<div class="text-center">
|
||||
<p
|
||||
class="text-3xl sm:text-4xl font-black bg-gradient-to-b from-brand-400 to-brand-600 bg-clip-text text-transparent"
|
||||
>
|
||||
{{ uniqueTags }}
|
||||
</p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mt-2 font-medium">
|
||||
{{ t('blog.stats.tags') }}
|
||||
</p>
|
||||
</div>
|
||||
<div
|
||||
class="w-px bg-gradient-to-b from-transparent via-gray-300 dark:via-gray-700 to-transparent"
|
||||
/>
|
||||
<div class="text-center">
|
||||
<p
|
||||
class="text-3xl sm:text-4xl font-black bg-gradient-to-b from-brand-400 to-brand-600 bg-clip-text text-transparent"
|
||||
>
|
||||
{{ totalLanguages }}
|
||||
</p>
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400 mt-2 font-medium">
|
||||
{{ t('blog.stats.languages') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Grid or Empty state -->
|
||||
<section class="py-16 md:py-20 px-4 sm:px-6 lg:px-8">
|
||||
<div class="max-w-7xl mx-auto">
|
||||
<!-- Grille responsive 1/2/3 cols (D-01) -->
|
||||
<div
|
||||
v-if="articles && articles.length > 0"
|
||||
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5 lg:gap-6"
|
||||
>
|
||||
<BlogCard
|
||||
v-for="article in articles"
|
||||
:key="article.path"
|
||||
:article="article"
|
||||
variant="default"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Empty state (D-16 + UI-SPEC §Empty state copywriting) -->
|
||||
<div v-else class="text-center py-32">
|
||||
<div
|
||||
class="w-16 h-16 mx-auto mb-6 rounded-2xl bg-gray-100 dark:bg-gray-800/60 border border-gray-200/80 dark:border-gray-700/30 flex items-center justify-center"
|
||||
>
|
||||
<UIcon name="i-lucide-book-open" class="text-2xl text-gray-400" />
|
||||
</div>
|
||||
<h3 class="text-xl font-bold text-gray-900 dark:text-white mb-3">
|
||||
{{ t('blog.emptyState.title') }}
|
||||
</h3>
|
||||
<p class="text-gray-500 dark:text-gray-400 mb-8 max-w-md mx-auto leading-relaxed">
|
||||
{{ t('blog.emptyState.description') }}
|
||||
</p>
|
||||
<UButton
|
||||
color="primary"
|
||||
variant="solid"
|
||||
size="md"
|
||||
icon="i-lucide-mail"
|
||||
:to="localePath('/contact')"
|
||||
>
|
||||
{{ t('blog.emptyState.cta') }}
|
||||
</UButton>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
Reference in New Issue
Block a user