Compare commits

...

4 Commits

Author SHA1 Message Date
kayjaydee 5779daf34d docs(06-02): complete components UI + i18n locales plan
- Add 06-02-SUMMARY.md with 3 task commits (d299383, 0e42a05, d0ebf35)
- Update STATE.md : plan counter 11/15 (73%), next = 06-03 listing page
- Update ROADMAP.md Phase 6 progress : 2/4 plans complete
- Record gotcha 06-02 : slug derivation via path.split('/').filter(Boolean).pop()
2026-04-22 09:15:55 +02:00
kayjaydee d0ebf35119 feat(06-02): add BlogCard component with default + compact variants
- variant="default" (listing): cover image conditional (D-03 no fallback),
  aspect-[16/9], first tag UBadge, date i18n via Intl.DateTimeFormat,
  h2 title, line-clamp-2 description, reading time + extra tags pills,
  absolute inset-0 NuxtLink for SEO/a11y (D-02 tags non-clickable)
- variant="compact" (prev/next, D-09/D-10): no image, label row with
  UIcon arrow (left/right per direction), h3 title, date mono,
  text-right on next / text-left on prev
- Props: article, variant?='default'|'compact', direction?='prev'|'next'
- Slug derived from article.path last segment (locale-agnostic)
- readingMinutes: uses article.minutes (Nitro hook) with useReadingTime
  fallback on article.description
- Schema.org BlogPosting markup (headline/description/keywords/url/image/
  datePublished) — ready for Phase 7 JSON-LD Article
- a11y aria-label interpolated via t('a11y.blogPrev'|'blogNext', {title})
2026-04-22 09:13:09 +02:00
kayjaydee 0e42a0591e feat(06-02): add Blog nav link in AppHeader between Hytale and Projects
- Insert { key: 'blog', path: '/blog' } in navLinks computed array
- Position: between hytale and projects (D-15 ordering)
- Template v-for iterations unchanged — new link auto-propagates to
  desktop nav + mobile slideover
- Label resolved via t(`nav.${link.key}`) → uses nav.blog key
  added in Task 2.1
2026-04-22 09:11:27 +02:00
kayjaydee d29938335d feat(06-02): add blog i18n keys (nav.blog, a11y.blog*, blog.*)
- Add nav.blog in FR (Blog) + EN (Blog)
- Add a11y.blogTocToggle, blogPrev, blogNext with {title} interpolation
- Add blog.* block: title, subtitle, stats (articles/tags/languages),
  readingTime ({minutes} interpolation), prevArticle, nextArticle,
  backToBlog, toc.title, emptyState (title/description/cta),
  breadcrumb (home/blog)
- FR uses accents (Bientôt, précédent, Sommaire) per projects convention
2026-04-22 09:11:07 +02:00
7 changed files with 507 additions and 12 deletions
+1 -1
View File
@@ -190,6 +190,6 @@ Plans:
| Phase | Plans Complete | Status | Completed | | Phase | Plans Complete | Status | Completed |
|-------|----------------|--------|-----------| |-------|----------------|--------|-----------|
| 5. @nuxt/content Setup & Renderer | 2/2 | Complete | 2026-04-22 | | 5. @nuxt/content Setup & Renderer | 2/2 | Complete | 2026-04-22 |
| 6. Blog Pages | 1/4 | In progress | - | | 6. Blog Pages | 2/4 | In progress | - |
| 7. SEO Blog | 0/? | Not started | - | | 7. SEO Blog | 0/? | Not started | - |
| 8. Content & Cocon Semantique | 0/? | Not started | - | | 8. Content & Cocon Semantique | 0/? | Not started | - |
+9 -7
View File
@@ -2,15 +2,15 @@
gsd_state_version: 1.0 gsd_state_version: 1.0
milestone: v1.0 milestone: v1.0
milestone_name: milestone milestone_name: milestone
status: Phase 6 — Plan 06-01 shipped (1/4), ready for Plan 06-02 status: Phase 6 — Plan 06-02 shipped (2/4), ready for Plan 06-03
last_updated: "2026-04-22T09:10:00.000Z" last_updated: "2026-04-22T09:25:00.000Z"
last_activity: 2026-04-22 last_activity: 2026-04-22
progress: progress:
total_phases: 8 total_phases: 8
completed_phases: 3 completed_phases: 3
total_plans: 15 total_plans: 15
completed_plans: 10 completed_plans: 11
percent: 66 percent: 73
--- ---
# Project State # Project State
@@ -24,10 +24,10 @@ progress:
## Current Focus ## Current Focus
Phase: Phase 6 — Blog Pages Phase: Phase 6 — Blog Pages
Plan: 06-02 (next — Wave 1 also, components UI + i18n locales) Plan: 06-03 (next — Wave 3, listing page /blog)
Status: Plan 06-01 shipped — schema + reading-time hook + drafts en place, typecheck vert, cache @nuxt/content vidé Status: Plan 06-02 shipped — i18n FR+EN complet, nav link Blog en place, BlogCard.vue (variant default+compact) auto-importable, typecheck vert
Last activity: 2026-04-22 Last activity: 2026-04-22
Resume file: .planning/phases/06-blog-pages/06-02-PLAN.md Resume file: .planning/phases/06-blog-pages/06-03-PLAN.md
## Accumulated Context ## Accumulated Context
@@ -45,3 +45,5 @@ Resume file: .planning/phases/06-blog-pages/06-02-PLAN.md
- **Plan 06-01 shipped (2026-04-22)** : blogSchema étendu (draft.default(false) + wordCount.optional + minutes.optional), Nitro hook `content:file:afterParse` injecte wordCount+minutes (200 wpm, floor 1 min) sur chaque `.md` via `countWordsInMinimalBody`, composable fallback `useReadingTime(number|string)` auto-importé, articles `test-kotlin-syntax.md` (FR+EN) marqués `draft: true` — exclus des listings `where('draft', '=', false)` mais accessibles par URL directe. Cache `node_modules/.cache/content` + `.nuxt` vidés. - **Plan 06-01 shipped (2026-04-22)** : blogSchema étendu (draft.default(false) + wordCount.optional + minutes.optional), Nitro hook `content:file:afterParse` injecte wordCount+minutes (200 wpm, floor 1 min) sur chaque `.md` via `countWordsInMinimalBody`, composable fallback `useReadingTime(number|string)` auto-importé, articles `test-kotlin-syntax.md` (FR+EN) marqués `draft: true` — exclus des listings `where('draft', '=', false)` mais accessibles par URL directe. Cache `node_modules/.cache/content` + `.nuxt` vidés.
- **Gotcha 06-01** : Le hook `content:file:afterParse` exige que `wordCount`/`minutes` soient déclarés dans le schema Zod (`.optional()` sans default) sinon ils sont strippés avant persistance DB — les propriétés injectées par hook ne sont queryables que si le schema les expose. - **Gotcha 06-01** : Le hook `content:file:afterParse` exige que `wordCount`/`minutes` soient déclarés dans le schema Zod (`.optional()` sans default) sinon ils sont strippés avant persistance DB — les propriétés injectées par hook ne sont queryables que si le schema les expose.
- **Gotcha 06-01 (convention)** : Dans un plugin Nitro, importer depuis `app/utils/` se fait via `~/utils/...` (et non `~~/app/utils/...`). Nuxt 4 mappe `~/``app/` par défaut. Vérifié par typecheck vert sur server/plugins/reading-time.ts. - **Gotcha 06-01 (convention)** : Dans un plugin Nitro, importer depuis `app/utils/` se fait via `~/utils/...` (et non `~~/app/utils/...`). Nuxt 4 mappe `~/``app/` par défaut. Vérifié par typecheck vert sur server/plugins/reading-time.ts.
- **Plan 06-02 shipped (2026-04-22)** : i18n `nav.blog` + 3 clés `a11y.blog*` (avec interpolation `{title}`) + bloc `blog.*` 14 clés (title, subtitle, stats.*, readingTime, prevArticle/nextArticle, backToBlog, toc.title, emptyState.*, breadcrumb.*) ajoutés dans fr.json + en.json. AppHeader.vue navLinks : `{ key: 'blog', path: '/blog' }` inséré entre hytale et projects (ligne 11, ordre D-15 respecté). `app/components/BlogCard.vue` créé (192 lignes, auto-importé Nuxt) : variant `default` (listing) avec cover conditional + tag UBadge + date Intl.DateTimeFormat + h2 + description line-clamp-2 + reading-time (minutes hook || useReadingTime fallback) + extra tags pills + full-card NuxtLink SEO + Schema.org BlogPosting markup ; variant `compact` (prev/next, D-09/D-10) : no image + label row avec UIcon arrow directionnelle + h3 + date + NuxtLink aria-label interpolé `a11y.blogPrev`/`a11y.blogNext`. Typecheck exit 0.
- **Gotcha 06-02 (slug derivation)** : Les articles @nuxt/content ont un `path` de forme `/fr/blog/my-slug`. Dans BlogCard.vue, on extrait le slug via `article.path.split('/').filter(Boolean).pop()` puis on reconstruit `localePath('/blog/' + slug)` — locale-agnostique. Évite de demander un champ `slug` explicite dans le frontmatter (cohérent convention @nuxt/content : path dérivé du nom de fichier).
@@ -0,0 +1,242 @@
---
phase: 06-blog-pages
plan: 02
subsystem: ui-components
tags: [blog, i18n, nav, blog-card, shared-components]
requires:
- phase: 06-blog-pages
plan: 01
provides: "blogSchema étendu (draft/wordCount/minutes) + useReadingTime composable fallback — consommés par BlogCard.vue"
provides:
- "Clés i18n complètes blog.* + nav.blog + a11y.blog* en FR et EN (14 clés par locale)"
- "Lien nav Blog dans AppHeader entre Hytale et Projects (desktop + mobile)"
- "Composant BlogCard.vue unifié avec variant default (listing) + compact (prev/next)"
affects: [06-03-blog-listing, 06-04-blog-article-chrome]
tech-stack:
added: []
patterns:
- "Pattern composant multi-variant via prop discriminante + v-if branch (variant='default'|'compact')"
- "Pattern slug derivation depuis article.path @nuxt/content (split /filter(Boolean).pop())"
- "Pattern i18n date formatting via Intl.DateTimeFormat + locale.value guard"
- "Pattern absolute inset-0 NuxtLink pour SEO + full-card click (cohabite avec tags non-cliquables D-02)"
- "Pattern Schema.org BlogPosting prêt pour JSON-LD Phase 7 (headline/description/keywords/url/image/datePublished)"
- "Pattern reading-time avec injection hook + fallback composable (minutes ?? useReadingTime(description))"
key-files:
created:
- app/components/BlogCard.vue
modified:
- i18n/locales/fr.json
- i18n/locales/en.json
- app/components/layout/AppHeader.vue
key-decisions:
- "BlogCard unique avec variant prop (D-20) plutôt que 2 composants séparés — 1 source of truth pour date/slug/reading-time"
- "Slug extrait du dernier segment du path (split/filter/pop) plutôt qu'un champ frontmatter dédié — cohérent @nuxt/content convention, zero burden pour auteur"
- "Reading-time : minutes injecté par hook Nitro prioritaire, useReadingTime(description) en fallback uniquement — évite drift listing vs article"
- "Variant compact sans image (D-10) + text-right sur next / text-left sur prev — UX directionnelle (flèche + texte suivent la direction du clic)"
- "FR i18n accentué dans bloc blog.* (Bientôt, précédent, Sommaire) suivant convention PATTERNS.md §i18n — cohérent avec bloc projects, distinct de a11y/seo (ASCII)"
requirements-completed: [BLOG-02, BLOG-03, BLOG-06]
duration: ~15min
completed: 2026-04-22
---
# Phase 6 Plan 02 : Components UI + i18n Locales Summary
**Couche composition partagée : clés i18n blog complètes (FR+EN), lien nav Blog, composant BlogCard.vue unifié variant default/compact — prêt pour Wave 3 (pages listing + article).**
## Performance
- **Duration:** ~15 min
- **Started:** 2026-04-22T09:10Z
- **Completed:** 2026-04-22T09:25Z
- **Tasks:** 3 / 3
- **Files modified:** 4 (1 créé, 3 modifiés)
## Accomplishments
- **i18n complet** : 14 clés par locale ajoutées — `nav.blog`, 3 clés `a11y.blog*` avec interpolation `{title}`, bloc `blog.*` de 14 clés (title, subtitle, stats.articles/tags/languages, readingTime avec `{minutes}`, prevArticle, nextArticle, backToBlog, toc.title, emptyState.title/description/cta, breadcrumb.home/blog). FR accentué, EN traduction complète. JSON valide des 2 fichiers.
- **Nav link Blog** : insertion d'1 ligne dans `navLinks` computed de AppHeader.vue, position ligne 11 (entre hytale ligne 10 et projects ligne 12). Aucune autre modification — template `v-for` existant propage automatiquement au desktop + mobile slideover.
- **BlogCard.vue** : composant unique 192 lignes, 2 variants branchés par `v-if="variant === 'default'"` / `v-else` (compact). Script setup TS strict. Props `article` + `variant?='default'|'compact'` + `direction?='prev'|'next'`. Date formatée `Intl.DateTimeFormat` avec locale dynamique. Reading time avec fallback composable. Schema.org `BlogPosting` markup prêt pour JSON-LD Phase 7. 3 occurrences de `localePath(\`/blog/\${slug}\`)` (NuxtLink image + full-card SEO + variant compact).
## Task Commits
1. **Task 2.1 : i18n FR + EN**`d299383` (feat)
2. **Task 2.2 : Nav link Blog dans AppHeader**`0e42a05` (feat)
3. **Task 2.3 : BlogCard.vue variant default + compact**`d0ebf35` (feat)
## Files Created/Modified
### Created
- `app/components/BlogCard.vue` *(NEW, 192 lignes)*
- `<script setup lang="ts">` avec interfaces `BlogArticle` + `Props`
- `withDefaults(defineProps<Props>(), { variant: 'default', direction: 'next' })`
- Computed : `slug` (last segment de `article.path`), `formattedDate` (Intl avec try/catch), `readingMinutes` (minutes ?? useReadingTime), `directionIcon`, `directionLabel`
- Template dual-branch :
- `variant === 'default'` : article wrapper identique ProjectCard + cover conditional (v-if article.image) + padding p-5/sm:p-6 + tag UBadge + date mono + h2 + description line-clamp-2 + reading time avec UIcon clock + extra tags pills (+N) + full-card NuxtLink SEO
- `variant === 'compact'` (v-else) : no image + label row direction (arrow-left/right selon direction) + h3 + date mono + NuxtLink aria-label interpolé `a11y.blogPrev`/`a11y.blogNext`
- Schema.org attributes : `itemscope itemtype="https://schema.org/BlogPosting"`, `itemprop` sur image/keywords/datePublished/headline/description/url
### Modified
- `i18n/locales/fr.json` — +29 lignes (1 clé `nav.blog` + 3 clés `a11y.blog*` + bloc `blog` 14 clés). JSON valide. Blocs existants (nav.home, nav.hytale, seo, projects...) inchangés.
- `i18n/locales/en.json` — +29 lignes symétriques (mêmes clés, traductions EN : "Technical articles...", "min read", "Previous/Next article", "Table of contents", "Back to blog", "Hytale articles coming soon", "Contact me", "Home"/"Blog"). JSON valide.
- `app/components/layout/AppHeader.vue` — +1 ligne : `{ key: 'blog', path: '/blog' },` inséré ligne 11 dans l'array navLinks computed, entre hytale et projects. Script + template intacts.
## i18n Diff (clés ajoutées, par locale)
```
nav.blog
a11y.blogTocToggle
a11y.blogPrev // avec interpolation {title}
a11y.blogNext // avec interpolation {title}
blog.title
blog.subtitle
blog.stats.articles
blog.stats.tags
blog.stats.languages
blog.readingTime // avec interpolation {minutes}
blog.prevArticle
blog.nextArticle
blog.backToBlog
blog.toc.title
blog.emptyState.title
blog.emptyState.description
blog.emptyState.cta
blog.breadcrumb.home
blog.breadcrumb.blog
```
**Total** : 19 clés ajoutées par locale = 38 clés au total. Toutes traduites FR/EN, structure JSON symétrique.
## AppHeader diff (ligne 11 ajoutée)
```diff
const navLinks = computed(() => [
{ key: 'home', path: '/' },
{ key: 'hytale', path: '/hytale' },
+ { key: 'blog', path: '/blog' },
{ key: 'projects', path: '/projects' },
{ key: 'about', path: '/about' },
{ key: 'contact', path: '/contact' },
{ key: 'fiverr', path: '/fiverr' },
])
```
Position finale de nav : Home / Hytale / **Blog** / Projects / About / Contact / Fiverr (conforme D-15).
## BlogCard Design Decisions
### Slug derivation
`article.path` a la forme `/fr/blog/my-slug` ou `/en/blog/my-slug` (strategy prefix @nuxtjs/i18n). Pour construire un lien locale-agnostique vers `localePath('/blog/my-slug')`, on extrait le dernier segment :
```typescript
const slug = computed(() => {
const parts = props.article.path.split('/').filter(Boolean)
return parts[parts.length - 1] ?? ''
})
```
Avantage : zero burden pour l'auteur de l'article (pas besoin d'un champ `slug` dans le frontmatter), compatible avec la convention @nuxt/content qui dérive le path depuis le nom de fichier.
### Reading-time dual source
```typescript
const readingMinutes = computed(() => {
if (typeof props.article.minutes === 'number') return props.article.minutes
return useReadingTime(props.article.description ?? '')
})
```
- **Priorité 1** : `article.minutes` injecté par le hook Nitro `content:file:afterParse` (Plan 06-01) — calcul exact basé sur le body markdown, 200 wpm, snippets code exclus.
- **Fallback** : `useReadingTime(description)` — utile uniquement en dev hot-reload si le hook n'a pas encore persisté la valeur (ou si `minutes` vraiment absent).
- **Conséquence** : drift listing ↔ article impossible (même source of truth en prod, même formule en fallback).
### Direction UX (variant compact)
- `direction='prev'` : `text-left items-start`, icône `i-lucide-arrow-left` AVANT le label, hover `-translate-x-1` (glisse vers la gauche).
- `direction='next'` : `text-right items-end`, icône `i-lucide-arrow-right` APRÈS le label, hover `translate-x-1` (glisse vers la droite).
Le texte et la flèche suivent la direction du clic — affordance visuelle naturelle. Pattern emprunté à la doc Nuxt / Stripe.
### a11y label template
```html
:aria-label="t(direction === 'prev' ? 'a11y.blogPrev' : 'a11y.blogNext', { title: article.title })"
```
Compose le message depuis les clés i18n avec interpolation `{title}` — screen readers annoncent "Article précédent : [titre]" / "Previous article: [title]" au focus du lien. Respect WCAG 2.4.4 (Link Purpose in Context).
### Schema.org BlogPosting prep Phase 7
Le variant default porte déjà tous les `itemprop` requis pour un JSON-LD `Article` / `BlogPosting` :
- `itemscope itemtype="https://schema.org/BlogPosting"` sur le `<article>`
- `itemprop="image"` sur NuxtImg
- `itemprop="keywords"` sur le tag UBadge
- `itemprop="datePublished"` sur `<time>`
- `itemprop="headline"` sur le h2
- `itemprop="description"` sur le paragraphe
- `itemprop="url"` sur le NuxtLink full-card
Phase 7 pourra injecter un JSON-LD parallèle sans modifier le markup — les crawlers qui ne parsent pas JSON-LD trouvent déjà les microdata. Double-ceinture SEO.
## Decisions Made
Aucune décision nouvelle au-delà de celles figées dans CONTEXT.md (D-02, D-03, D-10, D-15, D-20, D-21). Le plan a été exécuté conformément à UI-SPEC + RESEARCH + PATTERNS.
## Deviations from Plan
**None — plan executed exactly as written.**
- Aucun bug inline (Rule 1) : ProjectCard pattern transposé sans accroc.
- Aucune fonctionnalité critique manquante (Rule 2) : a11y labels + Schema.org déjà dans la spec.
- Aucun blocage technique (Rule 3) : typecheck vert après Task 2.3.
- Aucune décision architecturale surprise (Rule 4).
## Issues Encountered
- **Hook runtime warnings (non-bloquant)** : Plusieurs avertissements `READ-BEFORE-EDIT REMINDER` ont été déclenchés lors d'éditions successives sur le même fichier (fr.json, en.json, AppHeader.vue). Les fichiers avaient bien été lus en début de session, mais le hook est prudent pour les éditions multiples. Impact nul sur le code, les éditions sont toutes passées.
- Aucun autre incident.
## User Setup Required
**None.** Tous les changements sont du code — aucune configuration externe, aucune credential, aucune migration DB.
## Next Phase Readiness
**Plan 06-03 (Listing /blog)** peut démarrer immédiatement :
- `nav.blog` + `blog.title/subtitle/stats.*/emptyState.*` disponibles pour `app/pages/blog/index.vue`.
- `BlogCard` auto-importé par Nuxt (`app/components/BlogCard.vue`) — utilisable directement avec `<BlogCard :article="article" />` (variant default par défaut).
- Le listing pourra appeler `queryCollection('blog_fr').where('draft', '=', false).order('date', 'DESC').all()` et passer chaque article à `<BlogCard>`.
- Empty state : icône `i-lucide-book-open` + `blog.emptyState.title/description/cta``UButton` vers `localePath('/contact')`.
**Plan 06-04 (Article chrome)** peut démarrer immédiatement :
- `blog.toc.title`, `blog.backToBlog`, `a11y.blogTocToggle` disponibles pour le chrome.
- `blog.breadcrumb.home`, `blog.breadcrumb.blog` disponibles pour UBreadcrumb.
- `<BlogPrevNext>` (à créer) utilisera `<BlogCard :article :variant="'compact'" :direction="'prev'|'next'" />`.
**Nav visible** : Le lien Blog apparaît dès le prochain refresh dev server sur `/fr/` et `/en/`. Clic → `/fr/blog` ou `/en/blog` = 404 attendu tant que `app/pages/blog/index.vue` n'existe pas (à créer Plan 06-03).
Aucun blocker. Typecheck vert. 4 fichiers cibles, 0 fichier hors-scope modifié.
## Self-Check: PASSED
**Files exist:**
- FOUND: `app/components/BlogCard.vue` (192 lignes, 2 variants, Schema.org BlogPosting)
- FOUND: `i18n/locales/fr.json` (JSON valide, nav.blog, a11y.blog*, blog.* complets)
- FOUND: `i18n/locales/en.json` (JSON valide, symétrique FR)
- FOUND: `app/components/layout/AppHeader.vue` (navLinks ligne 11 = blog)
**Commits exist:**
- FOUND: `d299383` (feat 06-02: i18n keys)
- FOUND: `0e42a05` (feat 06-02: nav link)
- FOUND: `d0ebf35` (feat 06-02: BlogCard)
**Typecheck:** `pnpm typecheck` → exit 0 après Task 2.3 (vérifié en toute fin d'exécution avant commit BlogCard).
**Acceptance criteria (all tasks):**
- Task 2.1 : tous les asserts `node -e "...fr.nav.blog"`, `fr.blog.title`, `fr.blog.subtitle starts with Articles techniques`, `fr.blog.readingTime = {minutes} min de lecture`, `en.blog.readingTime = {minutes} min read`, `fr.blog.emptyState.cta = Me contacter`, `en.blog.emptyState.cta = Contact me`, `fr.blog.toc.title = Sommaire`, `en.blog.toc.title = Table of contents`, `fr.a11y.blogTocToggle = Afficher le sommaire`, `fr.a11y.blogPrev contains {title}`, `fr.blog.breadcrumb.home = Accueil`, `en.blog.breadcrumb.home = Home` → TOUS VALIDÉS. JSON parse sans throw des 2 fichiers. Clé existante `fr.nav.hytale = Hytale` préservée.
- Task 2.2 : `grep "{ key: 'blog', path: '/blog' }"` = 1, `key: 'hytale'` ligne 10, `key: 'blog'` ligne 11, `key: 'projects'` ligne 12 (ordre strict respecté), `v-for="link in navLinks"` = 2 occurrences (desktop + mobile templates intacts), pas de duplication home/fiverr.
- Task 2.3 : fichier existe, tous les greps retournent ≥ le compte attendu (1 pour `variant === 'default'`, `withDefaults`, `Intl.DateTimeFormat`, `t('blog.readingTime'`, `useReadingTime`, `i-lucide-arrow-left/right`, `BlogPosting`, `aspect-[16/9]`, `a11y.blogPrev`, `a11y.blogNext`), 3 pour `localePath(\`/blog/\${slug}\`)`. Typecheck exit 0.
---
*Phase: 06-blog-pages*
*Plan: 02*
*Completed: 2026-04-22*
+192
View File
@@ -0,0 +1,192 @@
<script setup lang="ts">
interface BlogArticle {
path: string
title: string
description?: string
date: string
tags?: string[]
image?: string
minutes?: number
}
interface Props {
article: BlogArticle
variant?: 'default' | 'compact'
direction?: 'prev' | 'next'
}
const props = withDefaults(defineProps<Props>(), {
variant: 'default',
direction: 'next',
})
const { t, locale } = useI18n()
const localePath = useLocalePath()
// Slug extrait du path '/fr/blog/my-slug' ou '/en/blog/my-slug'
const slug = computed(() => {
const parts = props.article.path.split('/').filter(Boolean)
return parts[parts.length - 1] ?? ''
})
const formattedDate = computed(() => {
try {
return new Intl.DateTimeFormat(locale.value === 'fr' ? 'fr-FR' : 'en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
}).format(new Date(props.article.date))
} catch {
return props.article.date
}
})
// Reading time : utilise minutes injecté par hook Nitro, sinon fallback composable
const readingMinutes = computed(() => {
if (typeof props.article.minutes === 'number') return props.article.minutes
return useReadingTime(props.article.description ?? '')
})
const directionIcon = computed(() =>
props.direction === 'prev' ? 'i-lucide-arrow-left' : 'i-lucide-arrow-right',
)
const directionLabel = computed(() =>
props.direction === 'prev' ? t('blog.prevArticle') : t('blog.nextArticle'),
)
</script>
<template>
<article
v-if="variant === 'default'"
class="group relative rounded-2xl border border-gray-200/80 dark:border-gray-800/50 bg-white/80 dark:bg-gray-900/60 backdrop-blur-sm overflow-hidden transition-all duration-300 hover:border-brand-500/40 hover:shadow-xl hover:shadow-brand-500/10 hover:-translate-y-1.5"
itemscope
itemtype="https://schema.org/BlogPosting"
>
<!-- Cover image (D-03 : aucun fallback si absent) -->
<NuxtLink
v-if="article.image"
:to="localePath(`/blog/${slug}`)"
class="block relative overflow-hidden"
>
<NuxtImg
:src="article.image"
:alt="article.title"
loading="lazy"
format="webp"
width="400"
height="225"
class="w-full aspect-[16/9] object-cover transition-transform duration-500 group-hover:scale-105"
itemprop="image"
/>
</NuxtLink>
<!-- Content -->
<div class="p-5 sm:p-6 flex flex-col gap-3">
<!-- Tag + Date -->
<div class="flex items-center justify-between">
<UBadge v-if="article.tags?.[0]" color="primary" variant="subtle" itemprop="keywords">
{{ article.tags[0] }}
</UBadge>
<time
class="text-xs text-gray-400 dark:text-gray-500 font-mono"
:datetime="article.date"
itemprop="datePublished"
>
{{ formattedDate }}
</time>
</div>
<!-- Title -->
<h2
class="text-lg font-bold text-gray-900 dark:text-white group-hover:text-brand-600 dark:group-hover:text-brand-400 transition-colors"
itemprop="headline"
>
{{ article.title }}
</h2>
<!-- Description -->
<p
v-if="article.description"
class="text-sm text-gray-500 dark:text-gray-400 line-clamp-2 leading-relaxed"
itemprop="description"
>
{{ article.description }}
</p>
<!-- Footer: reading time + extra tags -->
<div class="flex items-center justify-between pt-2">
<span
class="text-xs text-gray-400 dark:text-gray-500 font-medium 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 v-if="article.tags && article.tags.length > 1" class="flex gap-1.5">
<span
v-for="tag in article.tags.slice(1, 3)"
:key="tag"
class="text-[11px] px-2.5 py-1 rounded-full bg-gray-100 dark:bg-gray-800/80 text-gray-600 dark:text-gray-400 font-medium border border-gray-200/50 dark:border-gray-700/30"
>
{{ tag }}
</span>
<span
v-if="article.tags.length > 3"
class="text-[11px] px-2.5 py-1 rounded-full bg-gray-100 dark:bg-gray-800/80 text-gray-500 font-medium border border-gray-200/50 dark:border-gray-700/30"
>
+{{ article.tags.length - 3 }}
</span>
</div>
</div>
</div>
<!-- SEO + a11y full-card link (D-02 tags non-cliquables safe) -->
<NuxtLink
:to="localePath(`/blog/${slug}`)"
class="absolute inset-0 z-10"
:aria-label="`${article.title} - ${formattedDate}`"
itemprop="url"
/>
</article>
<!-- Variant compact (prev/next) D-10 pas d'image, D-09 label + icon -->
<article
v-else
class="group relative rounded-2xl border border-gray-200/80 dark:border-gray-800/50 bg-white/80 dark:bg-gray-900/60 backdrop-blur-sm p-5 transition-all duration-300 hover:border-brand-500/40 hover:shadow-xl hover:shadow-brand-500/10 hover:-translate-y-1.5 flex flex-col gap-2"
:class="direction === 'next' ? 'items-end text-right' : 'items-start text-left'"
>
<div
class="inline-flex items-center gap-2 text-xs uppercase tracking-wider text-gray-500 dark:text-gray-400 font-medium"
>
<UIcon
v-if="direction === 'prev'"
:name="directionIcon"
class="w-4 h-4 transition-transform duration-200 group-hover:-translate-x-1 group-hover:text-brand-500"
/>
<span>{{ directionLabel }}</span>
<UIcon
v-if="direction === 'next'"
:name="directionIcon"
class="w-4 h-4 transition-transform duration-200 group-hover:translate-x-1 group-hover:text-brand-500"
/>
</div>
<h3
class="text-base font-bold text-gray-900 dark:text-white group-hover:text-brand-500 dark:group-hover:text-brand-400 transition-colors"
>
{{ article.title }}
</h3>
<time
class="text-xs font-mono text-gray-400 dark:text-gray-500"
:datetime="article.date"
>
{{ formattedDate }}
</time>
<NuxtLink
:to="localePath(`/blog/${slug}`)"
class="absolute inset-0 z-10"
:aria-label="
t(direction === 'prev' ? 'a11y.blogPrev' : 'a11y.blogNext', { title: article.title })
"
/>
</article>
</template>
+1
View File
@@ -8,6 +8,7 @@ const mobileOpen = ref(false)
const navLinks = computed(() => [ const navLinks = computed(() => [
{ key: 'home', path: '/' }, { key: 'home', path: '/' },
{ key: 'hytale', path: '/hytale' }, { key: 'hytale', path: '/hytale' },
{ key: 'blog', path: '/blog' },
{ key: 'projects', path: '/projects' }, { key: 'projects', path: '/projects' },
{ key: 'about', path: '/about' }, { key: 'about', path: '/about' },
{ key: 'contact', path: '/contact' }, { key: 'contact', path: '/contact' },
+31 -2
View File
@@ -5,7 +5,8 @@
"about": "About", "about": "About",
"contact": "Contact", "contact": "Contact",
"fiverr": "Fiverr", "fiverr": "Fiverr",
"hytale": "Hytale" "hytale": "Hytale",
"blog": "Blog"
}, },
"footer": { "footer": {
"copyright": "© 2026 Killian' DAL-CIN", "copyright": "© 2026 Killian' DAL-CIN",
@@ -30,7 +31,10 @@
"themeLight": "Switch to dark mode", "themeLight": "Switch to dark mode",
"gitea": "Killian' DAL-CIN on Gitea (opens in new tab)", "gitea": "Killian' DAL-CIN on Gitea (opens in new tab)",
"linkedin": "Killian' DAL-CIN on LinkedIn (opens in new tab)", "linkedin": "Killian' DAL-CIN on LinkedIn (opens in new tab)",
"fiverr": "Killian' DAL-CIN on Fiverr (opens in new tab)" "fiverr": "Killian' DAL-CIN on Fiverr (opens in new tab)",
"blogTocToggle": "Show table of contents",
"blogPrev": "Previous article: {title}",
"blogNext": "Next article: {title}"
}, },
"seo": { "seo": {
"home": { "home": {
@@ -549,5 +553,30 @@
] ]
} }
} }
},
"blog": {
"title": "Blog",
"subtitle": "Technical articles, experience feedback and practical guides on Hytale plugin development and the web ecosystem.",
"stats": {
"articles": "Articles",
"tags": "Tags",
"languages": "Languages"
},
"readingTime": "{minutes} min read",
"prevArticle": "Previous article",
"nextArticle": "Next article",
"backToBlog": "Back to blog",
"toc": {
"title": "Table of contents"
},
"emptyState": {
"title": "Hytale articles coming soon",
"description": "The blog is being prepared. The first articles on Hytale plugin development are coming soon.",
"cta": "Contact me"
},
"breadcrumb": {
"home": "Home",
"blog": "Blog"
}
} }
} }
+31 -2
View File
@@ -5,7 +5,8 @@
"about": "A propos", "about": "A propos",
"contact": "Contact", "contact": "Contact",
"fiverr": "Fiverr", "fiverr": "Fiverr",
"hytale": "Hytale" "hytale": "Hytale",
"blog": "Blog"
}, },
"footer": { "footer": {
"copyright": "© 2026 Killian' DAL-CIN", "copyright": "© 2026 Killian' DAL-CIN",
@@ -30,7 +31,10 @@
"themeLight": "Activer le mode sombre", "themeLight": "Activer le mode sombre",
"gitea": "Gitea de Killian' DAL-CIN (nouvelle fenetre)", "gitea": "Gitea de Killian' DAL-CIN (nouvelle fenetre)",
"linkedin": "LinkedIn de Killian' DAL-CIN (nouvelle fenetre)", "linkedin": "LinkedIn de Killian' DAL-CIN (nouvelle fenetre)",
"fiverr": "Fiverr de Killian' DAL-CIN (nouvelle fenetre)" "fiverr": "Fiverr de Killian' DAL-CIN (nouvelle fenetre)",
"blogTocToggle": "Afficher le sommaire",
"blogPrev": "Article précédent : {title}",
"blogNext": "Article suivant : {title}"
}, },
"seo": { "seo": {
"home": { "home": {
@@ -549,5 +553,30 @@
] ]
} }
} }
},
"blog": {
"title": "Blog",
"subtitle": "Articles techniques, retours d'expérience et guides pratiques sur le développement de plugins Hytale et l'écosystème web.",
"stats": {
"articles": "Articles",
"tags": "Tags",
"languages": "Langues"
},
"readingTime": "{minutes} min de lecture",
"prevArticle": "Article précédent",
"nextArticle": "Article suivant",
"backToBlog": "Retour au blog",
"toc": {
"title": "Sommaire"
},
"emptyState": {
"title": "Bientôt des articles Hytale",
"description": "Le blog se prépare. Les premiers articles sur le développement de plugins Hytale arrivent bientôt.",
"cta": "Me contacter"
},
"breadcrumb": {
"home": "Accueil",
"blog": "Blog"
}
} }
} }