docs(06): create phase plan (4 plans, 3 waves)
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. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,378 @@
|
||||
---
|
||||
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>
|
||||
Reference in New Issue
Block a user