Compare commits

...

5 Commits

Author SHA1 Message Date
kayjaydee f18b0bff2c feat(06-04): enrich blog article page with breadcrumb, TOC, prev/next
- isFr converti en computed (fix Phase 5 non-reactive isFr)
- { watch: [locale] } sur les 2 useAsyncData (article + surround)
- queryCollectionItemSurroundings avec littéraux 'blog_fr'/'blog_en', fields explicites
- Article query WITHOUT draft filter (direct URL access, D-14)
- Surround query WITH .where('draft','=',false).order('date','DESC')
- Mapping prev=surround[1], next=surround[0] (Pitfall 4 DESC order)
- Header: UBreadcrumb + H1 + meta row (date Intl + reading time) + tags + cover NuxtImg eager
- Layout grid desktop [1fr_16rem] avec max-w-3xl colonne article
- ContentRenderer prose wrapper Phase 5 préservé
- BlogToc aside + BlogPrevNext en bas
- ogType: 'article' (préparation Phase 7)

Requirements: BLOG-03, BLOG-06
2026-04-22 10:09:23 +02:00
kayjaydee 0ff36784e9 feat(06-04): add BlogPrevNext component (grid 2 cols, BlogCard compact variant) 2026-04-22 10:06:52 +02:00
kayjaydee b72b564b69 feat(06-04): add BlogToc component (sticky desktop + drawer mobile + IntersectionObserver highlight) 2026-04-22 10:06:38 +02:00
kayjaydee d8130bba70 docs(06-03): blog listing page SUMMARY 2026-04-22 10:05:57 +02:00
kayjaydee eca09e0c32 feat(06-03): add blog listing page /blog (hero + grid + empty state)
- Query bilingue queryCollection('blog_fr') / queryCollection('blog_en') literal branches (Phase 5 gotcha)
- .where('draft', '=', false).order('date', 'DESC') with { watch: [locale] }
- Hero pattern /projects.vue: slogan // blog + H1 gradient + 3 stats (articles/tags/languages)
- Grid 1/2/3 responsive cols using BlogCard default variant
- Empty state with UIcon book-open + UButton CTA to /contact
- useSeoMeta minimal (full SEO + JSON-LD reserved for Phase 7)

Requirements: BLOG-02, BLOG-06
2026-04-22 10:05:16 +02:00
5 changed files with 566 additions and 16 deletions
@@ -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).
+39
View File
@@ -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>
+158
View File
@@ -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>
+137 -13
View File
@@ -1,33 +1,157 @@
<script setup lang="ts"> <script setup lang="ts">
const { locale } = useI18n() const { t, locale } = useI18n()
const localePath = useLocalePath()
const route = useRoute() const route = useRoute()
const isFr = computed(() => locale.value === 'fr')
const slug = route.params.slug as string const slug = route.params.slug as string
const isFr = locale.value === 'fr' const path = computed(() => (isFr.value ? `/fr/blog/${slug}` : `/en/blog/${slug}`))
const path = isFr ? `/fr/blog/${slug}` : `/en/blog/${slug}`
const { data: page } = await useAsyncData(`blog-${locale.value}-${slug}`, () => // 1) Main article (NO draft filter — direct URL access allowed for drafts, D-14)
isFr const { data: page } = await useAsyncData(
? queryCollection('blog_fr').path(path).first() `blog-${locale.value}-${slug}`,
: queryCollection('blog_en').path(path).first() () =>
isFr.value
? queryCollection('blog_fr').path(path.value).first()
: queryCollection('blog_en').path(path.value).first(),
{ watch: [locale] },
) )
if (!page.value) { if (!page.value) {
throw createError({ statusCode: 404, statusMessage: 'Article introuvable' }) 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({ useSeoMeta({
title: page.value.title, title: () => page.value?.title,
description: page.value.description, description: () => page.value?.description,
ogTitle: page.value.title, ogTitle: () => page.value?.title,
ogDescription: page.value.description, ogDescription: () => page.value?.description,
ogType: 'article',
}) })
</script> </script>
<template> <template>
<div class="mx-auto max-w-3xl px-4 py-12"> <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"> <article class="prose dark:prose-invert max-w-none">
<ContentRenderer v-if="page" :value="page" /> <ContentRenderer v-if="page" :value="page" />
</article> </article>
<BlogPrevNext :prev="prevArticle" :next="nextArticle" />
</div>
<BlogToc v-if="tocLinks.length > 0" :links="tocLinks" />
</div>
</div> </div>
</template> </template>
+151
View File
@@ -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>