---
phase: 06-blog-pages
plan: 03
type: execute
wave: 3
depends_on:
  - 06-01
  - 06-02
files_modified:
  - app/pages/blog/index.vue
autonomous: true
requirements:
  - BLOG-02
  - BLOG-06
tags:
  - blog
  - listing
  - page
  - ssr

must_haves:
  truths:
    - "`curl localhost:3000/fr/blog` retourne du HTML SSR avec un bloc hero (slogan `// blog`, H1 Blog, subtitle, stats) et SOIT une grille de BlogCard SOIT un empty state"
    - "`curl localhost:3000/en/blog` retourne la même structure avec les textes en anglais"
    - "La query utilise `queryCollection('blog_fr')` et `queryCollection('blog_en')` en littéraux séparés par branche if/else (Phase 5 gotcha respecté)"
    - "La query filtre `.where('draft', '=', false)` — les articles test-kotlin-syntax (draft: true après Wave 1) sont exclus, ce qui fait apparaître l'empty state à ce stade du projet (comportement voulu Pitfall 7)"
    - "La query ordonne `.order('date', 'DESC')` — article le plus récent en premier (D-12)"
    - "Le switch de langue (FR → EN) recharge bien la liste via `{ watch: [locale] }` dans useAsyncData"
    - "Stats affichés : nombre d'articles non-draft, nombre de tags uniques, valeur fixe `2` pour languages (FR+EN)"
    - "Empty state affiche UIcon book-open + titre `Bientôt des articles Hytale` / `Hytale articles coming soon` + UButton CTA → `/contact` (via localePath)"
  artifacts:
    - path: "app/pages/blog/index.vue"
      provides: "Page listing SSR bilingue /blog avec hero + grille + empty state"
      contains: "queryCollection('blog_fr')"
      contains_also: "queryCollection('blog_en')"
      min_lines: 80
  key_links:
    - from: "app/pages/blog/index.vue"
      to: "queryCollection('blog_fr') / queryCollection('blog_en')"
      via: "useAsyncData avec branches if/else isFr (littéraux obligatoires)"
      pattern: "queryCollection\\('blog_(fr|en)'\\)"
    - from: "app/pages/blog/index.vue"
      to: "app/components/BlogCard.vue"
      via: "v-for sur articles + <BlogCard :article=... variant='default' />"
      pattern: "<BlogCard"
    - from: "app/pages/blog/index.vue"
      to: "i18n blog.title / blog.subtitle / blog.stats.* / blog.emptyState.*"
      via: "t('blog.title') etc. dans template"
      pattern: "t\\('blog\\."
---

<objective>
Créer `app/pages/blog/index.vue` — la page listing blog SSR bilingue. Hero (pattern /projects), grille responsive 1/2/3 cols de BlogCard, empty state avec CTA contact. Query bilingue avec branches littérales, filtre draft, order date DESC, watch locale.

**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).
</objective>

<execution_context>
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
@$HOME/.claude/get-shit-done/templates/summary.md
</execution_context>

<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

<interfaces>
<!-- Article shape après Wave 1 (schema étendu) -->
```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
}
```

<!-- Pattern query @nuxt/content v3 OBLIGATOIRE (littéraux) -->
```typescript
// 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 créé Wave 2 (props) -->
```typescript
<BlogCard :article="article" variant="default" />
// article: BlogArticle
```

<!-- Hero pattern app/pages/projects.vue lignes 56-83 à copier -->
```
<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>
```

<!-- i18n keys disponibles après Wave 2 -->
blog.title, blog.subtitle, blog.stats.articles, blog.stats.tags, blog.stats.languages,
blog.emptyState.title, blog.emptyState.description, blog.emptyState.cta
</interfaces>
</context>

<tasks>

<task type="auto" tdd="false">
  <name>Task 3.1 : Créer app/pages/blog/index.vue (hero + query bilingue + grille + empty state)</name>
  <files>app/pages/blog/index.vue</files>
  <read_first>
    - 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)
  </read_first>
  <action>
Créer `app/pages/blog/index.vue` (nouveau fichier — le dossier `app/pages/blog/` existe déjà et contient `[slug].vue`).

**Script setup complet :**

```vue
<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) :

```vue
<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 :**

1. **Littéraux `'blog_fr'` / `'blog_en'`** dans deux branches séparées — JAMAIS `queryCollection(col)` avec variable. Reproduit fidèlement le pattern `app/pages/blog/[slug].vue` existant.
2. **`{ watch: [locale] }`** sur le useAsyncData — sans ça, switch FR/EN affiche l'ancienne langue (Pitfall 3).
3. **Key `blog-list-${locale.value}`** — inclut la locale pour invalider le cache correctement.
4. **`computed(() => locale.value === 'fr')`** (pas `const isFr = locale.value === 'fr'`) — sinon pas de réactivité sur le switch.
5. **`articles.value?.length ?? 0`** avec optional chaining — articles peut être `null` durant l'initial fetch avant l'arrivée du SSR payload.
6. **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.
7. **SEO minimal** : pas d'ogImage custom, pas de canonical, pas de JSON-LD — Phase 7 traitera ça (hors scope 06).
8. **Pas de routeRules** : ne PAS ajouter de `routeRules: { '/blog': { ... } }` dans nuxt.config — la redirection FR/EN sans préfixe passe par `detectBrowserLanguage` (Phase 5 gotcha, ne pas toucher).
9. **Pas de layout personnalisé** : la page utilise le layout par défaut (header + footer globaux). Ne pas définir `definePageMeta({ layout: ... })`.
  </action>
  <verify>
    <automated>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</automated>
  </verify>
  <acceptance_criteria>
    - `test -f app/pages/blog/index.vue` retourne 0
    - `grep -c "queryCollection('blog_fr')" app/pages/blog/index.vue` retourne au moins 1
    - `grep -c "queryCollection('blog_en')" app/pages/blog/index.vue` retourne au moins 1
    - `grep "queryCollection(locale" app/pages/blog/index.vue` retourne rien (aucune variable dans queryCollection — littéraux uniquement)
    - `grep "queryCollection(col" app/pages/blog/index.vue` retourne rien
    - `grep -c "\\.where('draft', '=', false)" app/pages/blog/index.vue` retourne 2 (une par branche)
    - `grep -c "\\.order('date', 'DESC')" app/pages/blog/index.vue` retourne 2
    - `grep -c "watch: \\[locale\\]" app/pages/blog/index.vue` retourne 1
    - `grep -c "useAsyncData" app/pages/blog/index.vue` retourne 1
    - `grep "<BlogCard" app/pages/blog/index.vue` retourne 1+ match
    - `grep -c "variant=\"default\"" app/pages/blog/index.vue` retourne 1
    - `grep -c "v-for=\"article in articles\"" app/pages/blog/index.vue` retourne 1
    - `grep -c ":key=\"article.path\"" app/pages/blog/index.vue` retourne 1
    - `grep -c "t('blog.title')" app/pages/blog/index.vue` retourne 2+ matches (hero H1 + useSeoMeta)
    - `grep -c "t('blog.subtitle')" app/pages/blog/index.vue` retourne 2+ matches
    - `grep -c "t('blog.stats.articles')" app/pages/blog/index.vue` retourne 1
    - `grep -c "t('blog.stats.tags')" app/pages/blog/index.vue` retourne 1
    - `grep -c "t('blog.stats.languages')" app/pages/blog/index.vue` retourne 1
    - `grep -c "t('blog.emptyState.title')" app/pages/blog/index.vue` retourne 1
    - `grep -c "t('blog.emptyState.cta')" app/pages/blog/index.vue` retourne 1
    - `grep "i-lucide-book-open" app/pages/blog/index.vue` retourne 1 match
    - `grep "localePath('/contact')" app/pages/blog/index.vue` retourne 1 match
    - `grep "// blog" app/pages/blog/index.vue` retourne 1 match (slogan mono)
    - `grep "grid-cols-1 md:grid-cols-2 lg:grid-cols-3" app/pages/blog/index.vue` retourne 1 match (D-01 grille responsive)
    - `pnpm typecheck` passe sans erreur
    - `pnpm lint` passe sans nouvelle erreur
    - `pnpm build` complète sans erreur (validation SSR — le build fait le prerender et casse si queryCollection mal formé)
    - Tests runtime manuels (dans un shell avec `pnpm dev` lancé) :
      - `curl -s http://localhost:3000/fr/blog` retourne un 200 avec `<h1>` contenant `Blog` et `// blog` dans le HTML
      - `curl -s http://localhost:3000/en/blog` retourne un 200 avec `Hytale articles coming soon` ou `Blog` en H1
      - Les tests curl montrent le HTML de l'empty state (pas de grille) — normal, tous les articles sont draft à ce stade
  </acceptance_criteria>
  <done>
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.
  </done>
</task>

</tasks>

<verification>
1. `pnpm typecheck` passe
2. `pnpm lint` passe
3. `pnpm build` complète (validation SSR prerender inclus)
4. `pnpm dev` + curl HTML :
   - `curl -s http://localhost:3000/fr/blog | grep -c "// blog"` >= 1
   - `curl -s http://localhost:3000/fr/blog | grep -c "Blog"` >= 1 (H1)
   - `curl -s http://localhost:3000/fr/blog | grep -ci "Bientôt des articles Hytale"` >= 1 (empty state car tous les articles sont draft:true)
   - `curl -s http://localhost:3000/en/blog | grep -ci "Hytale articles coming soon"` >= 1
5. Switch de langue via le toggle AppHeader FR↔EN : le contenu change (empty state FR → EN et inversement). Pas de flash de contenu stale.
6. Navigation depuis le lien `Blog` de AppHeader (ajouté Wave 2) va bien vers `/fr/blog` ou `/en/blog` selon locale.
</verification>

<success_criteria>
- Page `app/pages/blog/index.vue` créé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/blog` et `curl /en/blog` retournent HTML SSR avec les bons textes traduits
- Success criteria 1 et 5 de la phase validés à la livraison
</success_criteria>

<output>
After completion, create `.planning/phases/06-blog-pages/06-03-SUMMARY.md` with:
- Commandes curl exécutées et extraits HTML (preuve SSR)
- Comportement empty state vérifié (FR + EN)
- Switch locale : délai de re-fetch constaté
- Any deviation (ex: ajustements Tailwind fins, valeurs stats edge cases)
</output>
