Phase 6 Blog Pages decomposed into: - 06-01 (Wave 1): content schema + reading-time Nitro hook + draft flags - 06-02 (Wave 2): i18n keys + AppHeader link + BlogCard unified - 06-03 (Wave 3): listing page /blog SSR bilingue - 06-04 (Wave 3): [slug] enrichment + BlogToc + BlogPrevNext Plans 06-03 and 06-04 have zero file overlap and run in parallel. Covers BLOG-02, BLOG-03, BLOG-06. Honors all 21 D-XX user decisions from 06-CONTEXT.md. Phase 5 gotchas (literal queryCollection, single [slug].vue, no routeRules /blog/**) respected in every query branch.
18 KiB
phase, plan, type, wave, depends_on, files_modified, autonomous, requirements, tags, must_haves
| phase | plan | type | wave | depends_on | files_modified | autonomous | requirements | tags | must_haves | |||||||||||||||||||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| 06-blog-pages | 03 | execute | 3 |
|
|
true |
|
|
|
Purpose: Cette page satisfait directement les success criteria 1 + 5 de la phase (listing SSR + version EN). Elle consomme les artefacts de Wave 1 (schema étendu avec draft) et Wave 2 (BlogCard + i18n + localePath).
Output: app/pages/blog/index.vue (nouveau fichier, n'existe PAS actuellement).
<execution_context> @$HOME/.claude/get-shit-done/workflows/execute-plan.md @$HOME/.claude/get-shit-done/templates/summary.md </execution_context>
@.planning/STATE.md @.planning/phases/06-blog-pages/06-CONTEXT.md @.planning/phases/06-blog-pages/06-RESEARCH.md @.planning/phases/06-blog-pages/06-PATTERNS.md @.planning/phases/06-blog-pages/06-UI-SPEC.md @app/pages/projects.vue @app/pages/blog/[slug].vue @app/pages/test.vue ```typescript interface BlogArticle { path: string // '/fr/blog/my-slug' ou '/en/blog/my-slug' title: string description: string date: string tags?: string[] image?: string draft?: boolean // via Wave 1 (filtré par .where) wordCount?: number // via Wave 1 hook minutes?: number // via Wave 1 hook — consommé par BlogCard } ```// CORRECT — branches if/else littérales
const { data } = 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] }
)
// ❌ INCORRECT — retourne {} silencieusement (Pitfall 1)
const col = isFr.value ? 'blog_fr' : 'blog_en'
const { data } = await useAsyncData(() => queryCollection(col).all())
<BlogCard :article="article" variant="default" />
// article: BlogArticle
<section class="relative pt-20 pb-16 px-4 sm:px-6 lg:px-8 overflow-hidden">
<!-- 2 absolute background blurs -->
<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 ...">{{ t('blog.title') }}</h1>
<p class="text-lg sm:text-xl ...">{{ t('blog.subtitle') }}</p>
<!-- Stats row avec 2 dividers verticaux -->
</div>
</section>
blog.title, blog.subtitle, blog.stats.articles, blog.stats.tags, blog.stats.languages, blog.emptyState.title, blog.emptyState.description, blog.emptyState.cta
Task 3.1 : Créer app/pages/blog/index.vue (hero + query bilingue + grille + empty state) app/pages/blog/index.vue - app/pages/projects.vue (ENTIER — source du hero pattern, stats, grid, empty state) - app/pages/blog/[slug].vue (pattern existant de query bilingue Phase 5 — branches isFr à reproduire dans le listing) - app/pages/test.vue (autre exemple de queryCollection littéral) - app/components/BlogCard.vue (créé Wave 2 Task 2.3 — interface props pour le v-for) - .planning/phases/06-blog-pages/06-UI-SPEC.md (§Hero section lignes 255-278 pour le contract hero + §Empty state lignes 143-152 pour le copywriting + §Layout lignes 295-305) - .planning/phases/06-blog-pages/06-PATTERNS.md (§app/pages/blog/index.vue lignes 25-104 pour le code skeleton complet) - .planning/phases/06-blog-pages/06-RESEARCH.md (§Code Examples lignes 641-709 pour le skeleton vérifié + §Pattern 1 pour les littéraux + §Pitfall 3 pour le watch locale) - .planning/phases/06-blog-pages/06-CONTEXT.md (D-01 grille 1/2/3 cols, D-04 hero pattern, D-16 empty state, D-17 URLs) - i18n/locales/fr.json (pour confirmer que blog.stats.articles/tags/languages, blog.emptyState.*, blog.title/subtitle existent — ajoutés Wave 2) Créer `app/pages/blog/index.vue` (nouveau fichier — le dossier `app/pages/blog/` existe déjà et contient `[slug].vue`).Script setup complet :
<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)
// { watch: [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 complet (transposition directe de /projects.vue avec substitution des clés i18n) :
<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>
Points critiques à respecter :
- Littéraux
'blog_fr'/'blog_en'dans deux branches séparées — JAMAISqueryCollection(col)avec variable. Reproduit fidèlement le patternapp/pages/blog/[slug].vueexistant. { watch: [locale] }sur le useAsyncData — sans ça, switch FR/EN affiche l'ancienne langue (Pitfall 3).- Key
blog-list-${locale.value}— inclut la locale pour invalider le cache correctement. computed(() => locale.value === 'fr')(pasconst isFr = locale.value === 'fr') — sinon pas de réactivité sur le switch.articles.value?.length ?? 0avec optional chaining — articles peut êtrenulldurant l'initial fetch avant l'arrivée du SSR payload.- Empty state apparaîtra à ce stade du projet — tous les articles ont
draft: true(Wave 1 Task 1.5 + Pitfall 7). C'est le comportement voulu : le blog se prépare, l'empty state est professionnel et CTA contact. Phase 8 ajoutera les vrais articles seed. - SEO minimal : pas d'ogImage custom, pas de canonical, pas de JSON-LD — Phase 7 traitera ça (hors scope 06).
- Pas de routeRules : ne PAS ajouter de
routeRules: { '/blog': { ... } }dans nuxt.config — la redirection FR/EN sans préfixe passe pardetectBrowserLanguage(Phase 5 gotcha, ne pas toucher). - Pas de layout personnalisé : la page utilise le layout par défaut (header + footer globaux). Ne pas définir
definePageMeta({ layout: ... }). test -f app/pages/blog/index.vue && grep -c "queryCollection('blog_fr')" app/pages/blog/index.vue && grep -c "queryCollection('blog_en')" app/pages/blog/index.vue <acceptance_criteria>test -f app/pages/blog/index.vueretourne 0grep -c "queryCollection('blog_fr')" app/pages/blog/index.vueretourne au moins 1grep -c "queryCollection('blog_en')" app/pages/blog/index.vueretourne au moins 1grep "queryCollection(locale" app/pages/blog/index.vueretourne rien (aucune variable dans queryCollection — littéraux uniquement)grep "queryCollection(col" app/pages/blog/index.vueretourne riengrep -c "\\.where('draft', '=', false)" app/pages/blog/index.vueretourne 2 (une par branche)grep -c "\\.order('date', 'DESC')" app/pages/blog/index.vueretourne 2grep -c "watch: \\[locale\\]" app/pages/blog/index.vueretourne 1grep -c "useAsyncData" app/pages/blog/index.vueretourne 1grep "<BlogCard" app/pages/blog/index.vueretourne 1+ matchgrep -c "variant=\"default\"" app/pages/blog/index.vueretourne 1grep -c "v-for=\"article in articles\"" app/pages/blog/index.vueretourne 1grep -c ":key=\"article.path\"" app/pages/blog/index.vueretourne 1grep -c "t('blog.title')" app/pages/blog/index.vueretourne 2+ matches (hero H1 + useSeoMeta)grep -c "t('blog.subtitle')" app/pages/blog/index.vueretourne 2+ matchesgrep -c "t('blog.stats.articles')" app/pages/blog/index.vueretourne 1grep -c "t('blog.stats.tags')" app/pages/blog/index.vueretourne 1grep -c "t('blog.stats.languages')" app/pages/blog/index.vueretourne 1grep -c "t('blog.emptyState.title')" app/pages/blog/index.vueretourne 1grep -c "t('blog.emptyState.cta')" app/pages/blog/index.vueretourne 1grep "i-lucide-book-open" app/pages/blog/index.vueretourne 1 matchgrep "localePath('/contact')" app/pages/blog/index.vueretourne 1 matchgrep "// blog" app/pages/blog/index.vueretourne 1 match (slogan mono)grep "grid-cols-1 md:grid-cols-2 lg:grid-cols-3" app/pages/blog/index.vueretourne 1 match (D-01 grille responsive)pnpm typecheckpasse sans erreurpnpm lintpasse sans nouvelle erreurpnpm buildcomplète sans erreur (validation SSR — le build fait le prerender et casse si queryCollection mal formé)- Tests runtime manuels (dans un shell avec
pnpm devlancé) :curl -s http://localhost:3000/fr/blogretourne un 200 avec<h1>contenantBloget// blogdans le HTMLcurl -s http://localhost:3000/en/blogretourne un 200 avecHytale articles coming soonouBlogen H1- Les tests curl montrent le HTML de l'empty state (pas de grille) — normal, tous les articles sont draft à ce stade
</acceptance_criteria>
app/pages/blog/index.vue créé. Query bilingue avec littéraux obligatoires respectés (Phase 5 gotcha).
.where('draft','=',false)+.order('date','DESC')+{ watch: [locale] }. Hero pattern projets transposé. Grille responsive 1/2/3 cols. Empty state avec UIcon book-open + UButton CTA contact. SEO minimal via useSeoMeta. Typecheck + lint + build verts. Les routes /fr/blog et /en/blog répondent en SSR.
<success_criteria>
- Page
app/pages/blog/index.vuecréée, 80+ lignes - Hero section SSR avec slogan
// blog+ H1 + subtitle + 3 stats - Grille conditionnelle (v-if articles.length > 0) avec BlogCard v-for variant=default
- Empty state (v-else) avec UIcon + UButton vers /contact
- Query @nuxt/content bilingue avec littéraux, .where('draft','=',false), .order('date','DESC'), { watch: [locale] }
curl /fr/blogetcurl /en/blogretournent HTML SSR avec les bons textes traduits- Success criteria 1 et 5 de la phase validés à la livraison </success_criteria>