Files
portfolio/.planning/phases/06-blog-pages/06-04-PLAN.md
T
kayjaydee d1ac5f9ee6 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.
2026-04-22 01:09:25 +02:00

38 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 04 execute 3
06-01
06-02
app/pages/blog/[slug].vue
app/components/BlogToc.vue
app/components/BlogPrevNext.vue
true
BLOG-03
BLOG-06
blog
article-chrome
toc
prev-next
intersection-observer
truths artifacts key_links
`curl localhost:3000/fr/blog/test-kotlin-syntax` retourne HTML SSR avec (ordre vertical) : UBreadcrumb (Accueil → Blog → titre) → H1 du titre → ligne meta (date formatée + · + reading time) → tags UBadge (si présents) → cover image NuxtImg (si frontmatter.image) → body markdown dans `.prose` → BlogPrevNext (en bas)
`curl localhost:3000/en/blog/test-kotlin-syntax` retourne la même structure avec textes EN (breadcrumb Home → Blog → title)
La page article filtre le `draft` dans la query de SURROUND (prev/next) — les articles draft:true ne viennent pas peupler la navigation. La query `.path(path).first()` n'a PAS le filtre (URL directe accessible pour les tests, D-14)
Sur desktop (>= lg), une `<aside>` sticky à droite contient la table des matières avec les headings h2/h3 de `page.body.toc.links`
Sur mobile (< lg), un UButton `i-lucide-list` dans la meta row ouvre un UDrawer side='right' contenant la même TOC
Le heading actif (premier visible dans la zone 20%-30% du viewport) est surligné `text-brand-500` via IntersectionObserver client-only (onMounted)
BlogPrevNext.vue rend 2 BlogCard variant='compact' avec direction='prev' et 'next'. Si un voisin est null, la cellule reste vide (alignement grid préservé, D-13)
Prev (article plus ancien) = `surround[1]`, Next (article plus récent) = `surround[0]` car order DESC retourne l'élément before la position courante dans l'ordre de la collection (Pitfall 4)
`queryCollectionItemSurroundings` utilise les littéraux 'blog_fr'/'blog_en' avec if/else (Phase 5 gotcha)
path provides contains contains_also min_lines
app/pages/blog/[slug].vue Page article enrichie : breadcrumb + header + TOC layout + surround + prev/next queryCollectionItemSurroundings UBreadcrumb 120
path provides contains contains_also
app/components/BlogToc.vue TOC sticky desktop + UDrawer mobile + IntersectionObserver highlight IntersectionObserver UDrawer
path provides contains
app/components/BlogPrevNext.vue Grid 2 cols de BlogCard variant compact (prev + next) variant="compact"
from to via pattern
app/pages/blog/[slug].vue queryCollectionItemSurroundings('blog_fr'|'blog_en', path, ...) useAsyncData secondaire avec littéraux if/else + watch locale queryCollectionItemSurroundings
from to via pattern
app/pages/blog/[slug].vue app/components/BlogToc.vue <BlogToc :links="page.body.toc.links" ... /> <BlogToc
from to via pattern
app/pages/blog/[slug].vue app/components/BlogPrevNext.vue <BlogPrevNext :prev :next /> <BlogPrevNext
from to via pattern
app/components/BlogToc.vue DOM headings h2/h3 rendus par ContentRenderer IntersectionObserver sur document.getElementById(link.id) dans onMounted IntersectionObserver
from to via pattern
app/components/BlogPrevNext.vue app/components/BlogCard.vue <BlogCard variant="compact" direction="prev"|"next" /> <BlogCard
Terminer la phase en enrichissant la page article `/blog/[slug]` avec le chrome complet (breadcrumb, header riche, TOC sticky + drawer mobile, prev/next cards). Créer 2 composants : `BlogToc.vue` (sticky desktop + UDrawer mobile + IntersectionObserver) et `BlogPrevNext.vue` (grid 2 cols de BlogCard compact).

Purpose: Cette page satisfait les success criteria 2, 3, 4 de la phase (rendu SSR article, TOC visible, prev/next visibles). Elle consomme tous les artefacts précédents (schema Wave 1, BlogCard + i18n + nav Wave 2) et corrige le isFr non-réactif de Phase 5.

Output:

  • app/components/BlogToc.vue (nouveau)
  • app/components/BlogPrevNext.vue (nouveau)
  • app/pages/blog/[slug].vue (modification substantielle de l'existant Phase 5)

<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/blog/[slug].vue @app/components/layout/AppHeader.vue @app/components/ProjectCard.vue @app/components/content/ProseImg.vue ```typescript interface TocLink { id: string // anchor id auto-généré (kebab-case du heading text) depth: number // 2 = h2, 3 = h3, etc. text: string children?: TocLink[] }

interface PageBody { toc?: { title: string searchDepth: number depth: number links: TocLink[] } // ... autre champs (type minimal, value) }


<!-- Shape queryCollectionItemSurroundings return -->
```typescript
// Signature
function queryCollectionItemSurroundings(
  collection: 'blog_fr' | 'blog_en',
  path: string,
  opts?: { before?: number, after?: number, fields?: string[] }
): ChainablePromise  // chain .where().order()

// Return: array de 2 éléments [before, after]
// En .order('date', 'DESC') : before = plus récent, after = plus ancien
// PITFALL 4 : UI "précédent" (plus ancien) = surround[1], UI "suivant" (plus récent) = surround[0]
<BlogCard
  :article="prevArticle"
  variant="compact"
  direction="prev"
/>
<UDrawer v-model:open="tocDrawerOpen" side="right">
  <template #header>...</template>
  <template #body>...</template>
</UDrawer>
items: Array<{ label: string, to?: string, icon?: string }>
<script setup lang="ts">
const { locale } = useI18n()
const route = useRoute()
const slug = route.params.slug as string
const isFr = locale.value === 'fr'  // ❌ NON-RÉACTIF — à convertir en computed
const path = isFr ? `/fr/blog/${slug}` : `/en/blog/${slug}`
// ... useAsyncData sans { watch: [locale] }
</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>
</template>
// rootMargin imposé UI-SPEC
{ rootMargin: '-20% 0px -70% 0px', threshold: 0 }
Task 4.1 : Créer app/components/BlogToc.vue (sticky desktop + UDrawer mobile + IntersectionObserver) app/components/BlogToc.vue - app/components/layout/AppHeader.vue (pattern USlideover v-model:open="mobileOpen" lignes 6 + 80-114 — UDrawer suit le même pattern d'API) - app/components/content/ProseImg.vue (pattern defineProps + withDefaults typé lignes 1-38) - .planning/phases/06-blog-pages/06-UI-SPEC.md (§BlogToc contract lignes 232-253 pour desktop + mobile + IntersectionObserver — valeurs rootMargin/threshold imposées) - .planning/phases/06-blog-pages/06-RESEARCH.md (§Pattern 3 pour la shape TocLink + §Pattern 4 lignes 392-442 pour l'IntersectionObserver COMPLET + §Pitfall 2 hydration pour initial state) - .planning/phases/06-blog-pages/06-PATTERNS.md (§BlogToc.vue lignes 256-310) - .planning/phases/06-blog-pages/06-CONTEXT.md (D-05 sticky desktop + drawer mobile, D-06 highlight IntersectionObserver client-only) - i18n/locales/fr.json / en.json (confirmer blog.toc.title et a11y.blogTocToggle existent après Wave 2) Créer `app/components/BlogToc.vue`. Le composant reçoit `links: TocLink[]` via props, gère : - Affichage desktop : `