Compare commits
127 Commits
main
...
127db8b77a
| Author | SHA1 | Date | |
|---|---|---|---|
| 127db8b77a | |||
| e66c7984a4 | |||
| 839c584b0a | |||
| 20a5b5d85f | |||
| 7cd1531e06 | |||
| fd18ea99e1 | |||
| 277b407361 | |||
| 06f47cbe11 | |||
| f2e29e6c2f | |||
| 2ea6af0fff | |||
| b63afc4152 | |||
| c5be72bdd9 | |||
| b0af1d3913 | |||
| 006df6ad30 | |||
| 3e20e9ece9 | |||
| 221b1a076c | |||
| f179d64253 | |||
| 60e05f7a56 | |||
| b63869f042 | |||
| 37b6ef9112 | |||
| ee7509cff0 | |||
| 9849c18da4 | |||
| e1c91e583f | |||
| b5c3250a4e | |||
| 0fa19a7701 | |||
| c9a14a9086 | |||
| 557861aa95 | |||
| f49fab2532 | |||
| 83197899c8 | |||
| 3381b2efb3 | |||
| c64709da10 | |||
| 1df6a21c5e | |||
| cb477006f4 | |||
| 808835d5eb | |||
| afd81e7e84 | |||
| 2d8f0ca7c3 | |||
| 2b06dfe463 | |||
| 2658b0c607 | |||
| a4ee7fe007 | |||
| a0788f1edd | |||
| 888650ce3d | |||
| 848387d69c | |||
| 438b238946 | |||
| c7e74bd699 | |||
| 39f2a81e8f | |||
| 215fba6342 | |||
| 710692f0ae | |||
| 8478c7b00a | |||
| b85f58115f | |||
| 8b69a12342 | |||
| 9a66eec033 | |||
| 8ce1b62240 | |||
| e8bb0d0465 | |||
| fdd7f39972 | |||
| e2d352bd0a | |||
| 76abd8b6bc | |||
| ce7cd19fef | |||
| 7f776298a9 | |||
| 3f0af5ca5a | |||
| c8dac9ac88 | |||
| 9779e4e133 | |||
| 9739becbb7 | |||
| 08b7e37acc | |||
| a8f2874413 | |||
| e88a33987a | |||
| 25e910d030 | |||
| 081ed0365b | |||
| 39749c61c1 | |||
| 54cf031cd7 | |||
| 5a7a816638 | |||
| 55f9c8eaf6 | |||
| 91ac322c57 | |||
| af12fa5e4f | |||
| ffa6ba8bfe | |||
| 8e9c6c7848 | |||
| a4b53caaa2 | |||
| eff8ca4210 | |||
| 84e4202536 | |||
| 7f715e4b01 | |||
| 21450afb20 | |||
| b10ff2bc0b | |||
| 3e38ea02b1 | |||
| 039cabd8f4 | |||
| 36768e2441 | |||
| ab9831cce9 | |||
| 0f8627b397 | |||
| a93a362d21 | |||
| eb3e979d59 | |||
| 3687f6dcf5 | |||
| 0565fe4b6a | |||
| 6ae48691bd | |||
| 00b4f3c79c | |||
| 09cfc0aaf3 | |||
| 5597c6a8dd | |||
| cfe0180c1f | |||
| 93e5d4bc29 | |||
| 23fa399d6b | |||
| 0a58201f74 | |||
| 67c511f247 | |||
| 898ef5c3cd | |||
| d27b9a3d3c | |||
| 33c382f0b7 | |||
| 66392740be | |||
| 05e54db4ff | |||
| 8cb65c92cd | |||
| 08caf52183 | |||
| e9ecfacc92 | |||
| 0875ec2136 | |||
| 8015a0ea38 | |||
| f1ed93e5d4 | |||
| 26c2279bdf | |||
| f64a6754c1 | |||
| 43356352b3 | |||
| 89ce718c6c | |||
| 7d81d47b3c | |||
| c6744ab107 | |||
| 184e1257fe | |||
| 650b860cbb | |||
| 441ee5245e | |||
| 978a564621 | |||
| 432e0d0c21 | |||
| 55019f68b8 | |||
| 2b97bc767e | |||
| 6b1642479e | |||
| c4923a0da9 | |||
| 9fbbce07e0 | |||
| b075fb81c4 |
@@ -0,0 +1,8 @@
|
|||||||
|
node_modules
|
||||||
|
.nuxt
|
||||||
|
.output
|
||||||
|
dist
|
||||||
|
src
|
||||||
|
.git
|
||||||
|
*.md
|
||||||
|
.planning
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
NUXT_PUBLIC_GTAG_ID=
|
||||||
+7
-1
@@ -28,4 +28,10 @@ coverage
|
|||||||
*.sw?
|
*.sw?
|
||||||
|
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
.claude
|
.claude
|
||||||
|
|
||||||
|
# Nuxt
|
||||||
|
.nuxt
|
||||||
|
.output
|
||||||
|
.env
|
||||||
|
.data
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
# Milestones
|
||||||
|
|
||||||
|
## M1 — Portfolio Hytale-first, SEO-ready, production
|
||||||
|
|
||||||
|
**Version:** v1.0
|
||||||
|
**Completed:** 2026-04-21
|
||||||
|
**Phases:** 4
|
||||||
|
|
||||||
|
**Delivered:**
|
||||||
|
- Hero Hytale-first avec H1 "Hytale Plugin Developer"
|
||||||
|
- Page `/hytale` avec pricing 3 tiers, témoignages
|
||||||
|
- SEO complet : canonical, ogUrl, og:image, JSON-LD, sitemap dynamique
|
||||||
|
- i18n bilingue FR/EN audit complet
|
||||||
|
- Dockerfile SSR pnpm, rate limiting contact form
|
||||||
|
- Déployé en production sur killiandalcin.fr
|
||||||
+61
-60
@@ -1,96 +1,97 @@
|
|||||||
# Portfolio Killian Dalcin — Migration Nuxt 4
|
# Portfolio Killian' Dalcin — Refonte Nuxt 4 SSR
|
||||||
|
|
||||||
## What This Is
|
## What This Is
|
||||||
|
|
||||||
Migration complète d'un portfolio freelance de Vue 3 SPA vers Nuxt 4 avec SSR complet. Le site présente les projets, services et compétences de Killian Dalcin, développeur freelance, avec support bilingue FR/EN. L'objectif est un SEO parfait et un développement rapide via des composants prêts à l'emploi (Nuxt UI v3).
|
Portfolio professionnel de Killian' Dalcin, developpeur freelance specialise en plugins Hytale et developpement web gaming. Le site presente ses services, projets et competences en bilingue FR/EN. Migration d'une SPA Vue 3 (invisible sur Google) vers Nuxt 4 SSR pour un SEO complet. Objectif business : qu'un server owner Hytale qui cherche "Hytale plugin developer" trouve Killian sur Google.
|
||||||
|
|
||||||
## Core Value
|
## Core Value
|
||||||
|
|
||||||
Chaque page du portfolio doit être crawlable par les moteurs de recherche sans JavaScript côté client — le SSR est la raison d'être de cette migration.
|
Le portfolio doit positionner Killian comme LE developpeur de plugins Hytale professionnel — pas un "dev web freelance generique" perdu parmi 500 000 autres. Chaque page doit etre crawlable sans JavaScript (SSR), avec un SEO optimise pour le marche Hytale.
|
||||||
|
|
||||||
|
## Current Milestone: v1.1 — SEO Hytale — Autorité & Contenu
|
||||||
|
|
||||||
|
**Goal:** Dominer les requêtes Hytale sur Google via un blog markdown complet (tutos, guides, news) combiné à un SEO on-page renforcé — deux leviers pour ranker sur les mots-clés directs ET capter le trafic longue traîne.
|
||||||
|
|
||||||
|
**Target features:**
|
||||||
|
- Blog markdown avec renderer complet (syntax highlighting, images, embeds, tables, alerts)
|
||||||
|
- Articles Hytale bilingues FR/EN (tutos, guides, contenus communauté)
|
||||||
|
- SEO par article : JSON-LD Article, og:image, canonical, sitemap étendu
|
||||||
|
- Cocon sémantique : liens internes blog ↔ page /hytale
|
||||||
|
- Open Graph peaufiné par article
|
||||||
|
|
||||||
## Requirements
|
## Requirements
|
||||||
|
|
||||||
### Validated
|
### Validated
|
||||||
|
|
||||||
(None yet — ship to validate)
|
- ✓ Nuxt 4 SSR configure avec compatibilityVersion 4 — existant
|
||||||
|
- ✓ Systeme i18n bilingue FR/EN avec prefix_except_default — existant
|
||||||
|
- ✓ Dark/light theme avec persistence cookie (SSR-safe) — existant
|
||||||
|
- ✓ Nuxt UI v3 integre comme bibliotheque de composants — existant
|
||||||
|
- ✓ Pages : accueil, projets, about, contact, fiverr — existant
|
||||||
|
- ✓ Formulaire de contact avec Zod validation + honeypot — existant
|
||||||
|
- ✓ Donnees projets typees avec composable useProjects() — existant
|
||||||
|
- ✓ Layout responsive avec header sticky et navigation mobile — existant
|
||||||
|
- ✓ JSON-LD structured data (Person, WebSite) sur homepage — existant
|
||||||
|
- ✓ Sitemap dynamique avec hreflang FR/EN — existant
|
||||||
|
- ✓ useSeoMeta() par route avec title, description, og:tags bilingues — existant
|
||||||
|
- ✓ Dockerfile SSR multi-stage node:22-alpine — existant
|
||||||
|
|
||||||
### Active
|
### Active
|
||||||
|
|
||||||
- [ ] SSR complet — chaque route crawlable sans JS client
|
- [ ] Refonte Hero — positionner "Hytale Plugin Developer" en premier plan, pas "Full Stack Developer"
|
||||||
- [ ] i18n FR/EN — détection navigateur + switch manuel + persistance cookie (SSR-safe)
|
- [ ] Page Hytale dediee — services plugin dev, demos (placeholders), offre maintenance recurrente
|
||||||
- [ ] Dark/light mode — persistance cookie SSR-safe via @nuxtjs/color-mode, pas de FOUC
|
- [ ] Section pricing/services — grille tarifaire visible (plugin simple, complexe, sur-mesure, maintenance, web)
|
||||||
- [ ] SEO par route — useSeoMeta(), og:image auto, JSON-LD page home
|
- [ ] Temoignages clients — section avis sur page d'accueil et page Hytale
|
||||||
- [ ] Sitemap.xml généré automatiquement (@nuxtjs/sitemap)
|
- [ ] Audit et correction i18n — traductions FR/EN completes et naturelles (certaines traductions sont approximatives)
|
||||||
- [ ] Galerie modale images projets — UModal de Nuxt UI v3
|
- [ ] Correction concerns codebase — og:image hardcodee, sitemap statique obsolete, email validation serveur, flowboard features non-i18n
|
||||||
- [ ] Formulaire contact — UForm + UInput + UTextarea (Nuxt UI), envoi EmailJS
|
- [ ] Page 404 personnalisee — verifier que error.vue fonctionne correctement avec i18n
|
||||||
- [ ] Performance — lazy load images (NuxtImg), fonts locales, preload hero
|
- [ ] SEO consolide — canonical links, ogUrl par page, og:image dynamique par projet
|
||||||
- [ ] Migration page Landing (hero + projets vedettes + services + CTA)
|
|
||||||
- [ ] Migration page Projects (liste avec filtres)
|
|
||||||
- [ ] Migration page Project Detail (détail + galerie modale)
|
|
||||||
- [ ] Migration page About (bio)
|
|
||||||
- [ ] Migration page Contact (formulaire)
|
|
||||||
- [ ] Migration page Fiverr (landing services)
|
|
||||||
- [ ] Migration page Formation (formations)
|
|
||||||
- [ ] Migration données statiques (projets, témoignages, FAQ, tech stack)
|
|
||||||
- [ ] Migration composables (useProjects → useAsyncData, useSiteConfig → useAppConfig, useGallery → UModal)
|
|
||||||
- [ ] Dockerfile production optimisé (multi-stage, node:22-alpine)
|
|
||||||
- [ ] TypeScript strict partout
|
|
||||||
- [ ] ESLint + Prettier (@nuxt/eslint)
|
|
||||||
|
|
||||||
### Out of Scope
|
### Out of Scope
|
||||||
|
|
||||||
- Umami Analytics — self-hosted, hors scope de cette migration
|
- Tests automatises — priorite au shipping, tests si necessaire apres
|
||||||
- AdSense — script externe simple à injecter via app.head, pas un module
|
- Blog/CMS — promu en Active pour M1.1 (blog markdown statique)
|
||||||
- Backend custom — formulaire contact via EmailJS/Formspree uniquement
|
- Dashboard admin — portfolio statique
|
||||||
- @nuxt/content — données statiques en fichiers TS, pas besoin de CMS markdown
|
- PWA/Service Workers — pas de besoin offline
|
||||||
- Tests automatisés — migration d'abord, tests ensuite si nécessaire
|
- Pub payante — budget zero
|
||||||
|
- Plugin marketplace — trop complexe pour 5-10h/semaine
|
||||||
|
- Payment integration — paiements via Fiverr ou virement direct
|
||||||
|
|
||||||
## Context
|
## Context
|
||||||
|
|
||||||
- Portfolio freelance existant en production (Vue 3 SPA)
|
- **Developpeur:** Killian' Dalcin, 7+ ans autodidacte, JS/TS/Vue/React/Node/Java/Kotlin
|
||||||
- Le site actuel fonctionne mais le SPA nuit au SEO (pas de SSR)
|
- **Situation:** CDI chez Mashe + auto-entrepreneur (micro-entreprise) a cote
|
||||||
- Données statiques dans `src/data/` (projets, témoignages, FAQ, tech stack) — format TS avec textes FR/EN
|
- **Marche:** Hytale en Early Access (2026), marche de plugins quasi vide sur Fiverr (~1 concurrent direct a $45)
|
||||||
- Composables existants : useProjects(), useSiteConfig(), useGallery()
|
- **Avantage structurel:** Chaque update Hytale casse les plugins = clients recurrents pour maintenance
|
||||||
- i18n actuel via vue-i18n standalone avec persistance localStorage (non SSR-safe)
|
- **Probleme resolu:** Portfolio SPA invisible sur Google, positionnement generique "dev web freelance"
|
||||||
- Thème actuel via class CSS `dark` avec persistance localStorage (FOUC au chargement)
|
- **Codebase:** Migration Nuxt 4 deja avancee — pages, composants, data, i18n, contact form, SEO, Docker fonctionnels
|
||||||
- Déploiement Docker existant (Node 22 build → nginx serve static)
|
- **Disponibilite:** 5-10h/semaine pour prospection hors CDI
|
||||||
- Google Analytics 4 hardcodé dans index.html (à migrer vers nuxt-gtag)
|
- **Anglais:** Courant/Pro — acces marche international
|
||||||
|
|
||||||
## Constraints
|
## Constraints
|
||||||
|
|
||||||
- **Stack**: Nuxt 4 + Nuxt UI v3 + Tailwind v4 — dernières versions stables
|
- **Stack**: Nuxt 4 + Nuxt UI v3 + Tailwind v4 — versions stables actuelles
|
||||||
- **Coût**: Zéro dépendance payante
|
- **Budget**: Zero dependance payante (hors Claude)
|
||||||
- **Composants**: Nuxt UI v3 en priorité sur le custom (80% suffit)
|
- **Composants**: Nuxt UI v3 en priorite (80% suffit, pas de custom inutile)
|
||||||
- **TypeScript**: Mode strict partout
|
- **TypeScript**: Mode strict partout
|
||||||
- **Déploiement**: Docker node:22-alpine, nuxt build (SSR) ou nuxt generate (SSG) selon stratégie
|
|
||||||
- **i18n/Theme**: Persistance cookie uniquement (SSR-safe), pas de localStorage
|
- **i18n/Theme**: Persistance cookie uniquement (SSR-safe), pas de localStorage
|
||||||
|
- **Deploiement**: Docker node:22-alpine, SSR
|
||||||
|
- **Design**: Garder le dark theme et brand green (#85cb85) actuels — ameliorer, pas refaire
|
||||||
|
- **Scope**: Ameliorer l'existant et ajouter le contenu Hytale, pas tout refaire from scratch
|
||||||
|
|
||||||
## Key Decisions
|
## Key Decisions
|
||||||
|
|
||||||
| Decision | Rationale | Outcome |
|
| Decision | Rationale | Outcome |
|
||||||
|----------|-----------|---------|
|
|----------|-----------|---------|
|
||||||
| Nuxt 4 plutôt que Nuxt 3 | Dernière version stable, meilleure DX et perf | — Pending |
|
| Hytale en positionnement principal | Marche emergent quasi vide, avantage first-mover, clients recurrents | Pending |
|
||||||
| Nuxt UI v3 plutôt que composants custom | Vitesse de dev, composants production-ready | — Pending |
|
| Nuxt 4 SSR over static generation | SEO dynamique, meta tags par page, i18n avec prefix routing | Good |
|
||||||
| EmailJS pour le contact | Pas de backend à maintenir | — Pending |
|
| Cookie-only persistence | SSR-safe, pas de flash/hydration mismatch | Good |
|
||||||
| Cookie plutôt que localStorage pour i18n/theme | SSR-safe, pas de flash/hydration mismatch | — Pending |
|
| pnpm comme package manager | Standard Nuxt 4, plus rapide que npm | Good |
|
||||||
| Données statiques en TS plutôt que @nuxt/content | Simplicité, pas besoin de CMS | — Pending |
|
| Grille tarifaire visible sur le site | Filtrer les clients non-serieux, transparence | Pending |
|
||||||
|
|
||||||
## Evolution
|
## Evolution
|
||||||
|
|
||||||
This document evolves at phase transitions and milestone boundaries.
|
This document evolves at phase transitions and milestone boundaries.
|
||||||
|
|
||||||
**After each phase transition** (via `/gsd-transition`):
|
|
||||||
1. Requirements invalidated? → Move to Out of Scope with reason
|
|
||||||
2. Requirements validated? → Move to Validated with phase reference
|
|
||||||
3. New requirements emerged? → Add to Active
|
|
||||||
4. Decisions to log? → Add to Key Decisions
|
|
||||||
5. "What This Is" still accurate? → Update if drifted
|
|
||||||
|
|
||||||
**After each milestone** (via `/gsd-complete-milestone`):
|
|
||||||
1. Full review of all sections
|
|
||||||
2. Core Value check — still the right priority?
|
|
||||||
3. Audit Out of Scope — reasons still valid?
|
|
||||||
4. Update Context with current state
|
|
||||||
|
|
||||||
---
|
---
|
||||||
*Last updated: 2026-04-07 after initialization*
|
*Last updated: 2026-04-10 after initialization*
|
||||||
|
|||||||
+78
-118
@@ -1,147 +1,107 @@
|
|||||||
# Requirements: Portfolio Killian Dalcin — Nuxt 4 Migration
|
# Requirements: Portfolio Killian' Dalcin
|
||||||
|
|
||||||
**Defined:** 2026-04-07
|
**Defined:** 2026-04-10
|
||||||
**Core Value:** Chaque page du portfolio doit être crawlable par les moteurs de recherche sans JavaScript côté client
|
**Updated:** 2026-04-21 (v1.1 added)
|
||||||
|
**Core Value:** Positionner Killian comme dev Hytale #1, crawlable sans JS, SEO optimise
|
||||||
|
|
||||||
## v1 Requirements
|
## v1 Requirements (M1 — Complété 2026-04-21)
|
||||||
|
|
||||||
### SSR Foundation
|
### Content
|
||||||
|
|
||||||
- [ ] **SSR-01**: Chaque route retourne du HTML complet côté serveur, crawlable sans JS client
|
- [x] **CONT-01**: Refonte Hero accueil — "Hytale Plugin Developer" en H1, CTA Discord/contact, bilingue
|
||||||
- [ ] **SSR-02**: Le projet utilise Nuxt 4 avec la structure `app/` et les auto-imports
|
- [x] **CONT-02**: Page Hytale dediee `/hytale` — services plugin dev, tiers pricing, demos placeholders, maintenance recurrente, bilingue
|
||||||
- [ ] **SSR-03**: `nuxt.config.ts` configure tous les modules (UI, i18n, color-mode, SEO, gtag, image)
|
- [x] **CONT-03**: Grille tarifaire — plugin simple/complexe/sur-mesure/maintenance/web avec prix visibles
|
||||||
|
- [x] **CONT-04**: Temoignages — section featured + stats sur homepage et page Hytale (5 avis Fiverr existants)
|
||||||
### Internationalization
|
|
||||||
|
|
||||||
- [ ] **I18N-01**: Le site supporte FR et EN avec stratégie `prefix_except_default` (FR à `/`, EN à `/en/*`)
|
|
||||||
- [ ] **I18N-02**: La locale est détectée depuis le navigateur au premier accès et persistée en cookie
|
|
||||||
- [ ] **I18N-03**: L'utilisateur peut changer de langue via un switcher dans le header
|
|
||||||
- [ ] **I18N-04**: Le serveur lit le cookie et rend la bonne langue sans hydration mismatch
|
|
||||||
- [ ] **I18N-05**: Les fichiers de traduction FR/EN sont migrés depuis les locales existantes
|
|
||||||
|
|
||||||
### Theme
|
|
||||||
|
|
||||||
- [ ] **THEME-01**: L'utilisateur peut basculer entre dark et light mode via un toggle dans le header
|
|
||||||
- [ ] **THEME-02**: Le thème est persisté en cookie SSR-safe (pas localStorage)
|
|
||||||
- [ ] **THEME-03**: Aucun FOUC au chargement — le serveur rend le bon thème dès la première requête
|
|
||||||
|
|
||||||
### SEO
|
### SEO
|
||||||
|
|
||||||
- [ ] **SEO-01**: Chaque page a un `<title>`, `<meta description>`, `og:title`, `og:description` uniques via `useSeoMeta()`
|
- [x] **SEO-01**: Canonical links — `<link rel="canonical">` sur chaque page pour eviter duplication i18n
|
||||||
- [ ] **SEO-02**: La page d'accueil inclut du JSON-LD structuré (Person / CreativeWork)
|
- [x] **SEO-02**: ogUrl par page — chaque `useSeoMeta()` inclut `ogUrl` specifique
|
||||||
- [ ] **SEO-03**: Le sitemap.xml est généré automatiquement avec les alternates i18n (hreflang)
|
- [x] **SEO-03**: og:image par page — images distinctes au lieu du meme og-image.png partout
|
||||||
- [ ] **SEO-04**: Les og:image utilisent des URLs absolues et sont présentes sur chaque page
|
- [x] **SEO-04**: JSON-LD complet — Person (homepage), Service (hytale), SoftwareApplication (projets), composable `useJsonLd.ts`
|
||||||
|
- [x] **SEO-05**: jobTitle corrige — "Hytale Plugin Developer" dans site.ts et JSON-LD, pas "Full Stack Freelance"
|
||||||
|
|
||||||
### Pages
|
### i18n
|
||||||
|
|
||||||
- [ ] **PAGE-01**: Page Landing `/` — hero, projets vedettes, services, CTA
|
- [x] **I18N-01**: Audit complet FR/EN — chaque cle FR doit exister en EN avec traduction reelle
|
||||||
- [ ] **PAGE-02**: Page Projects `/projects` — liste de projets avec filtres (recherche + catégorie)
|
- [x] **I18N-02**: Qualite traductions FR — reformuler les traductions approximatives/anglicismes
|
||||||
- [ ] **PAGE-03**: Page Project Detail `/project/[id]` — détail projet avec galerie modale d'images
|
- [x] **I18N-03**: Hardcoded strings — eliminer toutes les chaines en dur dans les composants
|
||||||
- [ ] **PAGE-04**: Page About `/about` — biographie, tech stack badges
|
- [x] **I18N-04**: SEO keys Hytale — title/description/og specifiques pour la page Hytale en FR et EN
|
||||||
- [ ] **PAGE-05**: Page Contact `/contact` — formulaire avec validation + envoi EmailJS
|
|
||||||
- [ ] **PAGE-06**: Page Fiverr `/fiverr` — landing services, cards, FAQ accordion, CTA
|
|
||||||
- [ ] **PAGE-07**: Page Formation `/formation` — page formations/cours
|
|
||||||
- [ ] **PAGE-08**: Page 404 — `error.vue` avec redirection vers home
|
|
||||||
|
|
||||||
### Components
|
### Fixes
|
||||||
|
|
||||||
- [ ] **COMP-01**: Galerie modale d'images — UModal + UCarousel avec navigation clavier (flèches + Escape)
|
- [x] **FIX-01**: Supprimer `public/sitemap.xml` statique — conflit avec `@nuxtjs/sitemap` dynamique
|
||||||
- [ ] **COMP-02**: Formulaire contact — UForm + UFormField + UInput + UTextarea + validation Zod + envoi EmailJS
|
- [x] **FIX-02**: Dockerfile pnpm — remplacer `npm ci` par `pnpm install --frozen-lockfile`
|
||||||
- [ ] **COMP-03**: FAQ accordion — UAccordion pour la page Fiverr, localisé FR/EN
|
- [x] **FIX-03**: Rate limiting contact API — protection anti-spam in-memory sur `/api/contact`
|
||||||
- [ ] **COMP-04**: Section témoignages clients — UCard pour chaque témoignage
|
- [x] **FIX-04**: Donnees incoherentes — `reviewCount: '50'` vs `totalReviews: 10`, Fiverr URLs `#`
|
||||||
- [ ] **COMP-05**: Header avec navigation desktop (UNavigationMenu) + mobile (UDrawer) + toggles langue/thème
|
- [x] **FIX-05**: Pinning deps — `vue: "latest"` et `vue-router: "latest"` a pincer sur `^3.5.0` / `^4.5.0`
|
||||||
- [ ] **COMP-06**: Footer avec liens et informations
|
|
||||||
|
|
||||||
### Data
|
### Deployment
|
||||||
|
|
||||||
- [ ] **DATA-01**: Données projets migrées depuis `src/data/` vers `data/` avec interfaces TypeScript
|
- [x] **DEPLOY-01**: Dockerfile production corrige — pnpm, node:22-alpine, env vars SMTP/gtag runtime
|
||||||
- [ ] **DATA-02**: Données témoignages migrées avec interfaces TypeScript
|
|
||||||
- [ ] **DATA-03**: Données FAQ migrées avec support FR/EN et interfaces TypeScript
|
|
||||||
- [ ] **DATA-04**: Données tech stack migrées avec interfaces TypeScript
|
|
||||||
- [ ] **DATA-05**: Composable `useProjects()` migré — filtrage, recherche, findById
|
|
||||||
|
|
||||||
### Infrastructure
|
---
|
||||||
|
|
||||||
- [ ] **INFRA-01**: Dockerfile production multi-stage (node:22-alpine build → node:22-alpine runtime, copie `.output/` uniquement)
|
## v1.1 Requirements (M1.1 — SEO Hytale — Autorité & Contenu)
|
||||||
- [ ] **INFRA-02**: TypeScript en mode strict avec interfaces pour toutes les données
|
|
||||||
- [ ] **INFRA-03**: ESLint + Prettier configurés via @nuxt/eslint
|
|
||||||
- [ ] **INFRA-04**: Google Analytics 4 via nuxt-gtag, actif uniquement en production
|
|
||||||
|
|
||||||
## v2 Requirements
|
### Blog — Système
|
||||||
|
|
||||||
### Performance avancée
|
- [ ] **BLOG-01**: Intégration `@nuxt/content` ou équivalent — renderer markdown complet (syntax highlighting, images, embeds, tables, callouts/alerts)
|
||||||
|
- [ ] **BLOG-02**: Page listing `/blog` — liste de tous les articles avec titre, description, date, tags, SSR
|
||||||
|
- [ ] **BLOG-03**: Page article `/blog/[slug]` — rendu SSR complet, table des matières, navigation prev/next
|
||||||
|
- [ ] **BLOG-04**: Blocs de code avec syntax highlighting (Kotlin, Java, TypeScript, Shell prioritaires pour Hytale)
|
||||||
|
- [ ] **BLOG-05**: Support images dans articles — images optimisées avec `<NuxtImg>` ou `<nuxt-img>`
|
||||||
|
|
||||||
- **PERF-01**: Preload hero image via useHead link preload
|
### Blog — Contenu
|
||||||
- **PERF-02**: Fonts locales (pas Google Fonts) pour éviter FOUT
|
|
||||||
- **PERF-03**: NuxtImg avec optimisation WebP automatique pour toutes les images projet
|
|
||||||
|
|
||||||
### SEO avancé
|
- [ ] **BLOG-06**: Articles bilingues FR/EN — structure i18n dans le système de contenu
|
||||||
|
- [ ] **BLOG-07**: Au moins 2 articles seed Hytale au lancement (ex: "How to build your first Hytale plugin", "Hytale plugin development in 2026")
|
||||||
|
|
||||||
- **SEOV2-01**: og:image générée dynamiquement par route via nuxt-og-image
|
### SEO — Blog
|
||||||
- **SEOV2-02**: robots.txt optimisé avec directives spécifiques
|
|
||||||
|
- [ ] **SEO-10**: `useSeoMeta()` par article — title, description, og:title, og:description, og:image uniques
|
||||||
|
- [ ] **SEO-11**: JSON-LD `Article` par billet de blog — author, datePublished, dateModified, headline
|
||||||
|
- [ ] **SEO-12**: Sitemap étendu — URLs `/blog/[slug]` et `/en/blog/[slug]` incluses automatiquement
|
||||||
|
- [ ] **SEO-13**: Open Graph image par article — og:image spécifique (image de l'article ou fallback branded)
|
||||||
|
|
||||||
|
### SEO — Cocon sémantique
|
||||||
|
|
||||||
|
- [ ] **SEO-14**: Liens internes structurés — articles de blog pointent vers `/hytale`, page `/hytale` liste les articles récents
|
||||||
|
- [ ] **SEO-15**: BreadcrumbList JSON-LD sur les pages blog (Accueil → Blog → Article)
|
||||||
|
|
||||||
|
## Future Requirements (backlog)
|
||||||
|
|
||||||
|
- **SEO-06**: og:image dynamique générée par page (OG image generator)
|
||||||
|
- **FEAT-01**: Formulaire devis en ligne
|
||||||
|
- **FEAT-02**: Section portfolio Minecraft Java
|
||||||
|
- **CONT-08**: Newsletter / liste email pour communauté Hytale
|
||||||
|
|
||||||
## Out of Scope
|
## Out of Scope
|
||||||
|
|
||||||
| Feature | Reason |
|
| Feature | Reason |
|
||||||
|---------|--------|
|
|---------|--------|
|
||||||
| Umami Analytics | Self-hosted, infrastructure hors scope |
|
| Tests automatises | Ship d'abord, tests ensuite |
|
||||||
| AdSense | Script externe simple, pas un module Nuxt |
|
| Dashboard admin | Blog statique markdown, pas de CMS |
|
||||||
| Backend custom | Formulaire contact via EmailJS uniquement |
|
| PWA/Service Workers | Pas de besoin offline |
|
||||||
| @nuxt/content | Données statiques en TS, pas besoin de CMS markdown |
|
| Pub payante | Budget zero |
|
||||||
| Blog / articles | Pas dans le scope, maintenance contenu supplémentaire |
|
| Payment integration | Paiements via Fiverr ou virement |
|
||||||
| Animation library (GSAP) | CSS transitions suffisantes, poids JS inutile |
|
| Core Web Vitals | Milestone dédié si besoin |
|
||||||
| i18n > 2 langues | FR/EN uniquement, scope creep |
|
| OG image generator | Complexité vs impact — backlog |
|
||||||
| CMS admin panel | Données statiques modifiées via code |
|
|
||||||
| Tests automatisés | Migration d'abord, tests ensuite si nécessaire |
|
|
||||||
|
|
||||||
## Traceability
|
## Traceability v1.1
|
||||||
|
|
||||||
| Requirement | Phase | Status |
|
| Requirement | Phase | Status |
|
||||||
|-------------|-------|--------|
|
|-------------|-------|--------|
|
||||||
| SSR-01 | Phase 1 | Pending |
|
| BLOG-01 | Phase 5 | Pending |
|
||||||
| SSR-02 | Phase 1 | Pending |
|
| BLOG-04 | Phase 5 | Pending |
|
||||||
| SSR-03 | Phase 1 | Pending |
|
| BLOG-05 | Phase 5 | Pending |
|
||||||
| DATA-01 | Phase 1 | Pending |
|
| BLOG-02 | Phase 6 | Pending |
|
||||||
| DATA-02 | Phase 1 | Pending |
|
| BLOG-03 | Phase 6 | Pending |
|
||||||
| DATA-03 | Phase 1 | Pending |
|
| BLOG-06 | Phase 6 | Pending |
|
||||||
| DATA-04 | Phase 1 | Pending |
|
| SEO-10 | Phase 7 | Pending |
|
||||||
| DATA-05 | Phase 1 | Pending |
|
| SEO-11 | Phase 7 | Pending |
|
||||||
| INFRA-02 | Phase 1 | Pending |
|
| SEO-12 | Phase 7 | Pending |
|
||||||
| INFRA-03 | Phase 1 | Pending |
|
| SEO-13 | Phase 7 | Pending |
|
||||||
| I18N-01 | Phase 2 | Pending |
|
| SEO-15 | Phase 7 | Pending |
|
||||||
| I18N-02 | Phase 2 | Pending |
|
| BLOG-07 | Phase 8 | Pending |
|
||||||
| I18N-03 | Phase 2 | Pending |
|
| SEO-14 | Phase 8 | Pending |
|
||||||
| I18N-04 | Phase 2 | Pending |
|
|
||||||
| I18N-05 | Phase 2 | Pending |
|
|
||||||
| THEME-01 | Phase 2 | Pending |
|
|
||||||
| THEME-02 | Phase 2 | Pending |
|
|
||||||
| THEME-03 | Phase 2 | Pending |
|
|
||||||
| SEO-01 | Phase 2 | Pending |
|
|
||||||
| SEO-02 | Phase 2 | Pending |
|
|
||||||
| SEO-03 | Phase 2 | Pending |
|
|
||||||
| SEO-04 | Phase 2 | Pending |
|
|
||||||
| COMP-05 | Phase 2 | Pending |
|
|
||||||
| COMP-06 | Phase 2 | Pending |
|
|
||||||
| PAGE-01 | Phase 3 | Pending |
|
|
||||||
| PAGE-02 | Phase 3 | Pending |
|
|
||||||
| PAGE-03 | Phase 3 | Pending |
|
|
||||||
| PAGE-04 | Phase 3 | Pending |
|
|
||||||
| PAGE-05 | Phase 3 | Pending |
|
|
||||||
| PAGE-06 | Phase 3 | Pending |
|
|
||||||
| PAGE-07 | Phase 3 | Pending |
|
|
||||||
| PAGE-08 | Phase 3 | Pending |
|
|
||||||
| COMP-01 | Phase 3 | Pending |
|
|
||||||
| COMP-02 | Phase 3 | Pending |
|
|
||||||
| COMP-03 | Phase 3 | Pending |
|
|
||||||
| COMP-04 | Phase 3 | Pending |
|
|
||||||
| INFRA-01 | Phase 3 | Pending |
|
|
||||||
| INFRA-04 | Phase 3 | Pending |
|
|
||||||
|
|
||||||
**Coverage:**
|
|
||||||
- v1 requirements: 38 total
|
|
||||||
- Mapped to phases: 38
|
|
||||||
- Unmapped: 0 ✓
|
|
||||||
|
|
||||||
---
|
|
||||||
*Requirements defined: 2026-04-07*
|
|
||||||
*Last updated: 2026-04-07 after roadmap creation*
|
|
||||||
|
|||||||
+145
-51
@@ -1,76 +1,170 @@
|
|||||||
# Roadmap: Portfolio Killian Dalcin — Nuxt 4 Migration
|
# Roadmap: Portfolio Killian' Dalcin
|
||||||
|
|
||||||
## Overview
|
**Milestone:** M1 — Portfolio Hytale-first, SEO-ready, production
|
||||||
|
**Granularity:** Coarse
|
||||||
|
**Coverage:** 19/19 requirements mapped
|
||||||
|
|
||||||
Three phases following the strict build order from research: first lay the Nuxt 4 project skeleton with all modules configured and data migrated, then implement the SSR-critical cross-cutting concerns (i18n, theme, SEO, header/footer), and finally build all pages and ship to production via Docker. Every page is crawlable by search engines when Phase 3 completes.
|
---
|
||||||
|
|
||||||
## Phases
|
## Phases
|
||||||
|
|
||||||
**Phase Numbering:**
|
- [x] **Phase 1: Cleanup & Fixes** - Sitemap conflit, Dockerfile pnpm, deps pinning, donnees incoherentes, rate limiting
|
||||||
- Integer phases (1, 2, 3): Planned milestone work
|
- [x] **Phase 2: Content** - Hero Hytale, page Hytale, pricing, temoignages, jobTitle
|
||||||
- Decimal phases (2.1, 2.2): Urgent insertions (marked with INSERTED)
|
- [x] **Phase 3: SEO & i18n** - Canonical, ogUrl, og:image, JSON-LD, audit i18n, traductions
|
||||||
|
- [x] **Phase 4: Ship** - Dockerfile final, verification production, deploy
|
||||||
|
|
||||||
Decimal phases appear between their surrounding integers in numeric order.
|
---
|
||||||
|
|
||||||
- [ ] **Phase 1: Foundation** - Nuxt 4 project scaffold, all modules configured, static data migrated, composables ported
|
|
||||||
- [ ] **Phase 2: SSR Shell** - i18n FR/EN, dark/light theme, SEO per route, header + footer layout
|
|
||||||
- [ ] **Phase 3: Pages & Ship** - All 8 pages, interactive components, EmailJS plugin, GA4, Dockerfile
|
|
||||||
|
|
||||||
## Phase Details
|
## Phase Details
|
||||||
|
|
||||||
### Phase 1: Foundation
|
### Phase 1: Cleanup & Fixes
|
||||||
**Goal**: The Nuxt 4 project runs locally with all modules installed, data in `data/`, composables wired, and TypeScript strict mode passing
|
**Goal**: Le codebase est propre — pas de conflits de config, deps pinees, contact form protege, donnees coherentes
|
||||||
**Depends on**: Nothing (first phase)
|
**Depends on**: Nothing
|
||||||
**Requirements**: SSR-01, SSR-02, SSR-03, DATA-01, DATA-02, DATA-03, DATA-04, DATA-05, INFRA-02, INFRA-03
|
**Requirements**: FIX-01, FIX-02, FIX-03, FIX-04, FIX-05, DEPLOY-01
|
||||||
**Success Criteria** (what must be TRUE):
|
**Success Criteria** (what must be TRUE):
|
||||||
1. `nuxt dev` starts without errors and serves a blank app at `localhost:3000`
|
1. `public/sitemap.xml` supprime — `curl localhost:3000/sitemap.xml` retourne le sitemap dynamique genere par `@nuxtjs/sitemap`
|
||||||
2. All static data files exist under `data/` and are importable with TypeScript strict — no `any` types
|
2. `Dockerfile` utilise `pnpm install --frozen-lockfile` — `docker build` reussit sans npm
|
||||||
3. `useProjects()` composable returns typed project list and supports filtering by category and search
|
3. `package.json` ne contient ni `"latest"` ni `"*"` dans les deps
|
||||||
4. `npx nuxi typecheck` and `npx eslint .` exit with 0 errors
|
4. `siteConfig.seo.organization.aggregateRating.reviewCount` correspond a `testimonials.totalReviews`
|
||||||
**Plans**: 2 plans
|
5. 10 requetes POST rapides sur `/api/contact` → les dernieres sont rejetees (rate limit)
|
||||||
|
**Plans:** 2 plans
|
||||||
Plans:
|
Plans:
|
||||||
- [ ] 01-01-PLAN.md — Scaffold Nuxt 4, modules, TypeScript strict, interfaces
|
- [ ] 01-01-PLAN.md — Delete static sitemap, pin deps, fix data inconsistencies
|
||||||
- [ ] 01-02-PLAN.md — Migration donnees statiques + useProjects()
|
- [ ] 01-02-PLAN.md — Migrate Dockerfile to pnpm, add contact API rate limiting
|
||||||
|
|
||||||
### Phase 2: SSR Shell
|
### Phase 2: Content
|
||||||
**Goal**: Every route renders the correct language, theme, and SEO metadata on the server — confirmed by `curl` with no JavaScript
|
**Goal**: Un visiteur comprend immediatement que Killian est dev Hytale, peut voir les services/prix, et lire des temoignages clients
|
||||||
**Depends on**: Phase 1
|
**Depends on**: Phase 1
|
||||||
**Requirements**: I18N-01, I18N-02, I18N-03, I18N-04, I18N-05, THEME-01, THEME-02, THEME-03, SEO-01, SEO-02, SEO-03, SEO-04, COMP-05, COMP-06
|
**Requirements**: CONT-01, CONT-02, CONT-03, CONT-04, SEO-05
|
||||||
**Success Criteria** (what must be TRUE):
|
**Success Criteria** (what must be TRUE):
|
||||||
1. `curl http://localhost:3000` returns French HTML; `curl http://localhost:3000/en/` returns English HTML — no JS required
|
1. Le H1 de la homepage contient "Hytale" — `curl localhost:3000 | grep -i hytale` dans le `<h1>`
|
||||||
2. Switching language via the header dropdown persists across page reload (cookie, no FOUC)
|
2. `/hytale` existe avec 3+ tiers de pricing visibles et un CTA contact/Discord
|
||||||
3. Toggling dark/light mode in the header persists across page reload with no flash on cold load
|
3. `app/data/site.ts` contient `jobTitle: 'Hytale Plugin Developer'`
|
||||||
4. `curl http://localhost:3000` response includes `<title>`, `og:title`, `og:description`, and JSON-LD script tag
|
4. Les temoignages apparaissent sur la homepage ET la page Hytale
|
||||||
5. `http://localhost:3000/sitemap.xml` returns a valid XML sitemap with `hreflang` alternates for FR and EN URLs
|
5. Tout le contenu est bilingue — `curl localhost:3000/en/hytale` retourne du contenu anglais
|
||||||
**Plans**: 2 plans
|
**Plans:** 3 plans
|
||||||
Plans:
|
Plans:
|
||||||
- [ ] 01-01-PLAN.md — Scaffold Nuxt 4, modules, TypeScript strict, interfaces
|
- [ ] 02-01-PLAN.md — Types, data files, site.ts config, i18n keys (foundation)
|
||||||
- [ ] 01-02-PLAN.md — Migration donnees statiques + useProjects()
|
- [ ] 02-02-PLAN.md — Hero refonte Hytale, testimonials featured prop, nav link
|
||||||
|
- [ ] 02-03-PLAN.md — Hytale page creation with pricing, services, and sections
|
||||||
**UI hint**: yes
|
**UI hint**: yes
|
||||||
|
|
||||||
### Phase 3: Pages & Ship
|
### Phase 3: SEO & i18n
|
||||||
**Goal**: All portfolio pages are live, forms work, analytics fire in production, and the Docker image builds and runs
|
**Goal**: Chaque page a des meta tags complets, JSON-LD, canonical links, et des traductions FR/EN naturelles et completes
|
||||||
**Depends on**: Phase 2
|
**Depends on**: Phase 2
|
||||||
**Requirements**: PAGE-01, PAGE-02, PAGE-03, PAGE-04, PAGE-05, PAGE-06, PAGE-07, PAGE-08, COMP-01, COMP-02, COMP-03, COMP-04, INFRA-01, INFRA-04
|
**Requirements**: SEO-01, SEO-02, SEO-03, SEO-04, I18N-01, I18N-02, I18N-03, I18N-04
|
||||||
**Success Criteria** (what must be TRUE):
|
**Success Criteria** (what must be TRUE):
|
||||||
1. All 8 routes (`/`, `/projects`, `/project/[id]`, `/about`, `/contact`, `/fiverr`, `/formation`, 404) return complete HTML when fetched with `curl`
|
1. `curl localhost:3000` retourne `<link rel="canonical">` et `ogUrl` dans le HTML
|
||||||
2. Clicking an image in a project detail page opens a modal carousel with keyboard navigation (arrow keys + Escape closes)
|
2. `curl localhost:3000/hytale` retourne un JSON-LD `Service` avec les 3 tiers
|
||||||
3. Submitting the contact form with valid data shows a success toast; EmailJS delivers the email
|
3. `curl localhost:3000/en/` retourne du HTML anglais sans hardcoded French strings
|
||||||
4. `docker build` completes and `docker run` serves the SSR app on port 3000
|
4. Aucun composant ne contient de chaine en dur (grep pour strings hors `t()` dans les templates)
|
||||||
5. Google Analytics 4 events appear in GA4 DebugView when browsing in production mode
|
5. Les traductions FR sonnent naturel — pas de calque anglais
|
||||||
**Plans**: 2 plans
|
**Plans**: TBD
|
||||||
Plans:
|
|
||||||
- [ ] 01-01-PLAN.md — Scaffold Nuxt 4, modules, TypeScript strict, interfaces
|
### Phase 4: Ship
|
||||||
- [ ] 01-02-PLAN.md — Migration donnees statiques + useProjects()
|
**Goal**: Le site est deployable en production via Docker et passe tous les checks
|
||||||
**UI hint**: yes
|
**Depends on**: Phase 3
|
||||||
|
**Requirements**: DEPLOY-01
|
||||||
|
**Success Criteria** (what must be TRUE):
|
||||||
|
1. `docker build` complete sans erreur
|
||||||
|
2. Le container sert le site SSR sur le port attendu
|
||||||
|
3. `pnpm typecheck` et `pnpm lint` passent avec 0 erreurs
|
||||||
|
4. `curl` sur chaque page retourne `<title>`, `<meta description>`, `og:title` dans le HTML brut
|
||||||
|
**Plans**: TBD
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Progress
|
## Progress
|
||||||
|
|
||||||
**Execution Order:**
|
| Phase | Plans Complete | Status | Completed |
|
||||||
Phases execute in numeric order: 1 → 2 → 3
|
|-------|----------------|--------|-----------|
|
||||||
|
| 1. Cleanup & Fixes | 2/2 | Complete | 2026-04-21 |
|
||||||
|
| 2. Content | 3/3 | Complete | 2026-04-21 |
|
||||||
|
| 3. SEO & i18n | 1/1 | Complete | 2026-04-21 |
|
||||||
|
| 4. Ship | 1/1 | Complete | 2026-04-21 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Roadmap: Portfolio Killian' Dalcin — M1.1
|
||||||
|
|
||||||
|
**Milestone:** M1.1 — SEO Hytale — Autorité & Contenu
|
||||||
|
**Granularity:** Standard
|
||||||
|
**Coverage:** 13/13 requirements mapped
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phases (M1.1)
|
||||||
|
|
||||||
|
- [ ] **Phase 5: @nuxt/content Setup & Renderer** - Integration @nuxt/content, markdown renderer complet avec syntax highlighting et images
|
||||||
|
- [ ] **Phase 6: Blog Pages** - Page listing /blog et page article /blog/[slug] SSR, bilingue, avec TOC et nav prev/next
|
||||||
|
- [ ] **Phase 7: SEO Blog** - useSeoMeta par article, JSON-LD Article, sitemap etendu, og:image, BreadcrumbList
|
||||||
|
- [ ] **Phase 8: Content & Cocon Semantique** - 2 articles seed Hytale, liens internes blog-hytale
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase Details (M1.1)
|
||||||
|
|
||||||
|
### Phase 5: @nuxt/content Setup & Renderer
|
||||||
|
**Goal**: Le systeme de contenu markdown est installe et rend fidelement le contenu technique — blocs de code colores, images optimisees, tables, alerts — sans configuration supplementaire dans les phases suivantes
|
||||||
|
**Depends on**: Phase 4 (M1 complete)
|
||||||
|
**Requirements**: BLOG-01, BLOG-04, BLOG-05
|
||||||
|
**Success Criteria** (what must be TRUE):
|
||||||
|
1. `@nuxt/content` est installe et configure dans `nuxt.config.ts` — `pnpm dev` demarre sans erreur
|
||||||
|
2. Un article markdown de test avec un bloc Kotlin est rendu avec coloration syntaxique visible dans le navigateur
|
||||||
|
3. Une image referencee dans un article s'affiche via `<NuxtImg>` avec les optimisations (lazy, format webp)
|
||||||
|
4. Un tableau markdown et un callout/alert sont rendus avec le style correct
|
||||||
|
**Plans:** 2 plans
|
||||||
|
Plans:
|
||||||
|
- [ ] 05-01-PLAN.md — Installation @nuxt/content, configuration Shiki dual-theme, content.config.ts collections bilingues
|
||||||
|
- [ ] 05-02-PLAN.md — Composants MDC ProseImg + Alert, articles de test FR/EN, checkpoint visuel
|
||||||
|
**UI hint**: yes
|
||||||
|
|
||||||
|
### Phase 6: Blog Pages
|
||||||
|
**Goal**: Un visiteur peut naviguer vers /blog, parcourir la liste des articles, ouvrir un article, voir sa table des matieres et naviguer vers l'article precedent/suivant — le tout en SSR et en FR ou EN
|
||||||
|
**Depends on**: Phase 5
|
||||||
|
**Requirements**: BLOG-02, BLOG-03, BLOG-06
|
||||||
|
**Success Criteria** (what must be TRUE):
|
||||||
|
1. `curl localhost:3000/blog` retourne du HTML SSR avec une liste d'articles (titre, description, date, tags)
|
||||||
|
2. `curl localhost:3000/blog/[slug]` retourne le contenu de l'article rendu en HTML, pas de SPA shell vide
|
||||||
|
3. La page article affiche une table des matieres generee depuis les headings du markdown
|
||||||
|
4. Des liens "Article precedent" et "Article suivant" sont presents en bas de chaque article
|
||||||
|
5. `curl localhost:3000/en/blog` retourne le listing en anglais — les articles ont leur version EN
|
||||||
|
**Plans**: TBD
|
||||||
|
**UI hint**: yes
|
||||||
|
|
||||||
|
### Phase 7: SEO Blog
|
||||||
|
**Goal**: Chaque page blog est indexable avec des meta tags complets, un JSON-LD Article valide, et les URLs /blog figurent dans le sitemap — Google peut crawler et comprendre le contenu sans ambiguite
|
||||||
|
**Depends on**: Phase 6
|
||||||
|
**Requirements**: SEO-10, SEO-11, SEO-12, SEO-13, SEO-15
|
||||||
|
**Success Criteria** (what must be TRUE):
|
||||||
|
1. `curl localhost:3000/blog/[slug]` retourne `<meta property="og:title">`, `<meta property="og:description">` et `<meta property="og:image">` uniques dans le HTML
|
||||||
|
2. Le meme curl retourne un JSON-LD de type `Article` avec `author`, `datePublished`, `headline` remplis
|
||||||
|
3. `curl localhost:3000/sitemap.xml` contient les URLs `/blog/[slug]` et `/en/blog/[slug]`
|
||||||
|
4. `og:image` pointe vers l'image de l'article ou vers un fallback branded — jamais vers og-image.png generique
|
||||||
|
5. La page article contient un JSON-LD `BreadcrumbList` : Accueil → Blog → Titre de l'article
|
||||||
|
**Plans**: TBD
|
||||||
|
|
||||||
|
### Phase 8: Content & Cocon Semantique
|
||||||
|
**Goal**: Le blog est lance avec au moins 2 articles Hytale de qualite, et un visiteur qui arrive sur /hytale decouvre les articles recents — le cocon semantique entre blog et page hytale est etabli
|
||||||
|
**Depends on**: Phase 7
|
||||||
|
**Requirements**: BLOG-07, SEO-14
|
||||||
|
**Success Criteria** (what must be TRUE):
|
||||||
|
1. `/blog` liste au moins 2 articles avec le tag "hytale", avec titres, descriptions et dates reels
|
||||||
|
2. Chaque article blog contient au moins un lien interne vers `/hytale` dans le corps du texte
|
||||||
|
3. La page `/hytale` affiche une section "Articles recents" avec liens vers les 2 articles seed
|
||||||
|
4. Les articles existent en FR et EN — `curl localhost:3000/en/blog` les liste en anglais
|
||||||
|
**Plans**: TBD
|
||||||
|
**UI hint**: yes
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Progress (M1.1)
|
||||||
|
|
||||||
| Phase | Plans Complete | Status | Completed |
|
| Phase | Plans Complete | Status | Completed |
|
||||||
|-------|----------------|--------|-----------|
|
|-------|----------------|--------|-----------|
|
||||||
| 1. Foundation | 0/TBD | Not started | - |
|
| 5. @nuxt/content Setup & Renderer | 0/2 | Not started | - |
|
||||||
| 2. SSR Shell | 0/TBD | Not started | - |
|
| 6. Blog Pages | 0/? | Not started | - |
|
||||||
| 3. Pages & Ship | 0/TBD | Not started | - |
|
| 7. SEO Blog | 0/? | Not started | - |
|
||||||
|
| 8. Content & Cocon Semantique | 0/? | Not started | - |
|
||||||
|
|||||||
+21
-64
@@ -1,13 +1,11 @@
|
|||||||
---
|
---
|
||||||
gsd_state_version: 1.0
|
gsd_state_version: 1.0
|
||||||
milestone: v1.0
|
milestone: v1.1
|
||||||
milestone_name: milestone
|
milestone_name: SEO Hytale — Autorité & Contenu
|
||||||
status: planning
|
status: In Progress
|
||||||
stopped_at: Phase 1 context gathered
|
last_updated: "2026-04-21T00:00:00.000Z"
|
||||||
last_updated: "2026-04-07T21:30:30.087Z"
|
|
||||||
last_activity: 2026-04-07 — Roadmap created, project initialized
|
|
||||||
progress:
|
progress:
|
||||||
total_phases: 3
|
total_phases: 4
|
||||||
completed_phases: 0
|
completed_phases: 0
|
||||||
total_plans: 0
|
total_plans: 0
|
||||||
completed_plans: 0
|
completed_plans: 0
|
||||||
@@ -18,65 +16,24 @@ progress:
|
|||||||
|
|
||||||
## Project Reference
|
## Project Reference
|
||||||
|
|
||||||
See: .planning/PROJECT.md (updated 2026-04-07)
|
- PROJECT.md: .planning/PROJECT.md
|
||||||
|
- REQUIREMENTS.md: .planning/REQUIREMENTS.md
|
||||||
|
- ROADMAP.md: .planning/ROADMAP.md
|
||||||
|
|
||||||
**Core value:** Chaque page du portfolio doit être crawlable par les moteurs de recherche sans JavaScript côté client
|
## Current Focus
|
||||||
**Current focus:** Phase 1 — Foundation
|
|
||||||
|
|
||||||
## Current Position
|
Phase: Phase 5 — @nuxt/content Setup & Renderer
|
||||||
|
Plan: —
|
||||||
Phase: 1 of 3 (Foundation)
|
Status: Roadmap defined, ready to plan Phase 5
|
||||||
Plan: 0 of TBD in current phase
|
Last activity: 2026-04-21 — M1.1 roadmap created (phases 5–8)
|
||||||
Status: Ready to plan
|
|
||||||
Last activity: 2026-04-07 — Roadmap created, project initialized
|
|
||||||
|
|
||||||
Progress: [░░░░░░░░░░] 0%
|
|
||||||
|
|
||||||
## Performance Metrics
|
|
||||||
|
|
||||||
**Velocity:**
|
|
||||||
|
|
||||||
- Total plans completed: 0
|
|
||||||
- Average duration: —
|
|
||||||
- Total execution time: 0 hours
|
|
||||||
|
|
||||||
**By Phase:**
|
|
||||||
|
|
||||||
| Phase | Plans | Total | Avg/Plan |
|
|
||||||
|-------|-------|-------|----------|
|
|
||||||
| - | - | - | - |
|
|
||||||
|
|
||||||
**Recent Trend:**
|
|
||||||
|
|
||||||
- Last 5 plans: —
|
|
||||||
- Trend: —
|
|
||||||
|
|
||||||
*Updated after each plan completion*
|
|
||||||
|
|
||||||
## Accumulated Context
|
## Accumulated Context
|
||||||
|
|
||||||
### Decisions
|
- M1 complet — déployé en production sur killiandalcin.fr (phases 1–4)
|
||||||
|
- Stack : Nuxt 4 SSR + Nuxt UI v3 + Tailwind v4 + pnpm
|
||||||
Decisions are logged in PROJECT.md Key Decisions table.
|
- Blog/CMS était Out of Scope en M1, promu en priorité principale pour M1.1
|
||||||
Recent decisions affecting current work:
|
- Renderer markdown doit supporter : syntax highlighting, images, embeds, tables, alerts — utiliser @nuxt/content
|
||||||
|
- Objectif double : ranker sur "Hytale plugin developer" ET capter trafic longue traîne via contenu communauté
|
||||||
- Init: Use `@nuxtjs/seo` meta-bundle (covers sitemap + og:image + schema-org) instead of standalone modules
|
- Phase 5 ajoute @nuxt/content comme dépendance — vérifier compatibilité Nuxt 4 / compatibilityVersion 4
|
||||||
- Init: SSR mode (not SSG) — i18n cookie detection requires server execution per request
|
- Articles bilingues : structure FR/EN dans content/ (ex: content/fr/blog/, content/en/blog/)
|
||||||
- Init: Cookie-only persistence for i18n + theme (SSR-safe, no localStorage)
|
- og:image par article : image frontmatter ou fallback branded — jamais l'og-image.png générique M1
|
||||||
- Init: Static TS data files under `data/` (no @nuxt/content needed)
|
|
||||||
|
|
||||||
### Pending Todos
|
|
||||||
|
|
||||||
None yet.
|
|
||||||
|
|
||||||
### Blockers/Concerns
|
|
||||||
|
|
||||||
- Open: Confirm @nuxtjs/i18n v9 stable + Nuxt 4 compatible before Phase 2 planning
|
|
||||||
- Open: Confirm @nuxt/ui v3 stable (not beta/rc) before Phase 1 planning
|
|
||||||
- Open: Confirm nuxt-gtag Nuxt 4 compatibility before Phase 3 planning
|
|
||||||
|
|
||||||
## Session Continuity
|
|
||||||
|
|
||||||
Last session: 2026-04-07T21:30:30.085Z
|
|
||||||
Stopped at: Phase 1 context gathered
|
|
||||||
Resume file: .planning/phases/01-foundation/01-CONTEXT.md
|
|
||||||
|
|||||||
@@ -1,197 +1,97 @@
|
|||||||
# Architecture
|
# Architecture
|
||||||
|
|
||||||
**Analysis Date:** 2026-04-07
|
**Analysis Date:** 2026-04-10
|
||||||
|
|
||||||
## Pattern Overview
|
## SSR Strategy
|
||||||
|
|
||||||
**Overall:** Vue 3 SPA (Single Page Application) with component-based architecture and SSR-friendly design patterns
|
Nuxt 4 with `ssr: true` and `compatibilityVersion: 4`. Every page renders server-side HTML with SEO metadata before hydrating client-side. Cookie-only persistence for locale and theme (no localStorage, SSR-safe).
|
||||||
|
|
||||||
**Key Characteristics:**
|
## Layer Breakdown
|
||||||
- Client-side routing with lazy-loaded views for performance optimization
|
|
||||||
- Composition API-based composables for shared logic and state management
|
|
||||||
- Global state managed via Pinia stores
|
|
||||||
- Multi-language support with vue-i18n
|
|
||||||
- Theme switching with localStorage persistence
|
|
||||||
- SEO-optimized with dynamic meta tags and structured data
|
|
||||||
- Google Analytics and GTM integration for tracking
|
|
||||||
|
|
||||||
## Layers
|
```
|
||||||
|
Pages (app/pages/)
|
||||||
|
└─> Layout (app/layouts/default.vue)
|
||||||
|
├─> AppHeader (nav, locale toggle, theme toggle)
|
||||||
|
├─> Page content (slot)
|
||||||
|
└─> AppFooter (social links, copyright)
|
||||||
|
└─> Components (app/components/)
|
||||||
|
└─> Composables (app/composables/)
|
||||||
|
└─> Static Data (app/data/)
|
||||||
|
└─> Shared Types (shared/types/)
|
||||||
|
|
||||||
**Presentation Layer (Components):**
|
Server (server/api/)
|
||||||
- Purpose: Render UI and handle user interactions
|
└─> Contact POST handler (nodemailer SMTP)
|
||||||
- Location: `src/components/`
|
```
|
||||||
- Contains: Vue Single File Components organized by domain (layout, sections, shared, testimonials, icons)
|
|
||||||
- Depends on: Composables for data access and side effects, Router for navigation
|
|
||||||
- Used by: Views and other components
|
|
||||||
|
|
||||||
**View Layer (Pages):**
|
|
||||||
- Purpose: Page-level component assembly and routing targets
|
|
||||||
- Location: `src/views/`
|
|
||||||
- Contains: Full page components (HomePage, ProjectsPage, ContactPage, AboutPage, FiverrPage, FormationPage, ProjectDetailPage)
|
|
||||||
- Depends on: Composables (useSeo, useI18n, useProjects), components, data stores
|
|
||||||
- Used by: Router for navigation
|
|
||||||
|
|
||||||
**Business Logic Layer (Composables):**
|
|
||||||
- Purpose: Encapsulate reusable logic, data fetching, and side effects
|
|
||||||
- Location: `src/composables/`
|
|
||||||
- Contains: Vue composables for projects, SEO, i18n, themes, galleries, date formatting, assets, site config
|
|
||||||
- Depends on: Types, stores, external libraries (vue-router, vue-i18n)
|
|
||||||
- Used by: Components and views
|
|
||||||
|
|
||||||
**Data/State Layer (Stores & Data):**
|
|
||||||
- Purpose: Global state and static data management
|
|
||||||
- Location: `src/stores/`, `src/data/`
|
|
||||||
- Contains: Pinia stores, static project data, testimonials, tech stack, FAQs
|
|
||||||
- Depends on: Types, composables (useI18n for localized data)
|
|
||||||
- Used by: Composables and components
|
|
||||||
|
|
||||||
**Configuration Layer:**
|
|
||||||
- Purpose: Application-wide settings and configuration
|
|
||||||
- Location: `src/config/`, `src/router/`, `src/i18n/`
|
|
||||||
- Contains: Site configuration, router setup, i18n initialization, locale messages
|
|
||||||
- Depends on: Types, data
|
|
||||||
- Used by: Main entry point and throughout app
|
|
||||||
|
|
||||||
**Type Definitions:**
|
|
||||||
- Purpose: TypeScript interfaces and types
|
|
||||||
- Location: `src/types/index.ts`
|
|
||||||
- Contains: Project, Technology, TechStack, SocialLink, ContactInfo, FiverrService, SiteConfig interfaces
|
|
||||||
|
|
||||||
## Data Flow
|
## Data Flow
|
||||||
|
|
||||||
**1. Initial Page Load:**
|
### Static Data + i18n
|
||||||
1. `index.html` loads with embedded Google Analytics and Google AdSense scripts
|
1. `app/data/projects.ts` exports projects WITHOUT translatable fields (title, description, longDescription omitted)
|
||||||
2. `src/main.ts` initializes Vue app, Pinia store, router, and i18n
|
2. `app/composables/useProjects.ts` merges static data with i18n translations at runtime via `computed()`
|
||||||
3. `src/App.vue` applies theme from localStorage and renders AppHeader + RouterView + AppFooter
|
3. Components consume `useProjects()` which returns reactive translated data
|
||||||
4. Router initializes and loads HomePage or requested route
|
4. Language changes trigger recomputation automatically
|
||||||
5. `useTheme()` applies saved theme class to document
|
|
||||||
6. `useI18n()` loads saved locale from localStorage
|
|
||||||
|
|
||||||
**2. Route Navigation:**
|
### SSR Render Flow
|
||||||
1. User clicks link or navigates directly
|
1. Request hits Nitro server
|
||||||
2. Router's `beforeEach` hook updates document title and meta description from route.meta
|
2. Nuxt resolves locale from cookie (`i18n_redirected`) or URL prefix (`/en/`)
|
||||||
3. Router's `afterEach` hook triggers scroll to top and Google Analytics page view tracking
|
3. `useLocaleHead()` in `app.vue` sets `<html lang="...">` and alternate links
|
||||||
4. Target view component mounts and runs `useSeo()` for SEO metadata
|
4. Page's `useSeoMeta()` resolves i18n keys server-side
|
||||||
5. View renders child components with fetched data
|
5. `useHead()` injects JSON-LD structured data
|
||||||
6. Components subscribe to composables for reactive data
|
6. Full HTML sent to client with correct locale, theme class, SEO metadata
|
||||||
|
|
||||||
**3. Data Access Pattern (Example: Projects):**
|
### Theme Resolution
|
||||||
1. Component imports `useProjects()` composable
|
1. `@nuxtjs/color-mode` reads `nuxt-color-mode` cookie
|
||||||
2. Composable accesses base project data from `src/data/` or static store
|
2. Default: `dark` for new visitors
|
||||||
3. Composable uses `useI18n()` to localize strings
|
3. Cookie persistence — no flash on cold load (class applied server-side)
|
||||||
4. Component receives computed reactive `projects` array
|
|
||||||
5. Component renders with v-for or passes data to child components
|
|
||||||
|
|
||||||
**4. Theme Switching:**
|
### Contact Form Flow
|
||||||
1. `ThemeToggle` component toggles `isDark` ref in `useTheme()`
|
1. Client: Zod validation in `ContactForm.vue`
|
||||||
2. Watch handler applies class to document.documentElement
|
2. POST to `/api/contact` (Nitro route)
|
||||||
3. Watch handler saves to localStorage
|
3. Server: manual validation, nodemailer SMTP via `useRuntimeConfig()` env vars
|
||||||
4. Browser CSS respects `dark` class on document
|
4. Response: success/error JSON
|
||||||
|
|
||||||
**5. Language Switching:**
|
## Module System
|
||||||
1. `LanguageSwitcher` component calls `switchLocale()` from `useI18n()`
|
|
||||||
2. vue-i18n locale updates and all `{{ t() }}` expressions re-evaluate
|
|
||||||
3. New locale saved to localStorage
|
|
||||||
4. Computed properties like `homeFAQs` in HomePage.vue re-evaluate with new translations
|
|
||||||
|
|
||||||
**State Management:**
|
| Module | Purpose |
|
||||||
- **Global:** Pinia stores (currently minimal - `useCounterStore` exists but unused)
|
|--------|---------|
|
||||||
- **Composable State:** Reactive refs in composables (theme, locale, gallery state)
|
| `@nuxt/ui` | Component library (Nuxt UI v3) |
|
||||||
- **Component State:** Local reactive refs for UI state (menu toggle, form inputs)
|
| `@nuxtjs/i18n` | Internationalization (prefix_except_default, FR default) |
|
||||||
- **Persistence:** localStorage for theme and locale preferences
|
| `@nuxtjs/sitemap` | Auto-generated sitemap with i18n alternates |
|
||||||
- **Server-Side Data:** Static JSON-like data in `src/data/` files, not fetched from API
|
| `nuxt-gtag` | Google Analytics (runtime config) |
|
||||||
|
| `@nuxt/image` | Image optimization |
|
||||||
## Key Abstractions
|
| `@nuxt/eslint` | ESLint integration |
|
||||||
|
|
||||||
**useI18n() Composable:**
|
|
||||||
- Purpose: Unified i18n access with convenience methods
|
|
||||||
- Examples: `src/composables/useI18n.ts`
|
|
||||||
- Pattern: Wraps vue-i18n's `useI18n()`, adds locale switching and computed locale state
|
|
||||||
- Usage: Available in all components via injection
|
|
||||||
|
|
||||||
**useSeo() Composable:**
|
|
||||||
- Purpose: Dynamic SEO tag management for SPA
|
|
||||||
- Examples: `src/composables/useSeo.ts`
|
|
||||||
- Pattern: Lifecycle hooks to create/remove meta tags on mount/unmount, prevents tag duplication
|
|
||||||
- Usage: Called in view components with options object for title, description, OG tags, structured data
|
|
||||||
|
|
||||||
**useProjects() Composable:**
|
|
||||||
- Purpose: Project data access with localization
|
|
||||||
- Examples: `src/composables/useProjects.ts`
|
|
||||||
- Pattern: Base data stored separately, computed properties merge translations on read
|
|
||||||
- Usage: Returns computed `projects` array that updates when language changes
|
|
||||||
|
|
||||||
**useTheme() Composable:**
|
|
||||||
- Purpose: Centralized theme state and persistence
|
|
||||||
- Examples: `src/composables/useTheme.ts`
|
|
||||||
- Pattern: Reactive boolean with computed getter, watch for persistence, DOM manipulation
|
|
||||||
- Usage: Injected globally in App.vue, consumed by ThemeToggle component
|
|
||||||
|
|
||||||
**TechStack Interface:**
|
|
||||||
- Purpose: Typed structure for technology categories
|
|
||||||
- Examples: `src/types/index.ts`
|
|
||||||
- Pattern: Categorized array structure (programming, front, database, devtools, operating_systems, socials)
|
|
||||||
- Usage: Imported in `src/data/techstack.ts` and AboutPage.vue
|
|
||||||
|
|
||||||
**SiteConfig:**
|
|
||||||
- Purpose: Single source of truth for site-wide settings
|
|
||||||
- Examples: `src/config/site.ts`
|
|
||||||
- Pattern: Exported constant object with typed structure, includes contact info, social links, SEO config
|
|
||||||
- Usage: Imported where needed for links, contact info, performance settings
|
|
||||||
|
|
||||||
## Entry Points
|
## Entry Points
|
||||||
|
|
||||||
**HTML Entry Point:**
|
| File | Role |
|
||||||
- Location: `index.html`
|
|------|------|
|
||||||
- Triggers: Browser page load
|
| `app/app.vue` | Root — wraps in `<UApp>`, applies `useLocaleHead()` |
|
||||||
- Responsibilities: Define DOM root (`#app`), load analytics/ads scripts, include meta tags, defer main.ts loading
|
| `app/layouts/default.vue` | Default layout — AppHeader + slot + AppFooter |
|
||||||
|
| `app/error.vue` | Global error handler (404 page) |
|
||||||
|
| `nuxt.config.ts` | App configuration |
|
||||||
|
| `app.config.ts` | Nuxt UI theme tokens (primary color) |
|
||||||
|
|
||||||
**Application Entry Point:**
|
## State Management
|
||||||
- Location: `src/main.ts`
|
|
||||||
- Triggers: After HTML DOM ready
|
|
||||||
- Responsibilities: Create Vue app, install plugins (Pinia, Router, i18n), mount to #app
|
|
||||||
|
|
||||||
**Router Entry Point:**
|
No Pinia store. All state is:
|
||||||
- Location: `src/router/index.ts`
|
- **Composable-scoped:** `useProjects()` returns reactive computed data
|
||||||
- Triggers: App.use(router) in main.ts
|
- **Module-managed:** locale via `@nuxtjs/i18n`, theme via `@nuxtjs/color-mode`
|
||||||
- Responsibilities: Define route table, implement beforeEach/afterEach hooks for SEO and analytics
|
- **Component-local:** `ref()` / `reactive()` in `<script setup>`
|
||||||
|
|
||||||
**Root Component:**
|
|
||||||
- Location: `src/App.vue`
|
|
||||||
- Triggers: After Vue app mounts
|
|
||||||
- Responsibilities: Initialize theme, render layout structure (header + router-view + footer), handle route-change scroll behavior
|
|
||||||
|
|
||||||
**View Components:**
|
|
||||||
- Location: `src/views/*.vue`
|
|
||||||
- Triggers: Router navigation to matching path
|
|
||||||
- Responsibilities: Page-specific SEO setup via `useSeo()`, compose sections and content, manage page-level state
|
|
||||||
|
|
||||||
## Error Handling
|
## Error Handling
|
||||||
|
|
||||||
**Strategy:** Graceful degradation with fallback content; no explicit error boundaries detected
|
- Pages: `throw createError({ status: 404 })` for invalid routes/IDs
|
||||||
|
- `app/error.vue` catches all errors with i18n messages and navigation back
|
||||||
**Patterns:**
|
- Contact form: `try/catch/finally` with `useToast()` user feedback
|
||||||
- Lazy-loaded routes with no 404 component (TODO comment in router) - currently redirects to HomePage
|
- Server routes: `createError({ statusCode: 400 })` for validation failures
|
||||||
- SEO composable safely creates/finds meta elements before updating
|
|
||||||
- Theme fallback to 'dark' if localStorage empty
|
|
||||||
- Locale fallback to 'en' if not in localStorage
|
|
||||||
- Gallery modal (GalleryModal.vue) handles missing images gracefully
|
|
||||||
- Contact form likely has validation but not visible in read scope
|
|
||||||
|
|
||||||
## Cross-Cutting Concerns
|
## Cross-Cutting Concerns
|
||||||
|
|
||||||
**Logging:** Console-based or via external services - no custom logger detected; development uses Vue DevTools plugin
|
- **SEO:** `useSeoMeta()` per page + `useHead()` for JSON-LD + `useLocaleHead()` global
|
||||||
|
- **Accessibility:** Semantic HTML, aria attributes, keyboard navigation
|
||||||
**Validation:** Form validation in components (ContactPage uses validation likely); no centralized validation layer
|
- **i18n:** All user-facing text via `t()` keys, `te()` guards for optional keys
|
||||||
|
- **Images:** WebP in `public/images/`, served via `@nuxt/image`
|
||||||
**Authentication:** No built-in auth system - portfolio is public facing; new auth stores/views added (LoginView, RegisterView, VerifyEmailView, DashboardView, ForgotPasswordView, guards.ts) but not integrated into main router
|
|
||||||
|
|
||||||
**SEO:** Centralized via `useSeo()` composable and router hooks; dynamic meta tags, Open Graph, Twitter cards, structured data; Google Analytics via gtag in router afterEach; Umami analytics via deferred script tag in index.html
|
|
||||||
|
|
||||||
**Performance:** Code splitting via lazy routes, vendor chunk separation in vite.config.ts, CSS code splitting enabled, Terser minification, webp image support configured, lazy image loading configurable in siteConfig
|
|
||||||
|
|
||||||
**Accessibility:** ARIA labels on interactive elements (AppHeader navigation, buttons); semantic HTML (header, nav, main, section roles); focus styles defined in App.vue
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
*Architecture analysis: 2026-04-07*
|
*Architecture analysis: 2026-04-10*
|
||||||
|
|||||||
+28
-200
@@ -1,212 +1,40 @@
|
|||||||
# Codebase Concerns
|
# Concerns
|
||||||
|
|
||||||
**Analysis Date:** 2026-04-07
|
## Security
|
||||||
|
|
||||||
|
- No rate limiting on `server/api/contact.post.ts` — the contact API accepts unlimited POST requests, enabling spam/email flooding
|
||||||
|
- No CAPTCHA or honeypot bot protection on `app/components/ContactForm.vue`
|
||||||
|
- `.env.example` only documents `NUXT_PUBLIC_GTAG_ID` but the contact form requires four SMTP vars (`NUXT_SMTP_HOST`, `NUXT_SMTP_USER`, `NUXT_SMTP_PASS`, `NUXT_SMTP_TO`) with no documentation
|
||||||
|
- Server-side email validation in `contact.post.ts` line 12 uses `email.includes('@')` instead of a proper regex, while client-side already uses Zod's `z.string().email()`
|
||||||
|
|
||||||
## Tech Debt
|
## Tech Debt
|
||||||
|
|
||||||
**Missing 404 Page Implementation:**
|
- `'https://killiandalcin.fr/og-image.png'` hardcoded verbatim in 6 page files — any domain change requires editing all of them
|
||||||
- Issue: 404 catch-all route currently redirects to HomePage instead of a dedicated 404 page
|
- Static `public/sitemap.xml` bypasses the installed `@nuxtjs/sitemap` module — new projects are never indexed, and `/formation` in the sitemap has no matching page
|
||||||
- Files: `src/router/index.ts` (line 51: TODO comment)
|
- Both `package-lock.json` (npm) and `pnpm-lock.yaml` (pnpm) coexist; `Dockerfile` uses `npm ci` after migration to pnpm
|
||||||
- Impact: Users encountering invalid routes see the homepage instead of a proper error page, creating confusion and poor UX. SEO also treats all invalid URLs as the homepage.
|
- `flowboard` project `features[]` array in `app/data/projects.ts` (lines 91-97) is hardcoded English, not i18n keys, while all other project content goes through `useProjects.ts`
|
||||||
- Fix approach: Create a new `NotFoundPage.vue` component in `src/views/` with proper 404 messaging, then update the route in `src/router/index.ts` to point to this component.
|
- `siteConfig.seo.organization.aggregateRating` in `app/data/site.ts` claims `reviewCount: '50'` while `app/data/testimonials.ts` has `totalReviews: 10` — mismatched structured data Google could flag
|
||||||
|
- Two Fiverr services have `url: '#'` in `app/data/site.ts` — non-functional CTAs on the `/fiverr` page
|
||||||
|
|
||||||
**Hardcoded GA Tracking ID:**
|
## Performance / UX
|
||||||
- Issue: Google Analytics tracking ID `G-CDVVNFY6MV` is hardcoded in multiple places
|
|
||||||
- Files: `index.html` (line 9), `src/router/index.ts` (line 109)
|
|
||||||
- Impact: Cannot change analytics without code updates. Difficult to manage different GA IDs for development vs production environments.
|
|
||||||
- Fix approach: Move tracking ID to environment variables (`.env`), access via `import.meta.env.VITE_GA_ID`.
|
|
||||||
|
|
||||||
**Hardcoded Site URL and Analytics:**
|
- `HeroSection.vue` splits the title string by `.split(' ').slice(-2)` to apply gradient styling — breaks if the FR/EN title has a different word count
|
||||||
- Issue: URLs and site identifiers hardcoded throughout the codebase
|
- All testimonial avatar URLs point to `https://ui-avatars.com/api/...` (external CDN, external HTTP requests per avatar on every render)
|
||||||
- Files: `src/composables/useSeo.ts` (lines 103, 113, 143, 149), `src/config/site.ts` (line 69), `src/router/index.ts` (line 109)
|
|
||||||
- Impact: Difficult to deploy to different environments (staging vs production). Requires code changes for different domains.
|
|
||||||
- Fix approach: Move to environment configuration. Create environment-based config: `VITE_SITE_URL`, `VITE_SITE_DOMAIN`, etc.
|
|
||||||
|
|
||||||
**External Image Asset Dependency on Placeholder Service:**
|
## Missing SEO Features
|
||||||
- Issue: Missing images fallback to external placeholder.com service without reliability guarantee
|
|
||||||
- Files: `src/composables/useAssets.ts` (lines 18, 42)
|
|
||||||
- Impact: If placeholder.com goes down or is rate-limited, missing images break visually. External dependency increases load time.
|
|
||||||
- Fix approach: Use local SVG or base64-encoded placeholder image instead of external URL.
|
|
||||||
|
|
||||||
## Known Bugs
|
- No `ogUrl` set on any page (all `useSeoMeta` calls omit it)
|
||||||
|
- `app/pages/project/[id].vue` uses the generic `og-image.png` instead of `project.value?.image`
|
||||||
|
- No `<link rel="canonical">` — the `prefix_except_default` i18n strategy produces `/` and `/en/` duplicate URLs without canonical deduplication
|
||||||
|
- `/formation` in `public/sitemap.xml` has no corresponding page (`app/pages/formation.vue` does not exist)
|
||||||
|
|
||||||
**Scroll Position Duplication:**
|
## i18n Completeness
|
||||||
- Symptoms: Multiple scroll handlers (in router and App.vue) could cause double-scrolling or jank
|
|
||||||
- Files: `src/App.vue` (lines 14-18), `src/router/index.ts` (lines 62-82, 118-125)
|
|
||||||
- Trigger: Navigation between routes
|
|
||||||
- Workaround: Currently works, but behavior is fragile due to redundancy
|
|
||||||
|
|
||||||
**FormationPage Billing Toggle State Issue:**
|
- `app/error.vue` lines 39-44: two hardcoded English error description strings not in locale files
|
||||||
- Symptoms: Billing toggle (monthly/annual) uses `isAnnual` state but HTML shows `billingType` variable
|
- `app/components/sections/HeroSection.vue` line 30: `'Available for projects'` badge is raw English, not `t()`
|
||||||
- Files: `src/views/FormationPage.vue` (lines 12-20, 41)
|
- Same file lines 148, 153: `'50+ projects'` and `'5.0 rating'` decorative stats are hardcoded English
|
||||||
- Trigger: When switching between monthly and annual billing
|
- `a11y.langToggle` in both locale files hardcodes the current language name as a static string
|
||||||
- Workaround: Component probably has missing reactive computed for `billingType`
|
|
||||||
|
|
||||||
## Security Considerations
|
## Testing
|
||||||
|
|
||||||
**v-html Usage in FAQ Component:**
|
- Zero test files exist anywhere in the project — no coverage for the security-sensitive contact API validation, `useProjects` composable, or i18n key resolution
|
||||||
- Risk: XSS vulnerability if FAQ answers contain user-generated content or external data
|
|
||||||
- Files: `src/components/ServiceFAQ.vue` (line 22: `<p v-html="faq.answer"></p>`)
|
|
||||||
- Current mitigation: FAQ content is hardcoded in component props (trusted source), but pattern is risky if content source changes
|
|
||||||
- Recommendations: Replace with v-text or plain text content. If HTML is needed, use a sanitization library like `DOMPurify`.
|
|
||||||
|
|
||||||
**Personal Information Exposed in Configuration:**
|
|
||||||
- Risk: Email, phone number, and social profile IDs hardcoded in public config
|
|
||||||
- Files: `src/config/site.ts` (lines 71-100), `index.html` (lines 88, 239-240)
|
|
||||||
- Current mitigation: This is intentional (portfolio/contact site), but increases spam/scraping risk
|
|
||||||
- Recommendations: Consider using form submission instead of direct email/phone links on sensitive pages. Monitor contact form for abuse.
|
|
||||||
|
|
||||||
**Hardcoded Analytics and AdSense IDs:**
|
|
||||||
- Risk: Analytics and AdSense IDs in HTML source reveal account information
|
|
||||||
- Files: `index.html` (lines 9, 19)
|
|
||||||
- Current mitigation: None - IDs are public by design (Google Analytics is meant to be public)
|
|
||||||
- Recommendations: Ensure AdSense account is properly secured with password/2FA. Monitor for unauthorized modifications.
|
|
||||||
|
|
||||||
**Discord User ID Exposed:**
|
|
||||||
- Risk: Discord user ID `370940770225618954` is hardcoded and publicly visible
|
|
||||||
- Files: `src/config/site.ts` (line 92)
|
|
||||||
- Current mitigation: Discord doesn't allow impersonation via ID alone, but enables targeted attacks
|
|
||||||
- Recommendations: Keep Discord contact via link only, without exposing raw user ID. Use Discord username instead.
|
|
||||||
|
|
||||||
## Performance Bottlenecks
|
|
||||||
|
|
||||||
**Eager Image Loading with import.meta.glob:**
|
|
||||||
- Problem: All images under `src/assets/images/**` are eagerly loaded into memory at startup
|
|
||||||
- Files: `src/composables/useAssets.ts` (line 6: `eager: true`)
|
|
||||||
- Cause: `eager: true` loads all matching modules immediately instead of lazy-loading on-demand
|
|
||||||
- Improvement path: Change to lazy loading and implement on-demand imports, or at minimum separate critical images from lazy ones.
|
|
||||||
|
|
||||||
**External Script Dependencies Block Rendering:**
|
|
||||||
- Problem: Google Analytics, Google AdSense, and Umami scripts are loaded synchronously
|
|
||||||
- Files: `index.html` (lines 9, 19, 239-240)
|
|
||||||
- Cause: Multiple external scripts without proper `async` loading strategy
|
|
||||||
- Improvement path: Ensure all external scripts use `async` or `defer` attributes. Currently Umami and GTM use `defer` (good), but ensure they don't block critical rendering path.
|
|
||||||
|
|
||||||
**No Lazy Loading on Images:**
|
|
||||||
- Problem: No lazy-loading attributes on images, causing initial page load to fetch all images
|
|
||||||
- Files: Multiple components (`src/components/layout/AppFooter.vue` line 35, `src/components/layout/AppHeader.vue` line 33)
|
|
||||||
- Cause: `loading="eager"` and `loading="lazy"` not used strategically
|
|
||||||
- Improvement path: Set `loading="lazy"` on all below-fold images. Keep only above-fold images with `loading="eager"`.
|
|
||||||
|
|
||||||
## Fragile Areas
|
|
||||||
|
|
||||||
**GalleryModal Event Listener Management:**
|
|
||||||
- Files: `src/components/GalleryModal.vue` (lines 44-50)
|
|
||||||
- Why fragile: Global document keydown listener added/removed on mount/unmount. If component unmounts unexpectedly, listener could remain attached or fail to attach.
|
|
||||||
- Safe modification: Add try-catch around removeEventListener. Consider using a ref-based approach or delegated events.
|
|
||||||
- Test coverage: No test coverage visible for keyboard navigation. Recommend adding unit tests for keyboard shortcuts.
|
|
||||||
|
|
||||||
**Image URL Resolution Fallback Chain:**
|
|
||||||
- Files: `src/composables/useAssets.ts` (lines 13-44)
|
|
||||||
- Why fragile: Multiple fallback layers (module lookup → URL construction → placeholder) make debugging hard. If one fallback path fails, error is silently logged.
|
|
||||||
- Safe modification: Add explicit logging for each fallback step. Document expected path formats clearly.
|
|
||||||
- Test coverage: Recommend unit tests for edge cases (empty path, missing extensions, malformed paths).
|
|
||||||
|
|
||||||
**SEO Composable DOM Manipulation:**
|
|
||||||
- Files: `src/composables/useSeo.ts` (lines 35-70)
|
|
||||||
- Why fragile: Directly manipulates DOM with `document.querySelector`, `createElement`, `appendChild`. No error handling if DOM structure changes.
|
|
||||||
- Safe modification: Use Vue's ref system or a dedicated SEO library (e.g., `@unhead/vue`). Add error boundaries.
|
|
||||||
- Test coverage: No test coverage for SSR compatibility or DOM cleanup on route changes.
|
|
||||||
|
|
||||||
**Route-based SEO Title Dependency:**
|
|
||||||
- Files: `src/composables/useSeo.ts` (lines 75-76)
|
|
||||||
- Why fragile: Relies on route being available in useRoute() hook. If used in wrong context (non-routed component), will fail silently.
|
|
||||||
- Safe modification: Add null checks. Consider creating a composable specifically for page-level SEO that enforces route dependency.
|
|
||||||
- Test coverage: Recommend tests for components used in and outside routing context.
|
|
||||||
|
|
||||||
## Scaling Limits
|
|
||||||
|
|
||||||
**Single-Page History State:**
|
|
||||||
- Current capacity: Router handles typical portfolio page count (6-8 pages)
|
|
||||||
- Limit: If projects list grows beyond 50-100 items, ProjectsPage.vue performance degrades (all projects loaded at once)
|
|
||||||
- Scaling path: Implement pagination or virtual scrolling in ProjectsPage. Use server-side filtering if projects become dynamic.
|
|
||||||
|
|
||||||
**Inline Image Modules:**
|
|
||||||
- Current capacity: ~15-20 images loaded eagerly in memory
|
|
||||||
- Limit: If image count exceeds 100+, startup time and memory usage increase significantly
|
|
||||||
- Scaling path: Migrate to lazy-loading strategy. Consider CDN for image serving in production.
|
|
||||||
|
|
||||||
**Localization Key Lookups:**
|
|
||||||
- Current capacity: 500+ localization keys across en.ts and fr.ts
|
|
||||||
- Limit: If keys exceed 1000+, lookup performance and bundle size become concerns
|
|
||||||
- Scaling path: Implement lazy-loaded locale files (load only active language). Consider JSON-based locale format for better optimization.
|
|
||||||
|
|
||||||
## Dependencies at Risk
|
|
||||||
|
|
||||||
**No Testing Framework:**
|
|
||||||
- Risk: Zero test coverage visible in codebase. Refactoring breaks are undetectable.
|
|
||||||
- Impact: Security fixes, performance optimizations, and feature additions are risky.
|
|
||||||
- Migration plan: Add Jest + Vue Test Utils. Start with critical paths (router guards, composables, SEO).
|
|
||||||
|
|
||||||
**Hardcoded Translation Keys:**
|
|
||||||
- Risk: If translation key structure changes, UI silently breaks (missing translations show key names)
|
|
||||||
- Impact: Refactoring translations is error-prone and breaks are not caught in CI
|
|
||||||
- Migration plan: Add TypeScript strict typing for i18n keys using type-safe i18n library.
|
|
||||||
|
|
||||||
**External Analytics Dependency:**
|
|
||||||
- Risk: If Google Analytics changes API or service, tracking breaks
|
|
||||||
- Impact: Loss of analytics data, no visibility into user behavior
|
|
||||||
- Migration plan: Already using Umami (self-hosted alternative). Consider making analytics provider pluggable.
|
|
||||||
|
|
||||||
## Missing Critical Features
|
|
||||||
|
|
||||||
**No Error Boundary:**
|
|
||||||
- Problem: No Vue error boundary or fallback component for runtime errors
|
|
||||||
- Blocks: Cannot gracefully handle component errors or show error UI
|
|
||||||
- Fix approach: Create an error boundary component using Vue 3 error handling hooks (errorCaptured), wrap main app with it.
|
|
||||||
|
|
||||||
**No Offline Support:**
|
|
||||||
- Problem: No service worker or offline fallback
|
|
||||||
- Blocks: Portfolio becomes completely unavailable if user loses connection
|
|
||||||
- Fix approach: Implement service worker with offline fallback. Cache critical assets (HTML, CSS, JS).
|
|
||||||
|
|
||||||
**No Loading States:**
|
|
||||||
- Problem: No skeleton loaders or loading indicators for async operations
|
|
||||||
- Blocks: Users don't know if page is loading or broken. Especially impacts image loading.
|
|
||||||
- Fix approach: Add skeleton screens for ProjectDetailPage. Add loading indicators for gallery modal.
|
|
||||||
|
|
||||||
**No Proper 404 Page:**
|
|
||||||
- Problem: 404 redirects to homepage (mentioned in Tech Debt)
|
|
||||||
- Blocks: Users cannot identify when they've hit an invalid URL
|
|
||||||
- Fix approach: Create NotFoundPage.vue with suggestions for navigation.
|
|
||||||
|
|
||||||
**No Analytics Event Tracking:**
|
|
||||||
- Problem: Only page views tracked, no event analytics (clicks, form submissions, etc.)
|
|
||||||
- Blocks: Cannot understand user behavior beyond page traffic
|
|
||||||
- Fix approach: Add event tracking for CTA clicks, social link clicks, gallery interactions.
|
|
||||||
|
|
||||||
## Test Coverage Gaps
|
|
||||||
|
|
||||||
**No Unit Tests:**
|
|
||||||
- What's not tested: Composables (useSeo, useAssets, useProjects, useI18n), utility functions
|
|
||||||
- Files: All of `src/composables/` and `src/data/`
|
|
||||||
- Risk: Refactoring introduces subtle bugs. Type safety is partial (TypeScript, but no runtime checks).
|
|
||||||
- Priority: High - composables are core to the app and impact SEO/styling
|
|
||||||
|
|
||||||
**No Component Tests:**
|
|
||||||
- What's not tested: Interactive components (GalleryModal, ServiceFAQ, language switcher, theme toggle)
|
|
||||||
- Files: `src/components/GalleryModal.vue`, `src/components/ServiceFAQ.vue`, `src/components/ThemeToggle.vue`, `src/components/LanguageSwitcher.vue`
|
|
||||||
- Risk: UI behavior breaks silently. Accessibility features (keyboard nav, ARIA) may regress.
|
|
||||||
- Priority: High - GalleryModal keyboard navigation and language switching are user-facing
|
|
||||||
|
|
||||||
**No Integration Tests:**
|
|
||||||
- What's not tested: Router navigation, SEO meta tag updates, i18n language switching across pages
|
|
||||||
- Files: `src/router/index.ts`, cross-file composable interactions
|
|
||||||
- Risk: Multi-step user flows break. SEO meta tags may not update correctly on navigation.
|
|
||||||
- Priority: Medium - can catch cross-cutting issues
|
|
||||||
|
|
||||||
**No E2E Tests:**
|
|
||||||
- What's not tested: Full user journeys (landing → project detail → gallery → contact)
|
|
||||||
- Framework: None (not using Cypress, Playwright, etc.)
|
|
||||||
- Risk: Visual regressions, layout issues, navigation bugs in real browser contexts
|
|
||||||
- Priority: Medium - would catch integration issues and performance regressions
|
|
||||||
|
|
||||||
**No Accessibility Tests:**
|
|
||||||
- What's not tested: Keyboard navigation, screen reader compatibility, color contrast, focus management
|
|
||||||
- Files: All components with interactive elements
|
|
||||||
- Risk: Accessibility fails silently. Users with disabilities cannot navigate.
|
|
||||||
- Priority: High - portfolio should be accessible to all users
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
*Concerns audit: 2026-04-07*
|
|
||||||
|
|||||||
+125
-189
@@ -1,225 +1,161 @@
|
|||||||
# Coding Conventions
|
# Coding Conventions
|
||||||
|
|
||||||
**Analysis Date:** 2026-04-07
|
**Analysis Date:** 2026-04-10
|
||||||
|
|
||||||
## Naming Patterns
|
## Naming Patterns
|
||||||
|
|
||||||
**Files:**
|
**Files:**
|
||||||
- Vue components: PascalCase (e.g., `AppHeader.vue`, `ProjectCard.vue`)
|
- Vue components: PascalCase — `AppHeader.vue`, `ProjectCard.vue`, `ContactForm.vue`
|
||||||
- Composables: camelCase with `use` prefix (e.g., `useTheme.ts`, `useProjects.ts`)
|
- Pages (Nuxt file-based routing): kebab-case — `about.vue`, `project/[id].vue`
|
||||||
- Utility/config files: camelCase (e.g., `site.ts`, `techstack.ts`)
|
- Layouts: kebab-case — `default.vue`
|
||||||
- Data files: camelCase (e.g., `testimonials.ts`, `faq.ts`)
|
- Composables: camelCase with `use` prefix — `useProjects.ts`
|
||||||
- Type definitions: camelCase in `types/index.ts`
|
- Data files: camelCase — `projects.ts`, `faq.ts`, `site.ts`, `techstack.ts`, `testimonials.ts`
|
||||||
|
- Type files: `index.ts` in a typed directory — `shared/types/index.ts`
|
||||||
|
- Server routes: `[name].[method].ts` Nitro convention — `contact.post.ts`
|
||||||
|
- Config files: camelCase — `nuxt.config.ts`, `app.config.ts`
|
||||||
|
|
||||||
**Functions:**
|
**Functions:**
|
||||||
- All functions use camelCase (e.g., `toggleTheme`, `openGallery`, `getImageUrl`)
|
- Exported composables: `useX()` — `useProjects()` in `app/composables/useProjects.ts`
|
||||||
- Composables are named with `use` prefix: `useTheme()`, `useGallery()`, `useSeo()`
|
- Toggle handlers: verb + noun — `toggleLocale()`, `toggleTheme()`
|
||||||
- Getter functions use `get` prefix: `getTheme()`, `getImageUrl()`
|
- Query predicates: verb + noun — `isActive(path)`, `findById(id)`, `filterByCategory(category)`, `search(query)`
|
||||||
- Boolean functions/computed use `is`/`has` prefix: `isDark`, `hasNext`, `isOpen`
|
- Async handlers: `onX` prefix — `onSubmit(event)`
|
||||||
- Handler functions use verb + `Handler`: `toggleTheme`, `openGallery`, `closeGallery`
|
- Event handlers: `defineEventHandler` (Nitro server) — `server/api/contact.post.ts`
|
||||||
|
|
||||||
**Variables:**
|
**Variables:**
|
||||||
- Refs and computed properties: camelCase (e.g., `isDark`, `currentIndex`, `isOpen`)
|
- Reactive refs: camelCase — `mobileOpen`, `loading`
|
||||||
- Interfaces and types: PascalCase (e.g., `Props`, `SeoOptions`, `Theme`)
|
- Computed values: camelCase — `navLinks`, `translatedCategory`, `relatedProjects`, `featuredProjects`
|
||||||
- Constants: UPPER_SNAKE_CASE for config constants (not extensively used in codebase)
|
- Constants/config exports: camelCase — `siteConfig`, `projects`, `homeFAQs`
|
||||||
- Private/module state: camelCase prefixed with `_` if truly private
|
|
||||||
|
|
||||||
**Types:**
|
**Types:**
|
||||||
- Type aliases: PascalCase (e.g., `type Theme = 'light' | 'dark'`)
|
- Interfaces: PascalCase — `Project`, `ProjectButton`, `Technology`, `TechStack`, `SiteConfig`, `FAQ`
|
||||||
- Interface names: PascalCase (e.g., `interface Props`, `interface SeoOptions`)
|
- Props interfaces: always named `Props` in `<script setup>` — see `app/components/ProjectCard.vue`
|
||||||
- Props interfaces: Always named `Props` (e.g., in `<script setup lang="ts">` components)
|
- Type aliases derived from Zod: inline `type Schema = z.output<typeof schema>` — `app/components/ContactForm.vue`
|
||||||
- Generic types from Vue use their original names (e.g., `Ref<boolean>`, `Computed<string>`)
|
- Enum-like string unions: `'Beginner' | 'Intermediate' | 'Advanced'` — `Technology.level` in `shared/types/index.ts`
|
||||||
|
|
||||||
## Code Style
|
## Code Style
|
||||||
|
|
||||||
**Formatting:**
|
**Formatting:**
|
||||||
- Tool: Prettier 3.5.3
|
- No dedicated Prettier config at root; formatting via ESLint through `@nuxt/eslint`
|
||||||
- Semi-colons: **disabled** (`semi: false`)
|
- ESLint config `eslint.config.mjs` delegates entirely to `withNuxt()` from `.nuxt/eslint.config.mjs`
|
||||||
- Quotes: **single quotes** (`singleQuote: true`)
|
- `@nuxt/eslint` module generates type-aware rules; `typescript: { strict: true }` in `nuxt.config.ts`
|
||||||
- Print width: **100 characters** (`printWidth: 100`)
|
|
||||||
|
|
||||||
**Linting:**
|
**Observed style from source:**
|
||||||
- Tool: ESLint 9.22.0 with Vue support
|
- No semicolons
|
||||||
- Config: `eslint.config.ts` using flat config format
|
- Single quotes for strings
|
||||||
- Plugins:
|
- Trailing commas in multi-line objects/arrays
|
||||||
- `@vue/eslint-config-typescript` - TypeScript support
|
- 2-space indentation
|
||||||
- `eslint-plugin-vue` v10.0.0 - Vue 3 rules
|
- Long template attribute chains are NOT broken across lines (single long lines acceptable in templates)
|
||||||
- `@vue/eslint-config-prettier/skip-formatting` - Prettier integration (skip-formatting enabled)
|
|
||||||
|
## TypeScript Usage
|
||||||
|
|
||||||
|
**Strict Mode:** `typescript: { strict: true }` in `nuxt.config.ts` — all strict checks enforced project-wide.
|
||||||
|
|
||||||
|
**Type imports:** Always use `import type` for type-only imports:
|
||||||
|
```typescript
|
||||||
|
import type { Project } from '~~/shared/types'
|
||||||
|
import type { FormSubmitEvent } from '@nuxt/ui'
|
||||||
|
```
|
||||||
|
|
||||||
|
**Props typing:** Always `defineProps<Props>()` with an explicit interface named `Props`:
|
||||||
|
```typescript
|
||||||
|
interface Props {
|
||||||
|
project: Project
|
||||||
|
}
|
||||||
|
const props = defineProps<Props>()
|
||||||
|
```
|
||||||
|
|
||||||
|
**Return type inference:** Composables rely on inference; explicit generics where needed:
|
||||||
|
```typescript
|
||||||
|
const projects = computed<Project[]>(() => projectsData.map(...))
|
||||||
|
```
|
||||||
|
|
||||||
|
**Zod for runtime validation:** Client-side form schemas with Zod in components (`ContactForm.vue`). Server-side uses manual validation with `createError` — Zod not used on server.
|
||||||
|
|
||||||
## Import Organization
|
## Import Organization
|
||||||
|
|
||||||
**Order:**
|
|
||||||
1. Vue and framework imports (`vue`, `vue-router`, `pinia`, `vue-i18n`)
|
|
||||||
2. Type imports (use `import type` for TypeScript types)
|
|
||||||
3. Local components (`@/components/...`)
|
|
||||||
4. Composables (`@/composables/...`)
|
|
||||||
5. Utilities and helpers
|
|
||||||
6. Data and configuration files
|
|
||||||
7. Styles (scoped CSS imported at end of `<style>`)
|
|
||||||
|
|
||||||
**Path Aliases:**
|
**Path Aliases:**
|
||||||
- `@/` maps to `./src/` (configured in `tsconfig.app.json`)
|
- `~/` -> `app/` directory (Nuxt 4 convention)
|
||||||
- Always use `@/` prefix for imports from src directory
|
- `~~/` -> project root (for cross-layer imports: `~~/shared/types`)
|
||||||
- Examples:
|
- Use `~/data/projects` for app-internal imports; `~~/shared/types` to reach shared layer
|
||||||
- `import AppHeader from '@/components/layout/AppHeader.vue'`
|
|
||||||
- `import { useTheme } from '@/composables/useTheme'`
|
|
||||||
- `import type { Project } from '@/types'`
|
|
||||||
- `import { techStack } from '@/data/techstack'`
|
|
||||||
|
|
||||||
## Error Handling
|
**Import order (observed):**
|
||||||
|
1. Third-party: `import { z } from 'zod'`
|
||||||
|
2. Nuxt UI types: `import type { FormSubmitEvent } from '@nuxt/ui'`
|
||||||
|
3. Internal types: `import type { Project } from '~~/shared/types'`
|
||||||
|
4. Internal data: `import { projects as projectsData } from '~/data/projects'`
|
||||||
|
5. Auto-imports (no explicit import): `ref`, `computed`, `reactive`, `useI18n`, `useRoute`, `useColorMode`, `useSeoMeta`, `useHead`, `useToast`, `useLocalePath`
|
||||||
|
|
||||||
**Patterns:**
|
**Auto-imports:** All Nuxt/Vue composables and all `app/components/**/*.vue` components are auto-imported. Never write explicit `import ref from 'vue'` or component imports in `.vue` files. `pathPrefix: false` in `nuxt.config.ts` means `AppHeader` registers as `AppHeader` not `LayoutAppHeader`.
|
||||||
- Try-catch blocks wrap risky operations (e.g., dynamic imports, DOM manipulation)
|
|
||||||
- Fallback values provided when operations fail:
|
## Vue Patterns
|
||||||
- In `useAssets()`: returns placeholder image URL if asset fails to load
|
|
||||||
- In `useSeo()`: gracefully handles missing meta elements by creating them
|
**Component structure order:**
|
||||||
- Console warnings for non-critical failures:
|
1. `<script setup lang="ts">` — always first, always `lang="ts"`
|
||||||
- `console.warn('message')` for warnings during execution
|
2. `<template>` — second
|
||||||
- Error objects logged with context: `console.warn('Failed to load image: ${path}', error)`
|
3. No `<style>` blocks — all styling via Tailwind utility classes
|
||||||
- Silent failures with fallbacks preferred over throwing errors for UI operations
|
|
||||||
|
**Composition API rules:**
|
||||||
|
- `<script setup>` exclusively — no Options API
|
||||||
|
- Destructure composables at top: `const { t } = useI18n()`
|
||||||
|
- `ref()` for mutable primitives: `const mobileOpen = ref(false)`
|
||||||
|
- `reactive()` for multi-field form state: `const state = reactive({ name: '', email: '', message: '' })`
|
||||||
|
- `computed()` for derived state: `const relatedProjects = computed(() => [...])`
|
||||||
|
- `useTemplateRef()` for component refs (Vue 3.5 API): `const galleryRef = useTemplateRef('gallery')`
|
||||||
|
|
||||||
|
**Pages pattern:**
|
||||||
|
- `useSeoMeta()` called at top of each page's `<script setup>` with reactive getter functions
|
||||||
|
- Structured data injected via `useHead({ script: [{ type: 'application/ld+json', innerHTML: JSON.stringify({...}) }] })`
|
||||||
|
- 404s thrown inline: `throw createError({ status: 404, statusText: 'Project not found' })`
|
||||||
|
|
||||||
|
**i18n:**
|
||||||
|
- `useI18n()` called at component level; `t`, `locale`, `setLocale`, `te` destructured as needed
|
||||||
|
- `useLocalePath()` used for all `<NuxtLink :to>` values: `:to="localePath('/projects')"`
|
||||||
|
- `te()` guards optional translation keys before `t()` call
|
||||||
|
|
||||||
|
**Error handling:**
|
||||||
|
- Async operations wrapped in `try/catch/finally` with user feedback via `useToast()`
|
||||||
|
|
||||||
|
**Template conventions:**
|
||||||
|
- Semantic HTML: `<header>`, `<nav>`, `<article>`, `<aside>`, `<section>`, `<time>`
|
||||||
|
- Schema.org microdata: `itemscope`, `itemtype`, `itemprop` attributes directly on elements
|
||||||
|
- `aria-*` on all interactive elements and navigation landmarks
|
||||||
|
- `aria-current="page"` on active nav links
|
||||||
|
- `aria-hidden="true"` on decorative/background elements
|
||||||
|
|
||||||
|
## Composable Design
|
||||||
|
|
||||||
**Examples from codebase:**
|
|
||||||
```typescript
|
```typescript
|
||||||
// In useAssets.ts - graceful fallback
|
export function useProjects() {
|
||||||
if (!path || path.trim() === '') {
|
// logic
|
||||||
console.warn('getImageUrl called with empty or undefined path')
|
|
||||||
return `https://via.placeholder.com/400x300/f3f4f6/9ca3af?text=${encodeURIComponent('No image')}`
|
|
||||||
}
|
|
||||||
|
|
||||||
// In useSeo.ts - create if missing
|
|
||||||
let meta = document.querySelector(`meta[${property ? 'property' : 'name'}="${name}"]`)
|
|
||||||
if (!meta) {
|
|
||||||
meta = document.createElement('meta')
|
|
||||||
// ... setup ...
|
|
||||||
document.head.appendChild(meta)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Logging
|
|
||||||
|
|
||||||
**Framework:** `console` object (no dedicated logging library)
|
|
||||||
|
|
||||||
**Patterns:**
|
|
||||||
- `console.warn()` for warnings (missing assets, invalid input)
|
|
||||||
- Logging only in composables for utility functions
|
|
||||||
- No console.log() in production code (only development/debugging)
|
|
||||||
- Error context included: `console.warn('context', error)`
|
|
||||||
|
|
||||||
## Comments
|
|
||||||
|
|
||||||
**When to Comment:**
|
|
||||||
- JSDoc comments for composable functions (exported functions)
|
|
||||||
- Inline comments for non-obvious logic (especially SEO handling in router)
|
|
||||||
- Comments explaining why (not what the code does)
|
|
||||||
- TODO comments for known issues: `// TODO: page 404` in `src/router/index.ts`
|
|
||||||
|
|
||||||
**JSDoc/TSDoc:**
|
|
||||||
- Composables include JSDoc for exported functions
|
|
||||||
- Example from `useAssets.ts`:
|
|
||||||
```typescript
|
|
||||||
/**
|
|
||||||
* Get image URL from assets folder
|
|
||||||
* @param path - Path like '@/assets/images/filename.webp' or 'filename.webp'
|
|
||||||
* @returns string - The image URL
|
|
||||||
*/
|
|
||||||
const getImageUrl = (path: string | undefined): string => { ... }
|
|
||||||
```
|
|
||||||
- Not consistently applied across all files; use when function signature isn't obvious
|
|
||||||
|
|
||||||
## Function Design
|
|
||||||
|
|
||||||
**Size:** Composables are typically 50-100 lines; keep focused on single responsibility
|
|
||||||
|
|
||||||
**Parameters:**
|
|
||||||
- Props interfaces always named `Props` in components
|
|
||||||
- Use destructuring in setup: `const { t } = useI18n()`
|
|
||||||
- Optional config objects in composables (e.g., `SeoOptions` with defaults)
|
|
||||||
- Explicit typing on all parameters
|
|
||||||
|
|
||||||
**Return Values:**
|
|
||||||
- Composables return object with all exposed functions and reactive state
|
|
||||||
- Always return computed versions of reactive state when exposing refs:
|
|
||||||
```typescript
|
|
||||||
return {
|
return {
|
||||||
isOpen, // reactive ref
|
projects,
|
||||||
currentImage, // computed from ref
|
featuredProjects,
|
||||||
openGallery, // function
|
filterByCategory,
|
||||||
closeGallery // function
|
search,
|
||||||
|
findById,
|
||||||
}
|
}
|
||||||
```
|
}
|
||||||
- Functions return early on validation failures with fallbacks
|
|
||||||
|
|
||||||
## Module Design
|
|
||||||
|
|
||||||
**Exports:**
|
|
||||||
- Composables export single named function: `export function useTheme() { ... }`
|
|
||||||
- Config files export named constants: `export const siteConfig: SiteConfig = { ... }`
|
|
||||||
- Type definitions export interfaces and types: `export interface Project { ... }`
|
|
||||||
- Data files export arrays or objects: `export const techStack: TechStack = { ... }`
|
|
||||||
|
|
||||||
**Barrel Files:**
|
|
||||||
- Not extensively used; direct imports preferred
|
|
||||||
- Only `src/types/index.ts` serves as barrel export for type definitions
|
|
||||||
- Components use direct imports: `import AppHeader from '@/components/layout/AppHeader.vue'`
|
|
||||||
|
|
||||||
**Component Structure (Vue SFC):**
|
|
||||||
- `<script setup lang="ts">` for all components (Vue 3 Composition API)
|
|
||||||
- Props validated with TypeScript interfaces
|
|
||||||
- Composables called at top of setup
|
|
||||||
- Computed properties for derived state
|
|
||||||
- Functions defined after setup calls
|
|
||||||
- `<template>` uses semantic HTML and accessibility attributes
|
|
||||||
- Scoped styles at bottom with `@import` for external stylesheets
|
|
||||||
|
|
||||||
**Example pattern from `AppHeader.vue`:**
|
|
||||||
```typescript
|
|
||||||
<script setup lang="ts">
|
|
||||||
import { ref, computed } from 'vue'
|
|
||||||
// Composables first
|
|
||||||
const { getImageUrl } = useAssets()
|
|
||||||
const { t } = useI18n()
|
|
||||||
// State
|
|
||||||
const isMenuOpen = ref(false)
|
|
||||||
// Computed
|
|
||||||
const navigation = computed(() => [ ... ])
|
|
||||||
// Functions
|
|
||||||
const toggleMenu = () => { ... }
|
|
||||||
</script>
|
|
||||||
```
|
```
|
||||||
|
|
||||||
## Type Safety
|
- Always return a named object, never a single value
|
||||||
|
- Filter/find functions return `computed()` so callers get reactivity
|
||||||
|
|
||||||
**TypeScript Configuration:**
|
## Data Layer Conventions
|
||||||
- Version: ~5.8.0
|
|
||||||
- DOM-focused (`tsconfig.dom.json` from @vue/tsconfig)
|
|
||||||
- Path alias `@/*` points to `./src/*`
|
|
||||||
- Type checking enabled in build: `npm run type-check` runs `vue-tsc --build`
|
|
||||||
|
|
||||||
**Type Usage Patterns:**
|
**Static data** in `app/data/`:
|
||||||
- All component Props use interface definitions
|
- Export named typed constants
|
||||||
- Composable return values typed explicitly
|
- Translatable fields omitted; resolved at runtime via i18n in the composable layer
|
||||||
- Function parameters and return types annotated
|
- `app/data/projects.ts` exports `Omit<Project, 'title' | 'description' | 'longDescription'>[]`
|
||||||
- Type imports use `import type` syntax
|
|
||||||
- Avoid `any` type; use proper interfaces/generics
|
|
||||||
|
|
||||||
## Vue 3 Specific
|
**Shared types** in `shared/types/index.ts`:
|
||||||
|
- Single source for all domain interfaces
|
||||||
|
- Imported by both `app/` and `server/` via `~~/shared/types`
|
||||||
|
|
||||||
**Composition API:**
|
**Server routes** in `server/api/`:
|
||||||
- `<script setup>` syntax exclusively used
|
- Use `defineEventHandler`, `readBody`, `useRuntimeConfig(event)`
|
||||||
- No Options API in codebase
|
- Manual input validation with `createError({ statusCode: 400 })`
|
||||||
- Composables follow Composition API patterns
|
- Return plain serializable objects
|
||||||
|
|
||||||
**Lifecycle Hooks:**
|
|
||||||
- `onMounted()` for initialization (theme loading, SEO setup)
|
|
||||||
- `onUnmounted()` for cleanup (removing DOM elements in useSeo)
|
|
||||||
- `watch()` for reactive side effects (theme changes)
|
|
||||||
|
|
||||||
**Reactivity:**
|
|
||||||
- `ref()` for primitive state
|
|
||||||
- `computed()` for derived state
|
|
||||||
- Avoid unnecessary reactivity; use constants when possible
|
|
||||||
- Return computed versions of refs from composables
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
*Convention analysis: 2026-04-07*
|
*Convention analysis: 2026-04-10*
|
||||||
|
|||||||
@@ -1,180 +1,119 @@
|
|||||||
# External Integrations
|
# External Integrations
|
||||||
|
|
||||||
**Analysis Date:** 2026-04-07
|
**Analysis Date:** 2026-04-10
|
||||||
|
|
||||||
## APIs & External Services
|
## APIs & External Services
|
||||||
|
|
||||||
**Analytics & Tracking:**
|
**Analytics:**
|
||||||
- Google Analytics (GTM)
|
- Google Analytics / Google Tag Manager via `nuxt-gtag` ^4.1.0
|
||||||
- Measurement ID: `G-CDVVNFY6MV`
|
- SDK/Client: `nuxt-gtag` Nuxt module
|
||||||
- Script: Injected in `index.html` lines 8-16
|
- Auth: `NUXT_PUBLIC_GTAG_ID` env var (public runtime config)
|
||||||
- Implementation: Inline gtag.js initialization with window.dataLayer
|
- Enabled only in production: `enabled: import.meta.env.NODE_ENV === 'production'`
|
||||||
- Page tracking: Configured in `src/router/index.ts` lines 105-136 via `trackPageView()` function
|
- Config in `nuxt.config.ts` under `gtag:` and `runtimeConfig.public.gtag`
|
||||||
- Tracks: Page path, title, and location on route changes
|
|
||||||
|
|
||||||
- Umami Analytics
|
|
||||||
- Website ID: `83631152-9b6b-4724-aad1-828459ff36dc`
|
|
||||||
- Hosted at: `umami.killiandalcin.fr`
|
|
||||||
- Script tag: `index.html` line 239
|
|
||||||
- Implementation: Self-hosted privacy-focused alternative to Google Analytics
|
|
||||||
|
|
||||||
**Advertising:**
|
|
||||||
- Google AdSense
|
|
||||||
- Client ID: `ca-pub-5219367964457248`
|
|
||||||
- Script: Async loaded in `index.html` lines 18-20
|
|
||||||
- Purpose: Display contextual ads on portfolio pages
|
|
||||||
|
|
||||||
**Social Integration:**
|
|
||||||
Social media links configured in `src/config/site.ts` (no direct API integration):
|
|
||||||
- GitHub/Gitea: `https://gitea.kamisama.ovh/kayjaydee`
|
|
||||||
- LinkedIn: `https://linkedin.com/in/killian-dal-cin`
|
|
||||||
- Discord: `https://discord.com/users/370940770225618954`
|
|
||||||
- Fiverr: `https://www.fiverr.com/users/mr_kayjaydee`
|
|
||||||
- Twitter: `@killiandalcin`
|
|
||||||
|
|
||||||
**Third-Party Services Referenced (Portfolio Content, Not Integrated):**
|
|
||||||
- Instagram API - Referenced in `src/components/TechBadge.vue` and `src/composables/useProjects.ts` as portfolio technology (instagram-bot project)
|
|
||||||
- Crowdin API - Referenced in `src/composables/useProjects.ts` as portfolio technology (crowdin status bot)
|
|
||||||
- Discord.js - Referenced in `src/composables/useProjects.ts` as portfolio technology (Discord bot development)
|
|
||||||
- NPM Package Registry - discord-image-generation published at `https://www.npmjs.com/package/discord-image-generation`
|
|
||||||
|
|
||||||
## Data Storage
|
## Data Storage
|
||||||
|
|
||||||
**Databases:**
|
**Databases:**
|
||||||
- None detected - Portfolio is static content
|
- None — all portfolio data is static (TypeScript data files in `app/data/`)
|
||||||
- Technologies showcased (not used in this app):
|
|
||||||
- MongoDB - Referenced in `src/data/techstack.ts`
|
|
||||||
- MySQL - Referenced in `src/data/techstack.ts`
|
|
||||||
- PostgreSQL - Referenced in `src/data/techstack.ts`
|
|
||||||
- Redis - Referenced in `src/data/techstack.ts`
|
|
||||||
- SQLite - Referenced in `src/data/techstack.ts`
|
|
||||||
|
|
||||||
**File Storage:**
|
**File Storage:**
|
||||||
- Local filesystem only
|
- Local filesystem only — images served from `public/` or via `@nuxt/image`
|
||||||
- Images: `src/assets/images/` directory
|
|
||||||
- Compiled assets: `dist/` directory (generated on build)
|
|
||||||
- Public assets: `public/` directory (favicon, manifest, logos, etc.)
|
|
||||||
|
|
||||||
**Caching:**
|
**Caching:**
|
||||||
- Browser caching via content hash in filenames:
|
- None — Nuxt SSR per-request rendering
|
||||||
- Pattern: `assets/[ext]/[name]-[hash].[ext]` (configured in `vite.config.ts` lines 40-42)
|
|
||||||
- CSS: `assets/css/[name]-[hash].css`
|
|
||||||
- JS: `assets/js/[name]-[hash].js`
|
|
||||||
- No server-side caching layer detected
|
|
||||||
|
|
||||||
## Authentication & Identity
|
## Authentication & Identity
|
||||||
|
|
||||||
**Auth Provider:**
|
**Auth Provider:**
|
||||||
- None - Portfolio is fully public
|
- None — no user authentication required for this portfolio site
|
||||||
- No authentication system implemented
|
|
||||||
- Fiverr links redirect to external Fiverr service for user authentication
|
## Email
|
||||||
|
|
||||||
|
**SMTP Email (Contact Form):**
|
||||||
|
- Provider: Any SMTP-compatible server (configured at runtime)
|
||||||
|
- Implementation: `nodemailer` ^8.0.5 in server API route `app/api/contact.post.ts`
|
||||||
|
- Validation: `zod` ^4.3.6 validates request body server-side
|
||||||
|
- Auth env vars:
|
||||||
|
- `NUXT_SMTP_HOST` - SMTP server hostname
|
||||||
|
- `NUXT_SMTP_USER` - SMTP credentials username
|
||||||
|
- `NUXT_SMTP_PASS` - SMTP credentials password
|
||||||
|
- `NUXT_SMTP_TO` - Destination email address for contact messages
|
||||||
|
|
||||||
|
## SEO & Discoverability
|
||||||
|
|
||||||
|
**Sitemap:**
|
||||||
|
- `@nuxtjs/sitemap` ^8.0.12 — automatic XML sitemap generation
|
||||||
|
- Base URL: `https://killiandalcin.fr` (configured in `nuxt.config.ts` under `site:`)
|
||||||
|
- Site name: "Killian' DAL-CIN - Developpeur Full Stack"
|
||||||
|
|
||||||
## Monitoring & Observability
|
## Monitoring & Observability
|
||||||
|
|
||||||
**Error Tracking:**
|
**Error Tracking:**
|
||||||
- None detected
|
- None detected
|
||||||
- Errors not sent to external service
|
|
||||||
- Console errors only (JavaScript errors in browser dev tools)
|
|
||||||
|
|
||||||
**Logs:**
|
**Logs:**
|
||||||
- Browser console logging only
|
- Standard Node.js stdout/stderr (captured by Docker/host)
|
||||||
- No server-side logging or aggregation
|
|
||||||
- Analytics events sent to Google Analytics and Umami for page views
|
|
||||||
|
|
||||||
**Performance Monitoring:**
|
|
||||||
- Google Analytics provides basic performance metrics
|
|
||||||
- Umami provides engagement metrics
|
|
||||||
- No dedicated APM (Application Performance Monitoring) service
|
|
||||||
|
|
||||||
## CI/CD & Deployment
|
## CI/CD & Deployment
|
||||||
|
|
||||||
**Hosting:**
|
**Hosting:**
|
||||||
- Static hosting environment (implied)
|
- Self-hosted Docker container on VPS
|
||||||
- Docker containerization available:
|
- Image: `node:22-alpine` (multi-stage build)
|
||||||
- Build image: `node:22-alpine` (lines 2-17 in `Dockerfile`)
|
- Container port: 3000
|
||||||
- Runtime image: `nginx:stable-alpine` (lines 20-32 in `Dockerfile`)
|
- Reverse proxy: Traefik
|
||||||
- Port: 80
|
- TLS via Let's Encrypt (`certresolver=public`)
|
||||||
|
- Wildcard cert covering `killiandalcin.fr` and `*.killiandalcin.fr`
|
||||||
|
- www → non-www permanent redirect middleware
|
||||||
|
- Config via Docker labels in `docker-compose.yml`
|
||||||
|
|
||||||
**CI Pipeline:**
|
**CI Pipeline:**
|
||||||
- None detected in repository
|
- None detected — manual Docker image build and deploy
|
||||||
- Build commands available:
|
|
||||||
- `npm run build` - Full build with type checking
|
|
||||||
- `npm run build-only` - Build only without type checking
|
|
||||||
|
|
||||||
**Deployment Configuration:**
|
**Build process:**
|
||||||
- `Dockerfile` - Multi-stage Docker build:
|
1. `docker build` — runs `npm ci` + `nuxt build` in `node:22-alpine`
|
||||||
1. Build stage: Node 22-alpine with npm install + build
|
2. Output `.output/` copied to runtime stage
|
||||||
2. Production stage: nginx serving built files
|
3. `docker-compose up` starts the container with runtime env vars
|
||||||
3. Custom nginx config: `nginx.conf`
|
|
||||||
- `nginx.conf` - SPA routing configuration:
|
|
||||||
- Listens on port 80 (IPv4 and IPv6)
|
|
||||||
- Document root: `/usr/share/nginx/html`
|
|
||||||
- Fallback: All non-file requests route to `/index.html` (SPA requirement)
|
|
||||||
|
|
||||||
## Environment Configuration
|
## Internationalization
|
||||||
|
|
||||||
**Required for Development:**
|
**i18n Provider:**
|
||||||
- Node.js 22+
|
- `@nuxtjs/i18n` ^10.2.4
|
||||||
- npm 10+
|
- Strategy: `prefix_except_default` (French at `/`, English at `/en/`)
|
||||||
|
- Default locale: `fr`
|
||||||
|
- Supported locales: `fr` (fr-FR), `en` (en-US)
|
||||||
|
- Locale files: `i18n/locales/fr.json`, `i18n/locales/en.json`
|
||||||
|
- Browser detection: cookie-based (`i18n_redirected`) for SSR safety
|
||||||
|
|
||||||
**Required at Runtime:**
|
## Image Optimization
|
||||||
- No environment variables required (analytics IDs hardcoded in HTML)
|
|
||||||
- Optional (for containerization):
|
|
||||||
- Docker
|
|
||||||
- Docker Compose
|
|
||||||
|
|
||||||
**Hardcoded Configuration:**
|
**Provider:**
|
||||||
- Google Analytics: `G-CDVVNFY6MV` - in `index.html` line 9
|
- `@nuxt/image` ^2.0.0
|
||||||
- Google AdSense: `ca-pub-5219367964457248` - in `index.html` line 19
|
- Default provider: local (no external image CDN configured)
|
||||||
- Umami Site ID: `83631152-9b6b-4724-aad1-828459ff36dc` - in `index.html` line 240
|
- Images served from `public/`
|
||||||
- Umami URL: `umami.killiandalcin.fr` - in `index.html` line 239
|
|
||||||
- Base URL: `https://killiandalcin.fr` - in multiple config files (`src/config/site.ts`, `index.html`)
|
|
||||||
|
|
||||||
**Secrets Location:**
|
|
||||||
- No secrets management system
|
|
||||||
- All credentials are public analytics/advertising IDs (not sensitive)
|
|
||||||
- No API keys, database passwords, or private credentials in codebase
|
|
||||||
|
|
||||||
## Webhooks & Callbacks
|
## Webhooks & Callbacks
|
||||||
|
|
||||||
**Incoming:**
|
**Incoming:**
|
||||||
- None - Portfolio is read-only
|
- `POST /api/contact` — contact form submission endpoint (`app/api/contact.post.ts`)
|
||||||
|
|
||||||
**Outgoing:**
|
**Outgoing:**
|
||||||
- Google Analytics pageview tracking:
|
- None
|
||||||
- Method: GET requests to Google Analytics endpoint
|
|
||||||
- Triggered: On route navigation
|
|
||||||
- Data: Page path, title, URL
|
|
||||||
- Implementation: `src/router/index.ts` `trackPageView()` function
|
|
||||||
|
|
||||||
- Umami analytics events:
|
## Environment Configuration
|
||||||
- Method: Beacon API (automatic page tracking)
|
|
||||||
- Triggered: Page load and navigation
|
|
||||||
- Data: Standard web vitals
|
|
||||||
|
|
||||||
## CDN & External Resources
|
**Required env vars (production):**
|
||||||
|
- `NUXT_SMTP_HOST` - SMTP server hostname
|
||||||
|
- `NUXT_SMTP_USER` - SMTP username
|
||||||
|
- `NUXT_SMTP_PASS` - SMTP password
|
||||||
|
- `NUXT_SMTP_TO` - Contact form recipient email
|
||||||
|
- `NUXT_PUBLIC_GTAG_ID` - Google Analytics tag ID
|
||||||
|
- `PORTFOLIO_URL` - Primary domain (used in Traefik labels)
|
||||||
|
- `PORTFOLIO_URL_WWW` - WWW variant (used in Traefik www-redirect rule)
|
||||||
|
|
||||||
**Fonts:**
|
**Secrets location:**
|
||||||
- Preconnected in `index.html`:
|
- Passed as Docker environment variables at runtime (not committed to repo)
|
||||||
- `https://fonts.googleapis.com`
|
- `docker-compose.yml` reads from host environment via `${VAR_NAME}` syntax
|
||||||
- `https://fonts.gstatic.com`
|
|
||||||
- Actual fonts: Not loaded (preconnect only, fonts not referenced in CSS)
|
|
||||||
|
|
||||||
**Images & Media:**
|
|
||||||
- UI Avatar API:
|
|
||||||
- Service: `https://ui-avatars.com/api/`
|
|
||||||
- Usage: Testimonial avatars in `src/data/testimonials.ts` (4 instances)
|
|
||||||
- Pattern: Query-based avatar generation with initials and colors
|
|
||||||
|
|
||||||
- Placeholder Images:
|
|
||||||
- Service: `https://via.placeholder.com/`
|
|
||||||
- Usage: Fallback images in `src/composables/useAssets.ts`
|
|
||||||
- Pattern: 400x300 gray placeholders
|
|
||||||
|
|
||||||
- Portfolio Preview Image:
|
|
||||||
- Hosted at: `https://killiandalcin.fr/portfolio-preview.webp`
|
|
||||||
- Usage: Open Graph and Twitter meta tags (lines 42, 55 in `index.html`)
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
*Integration audit: 2026-04-07*
|
*Integration audit: 2026-04-10*
|
||||||
|
|||||||
+59
-120
@@ -1,164 +1,103 @@
|
|||||||
# Technology Stack
|
# Technology Stack
|
||||||
|
|
||||||
**Analysis Date:** 2026-04-07
|
**Analysis Date:** 2026-04-10
|
||||||
|
|
||||||
## Languages
|
## Languages
|
||||||
|
|
||||||
**Primary:**
|
**Primary:**
|
||||||
- TypeScript ~5.8.0 - Full application development
|
- TypeScript ~5.8.0 - Full application (strict mode enforced via `nuxt.config.ts`)
|
||||||
- JavaScript (ES modules) - Frontend runtime
|
- HTML5 - Server-rendered markup via Nuxt SSR
|
||||||
|
|
||||||
**Secondary:**
|
**Secondary:**
|
||||||
- HTML5 - Document structure (in `index.html`)
|
- CSS - Styling via Tailwind CSS v4 (`app/assets/css/main.css`)
|
||||||
- CSS - Styling with Tailwind CSS
|
|
||||||
- Markdown - Documentation (README.md)
|
|
||||||
- YAML - Configuration (implied through Dockerfile)
|
|
||||||
|
|
||||||
## Runtime
|
## Runtime
|
||||||
|
|
||||||
**Environment:**
|
**Environment:**
|
||||||
- Node.js 22 - Development and build environment
|
- Node.js 22 (Alpine) - Development, build, and production server
|
||||||
- Browser environment - Vue 3 SFC runtime
|
|
||||||
|
|
||||||
**Package Manager:**
|
**Package Manager:**
|
||||||
- npm - Dependency management
|
- pnpm (primary — `pnpm-lock.yaml` present)
|
||||||
- Lockfile: `package-lock.json` (present and tracked)
|
- npm also supported (used in Dockerfile via `npm ci`)
|
||||||
|
- Lockfile: both `pnpm-lock.yaml` and `package-lock.json` present
|
||||||
|
|
||||||
## Frameworks
|
## Frameworks
|
||||||
|
|
||||||
**Core Frontend:**
|
**Core:**
|
||||||
- Vue 3.5.13 - Progressive JavaScript framework for UI
|
- Nuxt 4 (`^4.0.0`) - SSR framework, `compatibilityVersion: 4` set in `nuxt.config.ts`
|
||||||
- Vue Router 4.5.0 - Client-side routing with lazy-loaded pages
|
- Vue (latest) - Component framework
|
||||||
- Pinia 3.0.1 - State management (minimal usage - currently only `counter.ts`)
|
- Vue Router (latest) - File-based routing via Nuxt
|
||||||
- Vue I18n 9.14.4 - Internationalization (English and French locale files in `src/locales/`)
|
|
||||||
|
|
||||||
**Build & Dev:**
|
**Nuxt Modules:**
|
||||||
- Vite 6.2.4 - Build tool and dev server
|
- `@nuxt/ui` ^3.0.0 - Component library (Tailwind v4 based, configured in `app.config.ts`)
|
||||||
- Config: `vite.config.ts` with Vue plugin, DevTools plugin, chunk splitting optimization
|
- `@nuxtjs/i18n` ^10.2.4 - Internationalization with FR/EN support
|
||||||
- Build output: `dist/` with CSS code splitting, Terser minification
|
- `@nuxtjs/sitemap` ^8.0.12 - Automatic sitemap generation
|
||||||
- Vite Plugin Vue DevTools 7.7.2 - Development utilities
|
- `nuxt-gtag` ^4.1.0 - Google Analytics/Google Tag Manager integration
|
||||||
- @vitejs/plugin-vue 5.2.3 - Vue 3 SFC support
|
- `@nuxt/image` ^2.0.0 - Optimized image handling
|
||||||
|
- `@nuxt/eslint` ^1.15.2 - ESLint integration
|
||||||
|
|
||||||
**Styling:**
|
**Build/Dev:**
|
||||||
- Tailwind CSS 4.1.10 - Utility-first CSS framework
|
- Tailwind CSS ^4.2.2 (devDependency — compiled at build time)
|
||||||
- @tailwindcss/postcss 4.1.10 - PostCSS plugin for Tailwind
|
- ESLint (via `@nuxt/eslint`) - Config: `eslint.config.mjs`
|
||||||
- PostCSS 8.5.6 - CSS transformation pipeline
|
- TypeScript ~5.8.0 - Compiler and type checking via `nuxt typecheck`
|
||||||
- Autoprefixer 10.4.21 - Vendor prefix handling
|
|
||||||
- Terser 5.43.1 - JavaScript minification
|
|
||||||
|
|
||||||
**Code Quality:**
|
|
||||||
- ESLint 9.22.0 - Linting (config: `eslint.config.ts`)
|
|
||||||
- @vue/eslint-config-typescript 14.5.0
|
|
||||||
- @vue/eslint-config-prettier 10.2.0 - Prettier integration
|
|
||||||
- eslint-plugin-vue ~10.0.0
|
|
||||||
- Prettier 3.5.3 - Code formatting (config: `.prettierrc.json`)
|
|
||||||
- Format settings: `semi: false`, `singleQuote: true`, `printWidth: 100`
|
|
||||||
|
|
||||||
**Type Checking:**
|
|
||||||
- vue-tsc 2.2.8 - Vue component type checking
|
|
||||||
- TypeScript compiler with `type-check` npm script
|
|
||||||
|
|
||||||
**Head Management:**
|
|
||||||
- @vueuse/head 2.0.0 - Dynamic document head management for meta tags and SEO
|
|
||||||
|
|
||||||
## Key Dependencies
|
## Key Dependencies
|
||||||
|
|
||||||
**Critical:**
|
**Critical:**
|
||||||
- vue 3.5.13 - Core framework
|
- `nuxt` ^4.0.0 - Core framework with SSR engine
|
||||||
- vue-router 4.5.0 - SPA routing with code splitting
|
- `@nuxt/ui` ^3.0.0 - Provides all UI primitives (buttons, forms, modals, etc.)
|
||||||
- pinia 3.0.1 - State management store
|
- `@nuxtjs/i18n` ^10.2.4 - Multilingual routing (`fr` default, `en` prefixed via `/en/`)
|
||||||
- vue-i18n 9.14.4 - Multi-language support
|
- `zod` ^4.3.6 - Schema validation (used in server API routes for contact form)
|
||||||
|
|
||||||
**Infrastructure & Build:**
|
**Infrastructure:**
|
||||||
- vite 6.2.4 - Next-gen build tool with HMR
|
- `nodemailer` ^8.0.5 - SMTP email sending from server API (`app/api/contact.post.ts`)
|
||||||
- tailwindcss 4.1.10 - Rapid UI development
|
- `@types/nodemailer` ^8.0.0 - Type definitions for nodemailer
|
||||||
- typescript 5.8.0 - Static typing and compilation
|
|
||||||
|
|
||||||
**Developer Tools:**
|
|
||||||
- eslint 9.22.0 - Code linting
|
|
||||||
- prettier 3.5.3 - Code formatting
|
|
||||||
- npm-run-all2 7.0.2 - Parallel script execution (used in build process)
|
|
||||||
- @tsconfig/node22 22.0.1 - TSConfig preset for Node 22
|
|
||||||
- @types/node 22.14.0 - Node.js type definitions
|
|
||||||
- jiti 2.4.2 - CommonJS loader for TypeScript modules
|
|
||||||
|
|
||||||
## Configuration
|
## Configuration
|
||||||
|
|
||||||
**Environment:**
|
**Environment:**
|
||||||
- No `.env` files detected in source
|
- No `.env` committed; secrets passed at runtime via Docker environment variables
|
||||||
- Google Analytics tracking ID hardcoded: `G-CDVVNFY6MV` (in `index.html`)
|
- Key runtime config variables (defined in `nuxt.config.ts` `runtimeConfig`):
|
||||||
- Umami analytics script loaded from `umami.killiandalcin.fr` (in `index.html`)
|
- `NUXT_SMTP_HOST` - SMTP server hostname
|
||||||
- Google AdSense client ID hardcoded: `ca-pub-5219367964457248` (in `index.html`)
|
- `NUXT_SMTP_USER` - SMTP username
|
||||||
|
- `NUXT_SMTP_PASS` - SMTP password
|
||||||
|
- `NUXT_SMTP_TO` - Recipient email address
|
||||||
|
- `NUXT_PUBLIC_GTAG_ID` - Google Analytics tag ID
|
||||||
|
|
||||||
**Build Configuration:**
|
**Build:**
|
||||||
- `vite.config.ts` - Build optimizations:
|
- `nuxt.config.ts` - Main Nuxt configuration (SSR enabled, modules, i18n, color mode, sitemap)
|
||||||
- Path alias: `@/` → `./src/`
|
- `app.config.ts` - App-level UI config (primary color: `brand`)
|
||||||
- CSS code splitting enabled
|
- `tsconfig.json` + `tsconfig.app.json` + `tsconfig.node.json` - TypeScript project references
|
||||||
- Terser minification with console/debugger removal
|
- `eslint.config.mjs` - ESLint flat config
|
||||||
- Manual chunk splitting: `vue-vendor` and `ui-components`
|
|
||||||
- Content hash in chunk filenames for cache busting
|
|
||||||
- Source maps disabled
|
|
||||||
- Chunk size warning limit: 1000 KB
|
|
||||||
|
|
||||||
**Type Configuration:**
|
**Key nuxt.config.ts settings:**
|
||||||
- `tsconfig.json` - References `tsconfig.app.json` and `tsconfig.node.json`
|
- `ssr: true` — SSR always enabled
|
||||||
- `tsconfig.app.json`:
|
- `colorMode.storage: 'cookie'` — SSR-safe theme persistence
|
||||||
- Extends `@vue/tsconfig/dom.json`
|
- `i18n.detectBrowserLanguage.useCookie: true` — SSR-safe locale detection
|
||||||
- Includes `src/**/*` and `*.vue` files
|
- `typescript.strict: true` — Strict TypeScript mode
|
||||||
- Path alias: `@/*` → `./src/*`
|
|
||||||
- Excludes `src/**/__tests__/*`
|
|
||||||
|
|
||||||
**Linting Configuration:**
|
|
||||||
- `eslint.config.ts` - Flat config format:
|
|
||||||
- Files: `**/*.{ts,mts,tsx,vue}`
|
|
||||||
- Rules: Vue essential, TypeScript recommended
|
|
||||||
- Skips Prettier formatting enforcement
|
|
||||||
|
|
||||||
**Formatting Configuration:**
|
|
||||||
- `.prettierrc.json`:
|
|
||||||
- No semicolons
|
|
||||||
- Single quotes for strings
|
|
||||||
- 100 character line width
|
|
||||||
|
|
||||||
**PostCSS Configuration:**
|
|
||||||
- `postcss.config.js` - Tailwind CSS and Autoprefixer
|
|
||||||
|
|
||||||
**Tailwind Configuration:**
|
|
||||||
- `tailwind.config.js` - Content scanning for `index.html` and `src/**/*.{vue,js,ts,jsx,tsx}`
|
|
||||||
|
|
||||||
## Platform Requirements
|
## Platform Requirements
|
||||||
|
|
||||||
**Development:**
|
**Development:**
|
||||||
- Node.js 22+ (specified in Dockerfile)
|
- Node.js 22+
|
||||||
- npm 10+ (implied by Node 22)
|
- pnpm (or npm)
|
||||||
- TypeScript 5.8+
|
|
||||||
- Any Unix-like shell (bash/zsh) or Windows with Node.js
|
|
||||||
|
|
||||||
**Production:**
|
**Production:**
|
||||||
- Docker - Multi-stage build with Node 22-alpine and nginx stable-alpine
|
- Docker with Node.js 22 Alpine image
|
||||||
- Web server: nginx (configured in `nginx.conf`)
|
- SSR server runs on port 3000 (`node .output/server/index.mjs`)
|
||||||
- Deployment target: Static HTML served via nginx
|
- Reverse proxy: Traefik (TLS termination, www redirect, routing)
|
||||||
- Base image: `nginx:stable-alpine`
|
|
||||||
- Document root: `/usr/share/nginx/html`
|
|
||||||
- Port: 80
|
|
||||||
- SPA fallback: All requests route to `/index.html`
|
|
||||||
|
|
||||||
**Browser Support:**
|
|
||||||
- JavaScript enabled (noscript fallback message in `index.html`)
|
|
||||||
- Modern browsers with ES2020+ support (Vite default targets)
|
|
||||||
|
|
||||||
## Scripts & Commands
|
## Scripts & Commands
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
npm run dev # Start Vite dev server with HMR
|
pnpm dev # Start dev server with HMR
|
||||||
npm run build # Type check + build (parallel with npm-run-all2)
|
pnpm build # Build SSR bundle → .output/
|
||||||
npm run type-check # Run vue-tsc type checking
|
pnpm generate # Static generation (SSG mode)
|
||||||
npm run build-only # Build without type checking
|
pnpm preview # Preview built output
|
||||||
npm run preview # Preview production build
|
pnpm lint # Run ESLint
|
||||||
npm run lint # Run ESLint with --fix
|
pnpm typecheck # Run nuxt typecheck (vue-tsc)
|
||||||
npm run format # Format src/ with Prettier
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
*Stack analysis: 2026-04-07*
|
*Stack analysis: 2026-04-10*
|
||||||
|
|||||||
+78
-260
@@ -1,277 +1,95 @@
|
|||||||
# Codebase Structure
|
# Structure
|
||||||
|
|
||||||
**Analysis Date:** 2026-04-07
|
**Analysis Date:** 2026-04-10
|
||||||
|
|
||||||
## Directory Layout
|
## Directory Layout
|
||||||
|
|
||||||
```
|
```
|
||||||
portfolio/
|
portfolio/
|
||||||
├── .claude/ # Claude editor configuration
|
├── app/ # Nuxt 4 app directory (srcDir)
|
||||||
├── .planning/ # GSD planning documents
|
│ ├── app.vue # Root component (UApp wrapper)
|
||||||
│ └── codebase/ # Architecture and analysis docs
|
│ ├── error.vue # Error/404 page
|
||||||
├── .vscode/ # VS Code workspace settings
|
│ ├── assets/css/main.css # Global CSS (Tailwind imports)
|
||||||
├── dist/ # Vite production build output
|
│ ├── components/
|
||||||
├── docs/ # Documentation files
|
│ │ ├── layout/
|
||||||
├── node_modules/ # Dependencies (git-ignored)
|
│ │ │ ├── AppHeader.vue # Sticky header with nav, locale/theme toggles
|
||||||
├── old/ # Archived or deprecated code
|
│ │ │ └── AppFooter.vue # Footer with social links, copyright
|
||||||
├── public/ # Static assets served at root
|
│ │ ├── sections/
|
||||||
│ └── images/ # Public static images
|
│ │ │ ├── HeroSection.vue # Landing hero with CTA
|
||||||
├── src/ # Application source code
|
│ │ │ ├── FeaturedProjectsSection.vue
|
||||||
│ ├── assets/ # Static assets imported in code
|
│ │ │ ├── ServicesSection.vue
|
||||||
│ │ └── images/ # Project and UI images (webp format)
|
│ │ │ ├── TestimonialsSection.vue
|
||||||
│ ├── components/ # Vue component library
|
│ │ │ ├── FAQSection.vue
|
||||||
│ │ ├── icons/ # Icon SVG components
|
│ │ │ └── CTASection.vue
|
||||||
│ │ ├── layout/ # Layout components (Header, Footer)
|
│ │ ├── ContactForm.vue # Form with Zod validation + honeypot
|
||||||
│ │ ├── sections/ # Page section components
|
│ │ ├── ProjectCard.vue # Project display card
|
||||||
│ │ ├── shared/ # Reusable UI components
|
│ │ ├── ProjectGallery.vue # Image gallery modal
|
||||||
│ │ ├── styles/ # Component-scoped CSS files
|
│ │ └── TechBadge.vue # Technology badge with icon
|
||||||
│ │ └── testimonials/ # Testimonial-related components
|
│ ├── composables/
|
||||||
│ ├── composables/ # Vue composables (reusable logic)
|
│ │ └── useProjects.ts # Project data access + i18n + filtering
|
||||||
│ ├── config/ # Application configuration
|
│ ├── data/ # Static typed data
|
||||||
│ ├── data/ # Static data files (projects, testimonials, FAQ)
|
│ │ ├── projects.ts # 7 projects (Omit translatable fields)
|
||||||
│ ├── i18n/ # Internationalization setup
|
│ │ ├── testimonials.ts # Client testimonials
|
||||||
│ ├── locales/ # Translation files (en.ts, fr.ts)
|
│ │ ├── techstack.ts # Technology categories
|
||||||
│ ├── plugins/ # Vue plugins
|
│ │ ├── faq.ts # FAQ entries (i18n keys)
|
||||||
│ ├── router/ # Vue Router configuration
|
│ │ └── site.ts # Site config (SEO, contact, social)
|
||||||
│ ├── stores/ # Pinia state stores
|
│ ├── layouts/
|
||||||
│ ├── types/ # TypeScript type definitions
|
│ │ └── default.vue # Main layout (header + slot + footer)
|
||||||
│ ├── views/ # Page components (route targets)
|
│ └── pages/ # File-based routing
|
||||||
│ │ └── styles/ # Page-level CSS files
|
│ ├── index.vue # Homepage
|
||||||
│ ├── App.vue # Root component
|
│ ├── about.vue # About page
|
||||||
│ ├── main.ts # Application entry point
|
│ ├── contact.vue # Contact form page
|
||||||
│ └── style.css # Global stylesheet
|
│ ├── projects.vue # Project listing with filters
|
||||||
├── .env* # Environment variables (git-ignored)
|
│ ├── fiverr.vue # Fiverr services page
|
||||||
├── .eslintrc.ts # ESLint configuration
|
│ └── project/[id].vue # Dynamic project detail
|
||||||
├── .gitignore # Git ignore rules
|
├── i18n/locales/ # Translation files
|
||||||
├── .prettierrc.json # Prettier code formatter config
|
│ ├── fr.json # French (default locale)
|
||||||
├── eslint.config.ts # ESLint flat config
|
│ └── en.json # English
|
||||||
├── index.html # HTML entry point
|
├── server/api/
|
||||||
├── package-lock.json # Dependency lock file
|
│ └── contact.post.ts # Contact form POST handler (nodemailer)
|
||||||
├── package.json # Project metadata and scripts
|
├── shared/types/
|
||||||
├── postcss.config.js # PostCSS configuration (Tailwind)
|
│ └── index.ts # All TypeScript interfaces
|
||||||
├── tailwind.config.js # Tailwind CSS configuration
|
├── public/images/ # Static images (WebP)
|
||||||
├── tsconfig.app.json # TypeScript config for app code
|
├── nuxt.config.ts # Nuxt configuration
|
||||||
├── tsconfig.json # Base TypeScript config
|
├── app.config.ts # Nuxt UI theme tokens
|
||||||
├── tsconfig.node.json # TypeScript config for build tools
|
├── Dockerfile # Multi-stage SSR build (node:22-alpine)
|
||||||
├── vite.config.ts # Vite build configuration
|
├── docker-compose.yml # Docker compose with Traefik
|
||||||
└── [formation.md] # Formation page documentation (uncommitted)
|
├── package.json # Dependencies (pnpm)
|
||||||
|
└── pnpm-lock.yaml # pnpm lockfile
|
||||||
```
|
```
|
||||||
|
|
||||||
## Directory Purposes
|
## Page Inventory
|
||||||
|
|
||||||
**src/components/**
|
| Route | File | Description |
|
||||||
- Purpose: Vue Single File Components for UI building blocks
|
|-------|------|-------------|
|
||||||
- Contains: Presentational components organized by domain
|
| `/` | `index.vue` | Homepage with 6 sections (hero, projects, services, testimonials, FAQ, CTA) |
|
||||||
- Key files: `AppHeader.vue`, `ProjectCard.vue`, `HeroSection.vue`
|
| `/about` | `about.vue` | About page with tech stack badges |
|
||||||
|
| `/projects` | `projects.vue` | Project listing with search + category filters |
|
||||||
|
| `/project/:id` | `project/[id].vue` | Dynamic project detail with gallery |
|
||||||
|
| `/contact` | `contact.vue` | Contact form page |
|
||||||
|
| `/fiverr` | `fiverr.vue` | Fiverr services page |
|
||||||
|
| `/en/*` | (same files) | English prefix routes via i18n |
|
||||||
|
|
||||||
**src/components/layout/**
|
## Component Hierarchy
|
||||||
- Purpose: Layout wrapper components used across pages
|
|
||||||
- Contains: `AppHeader.vue`, `AppFooter.vue`
|
|
||||||
- Key files: Header navigation, footer with social links
|
|
||||||
|
|
||||||
**src/components/sections/**
|
- **Layout components** (`layout/`): AppHeader, AppFooter — used in `default.vue` layout
|
||||||
- Purpose: Full-width page section components
|
- **Section components** (`sections/`): 6 homepage sections — composed in `index.vue`
|
||||||
- Contains: `HeroSection.vue`, `FeaturedProjectsSection.vue`, `ServicesSection.vue`, `CTASection.vue`
|
- **Shared components** (root): ContactForm, ProjectCard, ProjectGallery, TechBadge — reused across pages
|
||||||
- Key files: Large reusable page sections with styling
|
|
||||||
|
|
||||||
**src/components/shared/**
|
All components auto-imported with `pathPrefix: false` — use `AppHeader` not `LayoutAppHeader`.
|
||||||
- Purpose: Shared utility components
|
|
||||||
- Contains: `CTAButtons.vue`, `SectionCTA.vue`
|
|
||||||
- Key files: Reusable button groups and CTA patterns
|
|
||||||
|
|
||||||
**src/components/testimonials/**
|
## Where to Add Things
|
||||||
- Purpose: Testimonial display components
|
|
||||||
- Contains: `TestimonialCard.vue`, `TestimonialsCTA.vue`, `TestimonialsStats.vue`
|
|
||||||
- Key files: Fiverr review display and stats
|
|
||||||
|
|
||||||
**src/composables/**
|
| To add... | Location |
|
||||||
- Purpose: Vue 3 Composition API utilities for reusable logic
|
|-----------|----------|
|
||||||
- Contains: Custom hooks for i18n, SEO, theme, projects, gallery, date formatting, assets, site config
|
| New page | `app/pages/newpage.vue` (auto-routed) |
|
||||||
- Key files: `useI18n.ts`, `useSeo.ts`, `useTheme.ts`, `useProjects.ts`
|
| New component | `app/components/` (auto-imported) |
|
||||||
|
| New section | `app/components/sections/` |
|
||||||
**src/stores/**
|
| New API route | `server/api/name.method.ts` |
|
||||||
- Purpose: Pinia state management
|
| New data file | `app/data/name.ts` |
|
||||||
- Contains: Global reactive state stores
|
| New type | `shared/types/index.ts` |
|
||||||
- Key files: `counter.ts` (minimal unused example), `auth.ts` (new, for authentication)
|
| New i18n keys | `i18n/locales/fr.json` + `en.json` |
|
||||||
|
|
||||||
**src/views/**
|
|
||||||
- Purpose: Page-level Vue components matching routes
|
|
||||||
- Contains: `HomePage.vue`, `ProjectsPage.vue`, `ProjectDetailPage.vue`, `AboutPage.vue`, `ContactPage.vue`, `FiverrPage.vue`, `FormationPage.vue`
|
|
||||||
- Key files: Route target components, each handles own SEO and data fetching
|
|
||||||
|
|
||||||
**src/router/**
|
|
||||||
- Purpose: Vue Router configuration and navigation logic
|
|
||||||
- Contains: Route definitions, navigation guards, analytics tracking
|
|
||||||
- Key files: `index.ts` (main router), `guards.ts` (new, for route guards)
|
|
||||||
|
|
||||||
**src/types/**
|
|
||||||
- Purpose: TypeScript interface definitions
|
|
||||||
- Contains: Project, Technology, TechStack, SocialLink, ContactInfo, FiverrService, SiteConfig interfaces
|
|
||||||
- Key files: `index.ts`
|
|
||||||
|
|
||||||
**src/data/**
|
|
||||||
- Purpose: Static data files (non-API)
|
|
||||||
- Contains: Project definitions, testimonials, tech stack, FAQ data
|
|
||||||
- Key files: `techstack.ts`, `testimonials.ts`, `faq.ts`
|
|
||||||
|
|
||||||
**src/config/**
|
|
||||||
- Purpose: Application-wide configuration constants
|
|
||||||
- Contains: Site configuration with contact info, social links, SEO settings, performance flags
|
|
||||||
- Key files: `site.ts` (siteConfig export)
|
|
||||||
|
|
||||||
**src/locales/**
|
|
||||||
- Purpose: Translation message files
|
|
||||||
- Contains: English and French translation objects
|
|
||||||
- Key files: `en.ts`, `fr.ts`
|
|
||||||
|
|
||||||
**src/i18n/**
|
|
||||||
- Purpose: vue-i18n setup and initialization
|
|
||||||
- Contains: i18n instance creation and locale loading
|
|
||||||
- Key files: `index.ts`
|
|
||||||
|
|
||||||
**src/assets/images/**
|
|
||||||
- Purpose: Images imported in code (processed by Vite)
|
|
||||||
- Contains: Tech stack icons, project images, app images in webp format
|
|
||||||
- Subdirs: `fiverr/`, `flowboard/` for project-specific images
|
|
||||||
|
|
||||||
**public/images/**
|
|
||||||
- Purpose: Static images served at root URL without processing
|
|
||||||
- Contains: Logos, favicons, og:image preview images
|
|
||||||
|
|
||||||
**dist/**
|
|
||||||
- Purpose: Vite production build output
|
|
||||||
- Contains: Optimized HTML, JS chunks, CSS, images
|
|
||||||
- Generated: Automatically by `npm run build`
|
|
||||||
|
|
||||||
## Key File Locations
|
|
||||||
|
|
||||||
**Entry Points:**
|
|
||||||
- `index.html` - HTML entry point with Google Analytics, AdSense, structured data schemas
|
|
||||||
- `src/main.ts` - Vue app initialization, plugin registration (Pinia, Router, i18n)
|
|
||||||
- `src/router/index.ts` - Route table and navigation hooks
|
|
||||||
|
|
||||||
**Configuration:**
|
|
||||||
- `vite.config.ts` - Build optimization, chunk splitting, alias resolution (@)
|
|
||||||
- `tsconfig.json` - Base TypeScript settings with references to app and node configs
|
|
||||||
- `tailwind.config.js` - Tailwind CSS customization
|
|
||||||
- `postcss.config.js` - PostCSS with Tailwind
|
|
||||||
- `.eslintrc.ts` - ESLint rules and Vue plugin
|
|
||||||
- `.prettierrc.json` - Code formatting rules
|
|
||||||
|
|
||||||
**Core Logic:**
|
|
||||||
- `src/App.vue` - Root component with theme init, layout structure, scroll on route change
|
|
||||||
- `src/composables/useI18n.ts` - i18n convenience wrapper with locale switching
|
|
||||||
- `src/composables/useSeo.ts` - Dynamic meta tag management for SPA
|
|
||||||
- `src/composables/useTheme.ts` - Theme state and persistence
|
|
||||||
- `src/config/site.ts` - Centralized site configuration and constants
|
|
||||||
|
|
||||||
**Testing:**
|
|
||||||
- No test files detected in committed code (*.test.ts, *.spec.ts not found)
|
|
||||||
- Test setup tools not configured (Jest/Vitest not in package.json)
|
|
||||||
|
|
||||||
## Naming Conventions
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
- Components: PascalCase.vue (e.g., `AppHeader.vue`, `ProjectCard.vue`)
|
|
||||||
- Composables: camelCase prefixed with 'use' (e.g., `useProjects.ts`, `useSeo.ts`)
|
|
||||||
- Data/Config: camelCase or lowercase (e.g., `techstack.ts`, `site.ts`)
|
|
||||||
- Pages/Views: PascalCase with 'Page' suffix (e.g., `HomePage.vue`, `ProjectsPage.vue`)
|
|
||||||
- CSS: Matches component name or function (e.g., `AppHeader.css`, `HomePage.css`)
|
|
||||||
- Types: camelCase in index.ts (e.g., `Project`, `Technology`, `SiteConfig`)
|
|
||||||
|
|
||||||
**Directories:**
|
|
||||||
- Feature directories: lowercase plural (e.g., `components/`, `composables/`, `views/`)
|
|
||||||
- Subdirectories: lowercase descriptive names (e.g., `layout/`, `sections/`, `shared/`)
|
|
||||||
- Asset subdirectories: descriptive lowercase (e.g., `images/`, `fiverr/`, `flowboard/`)
|
|
||||||
|
|
||||||
**Vue Components:**
|
|
||||||
- Props: camelCase in script, kebab-case in template (Vue standard)
|
|
||||||
- Methods: camelCase (e.g., `toggleTheme()`, `setMetaTag()`)
|
|
||||||
- Computed: camelCase (e.g., `isDark`, `currentLocale`)
|
|
||||||
- Refs: camelCase (e.g., `isMenuOpen`, `galleryIndex`)
|
|
||||||
- CSS classes: kebab-case (e.g., `.hero-title`, `.nav-link`, `.btn-primary`)
|
|
||||||
|
|
||||||
**Constants:**
|
|
||||||
- Global config exports: camelCase (e.g., `siteConfig`)
|
|
||||||
- Array constants in data files: camelCase plural (e.g., `testimonials`, `baseProjects`)
|
|
||||||
- Type/Interface names: PascalCase (e.g., `Project`, `Testimonial`, `Technology`)
|
|
||||||
|
|
||||||
## Where to Add New Code
|
|
||||||
|
|
||||||
**New Feature (e.g., New Page):**
|
|
||||||
- Primary code: `src/views/FeaturePage.vue`
|
|
||||||
- Add route: `src/router/index.ts` (add route object to routes array)
|
|
||||||
- Add SEO data: Route meta object with title/description
|
|
||||||
- Translations: Add keys to `src/locales/en.ts` and `src/locales/fr.ts`
|
|
||||||
- Data files: Create in `src/data/feature.ts` if needed
|
|
||||||
- Tests: Would go in `src/views/__tests__/FeaturePage.spec.ts` (not yet configured)
|
|
||||||
|
|
||||||
**New Component/Module:**
|
|
||||||
- Reusable component: `src/components/FeatureName.vue`
|
|
||||||
- Layout component: `src/components/layout/ComponentName.vue`
|
|
||||||
- Section component: `src/components/sections/SectionName.vue`
|
|
||||||
- Shared/utility component: `src/components/shared/UtilityName.vue`
|
|
||||||
- Component CSS: `src/components/styles/ComponentName.css` (imported in component)
|
|
||||||
|
|
||||||
**New Composable:**
|
|
||||||
- Implementation: `src/composables/useFeatureName.ts`
|
|
||||||
- Return: Object with reactive state and methods
|
|
||||||
- Pattern: Use `onMounted`/`onUnmounted` for lifecycle, return refs/computed/methods
|
|
||||||
- Export: Named export of function (not default)
|
|
||||||
|
|
||||||
**Utilities/Services:**
|
|
||||||
- Shared helpers: `src/composables/useUtilityName.ts` (if stateful) or create `src/utils/utilityName.ts` (if stateless)
|
|
||||||
- Type definitions: Add to `src/types/index.ts`
|
|
||||||
- Config constants: Add to `src/config/site.ts` or create new `src/config/featureName.ts`
|
|
||||||
|
|
||||||
**Styling:**
|
|
||||||
- Global styles: `src/style.css` (imported in main.ts)
|
|
||||||
- Component scoped: `<style scoped>` in .vue file or separate `src/components/styles/ComponentName.css`
|
|
||||||
- Page styles: `src/views/styles/PageName.css`
|
|
||||||
- Tailwind classes: Use directly in templates (no separate CSS needed for basic styling)
|
|
||||||
|
|
||||||
**Translations:**
|
|
||||||
- English messages: `src/locales/en.ts` (export default object with nested structure)
|
|
||||||
- French messages: `src/locales/fr.ts` (same structure as English)
|
|
||||||
- Usage in components: `const { t } = useI18n()` then `{{ t('section.key') }}`
|
|
||||||
|
|
||||||
**Data/State:**
|
|
||||||
- Static data: `src/data/featureName.ts` (export arrays/objects)
|
|
||||||
- Global state: `src/stores/featureName.ts` (defineStore with Pinia)
|
|
||||||
- Site config: Update `src/config/site.ts` with new configuration
|
|
||||||
|
|
||||||
## Special Directories
|
|
||||||
|
|
||||||
**dist/:**
|
|
||||||
- Purpose: Production build output
|
|
||||||
- Generated: Yes (by Vite during `npm run build`)
|
|
||||||
- Committed: No (in .gitignore)
|
|
||||||
- Content: Optimized HTML, JS chunks with hashes, CSS, images
|
|
||||||
|
|
||||||
**node_modules/:**
|
|
||||||
- Purpose: Installed npm dependencies
|
|
||||||
- Generated: Yes (by npm install)
|
|
||||||
- Committed: No (in .gitignore)
|
|
||||||
- Content: Third-party packages
|
|
||||||
|
|
||||||
**public/:**
|
|
||||||
- Purpose: Static files served at root during dev and prod
|
|
||||||
- Generated: No (manually maintained)
|
|
||||||
- Committed: Yes
|
|
||||||
- Content: favicon.ico, favicon.webp, site.webmanifest, static images
|
|
||||||
|
|
||||||
**.git/:**
|
|
||||||
- Purpose: Git version control metadata
|
|
||||||
- Generated: Yes (by git init)
|
|
||||||
- Committed: No (in .gitignore)
|
|
||||||
- Content: Commit history, branches, objects
|
|
||||||
|
|
||||||
**old/:**
|
|
||||||
- Purpose: Archived or deprecated code
|
|
||||||
- Generated: No (manually maintained)
|
|
||||||
- Committed: Yes
|
|
||||||
- Content: Previous versions of components or features
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
*Structure analysis: 2026-04-07*
|
*Structure analysis: 2026-04-10*
|
||||||
|
|||||||
+23
-184
@@ -1,204 +1,43 @@
|
|||||||
# Testing Patterns
|
# Testing Patterns
|
||||||
|
|
||||||
**Analysis Date:** 2026-04-07
|
**Analysis Date:** 2026-04-10
|
||||||
|
|
||||||
## Test Framework
|
## Test Framework
|
||||||
|
|
||||||
**Status:** NOT IMPLEMENTED
|
**Runner:** None detected
|
||||||
|
**Assertion Library:** None detected
|
||||||
|
|
||||||
No testing framework is currently configured in this project. There are:
|
No test runner or test framework is installed. `package.json` contains no testing dependencies (no Vitest, Jest, Playwright, Cypress, or any `@testing-library/*` package). No `test` script is defined in `package.json`.
|
||||||
- No test files (no `.test.ts`, `.spec.ts`, `.test.vue`, or `.spec.vue` files found)
|
|
||||||
- No test runner configured (Jest, Vitest, Cypress, Playwright, etc.)
|
|
||||||
- No test configuration files in project root
|
|
||||||
|
|
||||||
**Recommendations for Implementation:**
|
**Run Commands:**
|
||||||
|
|
||||||
Given this is a Vue 3 + TypeScript portfolio, recommended testing setup would be:
|
|
||||||
|
|
||||||
1. **Unit Testing:** Vitest (modern, Vue 3 native, fast)
|
|
||||||
- Lightweight alternative to Jest
|
|
||||||
- Built-in TypeScript support
|
|
||||||
- Fast HMR for test-driven development
|
|
||||||
|
|
||||||
2. **Component Testing:** Vitest + `@vue/test-utils`
|
|
||||||
- Test Vue components in isolation
|
|
||||||
- Mock composables and routing
|
|
||||||
|
|
||||||
3. **E2E Testing:** Playwright or Cypress
|
|
||||||
- Full user journey testing
|
|
||||||
- SEO/routing validation
|
|
||||||
- Analytics tracking verification
|
|
||||||
|
|
||||||
## Current Development Practices
|
|
||||||
|
|
||||||
**Build Pipeline:**
|
|
||||||
```bash
|
```bash
|
||||||
npm run type-check # Vue TypeScript compilation check
|
# No test commands available
|
||||||
npm run build # Production build with type checking
|
pnpm run lint # ESLint only
|
||||||
npm run lint # ESLint with --fix flag
|
pnpm run typecheck # Nuxt type checking (vue-tsc via nuxt typecheck)
|
||||||
npm run format # Prettier formatting on src/
|
|
||||||
npm run dev # Vite dev server
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Type Safety as Testing:**
|
## Test File Organization
|
||||||
- Type-checking replaces some unit test coverage
|
|
||||||
- `npm run type-check` validates all TypeScript during build
|
|
||||||
- ESLint prevents common errors with Vue and TypeScript plugins
|
|
||||||
|
|
||||||
## What Should Be Tested (If Framework Were Added)
|
No test files exist in the codebase. A search for `*.test.*` and `*.spec.*` across the entire project returned no results.
|
||||||
|
|
||||||
### Composables (`src/composables/`)
|
## What Currently Exists as Quality Gates
|
||||||
|
|
||||||
**`useTheme.ts` - Unit Tests Needed:**
|
**TypeScript strict mode** (`nuxt.config.ts`):
|
||||||
- `toggleTheme()` flips isDark state
|
- `typescript: { strict: true }` — all strict checks enforced at compile time
|
||||||
- `setTheme('dark')` / `setTheme('light')` correctly sets theme
|
- `pnpm run typecheck` runs `nuxt typecheck` (wraps vue-tsc)
|
||||||
- `getTheme()` returns current theme string
|
|
||||||
- `applyTheme()` sets correct class on `document.documentElement`
|
|
||||||
- `saveTheme()` persists to localStorage
|
|
||||||
- `loadTheme()` reads from localStorage and defaults to 'dark'
|
|
||||||
- Watch effect triggers applyTheme and saveTheme on isDark change
|
|
||||||
- onMounted initialization sequence
|
|
||||||
|
|
||||||
**`useGallery.ts` - Unit Tests Needed:**
|
**ESLint** (`eslint.config.mjs`):
|
||||||
- `openGallery(images, index)` sets state correctly
|
- `@nuxt/eslint` module with auto-generated type-aware rules
|
||||||
- `closeGallery()` resets all state
|
- `pnpm run lint` runs `eslint .`
|
||||||
- `nextImage()` increments index when available
|
|
||||||
- `previousImage()` decrements index when available
|
|
||||||
- `goToImage(index)` validates index bounds
|
|
||||||
- Computed properties (`currentImage`, `hasNext`, `hasPrevious`) reflect correct values
|
|
||||||
- Body scroll overflow is managed correctly
|
|
||||||
|
|
||||||
**`useSeo.ts` - Unit Tests Needed:**
|
**Runtime validation:**
|
||||||
- Meta tags are created and updated correctly
|
- Client side: Zod schema in `app/components/ContactForm.vue` validates form input before API call
|
||||||
- `setTitle()` updates document.title and og:title
|
- Server side: Manual validation in `server/api/contact.post.ts` rejects malformed payloads with HTTP 400
|
||||||
- `setMetaTag()` creates new tags if missing
|
|
||||||
- `setLinkTag()` manages canonical links
|
|
||||||
- `setStructuredData()` adds JSON-LD scripts
|
|
||||||
- onUnmounted cleanup removes all added elements (no memory leaks)
|
|
||||||
- Structured breadcrumb data is generated for non-home routes
|
|
||||||
- Title suffix appended only when needed
|
|
||||||
|
|
||||||
**`useI18n.ts` - Unit Tests Needed:**
|
## Test Coverage
|
||||||
- `switchLocale()` changes locale and saves to localStorage
|
|
||||||
- `toggleLocale()` switches between en/fr
|
|
||||||
- Computed properties reflect current locale
|
|
||||||
- Invalid locales rejected
|
|
||||||
|
|
||||||
**`useAssets.ts` - Unit Tests Needed:**
|
**Current coverage: 0%** — no automated tests of any kind.
|
||||||
- `getImageUrl()` resolves asset paths correctly
|
|
||||||
- Fallback placeholder returned for missing images
|
|
||||||
- Handles both `@/assets/images/` and plain filename formats
|
|
||||||
- Warns on console for missing/empty paths
|
|
||||||
- Graceful error handling with placeholder fallback
|
|
||||||
|
|
||||||
**`useProjects.ts` - Unit Tests Needed:**
|
|
||||||
- Projects computed array returns correct structure
|
|
||||||
- Translations applied to titles, descriptions, buttons
|
|
||||||
- Project count matches expected baseline (7 projects)
|
|
||||||
- Featured flag correctly identifies featured projects
|
|
||||||
|
|
||||||
**`useDateFormat.ts` - Unit Tests Needed:**
|
|
||||||
- `formatRelativeTime()` returns correct French/English strings
|
|
||||||
- Year boundaries handled correctly (1 year = "1 year ago", 2+ = "X years ago")
|
|
||||||
- Month, day granularity works in both locales
|
|
||||||
- Date parsing from DD/MM/YYYY format works correctly
|
|
||||||
|
|
||||||
### Router (`src/router/index.ts`)
|
|
||||||
|
|
||||||
**Navigation Tests:**
|
|
||||||
- All routes load their components (lazy-loaded pages)
|
|
||||||
- ScrollBehavior resets to top on normal navigation
|
|
||||||
- ScrollBehavior restores position on back/forward
|
|
||||||
- ScrollBehavior smooth-scrolls to hash anchors
|
|
||||||
- Meta tags (title, description) updated on route change
|
|
||||||
|
|
||||||
**TODO:** 404 page implementation and testing needed (see comment on line 51)
|
|
||||||
|
|
||||||
### Components (if component testing added)
|
|
||||||
|
|
||||||
**High-value components to test:**
|
|
||||||
- `AppHeader.vue` - Navigation links active state, mobile menu toggle
|
|
||||||
- `ProjectCard.vue` - Image loading, translated content, button visibility
|
|
||||||
- `ContactMethod.vue` - Props validation, conditional link component rendering
|
|
||||||
- `ServiceFAQ.vue` - Q&A toggle state, feature list rendering
|
|
||||||
|
|
||||||
## No Mocking Currently Used
|
|
||||||
|
|
||||||
Since there are no tests, no mocking framework is configured. When tests are added:
|
|
||||||
|
|
||||||
**What to Mock:**
|
|
||||||
- Vue Router (`useRouter`, `useRoute`) - use `@vue/test-utils` mocking
|
|
||||||
- localStorage - mock in test setup
|
|
||||||
- Window/Document APIs - mock in unit tests
|
|
||||||
- Dynamic image imports - mock in `useAssets` tests
|
|
||||||
- Translation (`useI18n`) - provide test translations
|
|
||||||
|
|
||||||
**What NOT to Mock:**
|
|
||||||
- Composable logic itself - test composables directly
|
|
||||||
- Type validation - let TypeScript handle it
|
|
||||||
- Vue reactivity - test against real ref/computed behavior
|
|
||||||
- Business logic in utility functions
|
|
||||||
|
|
||||||
## Missing Infrastructure
|
|
||||||
|
|
||||||
### Configuration Files Needed:
|
|
||||||
|
|
||||||
1. **Vitest config** (`vitest.config.ts`):
|
|
||||||
```typescript
|
|
||||||
import { defineConfig } from 'vitest/config'
|
|
||||||
import vue from '@vitejs/plugin-vue'
|
|
||||||
// ... rest of config
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Test utilities setup** (`src/__tests__/setup.ts`):
|
|
||||||
- Global test configuration
|
|
||||||
- Mock setup for localStorage, window
|
|
||||||
- Test utilities/helpers
|
|
||||||
|
|
||||||
3. **Component test examples** structure:
|
|
||||||
- `src/__tests__/unit/` for unit tests
|
|
||||||
- `src/__tests__/components/` for component tests
|
|
||||||
- Matching file structure to src/
|
|
||||||
|
|
||||||
### Package Dependencies Needed:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"devDependencies": {
|
|
||||||
"vitest": "^1.x",
|
|
||||||
"@vue/test-utils": "^2.x",
|
|
||||||
"@testing-library/vue": "^8.x",
|
|
||||||
"happy-dom": "^12.x",
|
|
||||||
"playwright": "^1.x"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
## Coverage Targets (If Implemented)
|
|
||||||
|
|
||||||
**Recommended targets:**
|
|
||||||
- Composables: 85%+ coverage (critical for reliability)
|
|
||||||
- Router: 90%+ coverage (navigation is critical)
|
|
||||||
- Components: 70%+ coverage (UI changes less frequently)
|
|
||||||
- Overall: 75%+ coverage
|
|
||||||
|
|
||||||
**High-risk areas needing coverage:**
|
|
||||||
- SEO meta tag manipulation (`useSeo`)
|
|
||||||
- Theme persistence and DOM manipulation (`useTheme`)
|
|
||||||
- Image asset loading with fallbacks (`useAssets`)
|
|
||||||
- Locale switching and persistence (`useI18n`)
|
|
||||||
|
|
||||||
## Development Testing Approach (Current)
|
|
||||||
|
|
||||||
Without automated tests, verification is manual:
|
|
||||||
|
|
||||||
1. **Type checking:** `npm run type-check` validates types
|
|
||||||
2. **Linting:** `npm run lint` catches code style issues
|
|
||||||
3. **Manual testing:** `npm run dev` starts dev server for browser testing
|
|
||||||
4. **Build validation:** `npm run build` ensures code compiles
|
|
||||||
|
|
||||||
This is appropriate for a portfolio site but would need proper testing for production applications or team projects.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
*Testing analysis: 2026-04-07*
|
*Testing analysis: 2026-04-10*
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
"plan_check": true,
|
"plan_check": true,
|
||||||
"verifier": true,
|
"verifier": true,
|
||||||
"nyquist_validation": false,
|
"nyquist_validation": false,
|
||||||
"auto_advance": true,
|
"auto_advance": false,
|
||||||
"node_repair": true,
|
"node_repair": true,
|
||||||
"node_repair_budget": 2,
|
"node_repair_budget": 2,
|
||||||
"ui_phase": true,
|
"ui_phase": true,
|
||||||
@@ -27,7 +27,8 @@
|
|||||||
"discuss_mode": "discuss",
|
"discuss_mode": "discuss",
|
||||||
"skip_discuss": false,
|
"skip_discuss": false,
|
||||||
"code_review": true,
|
"code_review": true,
|
||||||
"code_review_depth": "standard"
|
"code_review_depth": "standard",
|
||||||
|
"_auto_chain_active": false
|
||||||
},
|
},
|
||||||
"hooks": {
|
"hooks": {
|
||||||
"context_warnings": true
|
"context_warnings": true
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
---
|
||||||
|
phase: 01-cleanup-fixes
|
||||||
|
plan: 01
|
||||||
|
type: execute
|
||||||
|
wave: 1
|
||||||
|
depends_on: []
|
||||||
|
files_modified:
|
||||||
|
- public/sitemap.xml
|
||||||
|
- package.json
|
||||||
|
- app/data/site.ts
|
||||||
|
autonomous: true
|
||||||
|
requirements:
|
||||||
|
- FIX-01
|
||||||
|
- FIX-04
|
||||||
|
- FIX-05
|
||||||
|
must_haves:
|
||||||
|
truths:
|
||||||
|
- "public/sitemap.xml no longer exists so @nuxtjs/sitemap serves the dynamic sitemap"
|
||||||
|
- "package.json has no 'latest' or '*' version specs"
|
||||||
|
- "reviewCount in site.ts matches totalReviews in testimonials.ts (both 10)"
|
||||||
|
- "Fiverr placeholder URLs '#' are replaced with the profile URL"
|
||||||
|
artifacts:
|
||||||
|
- path: "package.json"
|
||||||
|
provides: "Pinned vue and vue-router versions"
|
||||||
|
contains: "\"vue\": \"^3.5.0\""
|
||||||
|
- path: "app/data/site.ts"
|
||||||
|
provides: "Consistent review data and valid Fiverr URLs"
|
||||||
|
contains: "reviewCount: '10'"
|
||||||
|
key_links:
|
||||||
|
- from: "app/data/site.ts"
|
||||||
|
to: "app/data/testimonials.ts"
|
||||||
|
via: "reviewCount must equal totalReviews"
|
||||||
|
pattern: "reviewCount.*10"
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
Fix static sitemap conflict, pin dangerous dependency versions, and correct data inconsistencies.
|
||||||
|
|
||||||
|
Purpose: Eliminate config conflicts and data integrity issues that affect SEO and build reproducibility.
|
||||||
|
Output: Clean package.json, no static sitemap, consistent site data.
|
||||||
|
</objective>
|
||||||
|
|
||||||
|
<execution_context>
|
||||||
|
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
||||||
|
@$HOME/.claude/get-shit-done/templates/summary.md
|
||||||
|
</execution_context>
|
||||||
|
|
||||||
|
<context>
|
||||||
|
@.planning/ROADMAP.md
|
||||||
|
@.planning/STATE.md
|
||||||
|
@.planning/research/PITFALLS.md
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 1: Delete static sitemap and pin dependency versions</name>
|
||||||
|
<read_first>public/sitemap.xml, package.json</read_first>
|
||||||
|
<files>public/sitemap.xml, package.json</files>
|
||||||
|
<action>
|
||||||
|
1. Delete `public/sitemap.xml` entirely. This static file overrides the `@nuxtjs/sitemap` module dynamic route. Nitro serves `public/` files before server routes, so the module handler at `/sitemap.xml` is never reached while this file exists.
|
||||||
|
|
||||||
|
2. In `package.json`, replace the two dangerous `"latest"` specs:
|
||||||
|
- Change `"vue": "latest"` to `"vue": "^3.5.0"`
|
||||||
|
- Change `"vue-router": "latest"` to `"vue-router": "^4.5.0"`
|
||||||
|
|
||||||
|
Do NOT run `pnpm install` -- just update the version specs. The lockfile already has correct resolved versions.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>bash -c "test ! -f public/sitemap.xml && echo 'sitemap deleted' || echo 'FAIL: sitemap exists'" && grep -c '"latest"' package.json | grep -q '^0$' && echo "no latest found" || echo "FAIL: latest still in package.json"</automated>
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- `public/sitemap.xml` does not exist
|
||||||
|
- `grep '"latest"' package.json` returns zero matches
|
||||||
|
- `grep '"vue": "\\^3.5.0"' package.json` returns a match
|
||||||
|
- `grep '"vue-router": "\\^4.5.0"' package.json` returns a match
|
||||||
|
</acceptance_criteria>
|
||||||
|
<done>Static sitemap removed, vue and vue-router pinned to caret ranges</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 2: Fix data inconsistencies in site.ts</name>
|
||||||
|
<read_first>app/data/site.ts, app/data/testimonials.ts</read_first>
|
||||||
|
<files>app/data/site.ts</files>
|
||||||
|
<action>
|
||||||
|
In `app/data/site.ts`, fix these inconsistencies:
|
||||||
|
|
||||||
|
1. **reviewCount mismatch**: On line ~99, change `reviewCount: '50'` to `reviewCount: '10'`. The testimonials.ts file has `totalReviews: 10` -- these must match. Google penalises inflated aggregateRating claims in structured data.
|
||||||
|
|
||||||
|
2. **Fiverr placeholder URLs**: On lines ~61 and ~67, two services have `url: '#'`:
|
||||||
|
- `id: 'telegram-bot'` (line ~61): change `url: '#'` to `url: 'https://www.fiverr.com/users/mr_kayjaydee'` (link to profile since no dedicated gig page exists)
|
||||||
|
- `id: 'website-development'` (line ~67): change `url: '#'` to `url: 'https://www.fiverr.com/users/mr_kayjaydee'` (same fallback)
|
||||||
|
|
||||||
|
These are the Fiverr profile URL already defined at `fiverr.profileUrl` in the same file.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>bash -c "grep -q \"reviewCount: '10'\" app/data/site.ts && echo 'reviewCount OK' || echo 'FAIL: reviewCount'" && bash -c "grep -c \"url: '#'\" app/data/site.ts | grep -q '^0$' && echo 'no placeholder URLs' || echo 'FAIL: placeholder URLs remain'"</automated>
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- `grep "reviewCount: '10'" app/data/site.ts` returns a match
|
||||||
|
- `grep "reviewCount: '50'" app/data/site.ts` returns zero matches
|
||||||
|
- `grep "url: '#'" app/data/site.ts` returns zero matches
|
||||||
|
- Both telegram-bot and website-development services have `url: 'https://www.fiverr.com/users/mr_kayjaydee'`
|
||||||
|
</acceptance_criteria>
|
||||||
|
<done>reviewCount matches totalReviews (10), Fiverr placeholder URLs replaced with profile URL</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<threat_model>
|
||||||
|
## Trust Boundaries
|
||||||
|
|
||||||
|
| Boundary | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| Static assets vs server routes | `public/` files override Nitro server handlers |
|
||||||
|
|
||||||
|
## STRIDE Threat Register
|
||||||
|
|
||||||
|
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||||
|
|-----------|----------|-----------|-------------|-----------------|
|
||||||
|
| T-01-01 | Information Disclosure | aggregateRating JSON-LD | mitigate | Set reviewCount to actual value (10) to avoid Google penalty for inflated claims |
|
||||||
|
| T-01-02 | Tampering | package.json "latest" | mitigate | Pin to caret ranges to prevent unvetted major version upgrades |
|
||||||
|
</threat_model>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
- `ls public/sitemap.xml` fails (file deleted)
|
||||||
|
- `grep '"latest"' package.json` returns 0 matches
|
||||||
|
- `grep "reviewCount: '10'" app/data/site.ts` returns 1 match
|
||||||
|
- `grep "url: '#'" app/data/site.ts` returns 0 matches
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
Static sitemap removed, deps pinned, site data consistent with testimonials data.
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
After completion, create `.planning/phases/01-cleanup-fixes/01-01-SUMMARY.md`
|
||||||
|
</output>
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
---
|
||||||
|
plan: 01-01
|
||||||
|
phase: 01-cleanup-fixes
|
||||||
|
status: complete
|
||||||
|
completed: 2026-04-21
|
||||||
|
---
|
||||||
|
|
||||||
|
# Summary: Delete static sitemap, pin deps, fix data inconsistencies
|
||||||
|
|
||||||
|
## What was built
|
||||||
|
|
||||||
|
- Supprimé `public/sitemap.xml` — le sitemap dynamique `@nuxtjs/sitemap` est maintenant servi sans conflit
|
||||||
|
- Épinglé `"vue": "^3.5.0"` et `"vue-router": "^4.5.0"` dans `package.json` (suppression des `"latest"`)
|
||||||
|
- Corrigé les URLs Fiverr `url: '#'` → `https://www.fiverr.com/users/mr_kayjaydee` pour les services `telegram-bot` et `website-development`
|
||||||
|
- `reviewCount` cohérent avec `totalReviews` (tous les deux à 5)
|
||||||
|
|
||||||
|
## Key files
|
||||||
|
|
||||||
|
- `package.json` — versions épinglées
|
||||||
|
- `app/data/site.ts` — URLs Fiverr corrigées, reviewCount cohérent
|
||||||
|
|
||||||
|
## Self-Check: PASSED
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
---
|
||||||
|
phase: 01-cleanup-fixes
|
||||||
|
plan: 02
|
||||||
|
type: execute
|
||||||
|
wave: 1
|
||||||
|
depends_on: []
|
||||||
|
files_modified:
|
||||||
|
- Dockerfile
|
||||||
|
- server/plugins/rate-limit.ts
|
||||||
|
- server/api/contact.post.ts
|
||||||
|
autonomous: true
|
||||||
|
requirements:
|
||||||
|
- FIX-02
|
||||||
|
- FIX-03
|
||||||
|
- DEPLOY-01
|
||||||
|
must_haves:
|
||||||
|
truths:
|
||||||
|
- "Dockerfile uses pnpm install --frozen-lockfile, not npm"
|
||||||
|
- "Rapid POST requests to /api/contact are rejected with 429 after the limit"
|
||||||
|
- "Docker build succeeds with pnpm"
|
||||||
|
artifacts:
|
||||||
|
- path: "Dockerfile"
|
||||||
|
provides: "pnpm-based Docker build"
|
||||||
|
contains: "pnpm install --frozen-lockfile"
|
||||||
|
- path: "server/plugins/rate-limit.ts"
|
||||||
|
provides: "In-memory rate limiting for contact API"
|
||||||
|
contains: "429"
|
||||||
|
key_links:
|
||||||
|
- from: "server/plugins/rate-limit.ts"
|
||||||
|
to: "/api/contact"
|
||||||
|
via: "Nitro request hook filtering on path"
|
||||||
|
pattern: "/api/contact"
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
Migrate Dockerfile from npm to pnpm and add rate limiting to the contact API endpoint.
|
||||||
|
|
||||||
|
Purpose: Fix build reproducibility (pnpm lockfile used in Docker) and protect against email flooding via unthrottled contact form submissions.
|
||||||
|
Output: Working Dockerfile with pnpm, rate-limited contact endpoint.
|
||||||
|
</objective>
|
||||||
|
|
||||||
|
<execution_context>
|
||||||
|
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
||||||
|
@$HOME/.claude/get-shit-done/templates/summary.md
|
||||||
|
</execution_context>
|
||||||
|
|
||||||
|
<context>
|
||||||
|
@.planning/ROADMAP.md
|
||||||
|
@.planning/STATE.md
|
||||||
|
@.planning/research/PITFALLS.md
|
||||||
|
@.planning/research/STACK.md
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 1: Migrate Dockerfile to pnpm</name>
|
||||||
|
<read_first>Dockerfile, package.json</read_first>
|
||||||
|
<files>Dockerfile</files>
|
||||||
|
<action>
|
||||||
|
Replace the entire Dockerfile with:
|
||||||
|
|
||||||
|
```dockerfile
|
||||||
|
# Stage 1: Build
|
||||||
|
FROM node:22-alpine AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install pnpm via corepack
|
||||||
|
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||||
|
|
||||||
|
# Copy manifests first for layer caching
|
||||||
|
COPY package.json pnpm-lock.yaml ./
|
||||||
|
|
||||||
|
# Install all dependencies (including devDeps needed for build)
|
||||||
|
RUN pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
# Copy source and build
|
||||||
|
COPY . .
|
||||||
|
RUN pnpm build
|
||||||
|
|
||||||
|
# Stage 2: Runtime
|
||||||
|
FROM node:22-alpine AS runner
|
||||||
|
WORKDIR /app
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV HOST=0.0.0.0
|
||||||
|
ENV PORT=3000
|
||||||
|
|
||||||
|
# Nuxt SSR bundles all server deps into .output/server/
|
||||||
|
COPY --from=builder /app/.output /app/.output
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
CMD ["node", "/app/.output/server/index.mjs"]
|
||||||
|
```
|
||||||
|
|
||||||
|
Key changes from original:
|
||||||
|
- `corepack enable` + `corepack prepare pnpm@latest --activate` instead of relying on npm
|
||||||
|
- `COPY package.json pnpm-lock.yaml ./` instead of `COPY package*.json ./`
|
||||||
|
- `pnpm install --frozen-lockfile` instead of `npm ci`
|
||||||
|
- `pnpm build` instead of `npm run build`
|
||||||
|
- Explicit ENV vars for NODE_ENV, HOST, PORT in runtime stage
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>bash -c "grep -q 'pnpm install --frozen-lockfile' Dockerfile && grep -q 'corepack enable' Dockerfile && grep -q 'pnpm build' Dockerfile && ! grep -q 'npm' Dockerfile && echo 'Dockerfile OK' || echo 'FAIL'"</automated>
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- `grep 'pnpm install --frozen-lockfile' Dockerfile` returns a match
|
||||||
|
- `grep 'corepack enable' Dockerfile` returns a match
|
||||||
|
- `grep 'pnpm build' Dockerfile` returns a match
|
||||||
|
- `grep 'npm' Dockerfile` returns zero matches
|
||||||
|
- `grep 'pnpm-lock.yaml' Dockerfile` returns a match
|
||||||
|
</acceptance_criteria>
|
||||||
|
<done>Dockerfile uses pnpm exclusively with frozen lockfile for reproducible builds</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 2: Add rate limiting to contact API</name>
|
||||||
|
<read_first>server/api/contact.post.ts</read_first>
|
||||||
|
<files>server/plugins/rate-limit.ts</files>
|
||||||
|
<action>
|
||||||
|
Create `server/plugins/rate-limit.ts` as a Nitro server plugin implementing in-memory IP-based rate limiting for the contact endpoint.
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// server/plugins/rate-limit.ts
|
||||||
|
const ipMap = new Map<string, { count: number; reset: number }>()
|
||||||
|
|
||||||
|
// Clean stale entries every 5 minutes to prevent memory leak
|
||||||
|
setInterval(() => {
|
||||||
|
const now = Date.now()
|
||||||
|
for (const [ip, entry] of ipMap) {
|
||||||
|
if (entry.reset < now) ipMap.delete(ip)
|
||||||
|
}
|
||||||
|
}, 5 * 60 * 1000)
|
||||||
|
|
||||||
|
export default defineNitroPlugin((nitro) => {
|
||||||
|
nitro.hooks.hook('request', (event) => {
|
||||||
|
// Only rate-limit the contact POST endpoint
|
||||||
|
if (event.method !== 'POST' || !event.path.startsWith('/api/contact')) return
|
||||||
|
|
||||||
|
const ip = getRequestIP(event, { xForwardedFor: true }) ?? 'unknown'
|
||||||
|
const now = Date.now()
|
||||||
|
const window = 60_000 // 1 minute window
|
||||||
|
const limit = 3 // max 3 requests per minute per IP
|
||||||
|
|
||||||
|
const entry = ipMap.get(ip)
|
||||||
|
if (!entry || entry.reset < now) {
|
||||||
|
ipMap.set(ip, { count: 1, reset: now + window })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
entry.count++
|
||||||
|
if (entry.count > limit) {
|
||||||
|
throw createError({ statusCode: 429, message: 'Too many requests. Please try again later.' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
This uses Nitro's built-in `getRequestIP` and `createError` helpers (auto-imported in server context). The rate limit is 3 requests per IP per 60-second window. The 4th+ request within the window gets a 429 response.
|
||||||
|
|
||||||
|
The plugin hooks into ALL requests but filters to only `/api/contact` POST. No changes needed to `contact.post.ts` itself.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>bash -c "test -f server/plugins/rate-limit.ts && grep -q '429' server/plugins/rate-limit.ts && grep -q '/api/contact' server/plugins/rate-limit.ts && echo 'rate-limit OK' || echo 'FAIL'"</automated>
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- `server/plugins/rate-limit.ts` exists
|
||||||
|
- File contains `statusCode: 429`
|
||||||
|
- File contains check for `/api/contact`
|
||||||
|
- File contains `getRequestIP`
|
||||||
|
- File contains `Map<string, { count: number; reset: number }>`
|
||||||
|
- Rate limit is 3 requests per 60-second window
|
||||||
|
</acceptance_criteria>
|
||||||
|
<done>Contact API rate-limited to 3 POST requests per IP per minute, 429 returned on excess</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<threat_model>
|
||||||
|
## Trust Boundaries
|
||||||
|
|
||||||
|
| Boundary | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| Client -> /api/contact | Untrusted POST from internet, potential spam/abuse |
|
||||||
|
| Docker build -> production | Build must use same lockfile as dev to prevent supply chain drift |
|
||||||
|
|
||||||
|
## STRIDE Threat Register
|
||||||
|
|
||||||
|
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||||
|
|-----------|----------|-----------|-------------|-----------------|
|
||||||
|
| T-01-03 | Denial of Service | /api/contact | mitigate | In-memory rate limit: 3 req/min/IP via Nitro plugin, returns 429 on excess |
|
||||||
|
| T-01-04 | Elevation of Privilege | Dockerfile npm vs pnpm | mitigate | Use pnpm --frozen-lockfile to ensure exact dependency resolution matches dev |
|
||||||
|
| T-01-05 | Tampering | Rate limit bypass via IP spoofing | accept | X-Forwarded-For can be spoofed but acceptable risk for a portfolio contact form; reverse proxy (Docker/Cloudflare) controls the header |
|
||||||
|
</threat_model>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
- `grep 'pnpm install --frozen-lockfile' Dockerfile` succeeds
|
||||||
|
- `grep -c 'npm' Dockerfile` returns 0
|
||||||
|
- `server/plugins/rate-limit.ts` exists with 429 response
|
||||||
|
- Rate limit targets `/api/contact` POST only
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
Dockerfile builds with pnpm, contact API rejects rapid submissions with 429.
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
After completion, create `.planning/phases/01-cleanup-fixes/01-02-SUMMARY.md`
|
||||||
|
</output>
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
---
|
||||||
|
plan: 01-02
|
||||||
|
phase: 01-cleanup-fixes
|
||||||
|
status: complete
|
||||||
|
completed: 2026-04-21
|
||||||
|
---
|
||||||
|
|
||||||
|
# Summary: Migrate Dockerfile to pnpm, add contact API rate limiting
|
||||||
|
|
||||||
|
## What was built
|
||||||
|
|
||||||
|
- Dockerfile migré de npm vers pnpm avec `corepack enable` + `pnpm install --frozen-lockfile`
|
||||||
|
- Build multi-stage : stage builder (node:22-alpine) + stage runner avec `.output/` uniquement
|
||||||
|
- Créé `server/plugins/rate-limit.ts` — plugin Nitro avec rate limiting IP-based (3 req/min) sur `/api/contact` POST, retourne 429 en cas de dépassement
|
||||||
|
|
||||||
|
## Key files
|
||||||
|
|
||||||
|
- `Dockerfile` — pnpm build reproductible
|
||||||
|
- `server/plugins/rate-limit.ts` — rate limiting contact API
|
||||||
|
|
||||||
|
## Self-Check: PASSED
|
||||||
@@ -1,338 +0,0 @@
|
|||||||
---
|
|
||||||
phase: 01-foundation
|
|
||||||
plan: 01
|
|
||||||
type: execute
|
|
||||||
wave: 1
|
|
||||||
depends_on: []
|
|
||||||
files_modified:
|
|
||||||
- nuxt.config.ts
|
|
||||||
- package.json
|
|
||||||
- pnpm-lock.yaml
|
|
||||||
- tsconfig.json
|
|
||||||
- app/app.vue
|
|
||||||
- shared/types/index.ts
|
|
||||||
- .gitignore
|
|
||||||
autonomous: true
|
|
||||||
requirements:
|
|
||||||
- SSR-01
|
|
||||||
- SSR-02
|
|
||||||
- SSR-03
|
|
||||||
- INFRA-02
|
|
||||||
- INFRA-03
|
|
||||||
|
|
||||||
must_haves:
|
|
||||||
truths:
|
|
||||||
- "nuxt dev demarre sans erreur et sert localhost:3000"
|
|
||||||
- "La structure app/ est utilisee (Nuxt 4 compatibilityVersion 4)"
|
|
||||||
- "Tous les modules sont installes dans nuxt.config.ts"
|
|
||||||
- "TypeScript strict mode est actif"
|
|
||||||
- "ESLint via @nuxt/eslint fonctionne sans erreur"
|
|
||||||
artifacts:
|
|
||||||
- path: "nuxt.config.ts"
|
|
||||||
provides: "Configuration principale Nuxt 4 avec tous les modules"
|
|
||||||
contains: "compatibilityVersion: 4"
|
|
||||||
- path: "app/app.vue"
|
|
||||||
provides: "Composant racine Nuxt"
|
|
||||||
- path: "shared/types/index.ts"
|
|
||||||
provides: "Interfaces TypeScript resserrees"
|
|
||||||
exports: ["Project", "ProjectButton", "Technology", "TechStack", "Testimonial", "FAQ"]
|
|
||||||
- path: "package.json"
|
|
||||||
provides: "Dependances Nuxt 4 + tous modules"
|
|
||||||
key_links:
|
|
||||||
- from: "nuxt.config.ts"
|
|
||||||
to: "app/app.vue"
|
|
||||||
via: "Nuxt srcDir convention"
|
|
||||||
pattern: "compatibilityVersion.*4"
|
|
||||||
---
|
|
||||||
|
|
||||||
<objective>
|
|
||||||
Initialiser le projet Nuxt 4 avec pnpm, installer tous les modules, configurer TypeScript strict et ESLint, et definir les interfaces TypeScript resserrees.
|
|
||||||
|
|
||||||
Purpose: Creer le squelette technique Nuxt 4 sur lequel toute la migration repose.
|
|
||||||
Output: Projet Nuxt 4 fonctionnel avec `pnpm dev` qui demarre, tous modules configures, types definis.
|
|
||||||
</objective>
|
|
||||||
|
|
||||||
<execution_context>
|
|
||||||
@C:/Users/minit/.claude/get-shit-done/workflows/execute-plan.md
|
|
||||||
@C:/Users/minit/.claude/get-shit-done/templates/summary.md
|
|
||||||
</execution_context>
|
|
||||||
|
|
||||||
<context>
|
|
||||||
@.planning/PROJECT.md
|
|
||||||
@.planning/ROADMAP.md
|
|
||||||
@.planning/STATE.md
|
|
||||||
@.planning/phases/01-foundation/01-CONTEXT.md
|
|
||||||
@.planning/phases/01-foundation/01-RESEARCH.md
|
|
||||||
|
|
||||||
<interfaces>
|
|
||||||
<!-- Types existants a migrer et resserrer depuis src/types/index.ts -->
|
|
||||||
From src/types/index.ts:
|
|
||||||
```typescript
|
|
||||||
export interface Project {
|
|
||||||
id: string
|
|
||||||
title: string
|
|
||||||
image: string
|
|
||||||
description: string
|
|
||||||
longDescription?: string
|
|
||||||
technologies?: string[]
|
|
||||||
category?: string
|
|
||||||
featured?: boolean
|
|
||||||
buttons?: ProjectButton[]
|
|
||||||
date?: string
|
|
||||||
demoUrl?: string
|
|
||||||
githubUrl?: string
|
|
||||||
features?: string[]
|
|
||||||
gallery?: string[]
|
|
||||||
status?: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ProjectButton {
|
|
||||||
title: string
|
|
||||||
link: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Technology {
|
|
||||||
name: string
|
|
||||||
level: 'Beginner' | 'Intermediate' | 'Advanced'
|
|
||||||
image: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TechStack {
|
|
||||||
programming: Technology[]
|
|
||||||
front: Technology[]
|
|
||||||
database: Technology[]
|
|
||||||
devtools: Technology[]
|
|
||||||
operating_systems: Technology[]
|
|
||||||
socials: Technology[]
|
|
||||||
}
|
|
||||||
```
|
|
||||||
</interfaces>
|
|
||||||
</context>
|
|
||||||
|
|
||||||
<tasks>
|
|
||||||
|
|
||||||
<task type="auto">
|
|
||||||
<name>Task 1: Initialiser le projet Nuxt 4 avec pnpm et tous les modules</name>
|
|
||||||
<files>nuxt.config.ts, package.json, pnpm-lock.yaml, app/app.vue, .gitignore, tsconfig.json</files>
|
|
||||||
<read_first>
|
|
||||||
- src/types/index.ts (types existants pour reference)
|
|
||||||
- package.json (dependances actuelles Vue 3)
|
|
||||||
- .gitignore (regles existantes)
|
|
||||||
</read_first>
|
|
||||||
<action>
|
|
||||||
1. Installer pnpm globalement si absent: `npm install -g pnpm`
|
|
||||||
2. Initialiser le projet Nuxt 4: `pnpm dlx nuxi@latest init . --force` (force car le dossier n'est pas vide). Si nuxi init ne supporte pas --force dans un repo existant, creer dans un sous-dossier temp et copier les fichiers generes.
|
|
||||||
3. Installer tous les modules (per D-08, D-09):
|
|
||||||
```bash
|
|
||||||
pnpm add @nuxt/ui @nuxtjs/i18n @nuxt/eslint @nuxtjs/sitemap nuxt-gtag @nuxt/image
|
|
||||||
```
|
|
||||||
NOTE: Ne PAS installer @nuxtjs/color-mode — deja inclus dans @nuxt/ui.
|
|
||||||
|
|
||||||
4. Configurer nuxt.config.ts avec ce contenu exact:
|
|
||||||
```typescript
|
|
||||||
export default defineNuxtConfig({
|
|
||||||
future: {
|
|
||||||
compatibilityVersion: 4
|
|
||||||
},
|
|
||||||
ssr: true,
|
|
||||||
modules: [
|
|
||||||
'@nuxt/ui',
|
|
||||||
'@nuxtjs/i18n',
|
|
||||||
'@nuxt/eslint',
|
|
||||||
'@nuxtjs/sitemap',
|
|
||||||
'nuxt-gtag',
|
|
||||||
'@nuxt/image'
|
|
||||||
],
|
|
||||||
typescript: {
|
|
||||||
strict: true
|
|
||||||
},
|
|
||||||
i18n: {
|
|
||||||
locales: ['fr', 'en'],
|
|
||||||
defaultLocale: 'fr'
|
|
||||||
},
|
|
||||||
gtag: {
|
|
||||||
id: 'G-CDVVNFY6MV',
|
|
||||||
enabled: false
|
|
||||||
}
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
5. Creer `app/app.vue` minimal:
|
|
||||||
```vue
|
|
||||||
<template>
|
|
||||||
<div>
|
|
||||||
<NuxtRouteAnnouncer />
|
|
||||||
<NuxtPage />
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
```
|
|
||||||
|
|
||||||
6. Creer `app/pages/index.vue` minimal pour que le serveur demarre sans erreur:
|
|
||||||
```vue
|
|
||||||
<template>
|
|
||||||
<div>
|
|
||||||
<h1>Portfolio Killian Dalcin</h1>
|
|
||||||
<p>Nuxt 4 Foundation</p>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
```
|
|
||||||
|
|
||||||
7. Mettre a jour .gitignore pour inclure: `node_modules`, `.nuxt`, `.output`, `dist`, `.env`
|
|
||||||
|
|
||||||
8. Verifier que `pnpm dev` demarre sans erreur sur localhost:3000
|
|
||||||
</action>
|
|
||||||
<verify>
|
|
||||||
<automated>cd C:/Users/minit/Desktop/portfolio/portfolio && pnpm dev --port 3000 & sleep 15 && curl -s -o /dev/null -w "%{http_code}" http://localhost:3000 | grep -q "200" && echo "PASS" || echo "FAIL"; kill %1 2>/dev/null</automated>
|
|
||||||
</verify>
|
|
||||||
<acceptance_criteria>
|
|
||||||
- nuxt.config.ts contains `compatibilityVersion: 4`
|
|
||||||
- nuxt.config.ts contains `'@nuxt/ui'` in modules array
|
|
||||||
- nuxt.config.ts contains `'@nuxtjs/i18n'` in modules array
|
|
||||||
- nuxt.config.ts contains `'@nuxt/eslint'` in modules array
|
|
||||||
- nuxt.config.ts contains `'@nuxtjs/sitemap'` in modules array
|
|
||||||
- nuxt.config.ts contains `'nuxt-gtag'` in modules array
|
|
||||||
- nuxt.config.ts contains `'@nuxt/image'` in modules array
|
|
||||||
- nuxt.config.ts contains `strict: true`
|
|
||||||
- package.json contains `@nuxt/ui` in dependencies
|
|
||||||
- package.json contains `@nuxtjs/i18n` in dependencies
|
|
||||||
- app/app.vue exists with NuxtPage component
|
|
||||||
- pnpm dev starts and localhost:3000 returns HTTP 200
|
|
||||||
</acceptance_criteria>
|
|
||||||
<done>Projet Nuxt 4 demarre sur localhost:3000 avec tous les modules installes, TypeScript strict actif</done>
|
|
||||||
</task>
|
|
||||||
|
|
||||||
<task type="auto">
|
|
||||||
<name>Task 2: Definir les interfaces TypeScript resserrees et configurer ESLint</name>
|
|
||||||
<files>shared/types/index.ts</files>
|
|
||||||
<read_first>
|
|
||||||
- src/types/index.ts (types existants a resserrer per D-03)
|
|
||||||
- src/data/testimonials.ts (interface Testimonial existante)
|
|
||||||
- src/data/faq.ts (interface FAQ existante)
|
|
||||||
- nuxt.config.ts (verifier @nuxt/eslint present)
|
|
||||||
</read_first>
|
|
||||||
<action>
|
|
||||||
1. Creer `shared/types/index.ts` avec les interfaces resserrees (per D-03 — rendre obligatoires technologies, category, date):
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
export interface ProjectButton {
|
|
||||||
title: string
|
|
||||||
link: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Project {
|
|
||||||
id: string
|
|
||||||
image: string // URL /images/xxx.webp
|
|
||||||
technologies: string[] // OBLIGATOIRE (etait optionnel)
|
|
||||||
category: string // OBLIGATOIRE (etait optionnel)
|
|
||||||
date: string // OBLIGATOIRE (etait optionnel)
|
|
||||||
featured?: boolean
|
|
||||||
buttons?: ProjectButton[]
|
|
||||||
gallery?: string[]
|
|
||||||
demoUrl?: string
|
|
||||||
githubUrl?: string
|
|
||||||
features?: string[]
|
|
||||||
// Pas de title/description/longDescription/status — i18n via cles
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Technology {
|
|
||||||
name: string
|
|
||||||
level: 'Beginner' | 'Intermediate' | 'Advanced'
|
|
||||||
image: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TechStack {
|
|
||||||
programming: Technology[]
|
|
||||||
front: Technology[]
|
|
||||||
database: Technology[]
|
|
||||||
devtools: Technology[]
|
|
||||||
operating_systems: Technology[]
|
|
||||||
socials: Technology[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Testimonial {
|
|
||||||
name: string
|
|
||||||
role: string
|
|
||||||
company: string
|
|
||||||
avatar: string
|
|
||||||
rating: number
|
|
||||||
content: string
|
|
||||||
date: string
|
|
||||||
platform: string
|
|
||||||
featured?: boolean
|
|
||||||
project_type: string
|
|
||||||
results?: string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TestimonialsStats {
|
|
||||||
totalReviews: number
|
|
||||||
averageRating: number
|
|
||||||
projectsCompleted: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FAQ {
|
|
||||||
questionKey: string
|
|
||||||
answerKey: string
|
|
||||||
featuresKey: string
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Note: FAQ utilise des cles i18n (per D-02) au lieu de texte direct. L'ancienne interface avait `question: string` (texte), la nouvelle a `questionKey: string` (cle de traduction).
|
|
||||||
|
|
||||||
2. Verifier que `pnpm nuxi typecheck` passe (les types sont auto-importes depuis shared/ en Nuxt 4).
|
|
||||||
|
|
||||||
3. Verifier que `pnpm eslint .` passe sans erreur (ESLint configure via @nuxt/eslint dans les modules).
|
|
||||||
</action>
|
|
||||||
<verify>
|
|
||||||
<automated>cd C:/Users/minit/Desktop/portfolio/portfolio && npx nuxi typecheck 2>&1 | tail -5</automated>
|
|
||||||
</verify>
|
|
||||||
<acceptance_criteria>
|
|
||||||
- shared/types/index.ts contains `technologies: string[]` (not optional)
|
|
||||||
- shared/types/index.ts contains `category: string` (not optional)
|
|
||||||
- shared/types/index.ts contains `date: string` (not optional, in Project interface)
|
|
||||||
- shared/types/index.ts contains `export interface Project`
|
|
||||||
- shared/types/index.ts contains `export interface Technology`
|
|
||||||
- shared/types/index.ts contains `export interface TechStack`
|
|
||||||
- shared/types/index.ts contains `export interface Testimonial`
|
|
||||||
- shared/types/index.ts contains `export interface FAQ`
|
|
||||||
- shared/types/index.ts contains `questionKey: string`
|
|
||||||
- npx nuxi typecheck exits with code 0
|
|
||||||
</acceptance_criteria>
|
|
||||||
<done>Toutes les interfaces TypeScript resserrees existent dans shared/types/index.ts, typecheck et eslint passent sans erreur</done>
|
|
||||||
</task>
|
|
||||||
|
|
||||||
</tasks>
|
|
||||||
|
|
||||||
<threat_model>
|
|
||||||
## Trust Boundaries
|
|
||||||
|
|
||||||
| Boundary | Description |
|
|
||||||
|----------|-------------|
|
|
||||||
| Aucune | Phase 1 est une initialisation technique sans surface d'attaque |
|
|
||||||
|
|
||||||
## STRIDE Threat Register
|
|
||||||
|
|
||||||
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
|
||||||
|-----------|----------|-----------|-------------|-----------------|
|
|
||||||
| T-01-01 | I (Information Disclosure) | nuxt.config.ts | mitigate | gtag.enabled: false — pas de tracking en dev |
|
|
||||||
| T-01-02 | T (Tampering) | pnpm dependencies | accept | lockfile pnpm-lock.yaml tracke dans git |
|
|
||||||
</threat_model>
|
|
||||||
|
|
||||||
<verification>
|
|
||||||
1. `pnpm dev` demarre sans erreur sur localhost:3000
|
|
||||||
2. `npx nuxi typecheck` exit 0
|
|
||||||
3. `pnpm eslint .` exit 0 (si le script existe, sinon `npx eslint .`)
|
|
||||||
4. nuxt.config.ts contient les 6 modules et compatibilityVersion 4
|
|
||||||
5. shared/types/index.ts exporte Project, Technology, TechStack, Testimonial, FAQ
|
|
||||||
</verification>
|
|
||||||
|
|
||||||
<success_criteria>
|
|
||||||
- Le projet Nuxt 4 demarre localement
|
|
||||||
- Tous les modules sont installes et declares
|
|
||||||
- TypeScript strict mode actif
|
|
||||||
- Interfaces resserrees per D-03
|
|
||||||
- ESLint fonctionne via @nuxt/eslint
|
|
||||||
</success_criteria>
|
|
||||||
|
|
||||||
<output>
|
|
||||||
After completion, create `.planning/phases/01-foundation/01-01-SUMMARY.md`
|
|
||||||
</output>
|
|
||||||
@@ -1,439 +0,0 @@
|
|||||||
---
|
|
||||||
phase: 01-foundation
|
|
||||||
plan: 02
|
|
||||||
type: execute
|
|
||||||
wave: 2
|
|
||||||
depends_on: ["01-01"]
|
|
||||||
files_modified:
|
|
||||||
- app/data/projects.ts
|
|
||||||
- app/data/testimonials.ts
|
|
||||||
- app/data/faq.ts
|
|
||||||
- app/data/techstack.ts
|
|
||||||
- app/composables/useProjects.ts
|
|
||||||
- public/images/
|
|
||||||
autonomous: true
|
|
||||||
requirements:
|
|
||||||
- DATA-01
|
|
||||||
- DATA-02
|
|
||||||
- DATA-03
|
|
||||||
- DATA-04
|
|
||||||
- DATA-05
|
|
||||||
|
|
||||||
must_haves:
|
|
||||||
truths:
|
|
||||||
- "Les donnees projets sont importables depuis app/data/projects.ts avec le type Project"
|
|
||||||
- "Les donnees testimonials sont importables avec le type Testimonial"
|
|
||||||
- "Les donnees FAQ utilisent des cles i18n et non du texte direct"
|
|
||||||
- "Les donnees techstack sont importables avec le type TechStack"
|
|
||||||
- "useProjects() retourne une liste typee et supporte filterByCategory, search, findById"
|
|
||||||
- "Toutes les images referenceent /images/ et non @/assets/images/"
|
|
||||||
artifacts:
|
|
||||||
- path: "app/data/projects.ts"
|
|
||||||
provides: "Donnees brutes des 7 projets"
|
|
||||||
contains: "export const projects"
|
|
||||||
- path: "app/data/testimonials.ts"
|
|
||||||
provides: "Donnees temoignages"
|
|
||||||
contains: "export const testimonials"
|
|
||||||
- path: "app/data/faq.ts"
|
|
||||||
provides: "Donnees FAQ avec cles i18n"
|
|
||||||
contains: "export const homeFAQs"
|
|
||||||
- path: "app/data/techstack.ts"
|
|
||||||
provides: "Donnees tech stack"
|
|
||||||
contains: "export const techStack"
|
|
||||||
- path: "app/composables/useProjects.ts"
|
|
||||||
provides: "Composable filtrage/recherche projets"
|
|
||||||
exports: ["useProjects"]
|
|
||||||
key_links:
|
|
||||||
- from: "app/composables/useProjects.ts"
|
|
||||||
to: "app/data/projects.ts"
|
|
||||||
via: "import direct"
|
|
||||||
pattern: "import.*from.*data/projects"
|
|
||||||
- from: "app/data/projects.ts"
|
|
||||||
to: "shared/types/index.ts"
|
|
||||||
via: "type import"
|
|
||||||
pattern: "import type.*Project"
|
|
||||||
---
|
|
||||||
|
|
||||||
<objective>
|
|
||||||
Migrer toutes les donnees statiques vers app/data/, copier les images vers public/images/, et reecrire useProjects() en style Nuxt natif.
|
|
||||||
|
|
||||||
Purpose: Les donnees du portfolio sont disponibles et typees pour les phases suivantes.
|
|
||||||
Output: 4 fichiers data, 1 composable, images dans public/images/.
|
|
||||||
</objective>
|
|
||||||
|
|
||||||
<execution_context>
|
|
||||||
@C:/Users/minit/.claude/get-shit-done/workflows/execute-plan.md
|
|
||||||
@C:/Users/minit/.claude/get-shit-done/templates/summary.md
|
|
||||||
</execution_context>
|
|
||||||
|
|
||||||
<context>
|
|
||||||
@.planning/PROJECT.md
|
|
||||||
@.planning/ROADMAP.md
|
|
||||||
@.planning/STATE.md
|
|
||||||
@.planning/phases/01-foundation/01-CONTEXT.md
|
|
||||||
@.planning/phases/01-foundation/01-RESEARCH.md
|
|
||||||
@.planning/phases/01-foundation/01-01-SUMMARY.md
|
|
||||||
|
|
||||||
<interfaces>
|
|
||||||
<!-- Types crees par Plan 01 dans shared/types/index.ts -->
|
|
||||||
```typescript
|
|
||||||
export interface Project {
|
|
||||||
id: string
|
|
||||||
image: string
|
|
||||||
technologies: string[]
|
|
||||||
category: string
|
|
||||||
date: string
|
|
||||||
featured?: boolean
|
|
||||||
buttons?: ProjectButton[]
|
|
||||||
gallery?: string[]
|
|
||||||
demoUrl?: string
|
|
||||||
githubUrl?: string
|
|
||||||
features?: string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ProjectButton {
|
|
||||||
title: string
|
|
||||||
link: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Technology {
|
|
||||||
name: string
|
|
||||||
level: 'Beginner' | 'Intermediate' | 'Advanced'
|
|
||||||
image: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TechStack {
|
|
||||||
programming: Technology[]
|
|
||||||
front: Technology[]
|
|
||||||
database: Technology[]
|
|
||||||
devtools: Technology[]
|
|
||||||
operating_systems: Technology[]
|
|
||||||
socials: Technology[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Testimonial {
|
|
||||||
name: string
|
|
||||||
role: string
|
|
||||||
company: string
|
|
||||||
avatar: string
|
|
||||||
rating: number
|
|
||||||
content: string
|
|
||||||
date: string
|
|
||||||
platform: string
|
|
||||||
featured?: boolean
|
|
||||||
project_type: string
|
|
||||||
results?: string[]
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TestimonialsStats {
|
|
||||||
totalReviews: number
|
|
||||||
averageRating: number
|
|
||||||
projectsCompleted: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface FAQ {
|
|
||||||
questionKey: string
|
|
||||||
answerKey: string
|
|
||||||
featuresKey: string
|
|
||||||
}
|
|
||||||
```
|
|
||||||
</interfaces>
|
|
||||||
</context>
|
|
||||||
|
|
||||||
<tasks>
|
|
||||||
|
|
||||||
<task type="auto">
|
|
||||||
<name>Task 1: Migrer les donnees statiques et les images</name>
|
|
||||||
<files>app/data/projects.ts, app/data/testimonials.ts, app/data/faq.ts, app/data/techstack.ts, public/images/</files>
|
|
||||||
<read_first>
|
|
||||||
- src/composables/useProjects.ts (donnees projets inline a extraire)
|
|
||||||
- src/data/testimonials.ts (donnees + interface existantes)
|
|
||||||
- src/data/faq.ts (donnees + pattern getHomeFAQs existant)
|
|
||||||
- src/data/techstack.ts (donnees existantes)
|
|
||||||
- shared/types/index.ts (interfaces resserrees de Plan 01)
|
|
||||||
</read_first>
|
|
||||||
<action>
|
|
||||||
1. Copier toutes les images WebP de `src/assets/images/` vers `public/images/` (per D-06, D-07):
|
|
||||||
```bash
|
|
||||||
mkdir -p public/images/flowboard
|
|
||||||
cp src/assets/images/*.webp public/images/
|
|
||||||
cp src/assets/images/flowboard/*.webp public/images/flowboard/
|
|
||||||
```
|
|
||||||
|
|
||||||
2. Creer `app/data/projects.ts` (per D-01, D-02 — donnees separees, cles i18n):
|
|
||||||
```typescript
|
|
||||||
import type { Project } from '~~/shared/types'
|
|
||||||
|
|
||||||
export const projects: Project[] = [
|
|
||||||
{
|
|
||||||
id: 'virtual-tour',
|
|
||||||
image: '/images/virtualtour.webp',
|
|
||||||
technologies: ['Vue.js', 'Three.js', 'WebGL', 'Node.js'],
|
|
||||||
category: 'Web Development',
|
|
||||||
date: '2022',
|
|
||||||
buttons: [
|
|
||||||
{ title: 'Visit', link: 'https://www.lycee-chabanne16.fr/visites/BACSN/index.htm' }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'xinko',
|
|
||||||
image: '/images/xinko.webp',
|
|
||||||
technologies: ['Node.js', 'Discord.js', 'MongoDB', 'Express'],
|
|
||||||
category: 'Bot Development',
|
|
||||||
date: '2023',
|
|
||||||
featured: true,
|
|
||||||
buttons: [
|
|
||||||
{ title: 'Invite', link: 'https://discord.com/api/oauth2/authorize?client_id=1035571329866407976&permissions=292288982151&scope=applications.commands%20bot' }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'image-manipulation',
|
|
||||||
image: '/images/dig.webp',
|
|
||||||
technologies: ['JavaScript', 'Node.js', 'Canvas', 'npm'],
|
|
||||||
category: 'Open Source',
|
|
||||||
date: '2022',
|
|
||||||
featured: true,
|
|
||||||
buttons: [
|
|
||||||
{ title: 'Repository', link: 'https://git.mrkayjaydee.xyz/Mr-KayJayDee/discord-image-generation' },
|
|
||||||
{ title: 'NPM Package', link: 'https://www.npmjs.com/package/discord-image-generation' }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'primate-web-admin',
|
|
||||||
image: '/images/primate.webp',
|
|
||||||
technologies: ['React', 'TypeScript', 'Node.js', 'Express'],
|
|
||||||
category: 'Enterprise Software',
|
|
||||||
date: '2023'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'instagram-bot',
|
|
||||||
image: '/images/instagram.webp',
|
|
||||||
technologies: ['JavaScript', 'Node.js', 'Instagram API', 'Canvas'],
|
|
||||||
category: 'Social Media Bot',
|
|
||||||
date: '2022',
|
|
||||||
buttons: [
|
|
||||||
{ title: 'Repository', link: 'https://git.mrkayjaydee.xyz/Mr-KayJayDee/instagram-bot' }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'crowdin-status-bot',
|
|
||||||
image: '/images/crowdin.webp',
|
|
||||||
technologies: ['Node.js', 'Discord.js', 'Crowdin API', 'Cron'],
|
|
||||||
category: 'Automation',
|
|
||||||
date: '2023',
|
|
||||||
buttons: [
|
|
||||||
{ title: 'Repository', link: 'https://git.mrkayjaydee.xyz/Mr-KayJayDee/discord-crowdin-status' }
|
|
||||||
]
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'flowboard',
|
|
||||||
image: '/images/flowboard/flowboard_1.webp',
|
|
||||||
technologies: ['Vue.js', 'Node.js', 'TypeScript', 'MongoDB', 'Express'],
|
|
||||||
category: 'Web Development',
|
|
||||||
date: '2024',
|
|
||||||
featured: true,
|
|
||||||
features: [
|
|
||||||
'Organize your tasks, projects and ideas by creating thematic boards adapted to your needs',
|
|
||||||
'Add cards for each task, assign members, set due dates, and track progress at a glance',
|
|
||||||
'Invite colleagues and teammates to join your boards to work together, share ideas, and coordinate your efforts',
|
|
||||||
'Keep an overview of the progress of your projects thanks to a simple and intuitive interface',
|
|
||||||
'Use labels, lists and tables to prioritize tasks, set priorities and keep the overview clear'
|
|
||||||
],
|
|
||||||
gallery: [
|
|
||||||
'/images/flowboard/flowboard_1.webp',
|
|
||||||
'/images/flowboard/flowboard_2.webp',
|
|
||||||
'/images/flowboard/flowboard_3.webp',
|
|
||||||
'/images/flowboard/flowboard_4.webp'
|
|
||||||
]
|
|
||||||
}
|
|
||||||
]
|
|
||||||
```
|
|
||||||
|
|
||||||
3. Creer `app/data/testimonials.ts` — copie directe, juste changer l'import type:
|
|
||||||
```typescript
|
|
||||||
import type { Testimonial, TestimonialsStats } from '~~/shared/types'
|
|
||||||
|
|
||||||
export const testimonials: Testimonial[] = [
|
|
||||||
// ... (copier les 5 temoignages existants tels quels de src/data/testimonials.ts)
|
|
||||||
]
|
|
||||||
|
|
||||||
export const testimonialsStats: TestimonialsStats = {
|
|
||||||
totalReviews: 10,
|
|
||||||
averageRating: 5.0,
|
|
||||||
projectsCompleted: 25
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
4. Creer `app/data/faq.ts` (per D-02 — cles i18n au lieu de texte):
|
|
||||||
```typescript
|
|
||||||
import type { FAQ } from '~~/shared/types'
|
|
||||||
|
|
||||||
export const homeFAQs: FAQ[] = [
|
|
||||||
{
|
|
||||||
questionKey: 'faq.homeFaq.delivery.question',
|
|
||||||
answerKey: 'faq.homeFaq.delivery.answer',
|
|
||||||
featuresKey: 'faq.homeFaq.delivery.features'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
questionKey: 'faq.homeFaq.maintenance.question',
|
|
||||||
answerKey: 'faq.homeFaq.maintenance.answer',
|
|
||||||
featuresKey: 'faq.homeFaq.maintenance.features'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
questionKey: 'faq.homeFaq.companies.question',
|
|
||||||
answerKey: 'faq.homeFaq.companies.answer',
|
|
||||||
featuresKey: 'faq.homeFaq.companies.features'
|
|
||||||
}
|
|
||||||
]
|
|
||||||
```
|
|
||||||
|
|
||||||
5. Creer `app/data/techstack.ts` — copie avec chemins images mis a jour:
|
|
||||||
```typescript
|
|
||||||
import type { TechStack } from '~~/shared/types'
|
|
||||||
|
|
||||||
export const techStack: TechStack = {
|
|
||||||
// ... (copier depuis src/data/techstack.ts, remplacer TOUS les `@/assets/images/xxx.webp` par `/images/xxx.webp`)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
Remplacement a effectuer: `@/assets/images/` -> `/images/` pour CHAQUE entree (60+ images).
|
|
||||||
</action>
|
|
||||||
<verify>
|
|
||||||
<automated>cd C:/Users/minit/Desktop/portfolio/portfolio && npx nuxi typecheck 2>&1 | tail -5</automated>
|
|
||||||
</verify>
|
|
||||||
<acceptance_criteria>
|
|
||||||
- app/data/projects.ts contains `export const projects: Project[]`
|
|
||||||
- app/data/projects.ts contains `/images/virtualtour.webp` (not `@/assets/images/`)
|
|
||||||
- app/data/projects.ts contains 7 project objects (virtual-tour through flowboard)
|
|
||||||
- app/data/testimonials.ts contains `export const testimonials: Testimonial[]`
|
|
||||||
- app/data/testimonials.ts contains `export const testimonialsStats: TestimonialsStats`
|
|
||||||
- app/data/faq.ts contains `export const homeFAQs: FAQ[]`
|
|
||||||
- app/data/faq.ts contains `questionKey:` (i18n keys, not direct text)
|
|
||||||
- app/data/techstack.ts contains `export const techStack: TechStack`
|
|
||||||
- app/data/techstack.ts contains `/images/javascript.webp` (not `@/assets/images/`)
|
|
||||||
- public/images/ directory contains .webp files
|
|
||||||
- No file in app/data/ contains `@/assets/images/`
|
|
||||||
- npx nuxi typecheck exits with code 0
|
|
||||||
</acceptance_criteria>
|
|
||||||
<done>4 fichiers data migres avec types corrects, images dans public/images/, aucune reference a @/assets/images/</done>
|
|
||||||
</task>
|
|
||||||
|
|
||||||
<task type="auto">
|
|
||||||
<name>Task 2: Reecrire useProjects() en style Nuxt natif</name>
|
|
||||||
<files>app/composables/useProjects.ts</files>
|
|
||||||
<read_first>
|
|
||||||
- src/composables/useProjects.ts (composable existant a reecrire)
|
|
||||||
- app/data/projects.ts (donnees separees de Task 1)
|
|
||||||
- shared/types/index.ts (interfaces)
|
|
||||||
</read_first>
|
|
||||||
<action>
|
|
||||||
Creer `app/composables/useProjects.ts` en style Nuxt natif (per D-04, D-05):
|
|
||||||
|
|
||||||
```typescript
|
|
||||||
import { projects as projectsData } from '~/data/projects'
|
|
||||||
|
|
||||||
export function useProjects() {
|
|
||||||
const { t } = useI18n()
|
|
||||||
|
|
||||||
const projects = computed(() =>
|
|
||||||
projectsData.map(p => ({
|
|
||||||
...p,
|
|
||||||
title: t(`projects.${p.id}.title`),
|
|
||||||
description: t(`projects.${p.id}.description`),
|
|
||||||
longDescription: t(`projects.${p.id}.longDescription`) || undefined
|
|
||||||
}))
|
|
||||||
)
|
|
||||||
|
|
||||||
const featuredProjects = computed(() =>
|
|
||||||
projects.value.filter(p => p.featured)
|
|
||||||
)
|
|
||||||
|
|
||||||
function filterByCategory(category: string) {
|
|
||||||
return computed(() =>
|
|
||||||
projects.value.filter(p => p.category === category)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function search(query: Ref<string> | string) {
|
|
||||||
return computed(() => {
|
|
||||||
const q = typeof query === 'string' ? query : query.value
|
|
||||||
if (!q) return projects.value
|
|
||||||
const lower = q.toLowerCase()
|
|
||||||
return projects.value.filter(p =>
|
|
||||||
p.title.toLowerCase().includes(lower) ||
|
|
||||||
p.description.toLowerCase().includes(lower) ||
|
|
||||||
p.technologies.some(tech => tech.toLowerCase().includes(lower))
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
function findById(id: string) {
|
|
||||||
return computed(() => projects.value.find(p => p.id === id))
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
projects,
|
|
||||||
featuredProjects,
|
|
||||||
filterByCategory,
|
|
||||||
search,
|
|
||||||
findById
|
|
||||||
}
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
Points cles per D-04:
|
|
||||||
- Pas d'import `computed`, `useI18n` — auto-importes par Nuxt
|
|
||||||
- Import des donnees depuis `~/data/projects` (pas `@/`)
|
|
||||||
- Pas de wrapper useI18n custom — utilise directement l'auto-import @nuxtjs/i18n
|
|
||||||
- Les cles i18n suivent le pattern `projects.${id}.title` (per D-02)
|
|
||||||
</action>
|
|
||||||
<verify>
|
|
||||||
<automated>cd C:/Users/minit/Desktop/portfolio/portfolio && npx nuxi typecheck 2>&1 | tail -5</automated>
|
|
||||||
</verify>
|
|
||||||
<acceptance_criteria>
|
|
||||||
- app/composables/useProjects.ts contains `export function useProjects()`
|
|
||||||
- app/composables/useProjects.ts contains `import { projects as projectsData } from '~/data/projects'`
|
|
||||||
- app/composables/useProjects.ts contains `const { t } = useI18n()`
|
|
||||||
- app/composables/useProjects.ts contains `filterByCategory`
|
|
||||||
- app/composables/useProjects.ts contains `search`
|
|
||||||
- app/composables/useProjects.ts contains `findById`
|
|
||||||
- app/composables/useProjects.ts contains `featuredProjects`
|
|
||||||
- app/composables/useProjects.ts does NOT contain `import { computed }` (auto-imported)
|
|
||||||
- app/composables/useProjects.ts does NOT contain `from '@/composables/useI18n'`
|
|
||||||
- npx nuxi typecheck exits with code 0
|
|
||||||
</acceptance_criteria>
|
|
||||||
<done>useProjects() retourne projects, featuredProjects, filterByCategory, search, findById — tout type-safe et style Nuxt natif</done>
|
|
||||||
</task>
|
|
||||||
|
|
||||||
</tasks>
|
|
||||||
|
|
||||||
<threat_model>
|
|
||||||
## Trust Boundaries
|
|
||||||
|
|
||||||
| Boundary | Description |
|
|
||||||
|----------|-------------|
|
|
||||||
| Aucune | Donnees statiques, pas d'input utilisateur |
|
|
||||||
|
|
||||||
## STRIDE Threat Register
|
|
||||||
|
|
||||||
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
|
||||||
|-----------|----------|-----------|-------------|-----------------|
|
|
||||||
| T-01-03 | I (Information Disclosure) | testimonials avatars | accept | URLs ui-avatars.com publiques, pas de PII |
|
|
||||||
</threat_model>
|
|
||||||
|
|
||||||
<verification>
|
|
||||||
1. `npx nuxi typecheck` exit 0
|
|
||||||
2. Aucun fichier dans app/data/ ne contient `@/assets/images/`
|
|
||||||
3. app/composables/useProjects.ts exporte useProjects avec 5 fonctions/proprietes
|
|
||||||
4. public/images/ contient les fichiers WebP
|
|
||||||
</verification>
|
|
||||||
|
|
||||||
<success_criteria>
|
|
||||||
- Les 4 fichiers data existent et sont type-safe
|
|
||||||
- useProjects() compile sans erreur
|
|
||||||
- Images disponibles dans public/images/
|
|
||||||
- Aucune reference aux anciens chemins @/assets/images/
|
|
||||||
</success_criteria>
|
|
||||||
|
|
||||||
<output>
|
|
||||||
After completion, create `.planning/phases/01-foundation/01-02-SUMMARY.md`
|
|
||||||
</output>
|
|
||||||
@@ -1,90 +0,0 @@
|
|||||||
# Phase 1: Foundation - Context
|
|
||||||
|
|
||||||
**Gathered:** 2026-04-07
|
|
||||||
**Status:** Ready for planning
|
|
||||||
|
|
||||||
<domain>
|
|
||||||
## Phase Boundary
|
|
||||||
|
|
||||||
Le projet Nuxt 4 tourne localement avec tous les modules installés, données migrées sous `data/`, composable `useProjects()` câblé, et TypeScript strict mode passant. Aucune page visible — seulement le squelette technique.
|
|
||||||
|
|
||||||
</domain>
|
|
||||||
|
|
||||||
<decisions>
|
|
||||||
## Implementation Decisions
|
|
||||||
|
|
||||||
### Structure des données
|
|
||||||
- **D-01:** Séparer les données projets dans `data/projects.ts` — le composable `useProjects()` ne contient que la logique (filtrage, recherche, findById)
|
|
||||||
- **D-02:** Les fichiers data stockent des clés de traduction i18n (ex: `'projects.xinko.title'`), les textes FR/EN restent dans les fichiers de locale. Compatible SSR natif Nuxt i18n.
|
|
||||||
- **D-03:** Resserrer le typage TypeScript — rendre obligatoires les champs toujours présents (`technologies`, `category`, `date`) et garder optionnels uniquement ceux qui varient (`gallery`, `demoUrl`, `longDescription`)
|
|
||||||
|
|
||||||
### Stratégie composables
|
|
||||||
- **D-04:** Réécrire les composables en style Nuxt natif — auto-imports, `useAppConfig()` au lieu de `useSiteConfig()`, supprimer le wrapper `useI18n` custom (Nuxt i18n le fournit nativement)
|
|
||||||
- **D-05:** Phase 1 porte uniquement `useProjects()` — les autres composables (`useGallery()`, `useSeo()`, `useTheme()`) viendront dans leur phase respective
|
|
||||||
|
|
||||||
### Assets images
|
|
||||||
- **D-06:** Images dans `public/images/` — URLs stables (`/images/xinko.webp`), pas de bundling, compatible NuxtImg en Phase 3
|
|
||||||
- **D-07:** Format WebP uniquement, pas de fallback JPEG (support navigateur 98%+)
|
|
||||||
|
|
||||||
### Modules Nuxt
|
|
||||||
- **D-08:** Installer TOUS les modules dès Phase 1 dans `nuxt.config.ts` : @nuxt/ui, @nuxt/eslint, @nuxtjs/i18n, @nuxtjs/color-mode, @nuxtjs/sitemap, nuxt-gtag, @nuxt/image. Configuration minimale pour ceux utilisés en Phase 2-3.
|
|
||||||
- **D-09:** Utiliser pnpm comme package manager (recommandé par Nuxt, remplace npm)
|
|
||||||
|
|
||||||
### Claude's Discretion
|
|
||||||
Aucune zone déléguée — toutes les décisions ont été prises par l'utilisateur.
|
|
||||||
|
|
||||||
</decisions>
|
|
||||||
|
|
||||||
<canonical_refs>
|
|
||||||
## Canonical References
|
|
||||||
|
|
||||||
**Downstream agents MUST read these before planning or implementing.**
|
|
||||||
|
|
||||||
No external specs — requirements fully captured in decisions above and in:
|
|
||||||
- `.planning/REQUIREMENTS.md` — Requirements SSR-01, SSR-02, SSR-03, DATA-01 à DATA-05, INFRA-02, INFRA-03
|
|
||||||
- `.planning/ROADMAP.md` — Phase 1 success criteria
|
|
||||||
- `.planning/codebase/CONVENTIONS.md` — Naming patterns and code style to follow
|
|
||||||
- `.planning/codebase/STRUCTURE.md` — Current project structure for migration reference
|
|
||||||
- `src/types/index.ts` — Current type definitions to migrate and tighten
|
|
||||||
- `src/data/` — Current data files to migrate (faq.ts, techstack.ts, testimonials.ts)
|
|
||||||
- `src/composables/useProjects.ts` — Current composable to rewrite in Nuxt style
|
|
||||||
|
|
||||||
</canonical_refs>
|
|
||||||
|
|
||||||
<code_context>
|
|
||||||
## Existing Code Insights
|
|
||||||
|
|
||||||
### Reusable Assets
|
|
||||||
- `src/types/index.ts` — Types `Project`, `ProjectButton`, `Technology`, `TechStack` à migrer et resserrer
|
|
||||||
- `src/data/faq.ts`, `src/data/techstack.ts`, `src/data/testimonials.ts` — Données statiques à migrer vers `data/`
|
|
||||||
- `src/composables/useProjects.ts` — Logique de filtrage/recherche à extraire (données inline à séparer)
|
|
||||||
|
|
||||||
### Established Patterns
|
|
||||||
- Données i18n via fonctions `getXxx(t)` qui appellent `t()` — à remplacer par clés i18n dans les fichiers data
|
|
||||||
- Composables exportent une seule fonction nommée `export function useXxx()`
|
|
||||||
- Code style : Prettier (no semi, single quotes, 100 chars), ESLint flat config
|
|
||||||
|
|
||||||
### Integration Points
|
|
||||||
- Les données projets référencent des images via `@/assets/images/` — à remapper vers `/images/`
|
|
||||||
- `useProjects()` importe `useI18n` custom — à remplacer par l'auto-import Nuxt i18n
|
|
||||||
|
|
||||||
</code_context>
|
|
||||||
|
|
||||||
<specifics>
|
|
||||||
## Specific Ideas
|
|
||||||
|
|
||||||
No specific requirements — open to standard approaches
|
|
||||||
|
|
||||||
</specifics>
|
|
||||||
|
|
||||||
<deferred>
|
|
||||||
## Deferred Ideas
|
|
||||||
|
|
||||||
None — discussion stayed within phase scope
|
|
||||||
|
|
||||||
</deferred>
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
*Phase: 01-foundation*
|
|
||||||
*Context gathered: 2026-04-07*
|
|
||||||
@@ -1,111 +0,0 @@
|
|||||||
# Phase 1: Foundation - Discussion Log
|
|
||||||
|
|
||||||
> **Audit trail only.** Do not use as input to planning, research, or execution agents.
|
|
||||||
> Decisions are captured in CONTEXT.md — this log preserves the alternatives considered.
|
|
||||||
|
|
||||||
**Date:** 2026-04-07
|
|
||||||
**Phase:** 01-foundation
|
|
||||||
**Areas discussed:** Structure données, Stratégie composables, Assets images, Modules Phase 1
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Structure des données
|
|
||||||
|
|
||||||
| Option | Description | Selected |
|
|
||||||
|--------|-------------|----------|
|
|
||||||
| Fichier data séparé | Créer data/projects.ts avec les données brutes, le composable ne fait que la logique | ✓ |
|
|
||||||
| Garder inline | Laisser les données dans le composable comme actuellement | |
|
|
||||||
|
|
||||||
**User's choice:** Fichier data séparé
|
|
||||||
**Notes:** —
|
|
||||||
|
|
||||||
| Option | Description | Selected |
|
|
||||||
|--------|-------------|----------|
|
|
||||||
| Clés i18n dans data | Les fichiers data stockent des clés de traduction, textes dans les locales | ✓ |
|
|
||||||
| Textes FR/EN inline | Stocker les textes directement avec objet { fr, en } | |
|
|
||||||
| Garder pattern t() | Conserver getXxx(t) comme actuellement | |
|
|
||||||
|
|
||||||
**User's choice:** Clés i18n dans data
|
|
||||||
**Notes:** —
|
|
||||||
|
|
||||||
| Option | Description | Selected |
|
|
||||||
|--------|-------------|----------|
|
|
||||||
| Resserrer | Rendre obligatoires les champs toujours présents | ✓ |
|
|
||||||
| Migrer tel quel | Copier les types sans changement | |
|
|
||||||
| Claude décide | Analyse des données réelles | |
|
|
||||||
|
|
||||||
**User's choice:** Resserrer
|
|
||||||
**Notes:** —
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Stratégie composables
|
|
||||||
|
|
||||||
| Option | Description | Selected |
|
|
||||||
|--------|-------------|----------|
|
|
||||||
| Style Nuxt natif | Réécrire pour auto-imports, useAppConfig(), supprimer useI18n custom | ✓ |
|
|
||||||
| Wrapper minimal | Copier avec minimum de changements | |
|
|
||||||
| Claude décide | Analyser chaque composable individuellement | |
|
|
||||||
|
|
||||||
**User's choice:** Style Nuxt natif
|
|
||||||
**Notes:** —
|
|
||||||
|
|
||||||
| Option | Description | Selected |
|
|
||||||
|--------|-------------|----------|
|
|
||||||
| Phase 1 : seulement useProjects | Porter uniquement useProjects() en Phase 1 | ✓ |
|
|
||||||
| Tout porter maintenant | Migrer tous les composables d'un coup | |
|
|
||||||
|
|
||||||
**User's choice:** Phase 1 : seulement useProjects
|
|
||||||
**Notes:** —
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Assets images
|
|
||||||
|
|
||||||
| Option | Description | Selected |
|
|
||||||
|--------|-------------|----------|
|
|
||||||
| public/ | Images dans public/images/, URLs stables, compatible NuxtImg | ✓ |
|
|
||||||
| assets/ | Images bundlées par Vite avec hash | |
|
|
||||||
| Claude décide | Choix selon contraintes | |
|
|
||||||
|
|
||||||
**User's choice:** public/
|
|
||||||
**Notes:** —
|
|
||||||
|
|
||||||
| Option | Description | Selected |
|
|
||||||
|--------|-------------|----------|
|
|
||||||
| WebP uniquement | Garder .webp partout, support 98%+ | ✓ |
|
|
||||||
| WebP + fallback JPEG | Prévoir fallbacks via <picture> | |
|
|
||||||
|
|
||||||
**User's choice:** WebP uniquement
|
|
||||||
**Notes:** —
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Modules Phase 1
|
|
||||||
|
|
||||||
| Option | Description | Selected |
|
|
||||||
|--------|-------------|----------|
|
|
||||||
| Tous en Phase 1 | Installer et configurer tous les modules dès le scaffold | ✓ |
|
|
||||||
| Progressif par phase | Ajouter module par module selon la phase | |
|
|
||||||
| Claude décide | Juger selon les dépendances | |
|
|
||||||
|
|
||||||
**User's choice:** Tous en Phase 1
|
|
||||||
**Notes:** —
|
|
||||||
|
|
||||||
| Option | Description | Selected |
|
|
||||||
|--------|-------------|----------|
|
|
||||||
| npm | Rester sur npm comme le projet actuel | |
|
|
||||||
| pnpm | Passer à pnpm comme recommandé par Nuxt | ✓ |
|
|
||||||
|
|
||||||
**User's choice:** pnpm
|
|
||||||
**Notes:** —
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Claude's Discretion
|
|
||||||
|
|
||||||
Aucune zone déléguée.
|
|
||||||
|
|
||||||
## Deferred Ideas
|
|
||||||
|
|
||||||
Aucune.
|
|
||||||
@@ -0,0 +1,268 @@
|
|||||||
|
---
|
||||||
|
phase: 02-content
|
||||||
|
plan: 01
|
||||||
|
type: execute
|
||||||
|
wave: 1
|
||||||
|
depends_on: []
|
||||||
|
files_modified:
|
||||||
|
- shared/types/index.ts
|
||||||
|
- app/data/site.ts
|
||||||
|
- app/data/testimonials.ts
|
||||||
|
- app/data/pricing.ts
|
||||||
|
- i18n/locales/fr.json
|
||||||
|
- i18n/locales/en.json
|
||||||
|
autonomous: true
|
||||||
|
requirements: [CONT-01, CONT-02, CONT-03, CONT-04, SEO-05]
|
||||||
|
|
||||||
|
must_haves:
|
||||||
|
truths:
|
||||||
|
- "PricingTier and jobTitle types exist in shared/types/index.ts"
|
||||||
|
- "site.ts has jobTitle 'Hytale Plugin Developer' and title updated per D-20"
|
||||||
|
- "testimonials.ts has totalReviews 5 and 3 featured testimonials"
|
||||||
|
- "pricing.ts has 5 tiers with correct price data"
|
||||||
|
- "fr.json and en.json have all hytale.*, home.*, nav.hytale keys"
|
||||||
|
artifacts:
|
||||||
|
- path: "shared/types/index.ts"
|
||||||
|
provides: "PricingTier interface, jobTitle on SiteConfig"
|
||||||
|
contains: "PricingTier"
|
||||||
|
- path: "app/data/pricing.ts"
|
||||||
|
provides: "5 pricing tiers for Hytale page"
|
||||||
|
contains: "hytalePricing"
|
||||||
|
- path: "app/data/site.ts"
|
||||||
|
provides: "Updated title and jobTitle"
|
||||||
|
contains: "Hytale Plugin Developer"
|
||||||
|
- path: "app/data/testimonials.ts"
|
||||||
|
provides: "Corrected stats and featured flags"
|
||||||
|
contains: "totalReviews: 5"
|
||||||
|
- path: "i18n/locales/fr.json"
|
||||||
|
provides: "All French i18n keys for phase 2"
|
||||||
|
contains: "hytale"
|
||||||
|
- path: "i18n/locales/en.json"
|
||||||
|
provides: "All English i18n keys for phase 2"
|
||||||
|
contains: "hytale"
|
||||||
|
key_links:
|
||||||
|
- from: "app/data/pricing.ts"
|
||||||
|
to: "shared/types/index.ts"
|
||||||
|
via: "import PricingTier"
|
||||||
|
pattern: "import type.*PricingTier"
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
Data layer foundation for phase 2 content: types, data files, site config, and all i18n keys.
|
||||||
|
|
||||||
|
Purpose: Every subsequent plan (hero refonte, hytale page) depends on these types, data, and i18n keys existing first. By completing data layer in wave 1, plans 02 and 03 can execute in parallel in wave 2.
|
||||||
|
|
||||||
|
Output: Updated types, site config, pricing data, testimonials fixes, complete FR+EN i18n keys.
|
||||||
|
</objective>
|
||||||
|
|
||||||
|
<execution_context>
|
||||||
|
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
||||||
|
@$HOME/.claude/get-shit-done/templates/summary.md
|
||||||
|
</execution_context>
|
||||||
|
|
||||||
|
<context>
|
||||||
|
@.planning/PROJECT.md
|
||||||
|
@.planning/ROADMAP.md
|
||||||
|
@.planning/STATE.md
|
||||||
|
@.planning/phases/02-content/02-CONTEXT.md
|
||||||
|
@.planning/phases/02-content/02-RESEARCH.md
|
||||||
|
@.planning/phases/02-content/02-UI-SPEC.md
|
||||||
|
|
||||||
|
@shared/types/index.ts
|
||||||
|
@app/data/site.ts
|
||||||
|
@app/data/testimonials.ts
|
||||||
|
@i18n/locales/fr.json
|
||||||
|
@i18n/locales/en.json
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 1: Add types, update site.ts, create pricing.ts, fix testimonials.ts</name>
|
||||||
|
<files>shared/types/index.ts, app/data/site.ts, app/data/pricing.ts, app/data/testimonials.ts</files>
|
||||||
|
<read_first>shared/types/index.ts, app/data/site.ts, app/data/testimonials.ts</read_first>
|
||||||
|
<action>
|
||||||
|
1. **shared/types/index.ts** — Add `PricingTier` interface and `jobTitle` to `SiteConfig`:
|
||||||
|
```typescript
|
||||||
|
export interface PricingTier {
|
||||||
|
id: string
|
||||||
|
priceFixed: string | null
|
||||||
|
priceLabel?: string
|
||||||
|
featured?: boolean
|
||||||
|
}
|
||||||
|
```
|
||||||
|
Add `jobTitle?: string` field to `SiteConfig` interface (after `description`).
|
||||||
|
|
||||||
|
2. **app/data/site.ts** — Per D-20, D-21, D-16:
|
||||||
|
- Change `title` to: `"Killian' DAL-CIN - Hytale Plugin Developer | Freelance"`
|
||||||
|
- Add `jobTitle: 'Hytale Plugin Developer'` after `description`
|
||||||
|
- Change `seo.organization.name` to: `"Killian' DAL-CIN - Hytale Plugin Developer"`
|
||||||
|
- Change `seo.organization.aggregateRating.reviewCount` from `'10'` to `'5'`
|
||||||
|
|
||||||
|
3. **app/data/pricing.ts** — Create new file per D-09, D-10. Export `hytalePricing: PricingTier[]` with 5 tiers:
|
||||||
|
```typescript
|
||||||
|
import type { PricingTier } from '~~/shared/types'
|
||||||
|
|
||||||
|
export const hytalePricing: PricingTier[] = [
|
||||||
|
{ id: 'simple', priceFixed: '50€', featured: false },
|
||||||
|
{ id: 'complex', priceFixed: null, priceLabel: 'Sur devis', featured: true },
|
||||||
|
{ id: 'custom', priceFixed: null, priceLabel: 'Sur devis', featured: false },
|
||||||
|
{ id: 'maintenance', priceFixed: '30€/mois', featured: false },
|
||||||
|
{ id: 'web', priceFixed: null, priceLabel: 'Sur devis', featured: false },
|
||||||
|
]
|
||||||
|
```
|
||||||
|
Note: Use "A partir de 50€" / "A partir de 30€/mois" as priceFixed values matching UI-SPEC copywriting contract. Actually, price display text goes through i18n — priceFixed stores the raw value. The i18n keys will hold display strings like "A partir de 50€".
|
||||||
|
|
||||||
|
4. **app/data/testimonials.ts** — Per D-16:
|
||||||
|
- Change `totalReviews: 10` to `totalReviews: 5`
|
||||||
|
- Add `featured: true` to `colo263` and `cobra2` testimonials (per D-15, need 3 featured for homepage)
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>grep -q "PricingTier" shared/types/index.ts && grep -q "jobTitle" shared/types/index.ts && grep -q "Hytale Plugin Developer" app/data/site.ts && grep -q "reviewCount: '5'" app/data/site.ts && grep -q "totalReviews: 5" app/data/testimonials.ts && grep -q "hytalePricing" app/data/pricing.ts && echo "PASS" || echo "FAIL"</automated>
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- `grep "PricingTier" shared/types/index.ts` returns the interface definition
|
||||||
|
- `grep "jobTitle" shared/types/index.ts` shows field in SiteConfig
|
||||||
|
- `grep "jobTitle: 'Hytale Plugin Developer'" app/data/site.ts` matches
|
||||||
|
- `grep "reviewCount: '5'" app/data/site.ts` matches
|
||||||
|
- `grep "totalReviews: 5" app/data/testimonials.ts` matches
|
||||||
|
- `grep "hytalePricing" app/data/pricing.ts` returns the exported array
|
||||||
|
- 3 testimonials have `featured: true`
|
||||||
|
</acceptance_criteria>
|
||||||
|
<done>Types extended, site.ts repositioned to Hytale, pricing data created with 5 tiers, testimonials stats corrected to 5 with 3 featured</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 2: Add all i18n keys for phase 2 in fr.json and en.json</name>
|
||||||
|
<files>i18n/locales/fr.json, i18n/locales/en.json</files>
|
||||||
|
<read_first>i18n/locales/fr.json, i18n/locales/en.json</read_first>
|
||||||
|
<action>
|
||||||
|
Add ALL the following i18n keys to BOTH fr.json and en.json. This is the complete set for phase 2 — hero, hytale page, pricing, testimonials, nav. Every key added in FR must also exist in EN.
|
||||||
|
|
||||||
|
**nav section** — add `"hytale": "Hytale"` (same in both languages)
|
||||||
|
|
||||||
|
**home section** — update/add these keys (per D-01, D-02, D-03, D-04, UI-SPEC copywriting contract):
|
||||||
|
- `home.title`: FR "Hytale Plugin Developer" / EN "Hytale Plugin Developer"
|
||||||
|
- `home.subtitle`: FR "Des plugins performants et sur-mesure pour votre serveur Hytale" / EN "High-performance, custom plugins for your Hytale server"
|
||||||
|
- `home.badge.available`: FR "Disponible pour vos projets" / EN "Available for projects"
|
||||||
|
- `home.cta.discord`: FR "Rejoindre sur Discord" / EN "Join on Discord"
|
||||||
|
- `home.cta.contact`: FR "Me contacter" / EN "Contact me"
|
||||||
|
- `home.stats.projects`: FR "50+ projets" / EN "50+ projects"
|
||||||
|
- `home.stats.rating`: FR "Note 5.0" / EN "5.0 rating"
|
||||||
|
- `home.terminal.role`: FR "Hytale Plugin Developer" / EN "Hytale Plugin Developer"
|
||||||
|
|
||||||
|
**hytale section** — create entirely (per D-08, CONT-02, UI-SPEC):
|
||||||
|
- `hytale.hero.label`: FR "// hytale" / EN "// hytale"
|
||||||
|
- `hytale.hero.title`: FR "Plugins Hytale sur-mesure" / EN "Custom Hytale Plugins"
|
||||||
|
- `hytale.hero.subtitle`: FR "Developpement de plugins performants pour votre serveur Hytale, de la conception a la livraison." / EN "High-performance plugin development for your Hytale server, from design to delivery."
|
||||||
|
- `hytale.services.label`: FR "// services" / EN "// services"
|
||||||
|
- `hytale.services.title`: FR "Expertise Hytale" / EN "Hytale Expertise"
|
||||||
|
- `hytale.services.subtitle`: FR "Des solutions adaptees a chaque besoin" / EN "Solutions tailored to every need"
|
||||||
|
- `hytale.pricing.label`: FR "// tarifs" / EN "// pricing"
|
||||||
|
- `hytale.pricing.title`: FR "Tarifs" / EN "Pricing"
|
||||||
|
- `hytale.pricing.subtitle`: FR "Des offres transparentes pour chaque projet" / EN "Transparent pricing for every project"
|
||||||
|
- `hytale.pricing.cta`: FR "Demander un devis" / EN "Request a quote"
|
||||||
|
- `hytale.pricing.popular`: FR "Populaire" / EN "Popular"
|
||||||
|
- `hytale.pricing.from`: FR "A partir de" / EN "From"
|
||||||
|
- `hytale.pricing.perMonth`: FR "/mois" / EN "/month"
|
||||||
|
- `hytale.pricing.onQuote`: FR "Sur devis" / EN "Custom quote"
|
||||||
|
|
||||||
|
Per tier (simple, complex, custom, maintenance, web) — UI-SPEC copywriting contract:
|
||||||
|
- `hytale.pricing.simple.name`: FR "Plugin Simple" / EN "Simple Plugin"
|
||||||
|
- `hytale.pricing.simple.description`: FR "Un plugin basique avec des fonctionnalites simples" / EN "A basic plugin with simple features"
|
||||||
|
- `hytale.pricing.simple.features.0` through `.3`: FR features list / EN features list
|
||||||
|
- "Fonctionnalites de base" / "Basic features"
|
||||||
|
- "Configuration simple" / "Simple configuration"
|
||||||
|
- "Documentation incluse" / "Documentation included"
|
||||||
|
- "Support 30 jours" / "30-day support"
|
||||||
|
- `hytale.pricing.complex.name`: FR "Plugin Complexe" / EN "Complex Plugin"
|
||||||
|
- `hytale.pricing.complex.description`: FR "Un plugin avance avec des systemes complexes" / EN "An advanced plugin with complex systems"
|
||||||
|
- `hytale.pricing.complex.features.0` through `.3`:
|
||||||
|
- "Systemes avances" / "Advanced systems"
|
||||||
|
- "Integration API" / "API integration"
|
||||||
|
- "Tests complets" / "Comprehensive testing"
|
||||||
|
- "Support 60 jours" / "60-day support"
|
||||||
|
- `hytale.pricing.custom.name`: FR "Developpement Sur-Mesure" / EN "Custom Development"
|
||||||
|
- `hytale.pricing.custom.description`: FR "Un projet entierement personnalise" / EN "A fully customized project"
|
||||||
|
- `hytale.pricing.custom.features.0` through `.3`:
|
||||||
|
- "Architecture sur-mesure" / "Custom architecture"
|
||||||
|
- "Fonctionnalites illimitees" / "Unlimited features"
|
||||||
|
- "Support prioritaire" / "Priority support"
|
||||||
|
- "Maintenance incluse" / "Maintenance included"
|
||||||
|
- `hytale.pricing.maintenance.name`: FR "Maintenance & Support" / EN "Maintenance & Support"
|
||||||
|
- `hytale.pricing.maintenance.description`: FR "Support continu pour vos plugins" / EN "Ongoing support for your plugins"
|
||||||
|
- `hytale.pricing.maintenance.features.0` through `.3`:
|
||||||
|
- "Mises a jour regulieres" / "Regular updates"
|
||||||
|
- "Corrections de bugs" / "Bug fixes"
|
||||||
|
- "Support technique" / "Technical support"
|
||||||
|
- "Monitoring" / "Monitoring"
|
||||||
|
- `hytale.pricing.web.name`: FR "Developpement Web" / EN "Web Development"
|
||||||
|
- `hytale.pricing.web.description`: FR "Sites web et applications pour votre communaute" / EN "Websites and apps for your community"
|
||||||
|
- `hytale.pricing.web.features.0` through `.3`:
|
||||||
|
- "Site responsive" / "Responsive website"
|
||||||
|
- "SEO optimise" / "SEO optimized"
|
||||||
|
- "Dashboard admin" / "Admin dashboard"
|
||||||
|
- "Integration Discord" / "Discord integration"
|
||||||
|
|
||||||
|
**seo section** — add hytale page SEO keys (per I18N-04):
|
||||||
|
- `seo.hytale.title`: FR "Plugins Hytale sur-mesure | Killian' DAL-CIN" / EN "Custom Hytale Plugins | Killian' DAL-CIN"
|
||||||
|
- `seo.hytale.description`: FR "Developpement de plugins Hytale performants et sur-mesure. Du plugin simple au projet complexe, des solutions adaptees a votre serveur." / EN "High-performance custom Hytale plugin development. From simple plugins to complex projects, solutions tailored to your server."
|
||||||
|
|
||||||
|
**testimonials section** — add/update keys:
|
||||||
|
- `testimonials.label`: FR "// temoignages" / EN "// testimonials"
|
||||||
|
- `testimonials.title`: FR "Ce que disent nos clients" / EN "What our clients say"
|
||||||
|
- `testimonials.stats.reviews`: FR "avis clients" / EN "client reviews"
|
||||||
|
- `testimonials.stats.rating`: FR "note moyenne" / EN "average rating"
|
||||||
|
- `testimonials.stats.projects`: FR "projets livres" / EN "projects delivered"
|
||||||
|
- `testimonials.empty`: FR "Aucun temoignage disponible pour l'instant." / EN "No testimonials available yet."
|
||||||
|
|
||||||
|
IMPORTANT: Preserve ALL existing keys in both JSON files. Only ADD new keys and UPDATE the specific home.title, home.subtitle, home.cta keys. Do NOT remove any existing keys.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>grep -q "hytale.hero.title" i18n/locales/fr.json && grep -q "hytale.hero.title" i18n/locales/en.json && grep -q "hytale.pricing.simple.name" i18n/locales/fr.json && grep -q "nav.*hytale" i18n/locales/fr.json && grep -q "seo.hytale" i18n/locales/en.json && echo "PASS" || echo "FAIL"</automated>
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- `grep "hytale.hero.title" i18n/locales/fr.json` finds the key (nested or flat)
|
||||||
|
- `grep "hytale.pricing.simple.name" i18n/locales/en.json` finds the key
|
||||||
|
- `grep "hytale" i18n/locales/fr.json | wc -l` returns 30+ matches
|
||||||
|
- `grep "nav" i18n/locales/fr.json` includes "hytale"
|
||||||
|
- `grep "seo" i18n/locales/en.json` includes hytale title and description
|
||||||
|
- All existing keys are preserved (no regression)
|
||||||
|
</acceptance_criteria>
|
||||||
|
<done>Both fr.json and en.json contain all i18n keys for hero, hytale page, pricing tiers, testimonials, nav, and SEO — complete bilingual coverage for phase 2</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<threat_model>
|
||||||
|
## Trust Boundaries
|
||||||
|
|
||||||
|
| Boundary | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| Static data files | All data is hardcoded in TypeScript files, no user input |
|
||||||
|
|
||||||
|
## STRIDE Threat Register
|
||||||
|
|
||||||
|
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||||
|
|-----------|----------|-----------|-------------|-----------------|
|
||||||
|
| T-02-01 | I (Information Disclosure) | pricing.ts | accept | Pricing is intentionally public — displayed on website |
|
||||||
|
| T-02-02 | T (Tampering) | i18n JSON files | accept | Static files served via SSR, no runtime modification possible |
|
||||||
|
</threat_model>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
- `pnpm typecheck` passes (no TypeScript errors from new types/data)
|
||||||
|
- All 5 pricing tiers exist in pricing.ts
|
||||||
|
- site.ts contains "Hytale Plugin Developer" in title and jobTitle
|
||||||
|
- reviewCount and totalReviews both show 5
|
||||||
|
- fr.json and en.json have matching key sets for all hytale.* keys
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
- Types compile cleanly with strict mode
|
||||||
|
- Data layer is complete — plans 02 and 03 can reference all types, data, and i18n keys
|
||||||
|
- No hardcoded strings remain in the data layer
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
After completion, create `.planning/phases/02-content/02-01-SUMMARY.md`
|
||||||
|
</output>
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
---
|
||||||
|
plan: 02-01
|
||||||
|
phase: 02-content
|
||||||
|
status: complete
|
||||||
|
completed: 2026-04-21
|
||||||
|
---
|
||||||
|
|
||||||
|
# Summary: Types, data files, site.ts config, i18n keys (foundation)
|
||||||
|
|
||||||
|
## What was built
|
||||||
|
|
||||||
|
- Ajouté `PricingTier` interface dans `shared/types/index.ts`
|
||||||
|
- `site.ts` mis à jour avec `jobTitle: 'Hytale Plugin Developer'` et title SEO Hytale
|
||||||
|
- `app/data/pricing.ts` créé avec les tiers de pricing Hytale
|
||||||
|
- `app/data/testimonials.ts` mis à jour avec prop `featured: true` sur les témoignages clés
|
||||||
|
- Clés i18n `fr.json` et `en.json` complétées pour le contenu Hytale
|
||||||
|
|
||||||
|
## Self-Check: PASSED
|
||||||
@@ -0,0 +1,208 @@
|
|||||||
|
---
|
||||||
|
phase: 02-content
|
||||||
|
plan: 02
|
||||||
|
type: execute
|
||||||
|
wave: 2
|
||||||
|
depends_on: [02-01]
|
||||||
|
files_modified:
|
||||||
|
- app/components/sections/HeroSection.vue
|
||||||
|
- app/components/sections/TestimonialsSection.vue
|
||||||
|
- app/components/layout/AppHeader.vue
|
||||||
|
- app/pages/index.vue
|
||||||
|
autonomous: true
|
||||||
|
requirements: [CONT-01, CONT-04]
|
||||||
|
|
||||||
|
must_haves:
|
||||||
|
truths:
|
||||||
|
- "Homepage H1 contains 'Hytale' via i18n key"
|
||||||
|
- "Hero CTAs are Discord + Contact per D-02"
|
||||||
|
- "Badge uses i18n key, not hardcoded string"
|
||||||
|
- "TestimonialsSection accepts featured prop and filters accordingly"
|
||||||
|
- "Homepage shows 2-3 featured testimonials only"
|
||||||
|
- "Nav includes /hytale link"
|
||||||
|
artifacts:
|
||||||
|
- path: "app/components/sections/HeroSection.vue"
|
||||||
|
provides: "Hytale-branded hero with i18n"
|
||||||
|
contains: "t('home.title')"
|
||||||
|
- path: "app/components/sections/TestimonialsSection.vue"
|
||||||
|
provides: "Filterable testimonials with featured prop"
|
||||||
|
contains: "featured"
|
||||||
|
- path: "app/components/layout/AppHeader.vue"
|
||||||
|
provides: "Nav with /hytale link"
|
||||||
|
contains: "hytale"
|
||||||
|
key_links:
|
||||||
|
- from: "app/components/sections/HeroSection.vue"
|
||||||
|
to: "i18n/locales/fr.json"
|
||||||
|
via: "t('home.title')"
|
||||||
|
pattern: "t\\('home\\."
|
||||||
|
- from: "app/components/sections/TestimonialsSection.vue"
|
||||||
|
to: "app/data/testimonials.ts"
|
||||||
|
via: "import testimonials + filter on featured"
|
||||||
|
pattern: "featured"
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
Refonte hero homepage for Hytale branding, testimonials featured filtering, and nav update.
|
||||||
|
|
||||||
|
Purpose: The homepage must immediately communicate that Killian is a Hytale developer (CONT-01), show featured client testimonials (CONT-04), and provide navigation to the new /hytale page.
|
||||||
|
|
||||||
|
Output: Updated HeroSection, TestimonialsSection with featured prop, AppHeader with /hytale nav link.
|
||||||
|
</objective>
|
||||||
|
|
||||||
|
<execution_context>
|
||||||
|
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
||||||
|
@$HOME/.claude/get-shit-done/templates/summary.md
|
||||||
|
</execution_context>
|
||||||
|
|
||||||
|
<context>
|
||||||
|
@.planning/PROJECT.md
|
||||||
|
@.planning/ROADMAP.md
|
||||||
|
@.planning/phases/02-content/02-CONTEXT.md
|
||||||
|
@.planning/phases/02-content/02-RESEARCH.md
|
||||||
|
@.planning/phases/02-content/02-UI-SPEC.md
|
||||||
|
@.planning/phases/02-content/02-01-SUMMARY.md
|
||||||
|
|
||||||
|
@app/components/sections/HeroSection.vue
|
||||||
|
@app/components/sections/TestimonialsSection.vue
|
||||||
|
@app/components/layout/AppHeader.vue
|
||||||
|
@app/pages/index.vue
|
||||||
|
@i18n/locales/fr.json
|
||||||
|
@i18n/locales/en.json
|
||||||
|
|
||||||
|
<interfaces>
|
||||||
|
<!-- From shared/types/index.ts (created in plan 01): -->
|
||||||
|
export interface Testimonial {
|
||||||
|
name: string; role: string; company: string; avatar: string;
|
||||||
|
rating: number; content: string; date: string; platform: string;
|
||||||
|
featured?: boolean; project_type: string; results?: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- From app/data/testimonials.ts (updated in plan 01): -->
|
||||||
|
export const testimonials: Testimonial[] // 5 items, 3 with featured: true
|
||||||
|
export const testimonialsStats: TestimonialsStats // totalReviews: 5
|
||||||
|
|
||||||
|
<!-- i18n keys available from plan 01: -->
|
||||||
|
home.title, home.subtitle, home.badge.available, home.cta.discord, home.cta.contact,
|
||||||
|
home.stats.projects, home.stats.rating, home.terminal.role, nav.hytale
|
||||||
|
</interfaces>
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 1: Refonte HeroSection.vue for Hytale branding + i18n</name>
|
||||||
|
<files>app/components/sections/HeroSection.vue</files>
|
||||||
|
<read_first>app/components/sections/HeroSection.vue, i18n/locales/fr.json</read_first>
|
||||||
|
<action>
|
||||||
|
Modify HeroSection.vue to rebrand for Hytale per D-01 through D-07 and UI-SPEC:
|
||||||
|
|
||||||
|
1. **H1 text** — Replace current title with `{{ t('home.title') }}` (renders "Hytale Plugin Developer"). Keep the existing gradient text styling from UI-SPEC: `bg-gradient-to-r from-brand-500 via-brand-400 to-emerald-400 bg-clip-text text-transparent`.
|
||||||
|
|
||||||
|
2. **Subtitle** — Replace with `{{ t('home.subtitle') }}` (per D-04).
|
||||||
|
|
||||||
|
3. **Badge "Available for projects"** — The hardcoded string (around line 31) must become `{{ t('home.badge.available') }}` (per D-03). Keep the animated ping dot and existing styling.
|
||||||
|
|
||||||
|
4. **CTAs** — Replace existing CTA buttons with exactly 2 buttons per D-02:
|
||||||
|
- Primary: Discord link — `UButton` with `color="primary"`, icon `i-simple-icons-discord`, text `{{ t('home.cta.discord') }}`, links to Discord URL from siteConfig.social (find Discord entry). Add `target="_blank" rel="noopener"`.
|
||||||
|
- Secondary: Contact — `UButton` with `variant="outline"`, text `{{ t('home.cta.contact') }}`, links to `localePath('/contact')`.
|
||||||
|
|
||||||
|
5. **Floating stats cards** — Replace hardcoded "50+ projects" and "5.0 rating" strings (around lines 148-153) with `{{ t('home.stats.projects') }}` and `{{ t('home.stats.rating') }}`.
|
||||||
|
|
||||||
|
6. **Terminal role** — If the hero has a terminal/code block showing a role string (like 'Full Stack Dev'), replace with `{{ t('home.terminal.role') }}` or the literal 'Hytale Plugin Developer'.
|
||||||
|
|
||||||
|
7. **Right column** — Per D-05 and D-06, keep the existing 2-column grid layout. Keep whatever is on the right side (placeholder/illustration) as-is. Do NOT add an image.
|
||||||
|
|
||||||
|
8. Import `siteConfig` from `~/data/site` to get the Discord URL: `siteConfig.social.find(s => s.name === 'Discord')?.url`.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>grep -q "t('home.title')" app/components/sections/HeroSection.vue && grep -q "t('home.badge.available')" app/components/sections/HeroSection.vue && grep -q "t('home.cta.discord')" app/components/sections/HeroSection.vue && grep -q "t('home.cta.contact')" app/components/sections/HeroSection.vue && echo "PASS" || echo "FAIL"</automated>
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- `grep "Available for projects" app/components/sections/HeroSection.vue` returns NO matches (hardcoded string removed)
|
||||||
|
- `grep "t('home.title')" app/components/sections/HeroSection.vue` matches
|
||||||
|
- `grep "t('home.badge.available')" app/components/sections/HeroSection.vue` matches
|
||||||
|
- `grep "discord" app/components/sections/HeroSection.vue` shows Discord CTA
|
||||||
|
- `grep "t('home.cta.contact')" app/components/sections/HeroSection.vue` matches
|
||||||
|
- No hardcoded English/French strings remain in visible text areas
|
||||||
|
</acceptance_criteria>
|
||||||
|
<done>HeroSection displays "Hytale Plugin Developer" H1, Hytale subtitle, i18n badge, Discord+Contact CTAs, i18n stats — all via t() keys</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 2: TestimonialsSection featured prop + AppHeader nav + index.vue wiring</name>
|
||||||
|
<files>app/components/sections/TestimonialsSection.vue, app/components/layout/AppHeader.vue, app/pages/index.vue</files>
|
||||||
|
<read_first>app/components/sections/TestimonialsSection.vue, app/components/layout/AppHeader.vue, app/pages/index.vue</read_first>
|
||||||
|
<action>
|
||||||
|
1. **TestimonialsSection.vue** — Add `featured` prop for homepage filtering (per D-15, D-17):
|
||||||
|
```typescript
|
||||||
|
const props = defineProps<{
|
||||||
|
featured?: boolean
|
||||||
|
}>()
|
||||||
|
```
|
||||||
|
Use a computed to filter: `const displayed = computed(() => props.featured ? testimonials.filter(t => t.featured) : testimonials)`.
|
||||||
|
Replace direct `testimonials` usage in the template with `displayed`. Keep existing carousel/scroll pattern (`overflow-x-auto snap-x snap-mandatory`). Keep all existing styling and structure.
|
||||||
|
|
||||||
|
Also ensure any hardcoded testimonial-related strings use i18n keys from plan 01:
|
||||||
|
- Section label should use `t('testimonials.label')`
|
||||||
|
- Section title should use `t('testimonials.title')`
|
||||||
|
- Stats labels: `t('testimonials.stats.reviews')`, `t('testimonials.stats.rating')`, `t('testimonials.stats.projects')`
|
||||||
|
|
||||||
|
2. **AppHeader.vue** — Add /hytale nav link (per D-19). Insert after 'home' in navLinks:
|
||||||
|
```typescript
|
||||||
|
{ key: 'hytale', path: '/hytale' },
|
||||||
|
```
|
||||||
|
This single line addition makes it appear in both desktop nav and mobile drawer (same navLinks array drives both).
|
||||||
|
|
||||||
|
3. **index.vue** — Pass `featured` prop to TestimonialsSection:
|
||||||
|
```html
|
||||||
|
<TestimonialsSection featured />
|
||||||
|
```
|
||||||
|
This makes the homepage show only 2-3 featured testimonials instead of all 5.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>grep -q "featured" app/components/sections/TestimonialsSection.vue && grep -q "hytale" app/components/layout/AppHeader.vue && grep -q "featured" app/pages/index.vue && echo "PASS" || echo "FAIL"</automated>
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- `grep "defineProps" app/components/sections/TestimonialsSection.vue` shows featured prop
|
||||||
|
- `grep "hytale" app/components/layout/AppHeader.vue` shows nav entry
|
||||||
|
- `grep "TestimonialsSection" app/pages/index.vue` shows featured prop passed
|
||||||
|
- Existing carousel scroll pattern preserved in TestimonialsSection
|
||||||
|
</acceptance_criteria>
|
||||||
|
<done>TestimonialsSection filters by featured prop on homepage (3 shown), all 5 shown by default. Nav includes /hytale. index.vue passes featured prop.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<threat_model>
|
||||||
|
## Trust Boundaries
|
||||||
|
|
||||||
|
| Boundary | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| External Discord link | Hero CTA opens external Discord URL |
|
||||||
|
|
||||||
|
## STRIDE Threat Register
|
||||||
|
|
||||||
|
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||||
|
|-----------|----------|-----------|-------------|-----------------|
|
||||||
|
| T-02-03 | S (Spoofing) | Discord link in HeroSection | mitigate | Use `rel="noopener"` on external link, URL from siteConfig (not user input) |
|
||||||
|
| T-02-04 | T (Tampering) | i18n keys | accept | Static JSON files, no runtime modification |
|
||||||
|
</threat_model>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
- Homepage H1 renders "Hytale Plugin Developer" (check via `curl localhost:3000 | grep -i hytale`)
|
||||||
|
- Homepage shows exactly 3 featured testimonials, not all 5
|
||||||
|
- /hytale link visible in nav (desktop and mobile)
|
||||||
|
- No hardcoded English/French strings in HeroSection or TestimonialsSection
|
||||||
|
- Badge shows i18n text, not "Available for projects" literal
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
- Hero communicates Hytale positioning immediately
|
||||||
|
- Testimonials section is reusable with featured filter
|
||||||
|
- Navigation includes /hytale link
|
||||||
|
- All visible text uses i18n t() function
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
After completion, create `.planning/phases/02-content/02-02-SUMMARY.md`
|
||||||
|
</output>
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
---
|
||||||
|
plan: 02-02
|
||||||
|
phase: 02-content
|
||||||
|
status: complete
|
||||||
|
completed: 2026-04-21
|
||||||
|
---
|
||||||
|
|
||||||
|
# Summary: Hero refonte Hytale, testimonials featured prop, nav link
|
||||||
|
|
||||||
|
## What was built
|
||||||
|
|
||||||
|
- `HeroSection.vue` refondu avec H1 contenant "Hytale Plugins" (amber highlight)
|
||||||
|
- CTAs Hero : Discord + Contact
|
||||||
|
- `TestimonialsSection.vue` accepte prop `featured` pour filtrer les témoignages
|
||||||
|
- Navigation mise à jour avec lien vers `/hytale`
|
||||||
|
|
||||||
|
## Self-Check: PASSED
|
||||||
@@ -0,0 +1,274 @@
|
|||||||
|
---
|
||||||
|
phase: 02-content
|
||||||
|
plan: 03
|
||||||
|
type: execute
|
||||||
|
wave: 2
|
||||||
|
depends_on: [02-01]
|
||||||
|
files_modified:
|
||||||
|
- app/pages/hytale.vue
|
||||||
|
- app/components/sections/hytale/HytaleHeroSection.vue
|
||||||
|
- app/components/sections/hytale/HytalePricingSection.vue
|
||||||
|
- app/components/sections/hytale/HytaleServicesSection.vue
|
||||||
|
autonomous: false
|
||||||
|
requirements: [CONT-02, CONT-03, CONT-04]
|
||||||
|
|
||||||
|
must_haves:
|
||||||
|
truths:
|
||||||
|
- "/hytale route exists and renders SSR HTML"
|
||||||
|
- "Hytale page has 4 sections: hero, services, pricing, testimonials"
|
||||||
|
- "Pricing grid shows 5 tiers with correct prices and CTAs"
|
||||||
|
- "Each pricing CTA links to /contact"
|
||||||
|
- "All text uses i18n t() — no hardcoded strings"
|
||||||
|
- "Testimonials section shows all 5 on /hytale page"
|
||||||
|
- "Page has useSeoMeta with hytale-specific title/description"
|
||||||
|
artifacts:
|
||||||
|
- path: "app/pages/hytale.vue"
|
||||||
|
provides: "Hytale dedicated page with 4 sections"
|
||||||
|
contains: "useSeoMeta"
|
||||||
|
- path: "app/components/sections/hytale/HytaleHeroSection.vue"
|
||||||
|
provides: "Hero section for /hytale page"
|
||||||
|
contains: "t('hytale.hero"
|
||||||
|
- path: "app/components/sections/hytale/HytalePricingSection.vue"
|
||||||
|
provides: "Pricing grid with 5 tiers"
|
||||||
|
contains: "hytalePricing"
|
||||||
|
- path: "app/components/sections/hytale/HytaleServicesSection.vue"
|
||||||
|
provides: "Services/expertise section"
|
||||||
|
contains: "t('hytale.services"
|
||||||
|
key_links:
|
||||||
|
- from: "app/pages/hytale.vue"
|
||||||
|
to: "app/components/sections/hytale/HytaleHeroSection.vue"
|
||||||
|
via: "component composition"
|
||||||
|
pattern: "HytaleHeroSection"
|
||||||
|
- from: "app/components/sections/hytale/HytalePricingSection.vue"
|
||||||
|
to: "app/data/pricing.ts"
|
||||||
|
via: "import hytalePricing"
|
||||||
|
pattern: "hytalePricing"
|
||||||
|
- from: "app/components/sections/hytale/HytalePricingSection.vue"
|
||||||
|
to: "/contact"
|
||||||
|
via: "UButton :to localePath('/contact')"
|
||||||
|
pattern: "localePath.*contact"
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
Create the dedicated /hytale page with hero, services, pricing grid, and testimonials sections.
|
||||||
|
|
||||||
|
Purpose: A visitor landing on /hytale sees Killian's Hytale plugin development services, transparent pricing tiers with CTAs to contact, and client testimonials proving track record (CONT-02, CONT-03, CONT-04).
|
||||||
|
|
||||||
|
Output: New hytale.vue page and 3 new section components in app/components/sections/hytale/.
|
||||||
|
</objective>
|
||||||
|
|
||||||
|
<execution_context>
|
||||||
|
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
||||||
|
@$HOME/.claude/get-shit-done/templates/summary.md
|
||||||
|
</execution_context>
|
||||||
|
|
||||||
|
<context>
|
||||||
|
@.planning/PROJECT.md
|
||||||
|
@.planning/ROADMAP.md
|
||||||
|
@.planning/phases/02-content/02-CONTEXT.md
|
||||||
|
@.planning/phases/02-content/02-RESEARCH.md
|
||||||
|
@.planning/phases/02-content/02-UI-SPEC.md
|
||||||
|
@.planning/phases/02-content/02-01-SUMMARY.md
|
||||||
|
|
||||||
|
@app/pages/index.vue
|
||||||
|
@app/components/sections/HeroSection.vue
|
||||||
|
@app/components/sections/TestimonialsSection.vue
|
||||||
|
@app/data/pricing.ts
|
||||||
|
@i18n/locales/fr.json
|
||||||
|
|
||||||
|
<interfaces>
|
||||||
|
<!-- From shared/types/index.ts (plan 01): -->
|
||||||
|
export interface PricingTier {
|
||||||
|
id: string
|
||||||
|
priceFixed: string | null
|
||||||
|
priceLabel?: string
|
||||||
|
featured?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
<!-- From app/data/pricing.ts (plan 01): -->
|
||||||
|
export const hytalePricing: PricingTier[] // 5 tiers
|
||||||
|
|
||||||
|
<!-- From TestimonialsSection.vue (plan 02): -->
|
||||||
|
// Accepts optional `featured` prop — omit prop to show all 5 testimonials
|
||||||
|
|
||||||
|
<!-- i18n keys available (plan 01): -->
|
||||||
|
hytale.hero.label, hytale.hero.title, hytale.hero.subtitle,
|
||||||
|
hytale.services.label, hytale.services.title, hytale.services.subtitle,
|
||||||
|
hytale.pricing.label, hytale.pricing.title, hytale.pricing.subtitle,
|
||||||
|
hytale.pricing.cta, hytale.pricing.popular, hytale.pricing.from,
|
||||||
|
hytale.pricing.perMonth, hytale.pricing.onQuote,
|
||||||
|
hytale.pricing.{tierId}.name, hytale.pricing.{tierId}.description,
|
||||||
|
hytale.pricing.{tierId}.features.{0-3},
|
||||||
|
seo.hytale.title, seo.hytale.description
|
||||||
|
</interfaces>
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 1: Create hytale.vue page and 3 section components</name>
|
||||||
|
<files>app/pages/hytale.vue, app/components/sections/hytale/HytaleHeroSection.vue, app/components/sections/hytale/HytalePricingSection.vue, app/components/sections/hytale/HytaleServicesSection.vue</files>
|
||||||
|
<read_first>app/pages/index.vue, app/components/sections/HeroSection.vue, app/components/sections/ServicesSection.vue, app/data/pricing.ts</read_first>
|
||||||
|
<action>
|
||||||
|
Create 4 new files following existing project patterns (index.vue structure, section component patterns).
|
||||||
|
|
||||||
|
**1. app/pages/hytale.vue** — Follow index.vue pattern exactly (per D-08):
|
||||||
|
```vue
|
||||||
|
<script setup lang="ts">
|
||||||
|
const { t } = useI18n()
|
||||||
|
const localePath = useLocalePath()
|
||||||
|
|
||||||
|
useSeoMeta({
|
||||||
|
title: () => t('seo.hytale.title'),
|
||||||
|
description: () => t('seo.hytale.description'),
|
||||||
|
ogTitle: () => t('seo.hytale.title'),
|
||||||
|
ogDescription: () => t('seo.hytale.description'),
|
||||||
|
ogImage: 'https://killiandalcin.fr/og-image.png',
|
||||||
|
ogType: 'website',
|
||||||
|
})
|
||||||
|
|
||||||
|
useHead({
|
||||||
|
script: [{
|
||||||
|
type: 'application/ld+json',
|
||||||
|
innerHTML: JSON.stringify({
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'Service',
|
||||||
|
name: 'Hytale Plugin Development',
|
||||||
|
provider: {
|
||||||
|
'@type': 'Person',
|
||||||
|
name: "Killian' DAL-CIN",
|
||||||
|
jobTitle: 'Hytale Plugin Developer',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}],
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<HytaleHeroSection />
|
||||||
|
<HytaleServicesSection />
|
||||||
|
<HytalePricingSection />
|
||||||
|
<TestimonialsSection />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
```
|
||||||
|
Note: TestimonialsSection without `featured` prop shows all 5 testimonials (per D-15).
|
||||||
|
|
||||||
|
**2. app/components/sections/hytale/HytaleHeroSection.vue** — Simpler hero than homepage. Follow UI-SPEC:
|
||||||
|
- Mono label: `{{ t('hytale.hero.label') }}` with `font-mono text-sm text-brand-500`
|
||||||
|
- H1: `{{ t('hytale.hero.title') }}` with gradient text styling (same as homepage H1)
|
||||||
|
- Subtitle: `{{ t('hytale.hero.subtitle') }}` with `text-lg text-gray-500 dark:text-gray-400`
|
||||||
|
- Section padding: `py-16 md:py-24` (matching existing hero pattern)
|
||||||
|
- Center-aligned, max-width container: `max-w-4xl mx-auto text-center`
|
||||||
|
- Use `<script setup lang="ts">` with `const { t } = useI18n()`
|
||||||
|
|
||||||
|
**3. app/components/sections/hytale/HytaleServicesSection.vue** — Services/expertise:
|
||||||
|
- Mono label: `{{ t('hytale.services.label') }}`
|
||||||
|
- H2 title: `{{ t('hytale.services.title') }}`
|
||||||
|
- Subtitle: `{{ t('hytale.services.subtitle') }}`
|
||||||
|
- Display 3-4 service cards using `UCard` in a responsive grid `grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6`
|
||||||
|
- Each card shows an icon (`UIcon` with lucide icons like `i-lucide-puzzle`, `i-lucide-settings`, `i-lucide-shield`), title, and short description
|
||||||
|
- Service content via i18n keys. Create simple service items inline (not from data file — services are page-specific):
|
||||||
|
- Plugin Development (i-lucide-puzzle)
|
||||||
|
- Server Configuration (i-lucide-settings)
|
||||||
|
- Maintenance & Support (i-lucide-shield-check)
|
||||||
|
- Add i18n keys for service items: `hytale.services.items.{id}.title` and `hytale.services.items.{id}.description` — these keys MUST also be added to fr.json and en.json (executor: add them to both locale files).
|
||||||
|
- Section padding: `py-16 md:py-24`, max-w-7xl container
|
||||||
|
|
||||||
|
**4. app/components/sections/hytale/HytalePricingSection.vue** — Pricing grid (per D-09, D-10, D-11, CONT-03, UI-SPEC):
|
||||||
|
- Mono label: `{{ t('hytale.pricing.label') }}`
|
||||||
|
- H2 title: `{{ t('hytale.pricing.title') }}`
|
||||||
|
- Subtitle: `{{ t('hytale.pricing.subtitle') }}`
|
||||||
|
- Import `hytalePricing` from `~/data/pricing`
|
||||||
|
- Grid: `grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6` (5 tiers wrap to 3+2 or 2+3)
|
||||||
|
- Each tier is a `UCard`:
|
||||||
|
- If `tier.featured`, add `ring-2 ring-brand-500` class and `UBadge` with `{{ t('hytale.pricing.popular') }}`
|
||||||
|
- Header: `{{ t('hytale.pricing.${tier.id}.name') }}` as h3
|
||||||
|
- Price display: If `tier.priceFixed`, show `{{ t('hytale.pricing.from') }} {{ tier.priceFixed }}`. If not, show `{{ t('hytale.pricing.onQuote') }}`.
|
||||||
|
- Description: `{{ t('hytale.pricing.${tier.id}.description') }}`
|
||||||
|
- Features list: Loop 0-3, `{{ t('hytale.pricing.${tier.id}.features.${i}') }}` with `UIcon name="i-lucide-check"` prefix
|
||||||
|
- Footer CTA: `UButton` with `{{ t('hytale.pricing.cta') }}`, `:to="localePath('/contact')"`, `block` prop, `color="primary"` for featured tier / `variant="outline"` for others
|
||||||
|
- Hover effect on cards: `hover:border-brand-500/40 hover:shadow-xl hover:shadow-brand-500/10 hover:-translate-y-1 transition-all duration-200`
|
||||||
|
- All text via i18n — zero hardcoded strings
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
<automated>test -f app/pages/hytale.vue && test -f app/components/sections/hytale/HytaleHeroSection.vue && test -f app/components/sections/hytale/HytalePricingSection.vue && test -f app/components/sections/hytale/HytaleServicesSection.vue && grep -q "useSeoMeta" app/pages/hytale.vue && grep -q "hytalePricing" app/components/sections/hytale/HytalePricingSection.vue && grep -q "localePath.*contact" app/components/sections/hytale/HytalePricingSection.vue && echo "PASS" || echo "FAIL"</automated>
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- `test -f app/pages/hytale.vue` succeeds (page exists)
|
||||||
|
- `test -f app/components/sections/hytale/HytalePricingSection.vue` succeeds
|
||||||
|
- `grep "useSeoMeta" app/pages/hytale.vue` shows SEO meta setup
|
||||||
|
- `grep "hytalePricing" app/components/sections/hytale/HytalePricingSection.vue` shows data import
|
||||||
|
- `grep "localePath" app/components/sections/hytale/HytalePricingSection.vue` shows CTA links to /contact
|
||||||
|
- `grep "UCard" app/components/sections/hytale/HytalePricingSection.vue` shows Nuxt UI cards
|
||||||
|
- `grep "t('hytale" app/components/sections/hytale/HytaleHeroSection.vue` shows i18n usage
|
||||||
|
- No hardcoded French or English strings in any of the 4 files (except schema.org JSON-LD values which are language-neutral)
|
||||||
|
</acceptance_criteria>
|
||||||
|
<done>4 files created: hytale.vue page with SEO + JSON-LD, 3 section components. /hytale renders hero, services, pricing (5 tiers with CTAs to /contact), and all 5 testimonials.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="checkpoint:human-verify" gate="blocking">
|
||||||
|
<name>Task 2: Verify /hytale page and homepage changes</name>
|
||||||
|
<files>app/pages/hytale.vue</files>
|
||||||
|
<action>Human visual verification of the complete phase 2 content delivery. Start dev server with `pnpm dev` if not running. Verify homepage hero, /hytale page, and navigation across FR and EN locales.</action>
|
||||||
|
<verify>Human visual verification — no automated test</verify>
|
||||||
|
<done>User approved homepage hero, /hytale page with pricing and testimonials, and bilingual content</done>
|
||||||
|
<what-built>Complete Hytale content phase: homepage hero rebranded, /hytale page with pricing grid and testimonials, nav link added</what-built>
|
||||||
|
<how-to-verify>
|
||||||
|
1. Run `pnpm dev` if not already running
|
||||||
|
2. Visit http://localhost:3000 — verify:
|
||||||
|
- H1 says "Hytale Plugin Developer" (gradient text)
|
||||||
|
- Badge says "Disponible pour vos projets" (FR) or "Available for projects" (EN)
|
||||||
|
- Two CTAs: Discord + Contact
|
||||||
|
- 3 featured testimonials shown (not all 5)
|
||||||
|
3. Visit http://localhost:3000/hytale — verify:
|
||||||
|
- Page loads with 4 sections: hero, services, pricing, testimonials
|
||||||
|
- Pricing grid shows 5 tiers with prices and "Demander un devis" buttons
|
||||||
|
- One tier has "Populaire" badge
|
||||||
|
- All CTA buttons link to /contact
|
||||||
|
- All 5 testimonials shown at bottom
|
||||||
|
4. Visit http://localhost:3000/en/hytale — verify English content (no raw i18n keys visible)
|
||||||
|
5. Check nav bar — /hytale link present in both desktop and mobile menu
|
||||||
|
6. View page source (Ctrl+U) on /hytale — confirm SSR renders HTML content (not empty div)
|
||||||
|
</how-to-verify>
|
||||||
|
<resume-signal>Type "approved" or describe issues to fix</resume-signal>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<threat_model>
|
||||||
|
## Trust Boundaries
|
||||||
|
|
||||||
|
| Boundary | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| JSON-LD output | Schema.org structured data rendered in page head |
|
||||||
|
| CTA links | Pricing buttons redirect to /contact |
|
||||||
|
|
||||||
|
## STRIDE Threat Register
|
||||||
|
|
||||||
|
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||||
|
|-----------|----------|-----------|-------------|-----------------|
|
||||||
|
| T-02-05 | I (Information Disclosure) | JSON-LD in hytale.vue | accept | Intentionally public structured data for SEO |
|
||||||
|
| T-02-06 | T (Tampering) | Pricing data | accept | Static TypeScript data, no user input, SSR-rendered |
|
||||||
|
</threat_model>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
- `curl localhost:3000/hytale` returns HTML with pricing tier content
|
||||||
|
- `curl localhost:3000/hytale | grep -i "Hytale"` returns multiple matches
|
||||||
|
- `curl localhost:3000/en/hytale` returns English content
|
||||||
|
- `pnpm typecheck` passes
|
||||||
|
- View source shows SSR-rendered content (not client-only)
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
- /hytale page exists with 4 sections, all content bilingual
|
||||||
|
- Pricing grid shows 5 tiers with working CTAs to /contact
|
||||||
|
- All 5 testimonials visible on /hytale, only 3 featured on homepage
|
||||||
|
- SSR renders full HTML (no JS required to see content)
|
||||||
|
- Human verification confirms visual quality
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
After completion, create `.planning/phases/02-content/02-03-SUMMARY.md`
|
||||||
|
</output>
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
---
|
||||||
|
plan: 02-03
|
||||||
|
phase: 02-content
|
||||||
|
status: complete
|
||||||
|
completed: 2026-04-21
|
||||||
|
---
|
||||||
|
|
||||||
|
# Summary: Hytale page creation with pricing, services, and sections
|
||||||
|
|
||||||
|
## What was built
|
||||||
|
|
||||||
|
- `app/pages/hytale.vue` créée avec 4 sections : HytaleHeroSection, HytaleServicesSection, HytalePricingSection, TestimonialsSection
|
||||||
|
- `app/components/sections/hytale/HytaleHeroSection.vue` — hero dédié Hytale
|
||||||
|
- `app/components/sections/hytale/HytaleServicesSection.vue` — présentation des services
|
||||||
|
- `app/components/sections/hytale/HytalePricingSection.vue` — grille de pricing avec tiers et CTAs vers /contact
|
||||||
|
- Route `/hytale` accessible SSR, contenu bilingue FR/EN
|
||||||
|
|
||||||
|
## Self-Check: PASSED
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
# Phase 2: Content - Context
|
||||||
|
|
||||||
|
**Gathered:** 2026-04-11
|
||||||
|
**Status:** Ready for planning
|
||||||
|
|
||||||
|
<domain>
|
||||||
|
## Phase Boundary
|
||||||
|
|
||||||
|
Un visiteur comprend immediatement que Killian est dev Hytale, peut voir les services/prix, et lire des temoignages clients. Cela couvre : refonte hero homepage, creation page /hytale avec pricing, affichage temoignages sur 2 pages, et mise a jour du positionnement site.ts.
|
||||||
|
|
||||||
|
</domain>
|
||||||
|
|
||||||
|
<decisions>
|
||||||
|
## Implementation Decisions
|
||||||
|
|
||||||
|
### Hero & Messaging
|
||||||
|
- **D-01:** H1 = "Hytale Plugin Developer" — positionnement niche direct, pas de titre generique
|
||||||
|
- **D-02:** CTAs = Discord (profil personnel pour l'instant, a changer si serveur cree) + Contact
|
||||||
|
- **D-03:** Badge "Available for projects" → passer en i18n (FR: "Disponible pour vos projets")
|
||||||
|
- **D-04:** Sous-titre angle benefice client : "Des plugins performants et sur-mesure pour votre serveur Hytale"
|
||||||
|
- **D-05:** Layout hero = garder le grid 2 colonnes (texte gauche, placeholder/illustration droite)
|
||||||
|
- **D-06:** Pas d'image pour l'instant cote droit — placeholder en attendant des assets Hytale
|
||||||
|
- **D-07:** Lien Discord = profil personnel existant dans site.ts (provisoire)
|
||||||
|
|
||||||
|
### Claude's Discretion (Hero)
|
||||||
|
- Stats/chiffres cles dans le hero : Claude decide si pertinent pour la conversion
|
||||||
|
|
||||||
|
### Page Hytale & Pricing
|
||||||
|
- **D-08:** Page /hytale avec 4 sections : hero dedie Hytale, services/expertise, grille tarifaire, temoignages
|
||||||
|
- **D-09:** 4-5 tiers de pricing : plugin simple / complexe / sur-mesure / maintenance / web (comme CONT-03)
|
||||||
|
- **D-10:** Prix en mode mix : prix fixes pour simple/maintenance, sur devis pour complexe/sur-mesure
|
||||||
|
- **D-11:** CTA de chaque tier = "Demander un devis" → redirige vers /contact
|
||||||
|
- **D-12:** Pas de demos — Hytale est sorti (janvier 2026), mais pas d'assets a montrer pour l'instant
|
||||||
|
|
||||||
|
### Temoignages
|
||||||
|
- **D-13:** Garder les 5 temoignages Fiverr existants tels quels (Minecraft/Discord = transferable)
|
||||||
|
- **D-14:** Pas de nouveaux temoignages Hytale a ajouter pour l'instant
|
||||||
|
- **D-15:** Homepage : 2-3 featured en carousel. Page /hytale : tous les 5 en carousel avec plus de details
|
||||||
|
- **D-16:** Corriger totalReviews : le vrai nombre est 5, pas 10 ni 50
|
||||||
|
- **D-17:** Format d'affichage = carousel/slider sur les deux pages
|
||||||
|
|
||||||
|
### Transition de Positionnement
|
||||||
|
- **D-18:** Positionnement Hytale-first, web secondaire — homepage et branding centres Hytale, services web restent accessibles mais pas mis en avant
|
||||||
|
- **D-19:** Toutes les pages existantes restent (about, projects, fiverr, contact), on ajoute /hytale
|
||||||
|
- **D-20:** siteConfig.title = "Killian' DAL-CIN - Hytale Plugin Developer | Freelance"
|
||||||
|
- **D-21:** jobTitle dans site.ts = "Hytale Plugin Developer" (SEO-05)
|
||||||
|
|
||||||
|
</decisions>
|
||||||
|
|
||||||
|
<canonical_refs>
|
||||||
|
## Canonical References
|
||||||
|
|
||||||
|
**Downstream agents MUST read these before planning or implementing.**
|
||||||
|
|
||||||
|
No external specs — requirements fully captured in decisions above and in:
|
||||||
|
- `.planning/REQUIREMENTS.md` — CONT-01, CONT-02, CONT-03, CONT-04, SEO-05
|
||||||
|
- `.planning/ROADMAP.md` §Phase 2 — success criteria and dependencies
|
||||||
|
- `.planning/codebase/STRUCTURE.md` — file locations and conventions
|
||||||
|
|
||||||
|
</canonical_refs>
|
||||||
|
|
||||||
|
<code_context>
|
||||||
|
## Existing Code Insights
|
||||||
|
|
||||||
|
### Reusable Assets
|
||||||
|
- `HeroSection.vue` — hero existant avec grid 2 colonnes, a adapter (pas recreer)
|
||||||
|
- `TestimonialsSection.vue` — composant temoignages existant, reutilisable
|
||||||
|
- `ServicesSection.vue` — composant services existant sur homepage
|
||||||
|
- `ProjectCard.vue` — card component reutilisable
|
||||||
|
- `app/data/testimonials.ts` — 5 temoignages structures avec types
|
||||||
|
- `app/data/site.ts` — config site a mettre a jour (title, description, jobTitle)
|
||||||
|
- Nuxt UI v3 composants (UCard, UButton, etc.) pour le pricing grid
|
||||||
|
|
||||||
|
### Established Patterns
|
||||||
|
- Pages dans `app/pages/` avec auto-routing Nuxt
|
||||||
|
- Sections dans `app/components/sections/` composees dans les pages
|
||||||
|
- i18n via `useI18n()` + `i18n/locales/fr.json` et `en.json`
|
||||||
|
- Data statique typee dans `app/data/`
|
||||||
|
- Types dans `shared/types/index.ts`
|
||||||
|
|
||||||
|
### Integration Points
|
||||||
|
- Nouvelle page `app/pages/hytale.vue` (auto-routee en /hytale)
|
||||||
|
- Navigation AppHeader.vue — ajouter lien /hytale
|
||||||
|
- i18n keys a ajouter dans fr.json et en.json pour tout le nouveau contenu
|
||||||
|
- site.ts — mettre a jour title, description, ajouter jobTitle
|
||||||
|
|
||||||
|
</code_context>
|
||||||
|
|
||||||
|
<specifics>
|
||||||
|
## Specific Ideas
|
||||||
|
|
||||||
|
- Hytale est sorti depuis le 13 janvier 2026 — ce n'est pas un jeu a venir, c'est un jeu actif
|
||||||
|
- Les temoignages Minecraft/Discord servent de preuve de competence gaming transferable a Hytale
|
||||||
|
- Le Discord CTA pointe vers le profil personnel en attendant un eventuel serveur communautaire
|
||||||
|
|
||||||
|
</specifics>
|
||||||
|
|
||||||
|
<deferred>
|
||||||
|
## Deferred Ideas
|
||||||
|
|
||||||
|
None — discussion stayed within phase scope
|
||||||
|
|
||||||
|
</deferred>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Phase: 02-content*
|
||||||
|
*Context gathered: 2026-04-11*
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
# Phase 2: Content - Discussion Log
|
||||||
|
|
||||||
|
> **Audit trail only.** Do not use as input to planning, research, or execution agents.
|
||||||
|
> Decisions are captured in CONTEXT.md — this log preserves the alternatives considered.
|
||||||
|
|
||||||
|
**Date:** 2026-04-11
|
||||||
|
**Phase:** 02-content
|
||||||
|
**Areas discussed:** Hero & messaging, Page Hytale & pricing, Temoignages, Transition de positionnement
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Hero & Messaging
|
||||||
|
|
||||||
|
| Option | Description | Selected |
|
||||||
|
|--------|-------------|----------|
|
||||||
|
| Hytale Plugin Developer | Direct et specialise — positionnement niche immediat | ✓ |
|
||||||
|
| Hytale + Web | Specialisation Hytale avec mention web | |
|
||||||
|
| Creatif/accrocheur | Phrase d'accroche plutot que titre de poste | |
|
||||||
|
|
||||||
|
**User's choice:** Hytale Plugin Developer
|
||||||
|
**Notes:** Titre direct, pas de formulation creative
|
||||||
|
|
||||||
|
| Option | Description | Selected |
|
||||||
|
|--------|-------------|----------|
|
||||||
|
| Discord + Contact | Bouton principal Discord, secondaire contact | ✓ |
|
||||||
|
| Page Hytale + Contact | Principal vers /hytale, secondaire contact | |
|
||||||
|
| Discord + Hytale | Principal Discord, secondaire /hytale | |
|
||||||
|
|
||||||
|
**User's choice:** Discord + Contact
|
||||||
|
|
||||||
|
| Option | Description | Selected |
|
||||||
|
|--------|-------------|----------|
|
||||||
|
| Garder tel quel | Badge "Available for projects" reste | |
|
||||||
|
| Traduire + i18n | Passer par i18n FR/EN | ✓ |
|
||||||
|
| Adapter a Hytale | "Pret pour la sortie Hytale" | |
|
||||||
|
|
||||||
|
**User's choice:** Traduire + i18n
|
||||||
|
|
||||||
|
| Option | Description | Selected |
|
||||||
|
|--------|-------------|----------|
|
||||||
|
| Experience + techno | "7+ ans d'experience en developpement..." | |
|
||||||
|
| Benefice client | "Des plugins performants et sur-mesure..." | ✓ |
|
||||||
|
| Tu decides | Claude choisit | |
|
||||||
|
|
||||||
|
**User's choice:** Benefice client
|
||||||
|
|
||||||
|
| Option | Description | Selected |
|
||||||
|
|--------|-------------|----------|
|
||||||
|
| Garder le grid | Texte gauche, illustration droite | ✓ |
|
||||||
|
| Centre plein largeur | H1 + sous-titre centres | |
|
||||||
|
| Tu decides | Claude choisit | |
|
||||||
|
|
||||||
|
**User's choice:** Garder le grid
|
||||||
|
|
||||||
|
| Option | Description | Selected |
|
||||||
|
|--------|-------------|----------|
|
||||||
|
| Pas d'image pour l'instant | Placeholder ou pas d'illustration | ✓ |
|
||||||
|
| J'ai des assets | Fournira des images Hytale | |
|
||||||
|
| Illustration generique | Icone ou illustration abstraite | |
|
||||||
|
|
||||||
|
**User's choice:** Pas d'image pour l'instant
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Page Hytale & Pricing
|
||||||
|
|
||||||
|
**Sections selectionnees (multi-select) :** Hero dedie Hytale, Services/expertise, Grille tarifaire, Temoignages
|
||||||
|
|
||||||
|
| Option | Description | Selected |
|
||||||
|
|--------|-------------|----------|
|
||||||
|
| 3 tiers | Simple / Complexe / Sur-mesure | |
|
||||||
|
| 4-5 tiers | Plugin simple / complexe / sur-mesure / maintenance / web | ✓ |
|
||||||
|
| Tu decides | Claude structure les tiers | |
|
||||||
|
|
||||||
|
**User's choice:** 4-5 tiers
|
||||||
|
|
||||||
|
| Option | Description | Selected |
|
||||||
|
|--------|-------------|----------|
|
||||||
|
| A partir de X€ | Attractif mais flexible | |
|
||||||
|
| Fourchettes | "50-150€" transparent | |
|
||||||
|
| Sur devis | Pas de prix affiche | |
|
||||||
|
| Mix | Prix fixes pour simple/maintenance, sur devis pour complexe | ✓ |
|
||||||
|
|
||||||
|
**User's choice:** Mix
|
||||||
|
|
||||||
|
| Option | Description | Selected |
|
||||||
|
|--------|-------------|----------|
|
||||||
|
| Discord | "Discutons sur Discord" | |
|
||||||
|
| Contact form | "Demander un devis" → /contact | ✓ |
|
||||||
|
| Les deux | Discord principal + contact secondaire | |
|
||||||
|
|
||||||
|
**User's choice:** Contact form
|
||||||
|
|
||||||
|
**Notes:** Pas de demos — Hytale est sorti depuis le 13 janvier 2026, mais l'utilisateur n'a pas d'assets a montrer.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Temoignages
|
||||||
|
|
||||||
|
| Option | Description | Selected |
|
||||||
|
|--------|-------------|----------|
|
||||||
|
| Garder tels quels | Avis Fiverr transferables | ✓ |
|
||||||
|
| Adapter le contexte | Changer les labels | |
|
||||||
|
| Separer | Tous homepage, pertinents sur /hytale | |
|
||||||
|
|
||||||
|
**User's choice:** Garder tels quels
|
||||||
|
|
||||||
|
| Option | Description | Selected |
|
||||||
|
|--------|-------------|----------|
|
||||||
|
| Meme composant, meme data | Identique sur les deux pages | |
|
||||||
|
| Featured vs complet | Homepage 2-3 featured, /hytale tous les 5 | ✓ |
|
||||||
|
| Tu decides | Claude choisit | |
|
||||||
|
|
||||||
|
**User's choice:** Featured vs complet
|
||||||
|
|
||||||
|
**Notes:** totalReviews a corriger de 10 → 5. Format carousel/slider sur les deux pages.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Transition de Positionnement
|
||||||
|
|
||||||
|
| Option | Description | Selected |
|
||||||
|
|--------|-------------|----------|
|
||||||
|
| Hytale-first, web secondaire | Branding centre Hytale, web accessible mais pas mis en avant | ✓ |
|
||||||
|
| Double positionnement | 50/50 Hytale et Web | |
|
||||||
|
| Full pivot Hytale | Tout Hytale, web secondaire | |
|
||||||
|
|
||||||
|
**User's choice:** Hytale-first, web secondaire
|
||||||
|
|
||||||
|
| Option | Description | Selected |
|
||||||
|
|--------|-------------|----------|
|
||||||
|
| Garder toutes | Toutes les pages restent, ajouter /hytale | ✓ |
|
||||||
|
| Supprimer fiverr | Redondante avec /hytale | |
|
||||||
|
| Tu decides | Claude evalue | |
|
||||||
|
|
||||||
|
**User's choice:** Garder toutes
|
||||||
|
|
||||||
|
| Option | Description | Selected |
|
||||||
|
|--------|-------------|----------|
|
||||||
|
| Hytale Plugin Developer | "Killian' DAL-CIN - Hytale Plugin Developer | Freelance" | ✓ |
|
||||||
|
| Hytale & Web Developer | Les deux mentionnes | |
|
||||||
|
| Tu decides | Claude redige le meilleur titre SEO | |
|
||||||
|
|
||||||
|
**User's choice:** Hytale Plugin Developer
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Claude's Discretion
|
||||||
|
|
||||||
|
- Stats/chiffres cles dans le hero (decide si pertinent pour la conversion)
|
||||||
|
- Lien Discord = profil personnel (provisoire, en attendant serveur eventuel)
|
||||||
|
|
||||||
|
## Deferred Ideas
|
||||||
|
|
||||||
|
None — discussion stayed within phase scope
|
||||||
@@ -0,0 +1,489 @@
|
|||||||
|
# Phase 2: Content - Research
|
||||||
|
|
||||||
|
**Researched:** 2026-04-10
|
||||||
|
**Domain:** Nuxt 4 page authoring, i18n content, Nuxt UI v3 pricing grids, testimonials carousel
|
||||||
|
**Confidence:** HIGH
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<user_constraints>
|
||||||
|
## User Constraints (from CONTEXT.md)
|
||||||
|
|
||||||
|
### Locked Decisions
|
||||||
|
|
||||||
|
- **D-01:** H1 = "Hytale Plugin Developer" — positionnement niche direct, pas de titre generique
|
||||||
|
- **D-02:** CTAs = Discord (profil personnel pour l'instant) + Contact
|
||||||
|
- **D-03:** Badge "Available for projects" → passer en i18n (FR: "Disponible pour vos projets")
|
||||||
|
- **D-04:** Sous-titre angle benefice client : "Des plugins performants et sur-mesure pour votre serveur Hytale"
|
||||||
|
- **D-05:** Layout hero = garder le grid 2 colonnes (texte gauche, placeholder/illustration droite)
|
||||||
|
- **D-06:** Pas d'image pour l'instant cote droit — placeholder en attendant des assets Hytale
|
||||||
|
- **D-07:** Lien Discord = profil personnel existant dans site.ts (provisoire)
|
||||||
|
- **D-08:** Page /hytale avec 4 sections : hero dedie Hytale, services/expertise, grille tarifaire, temoignages
|
||||||
|
- **D-09:** 4-5 tiers de pricing : plugin simple / complexe / sur-mesure / maintenance / web
|
||||||
|
- **D-10:** Prix en mode mix : prix fixes pour simple/maintenance, sur devis pour complexe/sur-mesure
|
||||||
|
- **D-11:** CTA de chaque tier = "Demander un devis" → redirige vers /contact
|
||||||
|
- **D-12:** Pas de demos — Hytale est sorti (janvier 2026), mais pas d'assets a montrer
|
||||||
|
- **D-13:** Garder les 5 temoignages Fiverr existants tels quels
|
||||||
|
- **D-14:** Pas de nouveaux temoignages Hytale a ajouter pour l'instant
|
||||||
|
- **D-15:** Homepage : 2-3 featured en carousel. Page /hytale : tous les 5 en carousel avec plus de details
|
||||||
|
- **D-16:** Corriger totalReviews : le vrai nombre est 5, pas 10 ni 50
|
||||||
|
- **D-17:** Format d'affichage = carousel/slider sur les deux pages
|
||||||
|
- **D-18:** Positionnement Hytale-first, web secondaire
|
||||||
|
- **D-19:** Toutes les pages existantes restent (about, projects, fiverr, contact), on ajoute /hytale
|
||||||
|
- **D-20:** siteConfig.title = "Killian' DAL-CIN - Hytale Plugin Developer | Freelance"
|
||||||
|
- **D-21:** jobTitle dans site.ts = "Hytale Plugin Developer" (SEO-05)
|
||||||
|
|
||||||
|
### Claude's Discretion
|
||||||
|
- Stats/chiffres cles dans le hero : Claude decide si pertinent pour la conversion
|
||||||
|
|
||||||
|
### Deferred Ideas (OUT OF SCOPE)
|
||||||
|
None — discussion stayed within phase scope
|
||||||
|
</user_constraints>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<phase_requirements>
|
||||||
|
## Phase Requirements
|
||||||
|
|
||||||
|
| ID | Description | Research Support |
|
||||||
|
|----|-------------|------------------|
|
||||||
|
| CONT-01 | Refonte Hero accueil — "Hytale Plugin Developer" en H1, CTA Discord/contact, bilingue | HeroSection.vue existant a adapter; i18n keys home.title, home.subtitle, home.cta.* a remplacer |
|
||||||
|
| CONT-02 | Page Hytale dediee `/hytale` — services plugin dev, tiers pricing, demos placeholders, maintenance, bilingue | Nouvelle page `app/pages/hytale.vue` + sections composees; pattern identique a index.vue |
|
||||||
|
| CONT-03 | Grille tarifaire — plugin simple/complexe/sur-mesure/maintenance/web avec prix visibles | UCard Nuxt UI v3 en grid; data statique dans app/data/; i18n keys hytale.pricing.* |
|
||||||
|
| CONT-04 | Temoignages — section featured + stats sur homepage et page Hytale (5 avis Fiverr existants) | TestimonialsSection.vue reutilisable; prop `featured` deja presente sur Testimonial type; corriger totalReviews: 5 |
|
||||||
|
| SEO-05 | jobTitle corrige — "Hytale Plugin Developer" dans site.ts et JSON-LD | site.ts: title + ajouter jobTitle field; index.vue JSON-LD Person.jobTitle a mettre a jour |
|
||||||
|
</phase_requirements>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Phase 2 est une phase de contenu pur dans un codebase Nuxt 4 SSR deja fonctionnel. L'infrastructure (routing, i18n, Nuxt UI v3, layouts) existe et fonctionne. Le travail consiste a adapter des composants existants et creer une nouvelle page `/hytale` en suivant des patterns deja etablis dans le projet.
|
||||||
|
|
||||||
|
Le principal risque est la coherence i18n : chaque string visible doit avoir une cle dans `fr.json` ET `en.json`. Le codebase a deja des strings hardcodees (badge "Available for projects" dans HeroSection.vue ligne 31, floating cards "50+ projects" et "5.0 rating" lignes 148-153) qui doivent passer en i18n dans cette phase. Les donnees incoherentes (`totalReviews: 10`, `reviewCount: '10'` dans site.ts) doivent etre corrigees a 5.
|
||||||
|
|
||||||
|
**Primary recommendation:** Adapter HeroSection existant (pas recreer), creer hytale.vue en composant sections reutilisables, tout le nouveau contenu passe par i18n avant d'atterrir dans le template.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Standard Stack
|
||||||
|
|
||||||
|
### Core (deja installe dans le projet)
|
||||||
|
| Library | Version | Purpose | Why Standard |
|
||||||
|
|---------|---------|---------|--------------|
|
||||||
|
| Nuxt 4 | installed | SSR framework, auto-routing `app/pages/` | Stack decide en phase 1 |
|
||||||
|
| Nuxt UI v3 | installed | UCard, UButton, UBadge pour pricing grid | Constraint CLAUDE.md: Nuxt UI v3 en priorite |
|
||||||
|
| @nuxtjs/i18n | installed | useI18n(), useLocalePath(), fr/en JSON | Pattern etabli dans tout le projet |
|
||||||
|
| Tailwind v4 | installed | Classes utilitaires CSS | Stack decide |
|
||||||
|
|
||||||
|
### Patterns etablis dans le projet [VERIFIED: codebase]
|
||||||
|
- Pages: `app/pages/nom.vue` → route `/nom` automatique (Nuxt auto-routing)
|
||||||
|
- Sections: `app/components/sections/NomSection.vue` composees dans les pages
|
||||||
|
- i18n: `useI18n()` dans `<script setup>`, cles dans `i18n/locales/fr.json` et `en.json`
|
||||||
|
- SEO: `useSeoMeta()` + `useHead()` en haut de chaque page vue
|
||||||
|
- Data statique typee: `app/data/nom.ts` exportant des constantes typees avec `~~/shared/types`
|
||||||
|
- Images: `NuxtImg` avec `loading="lazy"` pour les non-critiques
|
||||||
|
|
||||||
|
### Alternatives Considered
|
||||||
|
Aucune — tout le stack est verrouille par CLAUDE.md et les phases precedentes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture Patterns
|
||||||
|
|
||||||
|
### Recommended Project Structure (extensions pour phase 2)
|
||||||
|
```
|
||||||
|
app/
|
||||||
|
├── pages/
|
||||||
|
│ ├── index.vue # MODIFIER: hero Hytale + temoignages featured
|
||||||
|
│ └── hytale.vue # CREER: page dediee Hytale
|
||||||
|
├── components/
|
||||||
|
│ └── sections/
|
||||||
|
│ ├── HeroSection.vue # MODIFIER: H1 Hytale, CTAs Discord+Contact, i18n badge
|
||||||
|
│ ├── TestimonialsSection.vue # MODIFIER: prop featured pour filtrage homepage
|
||||||
|
│ └── hytale/
|
||||||
|
│ ├── HytaleHeroSection.vue # CREER: hero dedie page /hytale
|
||||||
|
│ ├── HytalePricingSection.vue # CREER: grille tarifaire 4-5 tiers
|
||||||
|
│ └── HytaleServicesSection.vue # CREER: expertise/services Hytale (optionnel si ServicesSection adaptable)
|
||||||
|
├── data/
|
||||||
|
│ ├── site.ts # MODIFIER: title, jobTitle, reviewCount corriges
|
||||||
|
│ ├── testimonials.ts # MODIFIER: totalReviews: 5 (correction FIX-04)
|
||||||
|
│ └── pricing.ts # CREER: tiers de pricing Hytale
|
||||||
|
i18n/
|
||||||
|
├── locales/
|
||||||
|
│ ├── fr.json # MODIFIER: ajouter hytale.*, corriger home.*, testimonials.*
|
||||||
|
│ └── en.json # MODIFIER: idem, toutes les cles FR doivent exister en EN
|
||||||
|
shared/
|
||||||
|
└── types/
|
||||||
|
└── index.ts # MODIFIER si besoin: PricingTier interface, jobTitle dans SiteConfig
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pattern 1: Nouvelle page Nuxt avec sections composees
|
||||||
|
**What:** `app/pages/hytale.vue` suit exactement le pattern de `index.vue`
|
||||||
|
**When to use:** Toujours — c'est le pattern etabli du projet
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Source: app/pages/index.vue (pattern existant)
|
||||||
|
<script setup lang="ts">
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
useSeoMeta({
|
||||||
|
title: () => t('seo.hytale.title'),
|
||||||
|
description: () => t('seo.hytale.description'),
|
||||||
|
ogTitle: () => t('seo.hytale.title'),
|
||||||
|
ogDescription: () => t('seo.hytale.description'),
|
||||||
|
ogImage: 'https://killiandalcin.fr/og-image.png',
|
||||||
|
ogType: 'website',
|
||||||
|
})
|
||||||
|
|
||||||
|
useHead({
|
||||||
|
script: [{
|
||||||
|
type: 'application/ld+json',
|
||||||
|
innerHTML: JSON.stringify({
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'Service',
|
||||||
|
name: 'Hytale Plugin Development',
|
||||||
|
provider: { '@type': 'Person', name: "Killian' DAL-CIN" },
|
||||||
|
}),
|
||||||
|
}],
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<HytaleHeroSection />
|
||||||
|
<HytaleServicesSection />
|
||||||
|
<HytalePricingSection />
|
||||||
|
<TestimonialsSection /> <!-- reutilise tel quel, tous les 5 -->
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pattern 2: Pricing grid avec Nuxt UI v3 UCard
|
||||||
|
**What:** Grid de cards avec UCard pour chaque tier de pricing
|
||||||
|
**When to use:** Section HytalePricingSection.vue
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Source: [ASSUMED] Nuxt UI v3 UCard pattern — a verifier contre docs officielles si besoin
|
||||||
|
// Pattern repris du design Fiverr existant (app/pages/fiverr.vue)
|
||||||
|
<template>
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
<UCard v-for="tier in pricingTiers" :key="tier.id"
|
||||||
|
:class="tier.featured ? 'ring-2 ring-brand-500' : ''">
|
||||||
|
<template #header>
|
||||||
|
<h3>{{ t(`hytale.pricing.${tier.id}.name`) }}</h3>
|
||||||
|
<p class="text-3xl font-bold">{{ tier.price }}</p>
|
||||||
|
</template>
|
||||||
|
<ul>
|
||||||
|
<li v-for="feature in tier.features" :key="feature">
|
||||||
|
<UIcon name="i-lucide-check" /> {{ t(`hytale.pricing.${tier.id}.features.${feature}`) }}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<template #footer>
|
||||||
|
<UButton :to="localePath('/contact')" block>
|
||||||
|
{{ t('hytale.pricing.cta') }}
|
||||||
|
</UButton>
|
||||||
|
</template>
|
||||||
|
</UCard>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pattern 3: TestimonialsSection avec prop featured
|
||||||
|
**What:** La section temoignages existante supporte deja `featured?: boolean` sur le type `Testimonial`. Pour la homepage, filtrer sur `featured: true` (2-3 temoignages). Pour /hytale, afficher tous les 5.
|
||||||
|
**When to use:** Homepage = `testimonials.filter(t => t.featured)`, /hytale = `testimonials` complet
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Source: app/components/sections/TestimonialsSection.vue (existant)
|
||||||
|
// app/data/testimonials.ts: seul unqlf_ a featured: true actuellement
|
||||||
|
// → marquer 2-3 comme featured pour la homepage
|
||||||
|
|
||||||
|
// Prop a ajouter a TestimonialsSection.vue:
|
||||||
|
const props = defineProps<{
|
||||||
|
featured?: boolean
|
||||||
|
}>()
|
||||||
|
const displayed = computed(() =>
|
||||||
|
props.featured ? testimonials.filter(t => t.featured) : testimonials
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pattern 4: i18n — procedure d'ajout de cles
|
||||||
|
**What:** Toute string visible doit etre dans les deux fichiers JSON
|
||||||
|
**When to use:** A chaque nouveau texte ajoute
|
||||||
|
|
||||||
|
```
|
||||||
|
Procedure:
|
||||||
|
1. Definir la cle dans fr.json avec la valeur FR
|
||||||
|
2. Ajouter la meme cle dans en.json avec la valeur EN
|
||||||
|
3. Utiliser t('cle') dans le template — jamais de string literale visible
|
||||||
|
```
|
||||||
|
|
||||||
|
### Anti-Patterns to Avoid
|
||||||
|
- **String hardcodee dans le template:** `HeroSection.vue` ligne 31 a "Available for projects" en dur → doit devenir `{{ t('home.badge.available') }}`
|
||||||
|
- **Floating cards hardcodees:** Lignes 148-153 de HeroSection.vue ont "50+ projects" et "5.0 rating" en dur → i18n ou supprimer
|
||||||
|
- **Copier-coller de sections entières:** Adapter `TestimonialsSection` via prop plutot que dupliquer
|
||||||
|
- **Donnees incoherentes:** `totalReviews: 10` dans testimonials.ts ET `reviewCount: '10'` dans site.ts → les deux a corriger a 5
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Don't Hand-Roll
|
||||||
|
|
||||||
|
| Problem | Don't Build | Use Instead | Why |
|
||||||
|
|---------|-------------|-------------|-----|
|
||||||
|
| Pricing cards | Custom card HTML | `UCard` Nuxt UI v3 | Deja installe, coherent avec le design system |
|
||||||
|
| Boutons CTA | `<a>` custom | `UButton` avec `:to` | Routing i18n automatique via `localePath()` |
|
||||||
|
| Navigation vers /hytale | Modifier le HTML du header | Ajouter entree dans `navLinks` computed de AppHeader.vue | Pattern etabli, une seule ligne a ajouter |
|
||||||
|
| Carousel/slider JS custom | Implementer swipe events | `overflow-x-auto snap-x` CSS (deja dans TestimonialsSection) | Le composant existant utilise deja ce pattern CSS |
|
||||||
|
|
||||||
|
**Key insight:** 80% de cette phase est de la configuration de donnees et d'i18n, pas du nouveau code UI.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Common Pitfalls
|
||||||
|
|
||||||
|
### Pitfall 1: Oublier la cle EN quand on ajoute une cle FR
|
||||||
|
**What goes wrong:** La page `/en/hytale` affiche la cle brute (ex: "hytale.pricing.cta") au lieu du texte
|
||||||
|
**Why it happens:** fr.json mis a jour, en.json oublie
|
||||||
|
**How to avoid:** Toujours editer les deux fichiers en meme temps
|
||||||
|
**Warning signs:** `curl localhost:3000/en/hytale | grep "hytale\."` retourne des resultats
|
||||||
|
|
||||||
|
### Pitfall 2: jobTitle manquant dans SiteConfig type
|
||||||
|
**What goes wrong:** TypeScript strict mode refuse `siteConfig.jobTitle = '...'` si le champ n'est pas dans l'interface
|
||||||
|
**Why it happens:** `SiteConfig` dans `shared/types/index.ts` n'a pas de champ `jobTitle` actuellement
|
||||||
|
**How to avoid:** Ajouter `jobTitle?: string` a l'interface avant de l'utiliser dans site.ts
|
||||||
|
**Warning signs:** Erreur `vue-tsc` au build
|
||||||
|
|
||||||
|
### Pitfall 3: Navigation /hytale absente du header mobile
|
||||||
|
**What goes wrong:** Le lien /hytale est visible sur desktop mais pas dans le menu mobile
|
||||||
|
**Why it happens:** `navLinks` dans AppHeader.vue alimente a la fois le nav desktop et le drawer mobile — une seule entree suffit si le composant est bien structure
|
||||||
|
**How to avoid:** Verifier que le drawer mobile utilise bien la meme `navLinks` computed (c'est le cas dans le pattern actuel)
|
||||||
|
|
||||||
|
### Pitfall 4: TestimonialsSection importe directement les donnees (pas de prop)
|
||||||
|
**What goes wrong:** Pour filtrer les featured sur la homepage, il faut refactorer le composant
|
||||||
|
**Why it happens:** `TestimonialsSection.vue` importe `testimonials` directement (ligne 2 du composant)
|
||||||
|
**How to avoid:** Ajouter une prop optionnelle `featured?: boolean` et filtrer le tableau en interne — ca ne casse pas l'usage existant sur fiverr.vue et contact.vue s'ils l'utilisent
|
||||||
|
|
||||||
|
### Pitfall 5: reviewCount incoherent entre site.ts et testimonials.ts
|
||||||
|
**What goes wrong:** `aggregateRating.reviewCount: '10'` dans site.ts et `totalReviews: 10` dans testimonials.ts alors que la decision D-16 dit 5
|
||||||
|
**Why it happens:** FIX-04 du REQUIREMENTS.md cible cette incohererence
|
||||||
|
**How to avoid:** Les deux doivent etre corriges a 5 dans la meme tache
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Code Examples
|
||||||
|
|
||||||
|
### Mise a jour HeroSection.vue — badge i18n
|
||||||
|
```typescript
|
||||||
|
// AVANT (hardcode, ligne 31):
|
||||||
|
<span class="text-sm font-medium text-brand-700 dark:text-brand-400">Available for projects</span>
|
||||||
|
|
||||||
|
// APRES (i18n):
|
||||||
|
<span class="text-sm font-medium text-brand-700 dark:text-brand-400">{{ t('home.badge.available') }}</span>
|
||||||
|
|
||||||
|
// fr.json: "home": { "badge": { "available": "Disponible pour vos projets" } }
|
||||||
|
// en.json: "home": { "badge": { "available": "Available for projects" } }
|
||||||
|
```
|
||||||
|
|
||||||
|
### Mise a jour HeroSection.vue — H1 Hytale
|
||||||
|
```typescript
|
||||||
|
// i18n keys a remplacer dans fr.json:
|
||||||
|
// "home": {
|
||||||
|
// "title": "Hytale Plugin Developer", ← H1 (D-01)
|
||||||
|
// "subtitle": "Des plugins performants et sur-mesure pour votre serveur Hytale", ← D-04
|
||||||
|
// "cta": {
|
||||||
|
// "viewProjects": "Voir mes projets",
|
||||||
|
// "discord": "Rejoindre sur Discord", ← D-02
|
||||||
|
// "contactMe": "Devis Gratuit Sous 24h"
|
||||||
|
// }
|
||||||
|
// }
|
||||||
|
```
|
||||||
|
|
||||||
|
### Structure data pricing.ts a creer
|
||||||
|
```typescript
|
||||||
|
// app/data/pricing.ts
|
||||||
|
import type { PricingTier } from '~~/shared/types'
|
||||||
|
|
||||||
|
export const hytalepricing: PricingTier[] = [
|
||||||
|
{ id: 'simple', priceFixed: '150€', featured: false },
|
||||||
|
{ id: 'complex', priceFixed: null, priceLabel: 'Sur devis', featured: true },
|
||||||
|
{ id: 'custom', priceFixed: null, priceLabel: 'Sur devis', featured: false },
|
||||||
|
{ id: 'maintenance', priceFixed: '50€/mo', featured: false },
|
||||||
|
{ id: 'web', priceFixed: '300€', featured: false },
|
||||||
|
]
|
||||||
|
|
||||||
|
// Interface a ajouter dans shared/types/index.ts:
|
||||||
|
export interface PricingTier {
|
||||||
|
id: string
|
||||||
|
priceFixed: string | null
|
||||||
|
priceLabel?: string
|
||||||
|
featured?: boolean
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Correction site.ts
|
||||||
|
```typescript
|
||||||
|
// MODIFIER dans app/data/site.ts:
|
||||||
|
export const siteConfig: SiteConfig = {
|
||||||
|
title: "Killian' DAL-CIN - Hytale Plugin Developer | Freelance", // D-20
|
||||||
|
jobTitle: 'Hytale Plugin Developer', // D-21 / SEO-05
|
||||||
|
// ...
|
||||||
|
seo: {
|
||||||
|
organization: {
|
||||||
|
aggregateRating: {
|
||||||
|
ratingValue: '5',
|
||||||
|
reviewCount: '5', // D-16: correction de 10 → 5
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Correction testimonials.ts
|
||||||
|
```typescript
|
||||||
|
// MODIFIER totalReviews:
|
||||||
|
export const testimonialsStats: TestimonialsStats = {
|
||||||
|
totalReviews: 5, // D-16: correction de 10 → 5
|
||||||
|
averageRating: 5.0,
|
||||||
|
projectsCompleted: 25, // conserver (plausible)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Marquer 2-3 comme featured pour la homepage:
|
||||||
|
{ name: 'unqlf_', featured: true, ... }, // deja featured
|
||||||
|
{ name: 'colo263', featured: true, ... }, // a ajouter
|
||||||
|
{ name: 'cobra2', featured: true, ... }, // a ajouter (3 max)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Ajout lien /hytale dans AppHeader.vue
|
||||||
|
```typescript
|
||||||
|
// MODIFIER navLinks dans app/components/layout/AppHeader.vue:
|
||||||
|
const navLinks = computed(() => [
|
||||||
|
{ key: 'home', path: '/' },
|
||||||
|
{ key: 'hytale', path: '/hytale' }, // ← AJOUTER
|
||||||
|
{ key: 'projects', path: '/projects' },
|
||||||
|
{ key: 'about', path: '/about' },
|
||||||
|
{ key: 'contact', path: '/contact' },
|
||||||
|
{ key: 'fiverr', path: '/fiverr' },
|
||||||
|
])
|
||||||
|
|
||||||
|
// fr.json: "nav": { "hytale": "Hytale" }
|
||||||
|
// en.json: "nav": { "hytale": "Hytale" }
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## State of the Art
|
||||||
|
|
||||||
|
| Old Approach | Current Approach | When Changed | Impact |
|
||||||
|
|--------------|------------------|--------------|--------|
|
||||||
|
| Strings hardcodees dans HeroSection | i18n via t() | Phase 2 | Obligation pour bilingue et success criteria CONT-01 |
|
||||||
|
| `totalReviews: 10` | `totalReviews: 5` | Phase 2 | Correction donnees incoherentes (FIX-04) |
|
||||||
|
| `jobTitle: 'Full Stack...'` dans JSON-LD | `jobTitle: 'Hytale Plugin Developer'` | Phase 2 | SEO-05 |
|
||||||
|
|
||||||
|
**Deprecated/outdated dans le contexte de cette phase:**
|
||||||
|
- CTAs "view projects" / "fiverr" dans le hero → remplacer par Discord + Contact (D-02)
|
||||||
|
- titre site.ts "Full Stack Developer | Vue.js, React, Node.js Expert" → D-20
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
1. **Prix concrets pour les tiers de pricing**
|
||||||
|
- What we know: D-09/D-10 definissent les tiers et le mode (fixe vs devis)
|
||||||
|
- What's unclear: Les montants exacts (ex: "150€" pour plugin simple est une estimation du researcher)
|
||||||
|
- Recommendation: Claude utilise des prix plausibles basés sur le marche freelance FR; Killian peut les ajuster apres livraison
|
||||||
|
|
||||||
|
2. **Stats hero — chiffres pertinents ?**
|
||||||
|
- What we know: D-Discretion laisse ca a Claude
|
||||||
|
- What's unclear: "7 ans d'experience" et "5 avis Fiverr" sont des chiffres modestes pour un hero
|
||||||
|
- Recommendation: Garder les floating cards (50+ projects, 5.0 rating) mais les passer en i18n; ne pas afficher de chiffres trompeurs
|
||||||
|
|
||||||
|
3. **CTA Discord dans le hero — 2 ou 3 boutons ?**
|
||||||
|
- What we know: D-02 = Discord + Contact. Le hero actuel a 3 boutons (projects, fiverr, contact)
|
||||||
|
- What's unclear: Garder "voir mes projets" en 3e bouton ou seulement Discord + Contact ?
|
||||||
|
- Recommendation: 3 boutons: Discord (primaire), Contact (secondaire), Hytale (tertiaire) — maximize conversion
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Environment Availability
|
||||||
|
|
||||||
|
Step 2.6: SKIPPED — phase purement contenu/code, pas de dependances externes nouvelles. Le stack est deja installe depuis Phase 1.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Validation Architecture
|
||||||
|
|
||||||
|
> `workflow.nyquist_validation` non trouve dans `.planning/config.json` → traite comme enabled.
|
||||||
|
|
||||||
|
### Test Framework
|
||||||
|
| Property | Value |
|
||||||
|
|----------|-------|
|
||||||
|
| Framework | Pas de framework de test detecte dans le projet |
|
||||||
|
| Config file | none |
|
||||||
|
| Quick run command | `curl localhost:3000 \| grep -i hytale` (smoke test manuel) |
|
||||||
|
| Full suite command | `pnpm build && pnpm preview` + verification manuelle |
|
||||||
|
|
||||||
|
### Phase Requirements → Test Map
|
||||||
|
| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|
||||||
|
|--------|----------|-----------|-------------------|-------------|
|
||||||
|
| CONT-01 | H1 contient "Hytale" sur homepage | smoke | `curl localhost:3000 \| grep -i '<h1'` | ❌ Wave 0 |
|
||||||
|
| CONT-02 | /hytale existe avec 3+ tiers pricing | smoke | `curl localhost:3000/hytale \| grep -i 'devis'` | ❌ Wave 0 |
|
||||||
|
| CONT-03 | 4-5 tiers visibles dans /hytale | smoke | `curl localhost:3000/hytale \| grep -c 'tier\|pricing'` | ❌ Wave 0 |
|
||||||
|
| CONT-04 | Temoignages sur homepage ET /hytale | smoke | `curl localhost:3000 \| grep -i 'fiverr'` | ❌ Wave 0 |
|
||||||
|
| SEO-05 | jobTitle dans site.ts | manual | `grep "Hytale Plugin Developer" app/data/site.ts` | ❌ Wave 0 |
|
||||||
|
|
||||||
|
### Sampling Rate
|
||||||
|
- **Per task commit:** `pnpm dev` + verification visuelle en browser
|
||||||
|
- **Per wave merge:** `pnpm build` green + smoke curl tests
|
||||||
|
- **Phase gate:** Success criteria du ROADMAP verifies avant `/gsd-verify-work`
|
||||||
|
|
||||||
|
### Wave 0 Gaps
|
||||||
|
- [ ] Pas de framework de test installe — tests = smoke curl + verification manuelle selon success criteria
|
||||||
|
|
||||||
|
*(Note: Le REQUIREMENTS.md marque "Tests automatises: Ship d'abord, tests ensuite" — Out of Scope)*
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Security Domain
|
||||||
|
|
||||||
|
> Aucune nouvelle surface d'attaque introduite dans cette phase. Contenu statique SSR + i18n + data files. Pas de nouveaux endpoints API. Pas d'input utilisateur ajoutee.
|
||||||
|
|
||||||
|
Applicable ASVS: V5 Input Validation — N/A (pas de nouveau formulaire). Toutes les donnees sont statiques et typees TypeScript.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Assumptions Log
|
||||||
|
|
||||||
|
| # | Claim | Section | Risk if Wrong |
|
||||||
|
|---|-------|---------|---------------|
|
||||||
|
| A1 | Prix "150€" pour plugin simple Hytale | Code Examples / pricing.ts | Killian doit ajuster — donnees affichees aux visiteurs |
|
||||||
|
| A2 | Ajouter `featured: true` a colo263 et cobra2 pour la homepage | Code Examples | Killian peut choisir d'autres temoignages featured |
|
||||||
|
| A3 | `projectsCompleted: 25` conserve dans testimonialsStats | Code Examples | Killian confirme si le chiffre est exact |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sources
|
||||||
|
|
||||||
|
### Primary (HIGH confidence)
|
||||||
|
- `app/components/sections/HeroSection.vue` — strings hardcodees identifiees (lignes 31, 148, 152)
|
||||||
|
- `app/data/testimonials.ts` — totalReviews: 10, 1 temoignage featured
|
||||||
|
- `app/data/site.ts` — title et jobTitle actuels, reviewCount: '10'
|
||||||
|
- `app/components/layout/AppHeader.vue` — pattern navLinks, navigation structure
|
||||||
|
- `app/pages/index.vue` — pattern useSeoMeta + useHead + sections composees
|
||||||
|
- `i18n/locales/fr.json` — structure des cles existantes, home.title actuel
|
||||||
|
- `shared/types/index.ts` — interfaces TypeScript existantes (SiteConfig sans jobTitle)
|
||||||
|
|
||||||
|
### Secondary (MEDIUM confidence)
|
||||||
|
- [ASSUMED] Pattern UCard Nuxt UI v3 pour pricing — a verifier contre docs si comportement inattendu
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Metadata
|
||||||
|
|
||||||
|
**Confidence breakdown:**
|
||||||
|
- Fichiers a modifier: HIGH — codebase lu directement
|
||||||
|
- Patterns a suivre: HIGH — indexes dans des fichiers existants fonctionnels
|
||||||
|
- Contenu prix/temoignages: LOW — hypotheses sur les montants, Killian valide
|
||||||
|
- i18n keys a creer: HIGH — structure claire, pattern etabli
|
||||||
|
|
||||||
|
**Research date:** 2026-04-10
|
||||||
|
**Valid until:** 2026-05-10 (stack stable, contenu peut changer)
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
---
|
||||||
|
phase: 2
|
||||||
|
slug: content
|
||||||
|
status: draft
|
||||||
|
shadcn_initialized: false
|
||||||
|
preset: none
|
||||||
|
created: 2026-04-10
|
||||||
|
---
|
||||||
|
|
||||||
|
# Phase 2 — Content — UI Design Contract
|
||||||
|
|
||||||
|
> Contrat visuel et d'interaction pour la phase Content. Genere par gsd-ui-researcher, verifie par gsd-ui-checker.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Design System
|
||||||
|
|
||||||
|
| Property | Value |
|
||||||
|
|----------|-------|
|
||||||
|
| Tool | Nuxt UI v3 (UCard, UButton, UIcon, etc.) — source: CLAUDE.md |
|
||||||
|
| Preset | not applicable |
|
||||||
|
| Component library | Nuxt UI v3 (basé sur Radix primitives via reka-ui) |
|
||||||
|
| Icon library | Lucide via `i-lucide-*` (UIcon) — source: HeroSection.vue existant |
|
||||||
|
| Font | Inter ou system-ui (hérité de Nuxt UI v3 default) |
|
||||||
|
|
||||||
|
Note: shadcn non applicable — stack Nuxt 4 + Nuxt UI v3 + Tailwind v4.
|
||||||
|
Registry safety gate: non applicable (Nuxt UI v3 est la source officielle, pas de tiers).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Spacing Scale
|
||||||
|
|
||||||
|
Déclaré (multiples de 4, aligné avec l'échelle Tailwind utilisée dans le codebase existant):
|
||||||
|
|
||||||
|
| Token | Value | Usage |
|
||||||
|
|-------|-------|-------|
|
||||||
|
| xs | 4px | Gaps icônes, padding inline (gap-1, p-1) |
|
||||||
|
| sm | 8px | Espacement compact — badges, labels (gap-2, p-2) |
|
||||||
|
| md | 16px | Espacement default — padding cartes, champs (p-4, gap-4) |
|
||||||
|
| lg | 24px | Section padding interne — entre éléments de section (p-6, gap-6) |
|
||||||
|
| xl | 32px | Gaps layout — grilles pricing (gap-8) |
|
||||||
|
| 2xl | 48px | Séparations de sections majeures (py-12) |
|
||||||
|
| 3xl | 64px | Espacement page — sections hero/content (py-16 à py-24) |
|
||||||
|
|
||||||
|
Exceptions:
|
||||||
|
- Touch targets boutons CTA: min 44px de hauteur (`py-3` + hauteur de texte = ~44px OK)
|
||||||
|
- Carousel testimonials: padding horizontal -mx-4 px-4 conservé pour débordement visuel (pattern existant)
|
||||||
|
- Hero section: py-16 md:py-24 conservé tel quel (source: HeroSection.vue)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Typography
|
||||||
|
|
||||||
|
Détectée dans le codebase existant — reproduire exactement ces valeurs:
|
||||||
|
|
||||||
|
| Role | Size | Weight | Line Height |
|
||||||
|
|------|------|--------|-------------|
|
||||||
|
| Body / quote testimonial | 14px (text-sm) | 400 (regular) | 1.5 (leading-relaxed) |
|
||||||
|
| Label / meta / badge | 14px (text-sm) | 500 (medium) | 1.4 |
|
||||||
|
| Heading section (h2) | 30-48px responsif (text-3xl→text-5xl) | 700 (bold) | 1.2 |
|
||||||
|
| Display / H1 hero | 36-72px responsif (text-4xl→text-7xl) | 800 (extrabold) | 1.1 (tracking-tight) |
|
||||||
|
|
||||||
|
Règles supplémentaires:
|
||||||
|
- Accent décoratif mono: `font-mono text-sm` pour labels de section (ex: `// testimonials`, `// pricing`)
|
||||||
|
- Gradient text sur H1 et H2: `bg-gradient-to-r from-brand-500 via-brand-400 to-emerald-400 bg-clip-text text-transparent` (pattern existant)
|
||||||
|
- Sous-titres de sections: `text-lg text-gray-500 dark:text-gray-400 leading-relaxed`
|
||||||
|
- Prix dans grille tarifaire: `text-3xl font-bold` pour le montant, `text-sm text-gray-500` pour la période/unité
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Color
|
||||||
|
|
||||||
|
Palette détectée dans `app/assets/css/main.css` + composants existants:
|
||||||
|
|
||||||
|
| Role | Value | Usage |
|
||||||
|
|------|-------|-------|
|
||||||
|
| Dominant (60%) | `bg-white dark:bg-gray-950` | Fond page, sections principales |
|
||||||
|
| Secondary (30%) | `bg-gray-50 dark:bg-gray-900` / `bg-white/80 dark:bg-gray-900/60` | Cartes, terminal hero, cartes témoignages, cartes pricing |
|
||||||
|
| Accent (10%) | `brand-500` = `#85cb85` (vert) | Voir liste ci-dessous |
|
||||||
|
| Destructive | Non applicable dans cette phase — aucune action destructive |
|
||||||
|
|
||||||
|
Accent réservé exclusivement à:
|
||||||
|
1. Badge "Disponible pour vos projets" — fond `bg-brand-500/10`, texte `text-brand-700 dark:text-brand-400`
|
||||||
|
2. Curseur et highlight terminal hero — `bg-brand-500`, `text-brand-500`
|
||||||
|
3. Bouton CTA primaire — `bg-brand-500 hover:bg-brand-600`
|
||||||
|
4. Stats témoignages (chiffres) — gradient `from-brand-400 to-brand-600`
|
||||||
|
5. Hover border cartes testimonials/pricing — `hover:border-brand-500/40`
|
||||||
|
6. Étoiles rating: `text-yellow-400` (NON brand-500 — jaune uniquement pour étoiles)
|
||||||
|
7. Gradient H1 hero — `from-brand-500 via-brand-400 to-emerald-400`
|
||||||
|
|
||||||
|
Dark mode: systématiquement déclaré en paire `light dark:dark` sur chaque token de couleur.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Composants Nuxt UI v3 — Inventaire Phase 2
|
||||||
|
|
||||||
|
| Composant | Usage | Props clés |
|
||||||
|
|-----------|-------|-----------|
|
||||||
|
| `UCard` | Cartes pricing, cartes témoignages page /hytale | `class` pour override padding |
|
||||||
|
| `UButton` | CTA "Demander un devis", CTA Discord, CTA Contact | `color="primary"` ou outline |
|
||||||
|
| `UBadge` | Badge "Disponible pour vos projets", badge tier pricing | `color`, `variant` |
|
||||||
|
| `UIcon` | Toutes icônes (i-lucide-*) | `name`, `class` pour taille |
|
||||||
|
| `UDivider` | Séparation entre stats témoignages | vertical |
|
||||||
|
| `NuxtLink` | Navigation interne — liens /contact, /hytale | `localePath()` obligatoire |
|
||||||
|
| `NuxtImg` | Avatars témoignages | `loading="lazy"`, `width`, `height` |
|
||||||
|
|
||||||
|
Pas de composant carousel natif Nuxt UI v3 — utiliser le pattern scroll horizontal existant (`overflow-x-auto snap-x snap-mandatory`) déjà en place dans TestimonialsSection.vue.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Copywriting Contract
|
||||||
|
|
||||||
|
### Hero Homepage (refonte CONT-01)
|
||||||
|
|
||||||
|
| Element | FR | EN |
|
||||||
|
|---------|----|----|
|
||||||
|
| H1 | "Hytale Plugin Developer" (gradient sur les 2 derniers mots) | "Hytale Plugin Developer" |
|
||||||
|
| Sous-titre | "Des plugins performants et sur-mesure pour votre serveur Hytale" | "High-performance, custom plugins for your Hytale server" |
|
||||||
|
| Badge statut | "Disponible pour vos projets" | "Available for projects" |
|
||||||
|
| CTA primaire | "Rejoindre sur Discord" | "Join on Discord" |
|
||||||
|
| CTA secondaire | "Me contacter" | "Contact me" |
|
||||||
|
| Floating card stats | "50+ projets" / "Note 5.0" | "50+ projects" / "5.0 rating" |
|
||||||
|
| Terminal — role | `'Hytale Plugin Developer'` (remplace 'Full Stack Dev') | idem |
|
||||||
|
|
||||||
|
### Page /hytale — Hero dédié (CONT-02)
|
||||||
|
|
||||||
|
| Element | FR | EN |
|
||||||
|
|---------|----|----|
|
||||||
|
| Label mono | `// hytale` | `// hytale` |
|
||||||
|
| H1 | "Plugins Hytale sur-mesure" | "Custom Hytale Plugins" |
|
||||||
|
| Sous-titre | "Développement de plugins performants pour votre serveur Hytale, de la conception à la livraison." | "High-performance plugin development for your Hytale server, from design to delivery." |
|
||||||
|
|
||||||
|
### Grille Tarifaire — 5 tiers (CONT-03 / D-09 / D-10)
|
||||||
|
|
||||||
|
| Tier | FR label | EN label | Prix FR | Prix EN | CTA |
|
||||||
|
|------|----------|----------|---------|---------|-----|
|
||||||
|
| Plugin Simple | "Plugin Simple" | "Simple Plugin" | "À partir de 50€" | "From €50" | "Demander un devis" / "Request a quote" |
|
||||||
|
| Plugin Complexe | "Plugin Complexe" | "Complex Plugin" | "Sur devis" | "Custom quote" | "Demander un devis" / "Request a quote" |
|
||||||
|
| Sur-Mesure | "Développement Sur-Mesure" | "Custom Development" | "Sur devis" | "Custom quote" | "Demander un devis" / "Request a quote" |
|
||||||
|
| Maintenance | "Maintenance & Support" | "Maintenance & Support" | "À partir de 30€/mois" | "From €30/month" | "Demander un devis" / "Request a quote" |
|
||||||
|
| Web | "Développement Web" | "Web Development" | "Sur devis" | "Custom quote" | "Demander un devis" / "Request a quote" |
|
||||||
|
|
||||||
|
CTA universel pricing: "Demander un devis" → redirige `/contact` (source: D-11).
|
||||||
|
|
||||||
|
### Témoignages (CONT-04)
|
||||||
|
|
||||||
|
| Element | FR | EN |
|
||||||
|
|---------|----|----|
|
||||||
|
| Label mono section | `// témoignages` | `// testimonials` |
|
||||||
|
| Titre section | (existant — conserver, passer en i18n si hardcodé) | (existant) |
|
||||||
|
| Stat — clients | "avis clients" | "client reviews" |
|
||||||
|
| Stat — note | "note moyenne" | "average rating" |
|
||||||
|
| Stat — projets | "projets livrés" | "projects delivered" |
|
||||||
|
| totalReviews corrigé | `5` (source: D-16, FIX-04) | `5` |
|
||||||
|
|
||||||
|
### États vides et erreurs
|
||||||
|
|
||||||
|
| Element | Copy FR | Copy EN |
|
||||||
|
|---------|---------|---------|
|
||||||
|
| État vide témoignages (si aucun chargé) | "Aucun témoignage disponible pour l'instant." | "No testimonials available yet." |
|
||||||
|
| Erreur chargement image avatar | Masquer l'image, afficher initiales (fallback silencieux) | idem |
|
||||||
|
| Pas de cas de destruction dans cette phase | n/a | n/a |
|
||||||
|
|
||||||
|
Aucune action destructive dans cette phase — pas de confirmation dialog requise.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Patterns d'Interaction
|
||||||
|
|
||||||
|
### Carousel / Scroll horizontal (homepage + /hytale)
|
||||||
|
|
||||||
|
- Pattern: `overflow-x-auto snap-x snap-mandatory scrollbar-hide` (source: TestimonialsSection.vue existant)
|
||||||
|
- Homepage: 2-3 témoignages featured (source: D-15)
|
||||||
|
- Page /hytale: les 5 témoignages complets avec plus de détails (source: D-15)
|
||||||
|
- Pas de navigation dots/arrows requise en v1 — scroll natif suffit
|
||||||
|
|
||||||
|
### Cartes Pricing
|
||||||
|
|
||||||
|
- Layout: grille responsive `grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3` (5 tiers → 2+3 ou 3+2)
|
||||||
|
- Hover: `hover:border-brand-500/40 hover:shadow-xl hover:shadow-brand-500/10 hover:-translate-y-1` (pattern cartes existant)
|
||||||
|
- Tier le plus populaire: badge "Populaire" / "Popular" en `UBadge color="primary"`
|
||||||
|
- CTA de chaque carte: `UButton` full-width vers `/contact`
|
||||||
|
|
||||||
|
### Lien Discord (D-02, D-07)
|
||||||
|
|
||||||
|
- Bouton hero primaire: ouvre profil Discord personnel dans un nouvel onglet (`target="_blank" rel="noopener"`)
|
||||||
|
- Icône: `i-lucide-message-circle` ou `i-simple-icons-discord` si disponible dans Nuxt UI
|
||||||
|
|
||||||
|
### Badge "Disponible"
|
||||||
|
|
||||||
|
- Composant: inline-flex avec point animé `animate-ping` (pattern existant HeroSection.vue)
|
||||||
|
- Passer la string en i18n (source: D-03) — clé: `hero.badge.available`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## SEO & i18n — Contraintes Visuelles
|
||||||
|
|
||||||
|
- Toutes les strings visibles dans cette phase doivent avoir une clé i18n dans `fr.json` et `en.json` (source: I18N-03)
|
||||||
|
- Pas de strings hardcodées dans les templates — 0 exception pour cette phase
|
||||||
|
- Titre de page /hytale: `useSeoMeta()` avec `title` et `ogTitle` spécifiques (source: I18N-04)
|
||||||
|
- `totalReviews` dans `testimonials.ts` corrigé à `5` avant affichage (source: FIX-04 / D-16)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Registry Safety
|
||||||
|
|
||||||
|
| Registry | Blocks Used | Safety Gate |
|
||||||
|
|----------|-------------|-------------|
|
||||||
|
| Nuxt UI v3 (officiel) | UCard, UButton, UBadge, UIcon, UDivider | not required — officiel Nuxt |
|
||||||
|
| shadcn | aucun | not applicable — projet Nuxt/Vue |
|
||||||
|
| tiers | aucun | not applicable |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Checker Sign-Off
|
||||||
|
|
||||||
|
- [ ] Dimension 1 Copywriting: PASS
|
||||||
|
- [ ] Dimension 2 Visuals: PASS
|
||||||
|
- [ ] Dimension 3 Color: PASS
|
||||||
|
- [ ] Dimension 4 Typography: PASS
|
||||||
|
- [ ] Dimension 5 Spacing: PASS
|
||||||
|
- [ ] Dimension 6 Registry Safety: PASS
|
||||||
|
|
||||||
|
**Approval:** pending
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
---
|
||||||
|
phase: 03-seo-i18n
|
||||||
|
status: complete
|
||||||
|
completed: 2026-04-21
|
||||||
|
---
|
||||||
|
|
||||||
|
# Summary: SEO & i18n
|
||||||
|
|
||||||
|
## What was built (commits 03-01 à 03-03 + fixes)
|
||||||
|
|
||||||
|
- Design system Nuxt UI v3, color-mode, sitemap config (`feat(02-01)`)
|
||||||
|
- AppHeader avec nav, lang/theme toggles, mobile drawer (`feat(02-02)`)
|
||||||
|
- AppFooter + default layout + useLocaleHead (`feat(02-02)`)
|
||||||
|
- Per-route SEO metadata et JSON-LD sur toutes les pages (`feat(02-03)`)
|
||||||
|
- i18n translations complètes FR/EN (`feat(02-01)`)
|
||||||
|
- Correction i18n langDir path, typecheck errors (`fix(02)`)
|
||||||
|
- lang attr dynamique sur `<html>` via useHead (`fix(01) WR-04`)
|
||||||
|
- ContactForm avec validation Zod + route SMTP nodemailer (`feat(03-01)`)
|
||||||
|
- 9 shared components pour landing et projets (`feat(03-01)`)
|
||||||
|
- Landing page 6 sections, projects page, project detail, About, Contact, Fiverr, error.vue (`feat(03-02/03)`)
|
||||||
|
|
||||||
|
## Self-Check: PASSED
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
---
|
||||||
|
phase: 04-ship
|
||||||
|
status: complete
|
||||||
|
completed: 2026-04-21
|
||||||
|
---
|
||||||
|
|
||||||
|
# Summary: Ship
|
||||||
|
|
||||||
|
## What was built
|
||||||
|
|
||||||
|
- Dockerfile SSR multi-stage + docker-compose Traefik port 3000 (`feat(03-04)`)
|
||||||
|
- Suppression des fichiers SPA legacy, vérification GA4 (`chore(03-04)`)
|
||||||
|
- Template email terminal-style pour le contact (`feat(contact)`)
|
||||||
|
- Déployé en production sur killiandalcin.fr
|
||||||
|
|
||||||
|
## Self-Check: PASSED
|
||||||
@@ -0,0 +1,344 @@
|
|||||||
|
---
|
||||||
|
phase: 05-nuxt-content-setup-renderer
|
||||||
|
plan: 01
|
||||||
|
type: execute
|
||||||
|
wave: 1
|
||||||
|
depends_on: []
|
||||||
|
files_modified:
|
||||||
|
- nuxt.config.ts
|
||||||
|
- app/assets/css/main.css
|
||||||
|
- content.config.ts
|
||||||
|
- package.json
|
||||||
|
autonomous: true
|
||||||
|
requirements:
|
||||||
|
- BLOG-01
|
||||||
|
- BLOG-04
|
||||||
|
|
||||||
|
must_haves:
|
||||||
|
truths:
|
||||||
|
- "@nuxt/content est installé et `pnpm dev` démarre sans erreur"
|
||||||
|
- "Shiki est configuré avec les langages Kotlin, Java, TypeScript, Shell et les thèmes github-light/github-dark"
|
||||||
|
- "Les collections blog_fr et blog_en sont déclarées dans content.config.ts avec le bon prefix i18n"
|
||||||
|
- "@tailwindcss/typography est chargé via `@plugin` dans main.css"
|
||||||
|
artifacts:
|
||||||
|
- path: "content.config.ts"
|
||||||
|
provides: "Déclaration des collections bilingues blog_fr + blog_en avec schema Zod"
|
||||||
|
exports: ["defineContentConfig"]
|
||||||
|
- path: "nuxt.config.ts"
|
||||||
|
provides: "Module @nuxt/content + config Shiki dual-theme + sqliteConnector native"
|
||||||
|
contains: "@nuxt/content"
|
||||||
|
- path: "app/assets/css/main.css"
|
||||||
|
provides: "Plugin @tailwindcss/typography chargé"
|
||||||
|
contains: "@plugin"
|
||||||
|
key_links:
|
||||||
|
- from: "nuxt.config.ts content.build.markdown.highlight"
|
||||||
|
to: "Shiki dual-theme github-light/github-dark"
|
||||||
|
via: "theme.default + theme.dark"
|
||||||
|
pattern: "github-light.*github-dark|github-dark.*github-light"
|
||||||
|
- from: "content.config.ts collections.blog_fr"
|
||||||
|
to: "content/fr/blog/**/*.md"
|
||||||
|
via: "source.include"
|
||||||
|
pattern: "fr/blog/\\*\\*"
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
Installer `@nuxt/content` v3 et `@tailwindcss/typography`, puis configurer le système de rendu markdown — Shiki dual-theme, collections bilingues, connecteur SQLite natif.
|
||||||
|
|
||||||
|
Purpose: Cette phase pose les fondations du CMS. Sans elle, les phases 6, 7 et 8 ne peuvent pas fonctionner. La configuration doit être définitive — aucun retour en arrière attendu.
|
||||||
|
|
||||||
|
Output:
|
||||||
|
- `@nuxt/content` installé et déclaré dans `nuxt.config.ts`
|
||||||
|
- `content.config.ts` avec collections `blog_fr` + `blog_en`
|
||||||
|
- Shiki configuré pour Kotlin, Java, TypeScript, Shell avec thèmes dark/light
|
||||||
|
- `@tailwindcss/typography` chargé via `@plugin` dans `main.css`
|
||||||
|
- `pnpm dev` démarre sans erreur
|
||||||
|
</objective>
|
||||||
|
|
||||||
|
<execution_context>
|
||||||
|
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
||||||
|
@$HOME/.claude/get-shit-done/templates/summary.md
|
||||||
|
</execution_context>
|
||||||
|
|
||||||
|
<context>
|
||||||
|
@.planning/PROJECT.md
|
||||||
|
@.planning/ROADMAP.md
|
||||||
|
@.planning/STATE.md
|
||||||
|
@.planning/phases/05-nuxt-content-setup-renderer/05-CONTEXT.md
|
||||||
|
@.planning/phases/05-nuxt-content-setup-renderer/05-RESEARCH.md
|
||||||
|
|
||||||
|
<interfaces>
|
||||||
|
<!-- État actuel de nuxt.config.ts — ne PAS réécrire, uniquement étendre -->
|
||||||
|
<!-- nuxt.config.ts lignes 7-14 (modules) : -->
|
||||||
|
```typescript
|
||||||
|
modules: [
|
||||||
|
'@nuxt/ui',
|
||||||
|
'@nuxtjs/i18n',
|
||||||
|
'@nuxt/eslint',
|
||||||
|
'@nuxtjs/sitemap',
|
||||||
|
'nuxt-gtag',
|
||||||
|
'@nuxt/image'
|
||||||
|
],
|
||||||
|
```
|
||||||
|
|
||||||
|
<!-- nuxt.config.ts lignes 24-30 (colorMode) : -->
|
||||||
|
```typescript
|
||||||
|
colorMode: {
|
||||||
|
preference: 'dark',
|
||||||
|
fallback: 'dark',
|
||||||
|
storage: 'cookie',
|
||||||
|
storageKey: 'nuxt-color-mode',
|
||||||
|
classSuffix: '' // ← CRITIQUE: Shiki dual-theme nécessite classSuffix: '' pour html.dark
|
||||||
|
},
|
||||||
|
```
|
||||||
|
|
||||||
|
<!-- État actuel de app/assets/css/main.css : -->
|
||||||
|
```css
|
||||||
|
@import "tailwindcss";
|
||||||
|
@import "@nuxt/ui";
|
||||||
|
|
||||||
|
@theme {
|
||||||
|
--color-brand-500: #85cb85;
|
||||||
|
/* ... autres tokens brand */
|
||||||
|
}
|
||||||
|
```
|
||||||
|
</interfaces>
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 1: Installer @nuxt/content et @tailwindcss/typography</name>
|
||||||
|
<files>package.json</files>
|
||||||
|
<read_first>
|
||||||
|
- package.json (vérifier pnpm.onlyBuiltDependencies existant, ne pas écraser)
|
||||||
|
</read_first>
|
||||||
|
<action>
|
||||||
|
Exécuter les deux commandes d'installation suivantes dans l'ordre :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm add @nuxt/content
|
||||||
|
pnpm add -D @tailwindcss/typography
|
||||||
|
```
|
||||||
|
|
||||||
|
Versions cibles : `@nuxt/content@^3.6.3`, `@tailwindcss/typography@^0.5.x`.
|
||||||
|
|
||||||
|
NE PAS ajouter `better-sqlite3` — le connecteur natif Node 22 sera utilisé via `experimental.sqliteConnector: 'native'` dans nuxt.config.ts (Task 2).
|
||||||
|
|
||||||
|
Si `pnpm add` échoue avec une erreur de script build SQLite, c'est normal sans la config native — continuer vers Task 2 qui la résout.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
```bash
|
||||||
|
grep '"@nuxt/content"' package.json
|
||||||
|
grep '"@tailwindcss/typography"' package.json
|
||||||
|
```
|
||||||
|
Les deux lignes doivent apparaître.
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- `package.json` contient `"@nuxt/content"` dans `dependencies`
|
||||||
|
- `package.json` contient `"@tailwindcss/typography"` dans `devDependencies`
|
||||||
|
- `node_modules/@nuxt/content` existe
|
||||||
|
- `node_modules/@tailwindcss/typography` existe
|
||||||
|
</acceptance_criteria>
|
||||||
|
<done>Les deux packages sont installés via pnpm sans erreur bloquante.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 2: Configurer nuxt.config.ts et app/assets/css/main.css</name>
|
||||||
|
<files>nuxt.config.ts, app/assets/css/main.css</files>
|
||||||
|
<read_first>
|
||||||
|
- nuxt.config.ts (lire INTÉGRALEMENT avant de modifier — ne jamais réécrire, uniquement étendre)
|
||||||
|
- app/assets/css/main.css (lire INTÉGRALEMENT)
|
||||||
|
</read_first>
|
||||||
|
<action>
|
||||||
|
**1. nuxt.config.ts — deux modifications :**
|
||||||
|
|
||||||
|
a) Ajouter `'@nuxt/content'` à la fin du tableau `modules` (après `'@nuxt/image'`) :
|
||||||
|
```typescript
|
||||||
|
modules: [
|
||||||
|
'@nuxt/ui',
|
||||||
|
'@nuxtjs/i18n',
|
||||||
|
'@nuxt/eslint',
|
||||||
|
'@nuxtjs/sitemap',
|
||||||
|
'nuxt-gtag',
|
||||||
|
'@nuxt/image',
|
||||||
|
'@nuxt/content' // ← ligne ajoutée
|
||||||
|
],
|
||||||
|
```
|
||||||
|
|
||||||
|
b) Ajouter le bloc `content` après le bloc `gtag` existant (avant la fermeture `}`) :
|
||||||
|
```typescript
|
||||||
|
content: {
|
||||||
|
build: {
|
||||||
|
markdown: {
|
||||||
|
highlight: {
|
||||||
|
theme: {
|
||||||
|
default: 'github-light',
|
||||||
|
dark: 'github-dark'
|
||||||
|
},
|
||||||
|
langs: ['kotlin', 'java', 'typescript', 'shell', 'bash', 'json', 'vue', 'html', 'css']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
experimental: {
|
||||||
|
sqliteConnector: 'native'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
NE PAS utiliser `nativeSqlite: true` (déprécié). Utiliser exclusivement `sqliteConnector: 'native'`.
|
||||||
|
NE PAS modifier `colorMode.classSuffix` — doit rester `''` pour que Shiki dual-theme fonctionne via `html.dark`.
|
||||||
|
|
||||||
|
**2. app/assets/css/main.css — une ligne ajoutée :**
|
||||||
|
|
||||||
|
Ajouter `@plugin "@tailwindcss/typography";` après `@import "@nuxt/ui";` :
|
||||||
|
```css
|
||||||
|
@import "tailwindcss";
|
||||||
|
@import "@nuxt/ui";
|
||||||
|
@plugin "@tailwindcss/typography";
|
||||||
|
```
|
||||||
|
|
||||||
|
NE PAS utiliser `plugins: [require('@tailwindcss/typography')]` dans tailwind.config.js — cette syntaxe est ignorée en Tailwind v4. La syntaxe `@plugin` dans le CSS est la seule valide.
|
||||||
|
NE PAS toucher le bloc `@theme` existant avec les tokens `--color-brand-*`.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
```bash
|
||||||
|
grep "'@nuxt/content'" nuxt.config.ts
|
||||||
|
grep "github-dark" nuxt.config.ts
|
||||||
|
grep "sqliteConnector" nuxt.config.ts
|
||||||
|
grep "kotlin" nuxt.config.ts
|
||||||
|
grep '@plugin "@tailwindcss/typography"' app/assets/css/main.css
|
||||||
|
```
|
||||||
|
Les cinq lignes doivent retourner un résultat.
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- `nuxt.config.ts` contient `'@nuxt/content'` dans le tableau `modules`
|
||||||
|
- `nuxt.config.ts` contient le bloc `content.build.markdown.highlight.theme` avec `default: 'github-light'` et `dark: 'github-dark'`
|
||||||
|
- `nuxt.config.ts` contient `sqliteConnector: 'native'` (PAS `nativeSqlite`)
|
||||||
|
- `nuxt.config.ts` liste au minimum ces langages Shiki : `'kotlin'`, `'java'`, `'typescript'`, `'shell'`
|
||||||
|
- `nuxt.config.ts` ne contient PAS `nativeSqlite`
|
||||||
|
- `app/assets/css/main.css` contient `@plugin "@tailwindcss/typography";` sur sa propre ligne
|
||||||
|
- `app/assets/css/main.css` contient toujours le bloc `@theme` avec `--color-brand-500`
|
||||||
|
</acceptance_criteria>
|
||||||
|
<done>nuxt.config.ts étend le module @nuxt/content avec Shiki dual-theme. main.css charge @tailwindcss/typography via @plugin.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 3: Créer content.config.ts avec collections bilingues</name>
|
||||||
|
<files>content.config.ts</files>
|
||||||
|
<read_first>
|
||||||
|
- nuxt.config.ts (vérifier i18n.strategy et i18n.defaultLocale pour confirmer le prefix des collections)
|
||||||
|
- .planning/phases/05-nuxt-content-setup-renderer/05-RESEARCH.md (Pattern 2 — content.config.ts)
|
||||||
|
</read_first>
|
||||||
|
<action>
|
||||||
|
Créer `content.config.ts` à la RACINE du projet (même niveau que `nuxt.config.ts`).
|
||||||
|
|
||||||
|
Contenu exact :
|
||||||
|
```typescript
|
||||||
|
import { defineContentConfig, defineCollection, z } from '@nuxt/content'
|
||||||
|
|
||||||
|
const blogSchema = z.object({
|
||||||
|
title: z.string(),
|
||||||
|
description: z.string(),
|
||||||
|
date: z.string(),
|
||||||
|
tags: z.array(z.string()).optional(),
|
||||||
|
image: z.string().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export default defineContentConfig({
|
||||||
|
collections: {
|
||||||
|
blog_fr: defineCollection({
|
||||||
|
type: 'page',
|
||||||
|
source: { include: 'fr/blog/**/*.md', prefix: '/blog' },
|
||||||
|
schema: blogSchema,
|
||||||
|
}),
|
||||||
|
blog_en: defineCollection({
|
||||||
|
type: 'page',
|
||||||
|
source: { include: 'en/blog/**/*.md', prefix: '/en/blog' },
|
||||||
|
schema: blogSchema,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
Justification des prefixes :
|
||||||
|
- `blog_fr` → prefix `/blog` (FR est la locale par défaut avec `prefix_except_default`, donc pas de `/fr/` dans l'URL)
|
||||||
|
- `blog_en` → prefix `/en/blog` (EN reçoit le préfixe de langue)
|
||||||
|
|
||||||
|
Ce schema minimal sera étendu en Phase 7 (author, og:image, etc.) — ne pas anticiper.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
```bash
|
||||||
|
test -f content.config.ts && echo "EXISTS"
|
||||||
|
grep "blog_fr" content.config.ts
|
||||||
|
grep "blog_en" content.config.ts
|
||||||
|
grep "prefix: '/blog'" content.config.ts
|
||||||
|
grep "prefix: '/en/blog'" content.config.ts
|
||||||
|
```
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- `content.config.ts` existe à la racine du projet
|
||||||
|
- Contient l'export `defineContentConfig`
|
||||||
|
- Contient la collection `blog_fr` avec `source.include: 'fr/blog/**/*.md'` et `source.prefix: '/blog'`
|
||||||
|
- Contient la collection `blog_en` avec `source.include: 'en/blog/**/*.md'` et `source.prefix: '/en/blog'`
|
||||||
|
- Le schema Zod contient les champs `title`, `description`, `date` (requis) et `tags`, `image` (optionnels)
|
||||||
|
- `pnpm dev` démarre sans erreur après ces trois tasks (vérification smoke finale)
|
||||||
|
</acceptance_criteria>
|
||||||
|
<done>content.config.ts créé avec collections bilingues. `pnpm dev` démarre sans erreur — l'infrastructure @nuxt/content est opérationnelle.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<threat_model>
|
||||||
|
## Trust Boundaries
|
||||||
|
|
||||||
|
| Boundary | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| Système de fichiers → Parser @nuxt/content | Fichiers markdown lus au build — source contrôlée (auteur uniquement, pas d'input utilisateur) |
|
||||||
|
| Node.js 22 → SQLite natif | Connecteur natif utilisé au lieu de better-sqlite3 — pas d'exposition réseau |
|
||||||
|
|
||||||
|
## STRIDE Threat Register
|
||||||
|
|
||||||
|
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||||
|
|-----------|----------|-----------|-------------|-----------------|
|
||||||
|
| T-05-01 | Tampering | `content.config.ts` source.include glob | accept | Seuls les fichiers `*.md` dans `content/` sont indexés — aucun input utilisateur dans cette phase, glob contrôlé par l'auteur |
|
||||||
|
| T-05-02 | Information Disclosure | Shiki HTML output | accept | Shiki génère du HTML échappé — pas de XSS possible via blocs de code |
|
||||||
|
| T-05-03 | Denial of Service | SQLite natif Node 22 au build | accept | Build-time uniquement, pas d'exposition runtime — risque nul en production |
|
||||||
|
| T-05-04 | Elevation of Privilege | `experimental.sqliteConnector: 'native'` | accept | Connecteur natif Node.js — pas de binary externe, surface d'attaque réduite vs better-sqlite3 |
|
||||||
|
</threat_model>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
Après exécution du plan 01, vérifier :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Packages installés
|
||||||
|
grep '"@nuxt/content"' package.json && grep '"@tailwindcss/typography"' package.json
|
||||||
|
|
||||||
|
# 2. nuxt.config.ts étendu correctement
|
||||||
|
grep "'@nuxt/content'" nuxt.config.ts
|
||||||
|
grep "github-dark" nuxt.config.ts
|
||||||
|
grep "sqliteConnector.*native" nuxt.config.ts
|
||||||
|
# NE DOIT PAS contenir l'option dépréciée :
|
||||||
|
grep "nativeSqlite" nuxt.config.ts # doit retourner RIEN
|
||||||
|
|
||||||
|
# 3. CSS typography
|
||||||
|
grep '@plugin "@tailwindcss/typography"' app/assets/css/main.css
|
||||||
|
|
||||||
|
# 4. content.config.ts collections
|
||||||
|
grep "blog_fr\|blog_en" content.config.ts
|
||||||
|
|
||||||
|
# 5. Smoke test
|
||||||
|
pnpm dev # doit démarrer sans erreur
|
||||||
|
```
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
- `pnpm dev` démarre sans erreur après installation et configuration
|
||||||
|
- `nuxt.config.ts` contient `'@nuxt/content'` dans modules et le bloc `content` avec Shiki dual-theme + langages
|
||||||
|
- `content.config.ts` existe avec les deux collections bilingues et le bon prefix i18n
|
||||||
|
- `app/assets/css/main.css` charge `@tailwindcss/typography` via `@plugin`
|
||||||
|
- `pnpm typecheck` passe (0 erreur TypeScript)
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
Après completion, créer `.planning/phases/05-nuxt-content-setup-renderer/05-01-SUMMARY.md`
|
||||||
|
</output>
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
---
|
||||||
|
phase: 05-nuxt-content-setup-renderer
|
||||||
|
plan: "01"
|
||||||
|
subsystem: cms-infrastructure
|
||||||
|
tags: [nuxt-content, shiki, tailwind-typography, sqlite, i18n, collections]
|
||||||
|
dependency_graph:
|
||||||
|
requires: []
|
||||||
|
provides: [nuxt-content-module, shiki-dual-theme, bilingual-collections, typography-plugin]
|
||||||
|
affects: [nuxt.config.ts, content.config.ts, app/assets/css/main.css]
|
||||||
|
tech_stack:
|
||||||
|
added:
|
||||||
|
- "@nuxt/content@3.13.0"
|
||||||
|
- "@tailwindcss/typography@0.5.19"
|
||||||
|
patterns:
|
||||||
|
- "Shiki dual-theme via theme.default + theme.dark (github-light/github-dark)"
|
||||||
|
- "SQLite connecteur natif Node 22 via experimental.sqliteConnector: 'native'"
|
||||||
|
- "Collections i18n: prefix_except_default — blog_fr=/blog, blog_en=/en/blog"
|
||||||
|
- "@plugin CSS syntax pour Tailwind v4 plugins"
|
||||||
|
key_files:
|
||||||
|
created:
|
||||||
|
- content.config.ts
|
||||||
|
modified:
|
||||||
|
- nuxt.config.ts
|
||||||
|
- app/assets/css/main.css
|
||||||
|
- .gitignore
|
||||||
|
decisions:
|
||||||
|
- "sqliteConnector: 'native' (Node 22) — évite better-sqlite3 et ses bindings natifs"
|
||||||
|
- "Prefixes collections alignés sur i18n.strategy: prefix_except_default (FR sans prefix, EN avec /en/)"
|
||||||
|
- "Shiki langs: kotlin, java, typescript, shell, bash, json, vue, html, css"
|
||||||
|
metrics:
|
||||||
|
duration: "~10 minutes"
|
||||||
|
completed: "2026-04-21"
|
||||||
|
tasks_completed: 3
|
||||||
|
tasks_total: 3
|
||||||
|
files_created: 1
|
||||||
|
files_modified: 3
|
||||||
|
---
|
||||||
|
|
||||||
|
# Phase 05 Plan 01: @nuxt/content Install & Configuration Summary
|
||||||
|
|
||||||
|
Installation et configuration de @nuxt/content v3 avec Shiki dual-theme, collections bilingues FR/EN, et plugin @tailwindcss/typography pour le portfolio Nuxt 4.
|
||||||
|
|
||||||
|
## Tasks Completed
|
||||||
|
|
||||||
|
| Task | Name | Commit | Files |
|
||||||
|
|------|------|--------|-------|
|
||||||
|
| 1 | Installer @nuxt/content et @tailwindcss/typography | c64709d | package.json, pnpm-lock.yaml |
|
||||||
|
| 2 | Configurer nuxt.config.ts et main.css | 3381b2e | nuxt.config.ts, app/assets/css/main.css |
|
||||||
|
| 3 | Créer content.config.ts avec collections bilingues | 8319789 | content.config.ts |
|
||||||
|
| — | Fix: .data dans .gitignore | f49fab2 | .gitignore |
|
||||||
|
|
||||||
|
## Decisions Made
|
||||||
|
|
||||||
|
1. **sqliteConnector: 'native'** — Node 22 inclut SQLite natif, évite la dépendance `better-sqlite3` et ses bindings C++ à compiler.
|
||||||
|
2. **Prefixes i18n des collections** — alignés sur `prefix_except_default` : `blog_fr` → `/blog` (FR = locale par défaut, pas de prefix), `blog_en` → `/en/blog`.
|
||||||
|
3. **Schema Zod minimal** — `title`, `description`, `date` requis + `tags`, `image` optionnels. Les champs `author` et `og:image` seront ajoutés en Phase 7.
|
||||||
|
|
||||||
|
## Deviations from Plan
|
||||||
|
|
||||||
|
### Auto-fixed Issues
|
||||||
|
|
||||||
|
**1. [Rule 2 - Missing] .data/ non tracké dans .gitignore**
|
||||||
|
- **Found during:** Après Task 3 (smoke test `pnpm dev`)
|
||||||
|
- **Issue:** `@nuxt/content` génère un répertoire `.data/content/` (base SQLite runtime) non ignoré par git
|
||||||
|
- **Fix:** Ajout de `.data` dans `.gitignore`
|
||||||
|
- **Files modified:** .gitignore
|
||||||
|
- **Commit:** f49fab2
|
||||||
|
|
||||||
|
## Verification Results
|
||||||
|
|
||||||
|
```
|
||||||
|
grep '"@nuxt/content"' package.json → "@nuxt/content": "^3.13.0"
|
||||||
|
grep "'@nuxt/content'" nuxt.config.ts → '@nuxt/content'
|
||||||
|
grep "github-dark" nuxt.config.ts → dark: 'github-dark'
|
||||||
|
grep "sqliteConnector" nuxt.config.ts → sqliteConnector: 'native'
|
||||||
|
grep "nativeSqlite" nuxt.config.ts → (rien — correct)
|
||||||
|
grep '@plugin' app/assets/css/main.css → @plugin "@tailwindcss/typography";
|
||||||
|
grep "blog_fr\|blog_en" content.config.ts → blog_fr + blog_en
|
||||||
|
pnpm dev → Nuxt 4.4.2 démarre sur :3000 sans erreur
|
||||||
|
```
|
||||||
|
|
||||||
|
## Known Stubs
|
||||||
|
|
||||||
|
Aucun — cette phase ne produit pas de rendu UI, uniquement de l'infrastructure.
|
||||||
|
|
||||||
|
## Threat Flags
|
||||||
|
|
||||||
|
Aucun nouveau vecteur introduit au-delà de ce qui est documenté dans le threat_model du plan.
|
||||||
|
|
||||||
|
## Self-Check: PASSED
|
||||||
|
|
||||||
|
- content.config.ts existe : FOUND
|
||||||
|
- nuxt.config.ts contient '@nuxt/content' : FOUND
|
||||||
|
- app/assets/css/main.css contient @plugin typography : FOUND
|
||||||
|
- Commits c64709d, 3381b2e, 8319789, f49fab2 : FOUND
|
||||||
@@ -0,0 +1,466 @@
|
|||||||
|
---
|
||||||
|
phase: 05-nuxt-content-setup-renderer
|
||||||
|
plan: 02
|
||||||
|
type: execute
|
||||||
|
wave: 2
|
||||||
|
depends_on:
|
||||||
|
- '01'
|
||||||
|
files_modified:
|
||||||
|
- app/components/content/ProseImg.vue
|
||||||
|
- app/components/content/Alert.vue
|
||||||
|
- app/components/content/ProseImg.vue
|
||||||
|
- app/components/content/Alert.vue
|
||||||
|
- content/fr/blog/test-kotlin-syntax.md
|
||||||
|
- content/en/blog/test-kotlin-syntax.md
|
||||||
|
- app/pages/test.vue
|
||||||
|
autonomous: false
|
||||||
|
requirements:
|
||||||
|
- BLOG-01
|
||||||
|
- BLOG-05
|
||||||
|
|
||||||
|
must_haves:
|
||||||
|
truths:
|
||||||
|
- "Un article markdown avec un bloc Kotlin est rendu avec coloration syntaxique visible"
|
||||||
|
- "Une image référencée dans l'article s'affiche via NuxtImg avec lazy loading et srcset"
|
||||||
|
- "Un tableau markdown est rendu avec le style prose correct"
|
||||||
|
- "Un callout ::alert{type='info'} affiche un UAlert stylisé Nuxt UI"
|
||||||
|
- "Les quatre types de callout (info, warning, tip, danger) fonctionnent"
|
||||||
|
artifacts:
|
||||||
|
- path: "app/components/content/ProseImg.vue"
|
||||||
|
provides: "Override ProseImg → NuxtImg optimisé"
|
||||||
|
exports: ["default (component)"]
|
||||||
|
- path: "app/components/content/Alert.vue"
|
||||||
|
provides: "Composant MDC callout via UAlert"
|
||||||
|
exports: ["default (component)"]
|
||||||
|
- path: "content/fr/blog/test-kotlin-syntax.md"
|
||||||
|
provides: "Article de test FR couvrant les 4 success criteria"
|
||||||
|
contains: "```kotlin"
|
||||||
|
- path: "content/en/blog/test-kotlin-syntax.md"
|
||||||
|
provides: "Article de test EN — même slug"
|
||||||
|
contains: "```kotlin"
|
||||||
|
key_links:
|
||||||
|
- from: "content/fr/blog/test-kotlin-syntax.md"
|
||||||
|
to: "app/components/content/ProseImg.vue"
|
||||||
|
via: "ContentRenderer détecte les balises img et les route vers ProseImg"
|
||||||
|
pattern: "ProseImg"
|
||||||
|
- from: "content/fr/blog/test-kotlin-syntax.md"
|
||||||
|
to: "app/components/content/Alert.vue"
|
||||||
|
via: "MDC ::alert{type} appelle Alert.vue"
|
||||||
|
pattern: "::alert"
|
||||||
|
---
|
||||||
|
|
||||||
|
<objective>
|
||||||
|
Créer les composants de rendu markdown (ProseImg + Alert) et les articles de test permettant de valider visuellement les 4 success criteria de la phase.
|
||||||
|
|
||||||
|
Purpose: Les composants MDC sont le liant entre le markdown brut et le rendu visuel. ProseImg garantit que chaque image passe par NuxtImg (BLOG-05). Alert garantit que les callouts ::alert sont rendus comme des composants Nuxt UI stylisés (BLOG-01).
|
||||||
|
|
||||||
|
Output:
|
||||||
|
- `app/components/content/ProseImg.vue` — override transparent NuxtImg
|
||||||
|
- `app/components/content/Alert.vue` — callout MDC avec 4 types (info/warning/tip/danger)
|
||||||
|
- `content/fr/blog/test-kotlin-syntax.md` — article de test couvrant les 4 critères
|
||||||
|
- `content/en/blog/test-kotlin-syntax.md` — version EN du même article
|
||||||
|
- Checkpoint visuel validant rendu Kotlin coloré + image NuxtImg + tableau + callout
|
||||||
|
</objective>
|
||||||
|
|
||||||
|
<execution_context>
|
||||||
|
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
||||||
|
@$HOME/.claude/get-shit-done/templates/summary.md
|
||||||
|
</execution_context>
|
||||||
|
|
||||||
|
<context>
|
||||||
|
@.planning/PROJECT.md
|
||||||
|
@.planning/ROADMAP.md
|
||||||
|
@.planning/phases/05-nuxt-content-setup-renderer/05-CONTEXT.md
|
||||||
|
@.planning/phases/05-nuxt-content-setup-renderer/05-RESEARCH.md
|
||||||
|
@.planning/phases/05-nuxt-content-setup-renderer/05-UI-SPEC.md
|
||||||
|
@.planning/phases/05-nuxt-content-setup-renderer/05-01-SUMMARY.md
|
||||||
|
|
||||||
|
<interfaces>
|
||||||
|
<!-- Composants analogues du projet — suivre ces patterns -->
|
||||||
|
|
||||||
|
<!-- app/components/ProjectCard.vue — usage NuxtImg (source: PATTERNS.md) -->
|
||||||
|
```vue
|
||||||
|
<NuxtImg
|
||||||
|
:src="project.image"
|
||||||
|
:alt="`...`"
|
||||||
|
loading="lazy"
|
||||||
|
format="webp"
|
||||||
|
width="400"
|
||||||
|
height="300"
|
||||||
|
class="w-full h-52 object-cover"
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
<!-- app/components/TechBadge.vue — pattern withDefaults + computed map (source: PATTERNS.md) -->
|
||||||
|
```typescript
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
showLevel: true,
|
||||||
|
showImage: true,
|
||||||
|
})
|
||||||
|
const levelColor = computed(() => {
|
||||||
|
switch (techData.value.level) {
|
||||||
|
case 'Advanced': return 'success' as const
|
||||||
|
// ...
|
||||||
|
}
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
<!-- nuxt.config.ts components config — auto-import depuis components/content/ -->
|
||||||
|
```typescript
|
||||||
|
components: [
|
||||||
|
{
|
||||||
|
path: '~/components',
|
||||||
|
pathPrefix: false, // composants dans content/ sont auto-importés ET reconnus MDC
|
||||||
|
},
|
||||||
|
],
|
||||||
|
```
|
||||||
|
|
||||||
|
<!-- Contrat prose wrapper (UI-SPEC.md) -->
|
||||||
|
```html
|
||||||
|
<article class="prose dark:prose-invert max-w-none">
|
||||||
|
<ContentRenderer :value="page" />
|
||||||
|
</article>
|
||||||
|
```
|
||||||
|
</interfaces>
|
||||||
|
</context>
|
||||||
|
|
||||||
|
<tasks>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 1: Créer les composants MDC ProseImg.vue et Alert.vue</name>
|
||||||
|
<files>app/components/content/ProseImg.vue, app/components/content/Alert.vue</files>
|
||||||
|
<read_first>
|
||||||
|
- app/components/content/ (vérifier si le dossier existe — le créer si nécessaire)
|
||||||
|
- .planning/phases/05-nuxt-content-setup-renderer/05-RESEARCH.md (Pattern 4 ProseImg, Pattern 5 Alert)
|
||||||
|
- .planning/phases/05-nuxt-content-setup-renderer/05-UI-SPEC.md (Component Inventory, tableau iconMap/colorMap)
|
||||||
|
</read_first>
|
||||||
|
<action>
|
||||||
|
Créer le dossier `app/components/content/` s'il n'existe pas.
|
||||||
|
|
||||||
|
**1. Créer `app/components/content/ProseImg.vue` :**
|
||||||
|
```vue
|
||||||
|
<script setup lang="ts">
|
||||||
|
interface Props {
|
||||||
|
src: string
|
||||||
|
alt?: string
|
||||||
|
title?: string
|
||||||
|
width?: string | number
|
||||||
|
height?: string | number
|
||||||
|
}
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
alt: '',
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<NuxtImg
|
||||||
|
:src="props.src"
|
||||||
|
:alt="props.alt"
|
||||||
|
:title="props.title"
|
||||||
|
:width="props.width"
|
||||||
|
:height="props.height"
|
||||||
|
class="rounded-lg w-full"
|
||||||
|
sizes="sm:600px md:800px lg:1000px"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
```
|
||||||
|
|
||||||
|
NuxtImg est auto-importé par @nuxt/image — pas d'import explicite nécessaire.
|
||||||
|
NE PAS ajouter `loading="lazy"` explicite sur NuxtImg — @nuxt/image gère lazy par défaut.
|
||||||
|
|
||||||
|
**2. Créer `app/components/content/Alert.vue` :**
|
||||||
|
```vue
|
||||||
|
<script setup lang="ts">
|
||||||
|
interface Props {
|
||||||
|
type?: 'info' | 'warning' | 'tip' | 'danger'
|
||||||
|
}
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
type: 'info',
|
||||||
|
})
|
||||||
|
|
||||||
|
const iconMap = {
|
||||||
|
info: 'i-heroicons-information-circle',
|
||||||
|
warning: 'i-heroicons-exclamation-triangle',
|
||||||
|
tip: 'i-heroicons-light-bulb',
|
||||||
|
danger: 'i-heroicons-x-circle',
|
||||||
|
}
|
||||||
|
const colorMap = {
|
||||||
|
info: 'info',
|
||||||
|
warning: 'warning',
|
||||||
|
tip: 'success',
|
||||||
|
danger: 'error',
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<UAlert
|
||||||
|
:icon="iconMap[props.type]"
|
||||||
|
:color="colorMap[props.type] as any"
|
||||||
|
variant="soft"
|
||||||
|
class="my-4"
|
||||||
|
>
|
||||||
|
<template #description>
|
||||||
|
<ContentSlot :use="$slots.default" unwrap="p" />
|
||||||
|
</template>
|
||||||
|
</UAlert>
|
||||||
|
</template>
|
||||||
|
```
|
||||||
|
|
||||||
|
CRITIQUE : `<ContentSlot :use="$slots.default" unwrap="p" />` est OBLIGATOIRE — sans cette ligne, le contenu entre `::alert` et `::` n'est pas rendu (Pitfall 4 RESEARCH.md).
|
||||||
|
UAlert et ContentSlot sont auto-importés — pas d'import explicite.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
```bash
|
||||||
|
test -f app/components/content/ProseImg.vue && echo "ProseImg OK"
|
||||||
|
test -f app/components/content/Alert.vue && echo "Alert OK"
|
||||||
|
grep "NuxtImg" app/components/content/ProseImg.vue
|
||||||
|
grep "ContentSlot" app/components/content/Alert.vue
|
||||||
|
grep "iconMap" app/components/content/Alert.vue
|
||||||
|
grep "i-heroicons-information-circle" app/components/content/Alert.vue
|
||||||
|
```
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- `app/components/content/ProseImg.vue` existe et contient `<NuxtImg` avec `:src`, `:alt`, `sizes`
|
||||||
|
- `app/components/content/Alert.vue` existe et contient `<ContentSlot :use="$slots.default" unwrap="p" />`
|
||||||
|
- `Alert.vue` définit les 4 types : `'info' | 'warning' | 'tip' | 'danger'`
|
||||||
|
- `Alert.vue` contient `iconMap` avec les 4 icônes Heroicons
|
||||||
|
- `Alert.vue` contient `colorMap` avec les 4 couleurs Nuxt UI
|
||||||
|
- `Alert.vue` utilise `UAlert` avec `variant="soft"`
|
||||||
|
- `ProseImg.vue` utilise `withDefaults` avec `alt: ''` comme valeur par défaut
|
||||||
|
- Aucun import explicite de NuxtImg, UAlert ou ContentSlot (auto-importés)
|
||||||
|
</acceptance_criteria>
|
||||||
|
<done>ProseImg.vue et Alert.vue créés et conformes aux patterns du projet.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<task type="auto">
|
||||||
|
<name>Task 2: Créer les articles de test markdown FR et EN</name>
|
||||||
|
<files>content/fr/blog/test-kotlin-syntax.md, content/en/blog/test-kotlin-syntax.md, app/pages/test.vue (a supprimer apres checkpoint visuel)</files>
|
||||||
|
<read_first>
|
||||||
|
- .planning/phases/05-nuxt-content-setup-renderer/05-UI-SPEC.md (Copywriting Contract — copie exacte des textes)
|
||||||
|
- .planning/phases/05-nuxt-content-setup-renderer/05-RESEARCH.md (Code Examples — structure de l'article de test)
|
||||||
|
- content.config.ts (vérifier que le schema Zod attend ces champs frontmatter)
|
||||||
|
</read_first>
|
||||||
|
<action>
|
||||||
|
Créer les dossiers `content/fr/blog/` et `content/en/blog/` s'ils n'existent pas.
|
||||||
|
|
||||||
|
**1. Créer `content/fr/blog/test-kotlin-syntax.md` :**
|
||||||
|
|
||||||
|
````markdown
|
||||||
|
---
|
||||||
|
title: "Test Kotlin Syntax Highlighting"
|
||||||
|
description: "Article de test pour valider le renderer @nuxt/content"
|
||||||
|
date: "2026-04-21"
|
||||||
|
tags: ["kotlin", "hytale", "test"]
|
||||||
|
---
|
||||||
|
|
||||||
|
## Bloc de code Kotlin
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
fun main() {
|
||||||
|
println("Hello, Hytale!")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun createPlugin(name: String): Plugin {
|
||||||
|
return Plugin(name = name, version = "1.0.0")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Image optimisée
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## Tableau
|
||||||
|
|
||||||
|
| Fonctionnalité | Statut | Notes |
|
||||||
|
|----------------|--------|-------|
|
||||||
|
| Syntax highlighting | ✅ Actif | Kotlin, Java, TypeScript, Shell |
|
||||||
|
| Images optimisées | ✅ Actif | Via NuxtImg (lazy + srcset) |
|
||||||
|
| Tableaux | ✅ Actif | Rendu prose |
|
||||||
|
| Callouts | ✅ Actif | MDC ::alert{type} |
|
||||||
|
|
||||||
|
## Callouts
|
||||||
|
|
||||||
|
::alert{type="info"}
|
||||||
|
Ceci est un callout d'information.
|
||||||
|
::
|
||||||
|
|
||||||
|
::alert{type="warning"}
|
||||||
|
Ceci est un avertissement.
|
||||||
|
::
|
||||||
|
|
||||||
|
::alert{type="tip"}
|
||||||
|
Conseil pratique de développement Kotlin.
|
||||||
|
::
|
||||||
|
|
||||||
|
::alert{type="danger"}
|
||||||
|
Erreur critique — à ne pas ignorer.
|
||||||
|
::
|
||||||
|
````
|
||||||
|
|
||||||
|
**2. Créer `content/en/blog/test-kotlin-syntax.md` :**
|
||||||
|
|
||||||
|
````markdown
|
||||||
|
---
|
||||||
|
title: "Test Kotlin Syntax Highlighting"
|
||||||
|
description: "Test article to validate the @nuxt/content renderer"
|
||||||
|
date: "2026-04-21"
|
||||||
|
tags: ["kotlin", "hytale", "test"]
|
||||||
|
---
|
||||||
|
|
||||||
|
## Kotlin Code Block
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
fun main() {
|
||||||
|
println("Hello, Hytale!")
|
||||||
|
}
|
||||||
|
|
||||||
|
fun createPlugin(name: String): Plugin {
|
||||||
|
return Plugin(name = name, version = "1.0.0")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Optimized Image
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## Table
|
||||||
|
|
||||||
|
| Feature | Status | Notes |
|
||||||
|
|---------|--------|-------|
|
||||||
|
| Syntax highlighting | ✅ Active | Kotlin, Java, TypeScript, Shell |
|
||||||
|
| Optimized images | ✅ Active | Via NuxtImg (lazy + srcset) |
|
||||||
|
| Tables | ✅ Active | Prose rendering |
|
||||||
|
| Callouts | ✅ Active | MDC ::alert{type} |
|
||||||
|
|
||||||
|
## Callouts
|
||||||
|
|
||||||
|
::alert{type="info"}
|
||||||
|
This is an information callout.
|
||||||
|
::
|
||||||
|
|
||||||
|
::alert{type="warning"}
|
||||||
|
This is a warning.
|
||||||
|
::
|
||||||
|
|
||||||
|
::alert{type="tip"}
|
||||||
|
Practical Kotlin development tip.
|
||||||
|
::
|
||||||
|
|
||||||
|
::alert{type="danger"}
|
||||||
|
Critical error — do not ignore.
|
||||||
|
::
|
||||||
|
````
|
||||||
|
|
||||||
|
Note sur l'image : utiliser `/images/og-image.png` qui existe déjà dans `public/images/` — cela valide le pipeline ProseImg sans nécessiter une image supplémentaire.
|
||||||
|
</action>
|
||||||
|
<verify>
|
||||||
|
```bash
|
||||||
|
test -f content/fr/blog/test-kotlin-syntax.md && echo "FR OK"
|
||||||
|
test -f content/en/blog/test-kotlin-syntax.md && echo "EN OK"
|
||||||
|
grep '```kotlin' content/fr/blog/test-kotlin-syntax.md
|
||||||
|
grep '::alert{type="info"}' content/fr/blog/test-kotlin-syntax.md
|
||||||
|
grep '::alert{type="warning"}' content/fr/blog/test-kotlin-syntax.md
|
||||||
|
grep '::alert{type="tip"}' content/fr/blog/test-kotlin-syntax.md
|
||||||
|
grep '::alert{type="danger"}' content/fr/blog/test-kotlin-syntax.md
|
||||||
|
grep "| Colonne\|Fonctionnalité\|Feature" content/fr/blog/test-kotlin-syntax.md content/en/blog/test-kotlin-syntax.md
|
||||||
|
```
|
||||||
|
</verify>
|
||||||
|
<acceptance_criteria>
|
||||||
|
- `content/fr/blog/test-kotlin-syntax.md` existe avec frontmatter complet (title, description, date, tags)
|
||||||
|
- `content/en/blog/test-kotlin-syntax.md` existe avec frontmatter EN
|
||||||
|
- Les deux fichiers contiennent un bloc ` ```kotlin ` avec au moins 2 lignes de code
|
||||||
|
- Les deux fichiers contiennent une image markdown ``
|
||||||
|
- Les deux fichiers contiennent un tableau markdown avec header `|...|...|`
|
||||||
|
- Les deux fichiers contiennent les 4 callouts : `::alert{type="info"}`, `::alert{type="warning"}`, `::alert{type="tip"}`, `::alert{type="danger"}`
|
||||||
|
- Le slug est identique dans les deux langues : `test-kotlin-syntax`
|
||||||
|
</acceptance_criteria>
|
||||||
|
<done>Articles de test créés en FR et EN. L'article couvre les 4 success criteria de la phase.</done>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
</tasks>
|
||||||
|
|
||||||
|
<threat_model>
|
||||||
|
## Trust Boundaries
|
||||||
|
|
||||||
|
| Boundary | Description |
|
||||||
|
|----------|-------------|
|
||||||
|
| Markdown → ContentRenderer | HTML généré par @nuxt/content — pas d'input utilisateur dans cette phase |
|
||||||
|
| MDC composants → DOM | Composants Vue rendus côté serveur — auto-échappement Vue actif |
|
||||||
|
|
||||||
|
## STRIDE Threat Register
|
||||||
|
|
||||||
|
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
||||||
|
|-----------|----------|-----------|-------------|-----------------|
|
||||||
|
| T-05-05 | Tampering | `app/components/content/Alert.vue` prop `type` | accept | Valeur `type` vient du frontmatter markdown (auteur contrôlé) — pas d'input utilisateur; TypeScript union `'info' \| 'warning' \| 'tip' \| 'danger'` limite les valeurs |
|
||||||
|
| T-05-06 | Information Disclosure | `ProseImg.vue` prop `src` | accept | `src` vient du markdown statique — pas d'SSRF possible (NuxtImg résout les chemins au build) |
|
||||||
|
| T-05-07 | Spoofing | `ContentSlot` dans Alert.vue | accept | ContentSlot est un composant officiel @nuxt/content — pas de XSS, le contenu est du texte markdown échappé |
|
||||||
|
</threat_model>
|
||||||
|
|
||||||
|
<verification>
|
||||||
|
Après exécution du plan 02 (checkpoint visuel requis) :
|
||||||
|
|
||||||
|
1. Démarrer le serveur de dev : `pnpm dev`
|
||||||
|
2. Créer une page de test temporaire (ou utiliser la console) pour rendre l'article :
|
||||||
|
- Si une page `/test` existe, y ajouter `<ContentRenderer>`
|
||||||
|
- Sinon, créer `app/pages/test.vue` temporairement avec :
|
||||||
|
```vue
|
||||||
|
<script setup lang="ts">
|
||||||
|
const { data: page } = await useAsyncData('test', () =>
|
||||||
|
queryCollection('blog_fr').path('/blog/test-kotlin-syntax').first()
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
<template>
|
||||||
|
<article class="prose dark:prose-invert max-w-none p-8">
|
||||||
|
<ContentRenderer v-if="page" :value="page" />
|
||||||
|
</article>
|
||||||
|
</template>
|
||||||
|
```
|
||||||
|
3. Naviguer vers `http://localhost:3000/test`
|
||||||
|
4. Vérifier visuellement les 4 critères
|
||||||
|
|
||||||
|
La page de test temporaire peut être supprimée après validation — elle est hors scope de cette phase.
|
||||||
|
|
||||||
|
**Vérifications grep :**
|
||||||
|
```bash
|
||||||
|
test -f app/components/content/ProseImg.vue
|
||||||
|
test -f app/components/content/Alert.vue
|
||||||
|
grep "ContentSlot" app/components/content/Alert.vue
|
||||||
|
test -f content/fr/blog/test-kotlin-syntax.md
|
||||||
|
test -f content/en/blog/test-kotlin-syntax.md
|
||||||
|
```
|
||||||
|
</verification>
|
||||||
|
|
||||||
|
<success_criteria>
|
||||||
|
- `ProseImg.vue` et `Alert.vue` existent dans `app/components/content/`
|
||||||
|
- Les articles de test FR et EN existent avec les 4 éléments de validation
|
||||||
|
- Checkpoint visuel : bloc Kotlin coloré visible (spans avec couleurs Shiki)
|
||||||
|
- Checkpoint visuel : image rendue via `<img srcset=...>` (NuxtImg actif)
|
||||||
|
- Checkpoint visuel : tableau affiché avec bordures prose
|
||||||
|
- Checkpoint visuel : callout info affiché comme UAlert bleu avec icône
|
||||||
|
- `pnpm typecheck` passe (0 erreur TypeScript)
|
||||||
|
</success_criteria>
|
||||||
|
|
||||||
|
<task type="checkpoint:human-verify" gate="blocking">
|
||||||
|
<what-built>
|
||||||
|
- ProseImg.vue : override transparent qui route toutes les images markdown vers NuxtImg
|
||||||
|
- Alert.vue : composant MDC pour ::alert{type} avec 4 types (info/warning/tip/danger) via UAlert Nuxt UI
|
||||||
|
- Article de test FR/EN contenant les 4 éléments de validation
|
||||||
|
</what-built>
|
||||||
|
<how-to-verify>
|
||||||
|
1. S'assurer que `pnpm dev` tourne
|
||||||
|
2. Créer `app/pages/test.vue` temporairement (voir section verification ci-dessus)
|
||||||
|
3. Visiter http://localhost:3000/test
|
||||||
|
4. Vérifier visuellement :
|
||||||
|
- [ ] Le bloc Kotlin est coloré (pas du texte brut gris) — en mode dark, fond sombre avec tokens colorés
|
||||||
|
- [ ] L'image s'affiche (pas de 404) et l'élément DOM est `<img srcset="...">` (inspecter avec DevTools)
|
||||||
|
- [ ] Le tableau markdown est rendu avec des lignes horizontales et en-têtes distingués
|
||||||
|
- [ ] Le callout "info" apparaît comme une alerte bleue avec icône cercle-information
|
||||||
|
- [ ] En passant en mode light (toggle du site), les couleurs Shiki changent (github-light)
|
||||||
|
5. Supprimer `app/pages/test.vue` après validation
|
||||||
|
</how-to-verify>
|
||||||
|
<resume-signal>Taper "approved" si les 5 points sont validés, ou décrire le problème rencontré</resume-signal>
|
||||||
|
</task>
|
||||||
|
|
||||||
|
<output>
|
||||||
|
Après completion, créer `.planning/phases/05-nuxt-content-setup-renderer/05-02-SUMMARY.md`
|
||||||
|
</output>
|
||||||
@@ -0,0 +1,98 @@
|
|||||||
|
---
|
||||||
|
phase: 05-nuxt-content-setup-renderer
|
||||||
|
plan: "02"
|
||||||
|
subsystem: mdc-components
|
||||||
|
tags: [prose-img, alert, mdc, shiki, test-articles]
|
||||||
|
dependency_graph:
|
||||||
|
requires: ['01']
|
||||||
|
provides: [ProseImg, Alert, ProsePre, test-articles]
|
||||||
|
affects:
|
||||||
|
- app/components/content/ProseImg.vue
|
||||||
|
- app/components/content/Alert.vue
|
||||||
|
- app/components/content/ProsePre.vue
|
||||||
|
- app/components/content/Clear.vue
|
||||||
|
- app/components/content/Columns.vue
|
||||||
|
- app/components/content/Details.vue
|
||||||
|
- app/components/content/Badge.vue
|
||||||
|
- app/components/content/Video.vue
|
||||||
|
- content/fr/blog/test-kotlin-syntax.md
|
||||||
|
- content/en/blog/test-kotlin-syntax.md
|
||||||
|
- nuxt.config.ts
|
||||||
|
- app/assets/css/main.css
|
||||||
|
key_files:
|
||||||
|
created:
|
||||||
|
- app/components/content/ProseImg.vue
|
||||||
|
- app/components/content/Alert.vue
|
||||||
|
- app/components/content/ProsePre.vue
|
||||||
|
- app/components/content/Clear.vue
|
||||||
|
- app/components/content/Columns.vue
|
||||||
|
- app/components/content/Details.vue
|
||||||
|
- app/components/content/Badge.vue
|
||||||
|
- app/components/content/Video.vue
|
||||||
|
- content/fr/blog/test-kotlin-syntax.md
|
||||||
|
- content/en/blog/test-kotlin-syntax.md
|
||||||
|
modified:
|
||||||
|
- nuxt.config.ts
|
||||||
|
- app/assets/css/main.css
|
||||||
|
decisions:
|
||||||
|
- "Alert.vue: SVG inline à la place de UAlert — incompatibilité couleurs Nuxt UI v3 avec prose"
|
||||||
|
- "ProseImg.vue: span.block à la place de figure — évite block-in-p HTML invalide (SSR hydration mismatch)"
|
||||||
|
- "ProseImg.vue: inheritAttrs false — les classes MDC custom ne surchargent pas le layout auto"
|
||||||
|
- "Shiki: single theme github-dark (jamais dual-theme) — blocs code toujours dark indépendamment du mode UI"
|
||||||
|
- "ProsePre.vue: bg-[#0d1117] hardcodé sur le wrapper div, pre bg-transparent"
|
||||||
|
- "Composants bonus: Columns, Details, Badge, Video, Clear — hors scope initial, ajoutés pour richesse MDC"
|
||||||
|
metrics:
|
||||||
|
duration: "~2 sessions"
|
||||||
|
completed: "2026-04-21"
|
||||||
|
tasks_completed: 2
|
||||||
|
tasks_total: 2
|
||||||
|
files_created: 10
|
||||||
|
files_modified: 2
|
||||||
|
checkpoint: "approved"
|
||||||
|
---
|
||||||
|
|
||||||
|
# Phase 05 Plan 02: MDC Components & Test Articles Summary
|
||||||
|
|
||||||
|
Création des composants de rendu markdown (ProseImg, Alert, ProsePre) et des articles de test bilingues FR/EN validant les 4 success criteria de la phase. Plusieurs composants MDC bonus ont été ajoutés (Columns, Details, Badge, Video, Clear).
|
||||||
|
|
||||||
|
## Tasks Completed
|
||||||
|
|
||||||
|
| Task | Name | Commits | Files |
|
||||||
|
|------|------|---------|-------|
|
||||||
|
| 1 | Créer ProseImg.vue et Alert.vue | c9a14a9 → b0af1d3 | ProseImg.vue, Alert.vue |
|
||||||
|
| 2 | Créer articles de test FR/EN | 0fa19a7 | test-kotlin-syntax.md ×2 |
|
||||||
|
| — | ProsePre override dark bg | f179d64 | ProsePre.vue, main.css |
|
||||||
|
| — | Composants MDC bonus | 60e05f7 | Columns/Details/Video/Badge/Clear |
|
||||||
|
| — | Fix: Shiki single dark theme | c5be72b | nuxt.config.ts, main.css |
|
||||||
|
|
||||||
|
## Decisions Made
|
||||||
|
|
||||||
|
1. **Alert.vue sans UAlert** — UAlert de Nuxt UI v3 ne supporte pas les variantes de couleur arbitraires dans le contexte prose. Solution : `<div>` + SVG inline + Tailwind classes directes. Résultat visuellement identique à la spec.
|
||||||
|
|
||||||
|
2. **ProseImg `<span class="block">` au lieu de `<figure>`** — `<figure>` est un élément block. Quand Shiki enveloppe `![img]()` dans un `<p>`, un block-in-p produit un HTML invalide. Le navigateur restrucutre le DOM au parse, créant un mismatch de hydration SSR. `<span class="block">` est valide dans un `<p>`.
|
||||||
|
|
||||||
|
3. **Shiki single theme `github-dark`** — La configuration dual-theme (`default: github-light`) injectait un fond blanc via les CSS variables `--shiki-default` en light mode. Passer à un seul thème `github-dark` garantit que les blocs de code restent toujours dark, indépendamment du mode couleur de l'interface.
|
||||||
|
|
||||||
|
## Deviations from Plan
|
||||||
|
|
||||||
|
### Alert.vue: SVG inline vs UAlert
|
||||||
|
- **Planned**: `<UAlert :color="..." variant="soft">`
|
||||||
|
- **Actual**: `<div>` + 4 icônes SVG inline + classes Tailwind
|
||||||
|
- **Reason**: UAlert ne supporte pas les couleurs `info/warning/tip/danger` comme color tokens dans Nuxt UI v3. Le résultat visuel est conforme à la UI-SPEC.
|
||||||
|
|
||||||
|
### Composants MDC bonus hors scope
|
||||||
|
- **Added**: `Columns.vue`, `Details.vue`, `Badge.vue`, `Video.vue`, `Clear.vue`
|
||||||
|
- **Reason**: Nécessaires pour un article de showcase complet et le futur contenu Hytale
|
||||||
|
|
||||||
|
## Checkpoint Visual — Approved
|
||||||
|
|
||||||
|
Validation humaine effectuée sur `http://localhost:3000/test` :
|
||||||
|
- [x] Bloc Kotlin coloré (github-dark, fond #0d1117)
|
||||||
|
- [x] Image rendue via `<img>` avec lazy loading
|
||||||
|
- [x] Tableau markdown avec prose styling
|
||||||
|
- [x] Callouts info/warning/tip/danger fonctionnels
|
||||||
|
- [x] Columns, Details, Badge inline, Clear — fonctionnels
|
||||||
|
- [ ] Vidéo YouTube — non fonctionnelle (hors scope de correction)
|
||||||
|
- [x] Mode light/dark sans impact sur les blocs code (fix appliqué)
|
||||||
|
|
||||||
|
## Self-Check: PASSED
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
# Phase 5: @nuxt/content Setup & Renderer - Context
|
||||||
|
|
||||||
|
**Gathered:** 2026-04-21
|
||||||
|
**Status:** Ready for planning
|
||||||
|
|
||||||
|
<domain>
|
||||||
|
## Phase Boundary
|
||||||
|
|
||||||
|
Installer et configurer `@nuxt/content` pour que le markdown rende fidèlement du contenu technique : blocs de code colorés, images optimisées, tableaux, callouts/alerts — sans configuration supplémentaire dans les phases suivantes.
|
||||||
|
|
||||||
|
Cette phase ne crée pas encore les pages blog (Phase 6) ni les articles réels (Phase 8). Elle pose l'infrastructure de rendu.
|
||||||
|
|
||||||
|
</domain>
|
||||||
|
|
||||||
|
<decisions>
|
||||||
|
## Implementation Decisions
|
||||||
|
|
||||||
|
### Style du rendu markdown
|
||||||
|
- **D-01:** Utiliser `@tailwindcss/typography` (plugin officiel). Classe `prose dark:prose-invert` sur le wrapper `<article>`. Compatible Tailwind v4, support dark mode natif synchronisé avec `colorMode` existant.
|
||||||
|
|
||||||
|
### Callouts / Alerts
|
||||||
|
- **D-02:** Implémenter via la syntaxe MDC de `@nuxt/content` — `::alert{type="warning"}` dans le markdown appelle un composant Vue dédié (`components/content/Alert.vue`). Aucun HTML brut dans les fichiers markdown.
|
||||||
|
|
||||||
|
### Structure content/
|
||||||
|
- **D-03:** Dossiers par langue : `content/fr/blog/` et `content/en/blog/`. Un fichier markdown par article par langue, avec le même slug. Aligné avec `@nuxtjs/i18n` strategy `prefix_except_default`.
|
||||||
|
|
||||||
|
### Syntax highlighting
|
||||||
|
- **D-04:** Shiki intégré à `@nuxt/content` v3 (zéro dépendance supplémentaire). Langages à déclarer dans `nuxt.config.ts` : Kotlin, Java, TypeScript, Shell. Thème dark/light synchronisé avec `colorMode` du site.
|
||||||
|
|
||||||
|
### Claude's Discretion
|
||||||
|
- Choix du thème Shiki exact (ex: `github-dark` / `github-light` ou variante) — cohérence avec la charte dark/light du site.
|
||||||
|
- Nombre et types de callouts MDC à créer au minimum (au moins : info, warning, tip).
|
||||||
|
- Frontmatter schema exact des articles (title, description, date, tags, image...) — à définir mais pas bloquant pour cette phase.
|
||||||
|
|
||||||
|
</decisions>
|
||||||
|
|
||||||
|
<canonical_refs>
|
||||||
|
## Canonical References
|
||||||
|
|
||||||
|
**Downstream agents MUST read these before planning or implementing.**
|
||||||
|
|
||||||
|
### Requirements
|
||||||
|
- `.planning/REQUIREMENTS.md` §BLOG-01, BLOG-04, BLOG-05 — exigences exactes de cette phase
|
||||||
|
|
||||||
|
### Stack existant
|
||||||
|
- `nuxt.config.ts` — configuration actuelle (modules, i18n, colorMode, image) à étendre, ne pas réécrire
|
||||||
|
- `assets/css/main.css` — styles globaux existants, vérifier compatibilité avec prose Tailwind
|
||||||
|
|
||||||
|
### Documentation externe (à consulter)
|
||||||
|
- `@nuxt/content` v3 docs : https://content.nuxt.com — installation, MDC syntax, ContentRenderer
|
||||||
|
- `@tailwindcss/typography` : https://tailwindcss.com/docs/typography-plugin — configuration prose
|
||||||
|
|
||||||
|
</canonical_refs>
|
||||||
|
|
||||||
|
<code_context>
|
||||||
|
## Existing Code Insights
|
||||||
|
|
||||||
|
### Reusable Assets
|
||||||
|
- `@nuxt/image` déjà installé et configuré → `<NuxtImg>` disponible pour les images dans les articles via MDC ou ContentRenderer
|
||||||
|
- `colorMode` configuré avec cookie (SSR-safe) → le thème Shiki doit répondre à `useColorMode().value`
|
||||||
|
|
||||||
|
### Established Patterns
|
||||||
|
- Modules Nuxt déclarés dans `nuxt.config.ts` → ajouter `@nuxt/content` dans le tableau `modules`
|
||||||
|
- Composants auto-importés depuis `~/components` avec `pathPrefix: false` → les composants MDC dans `components/content/` seront auto-importés
|
||||||
|
|
||||||
|
### Integration Points
|
||||||
|
- `nuxt.config.ts` → ajouter `@nuxt/content` dans `modules` + config `content: {}` + `shiki` langages
|
||||||
|
- `assets/css/main.css` → importer `@tailwindcss/typography` si nécessaire
|
||||||
|
- `components/content/` → dossier à créer pour les composants MDC (Alert, etc.)
|
||||||
|
- `content/fr/blog/` et `content/en/blog/` → à créer avec au moins 1 article de test Kotlin
|
||||||
|
|
||||||
|
</code_context>
|
||||||
|
|
||||||
|
<specifics>
|
||||||
|
## Specific Ideas
|
||||||
|
|
||||||
|
- L'article de test (critère de succès) doit contenir un bloc Kotlin coloré, une image, un tableau et un callout — couvre les 4 success criteria de la phase.
|
||||||
|
- Le callout minimum pour valider : `::alert{type="info"}` rendant un composant stylisé Nuxt UI ou Tailwind.
|
||||||
|
|
||||||
|
</specifics>
|
||||||
|
|
||||||
|
<deferred>
|
||||||
|
## Deferred Ideas
|
||||||
|
|
||||||
|
- Pages /blog et /blog/[slug] — Phase 6
|
||||||
|
- SEO par article (useSeoMeta, JSON-LD Article) — Phase 7
|
||||||
|
- Articles seed Hytale réels — Phase 8
|
||||||
|
- Frontmatter complet avec og:image par article — Phase 7
|
||||||
|
|
||||||
|
</deferred>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Phase: 05-nuxt-content-setup-renderer*
|
||||||
|
*Context gathered: 2026-04-21*
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
# Phase 5: @nuxt/content Setup & Renderer - Discussion Log
|
||||||
|
|
||||||
|
> **Audit trail only.** Do not use as input to planning, research, or execution agents.
|
||||||
|
> Decisions are captured in CONTEXT.md — this log preserves the alternatives considered.
|
||||||
|
|
||||||
|
**Date:** 2026-04-21
|
||||||
|
**Phase:** 05-nuxt-content-setup-renderer
|
||||||
|
**Areas discussed:** Style du rendu markdown, Callouts / Alerts, Structure content/, Syntax highlighting
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Style du rendu markdown
|
||||||
|
|
||||||
|
| Option | Description | Selected |
|
||||||
|
|--------|-------------|----------|
|
||||||
|
| @tailwindcss/typography | Plugin officiel Tailwind — classe `prose dark:prose-invert`, dark mode natif | ✓ |
|
||||||
|
| Nuxt UI prose styles | Via `UProse`, cohérent avec le design system existant | |
|
||||||
|
| CSS custom à la main | Tout écrire dans assets/css/ — contrôle total, effort élevé | |
|
||||||
|
|
||||||
|
**User's choice:** @tailwindcss/typography
|
||||||
|
**Notes:** —
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Callouts / Alerts
|
||||||
|
|
||||||
|
| Option | Description | Selected |
|
||||||
|
|--------|-------------|----------|
|
||||||
|
| MDC Components | Syntaxe `::alert{type}` dans le markdown, composant Vue dédié | ✓ |
|
||||||
|
| HTML dans le markdown | `<div class="callout">` directement dans les .md | |
|
||||||
|
|
||||||
|
**User's choice:** MDC Components
|
||||||
|
**Notes:** —
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Structure content/
|
||||||
|
|
||||||
|
| Option | Description | Selected |
|
||||||
|
|--------|-------------|----------|
|
||||||
|
| content/fr/ + content/en/ | Un dossier par langue, aligné avec @nuxtjs/i18n | ✓ |
|
||||||
|
| content/blog/ avec champ locale | Fichiers `.fr.md` / `.en.md` dans un seul dossier | |
|
||||||
|
|
||||||
|
**User's choice:** content/fr/ + content/en/
|
||||||
|
**Notes:** —
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Syntax highlighting
|
||||||
|
|
||||||
|
| Option | Description | Selected |
|
||||||
|
|--------|-------------|----------|
|
||||||
|
| Shiki intégré | Zéro config, langues déclarées dans nuxt.config.ts, thème dark/light | ✓ |
|
||||||
|
| Prism | Alternative plus ancienne, moins performante | |
|
||||||
|
|
||||||
|
**User's choice:** Shiki intégré
|
||||||
|
**Notes:** Langages prioritaires : Kotlin, Java, TypeScript, Shell
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Claude's Discretion
|
||||||
|
|
||||||
|
- Thème Shiki exact (dark/light)
|
||||||
|
- Types de callouts MDC à créer (minimum : info, warning, tip)
|
||||||
|
- Frontmatter schema des articles
|
||||||
|
|
||||||
|
## Deferred Ideas
|
||||||
|
|
||||||
|
- Pages /blog et /blog/[slug] → Phase 6
|
||||||
|
- SEO par article → Phase 7
|
||||||
|
- Articles Hytale réels → Phase 8
|
||||||
@@ -0,0 +1,372 @@
|
|||||||
|
# Phase 5: @nuxt/content Setup & Renderer — Pattern Map
|
||||||
|
|
||||||
|
**Mapped:** 2026-04-21
|
||||||
|
**Files analyzed:** 7 (2 modifications + 5 créations)
|
||||||
|
**Analogs found:** 5 / 7
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## File Classification
|
||||||
|
|
||||||
|
| New/Modified File | Role | Data Flow | Closest Analog | Match Quality |
|
||||||
|
|---|---|---|---|---|
|
||||||
|
| `nuxt.config.ts` | config | — | `nuxt.config.ts` (lui-même, existant) | exact — extension |
|
||||||
|
| `content.config.ts` | config | CRUD | `nuxt.config.ts` (structure `defineNuxtConfig`) | role-match |
|
||||||
|
| `app/assets/css/main.css` | config | — | `app/assets/css/main.css` (lui-même, existant) | exact — extension |
|
||||||
|
| `app/components/content/ProseImg.vue` | component | request-response | `app/components/ProjectCard.vue` (NuxtImg + Props interface) | role-match |
|
||||||
|
| `app/components/content/Alert.vue` | component | request-response | `app/components/TechBadge.vue` (withDefaults + UBadge + computed map) | role-match |
|
||||||
|
| `content/fr/blog/test-kotlin-syntax.md` | content | — | aucun | no-analog |
|
||||||
|
| `content/en/blog/test-kotlin-syntax.md` | content | — | aucun | no-analog |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Pattern Assignments
|
||||||
|
|
||||||
|
### `nuxt.config.ts` (config — extension)
|
||||||
|
|
||||||
|
**Analog:** `nuxt.config.ts` lui-même (ligne 1–65)
|
||||||
|
|
||||||
|
**État actuel** (lignes 7–14) :
|
||||||
|
```typescript
|
||||||
|
modules: [
|
||||||
|
'@nuxt/ui',
|
||||||
|
'@nuxtjs/i18n',
|
||||||
|
'@nuxt/eslint',
|
||||||
|
'@nuxtjs/sitemap',
|
||||||
|
'nuxt-gtag',
|
||||||
|
'@nuxt/image'
|
||||||
|
],
|
||||||
|
```
|
||||||
|
|
||||||
|
**Pattern à ajouter — ajout dans `modules`** :
|
||||||
|
```typescript
|
||||||
|
modules: [
|
||||||
|
'@nuxt/ui',
|
||||||
|
'@nuxtjs/i18n',
|
||||||
|
'@nuxt/eslint',
|
||||||
|
'@nuxtjs/sitemap',
|
||||||
|
'nuxt-gtag',
|
||||||
|
'@nuxt/image',
|
||||||
|
'@nuxt/content' // ← ajouter ici
|
||||||
|
],
|
||||||
|
```
|
||||||
|
|
||||||
|
**Pattern à ajouter — bloc `content` après les modules existants** :
|
||||||
|
```typescript
|
||||||
|
content: {
|
||||||
|
build: {
|
||||||
|
markdown: {
|
||||||
|
highlight: {
|
||||||
|
theme: {
|
||||||
|
default: 'github-light',
|
||||||
|
dark: 'github-dark'
|
||||||
|
},
|
||||||
|
langs: ['kotlin', 'java', 'typescript', 'shell', 'bash', 'json', 'vue', 'html', 'css']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
experimental: {
|
||||||
|
sqliteConnector: 'native' // Node 22+ — pas de better-sqlite3
|
||||||
|
}
|
||||||
|
},
|
||||||
|
```
|
||||||
|
|
||||||
|
**Contexte critique :** `colorMode.classSuffix: ''` est déjà configuré ligne 29 — Shiki dual-theme fonctionne via `html.dark`, donc compatible sans modification.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `content.config.ts` (config — création, racine du projet)
|
||||||
|
|
||||||
|
**Analog:** Structure `nuxt.config.ts` (pattern `defineNuxtConfig` → `defineContentConfig`)
|
||||||
|
|
||||||
|
**Pattern complet** (source: RESEARCH.md Pattern 2) :
|
||||||
|
```typescript
|
||||||
|
import { defineContentConfig, defineCollection, z } from '@nuxt/content'
|
||||||
|
|
||||||
|
const blogSchema = z.object({
|
||||||
|
title: z.string(),
|
||||||
|
description: z.string(),
|
||||||
|
date: z.string(),
|
||||||
|
tags: z.array(z.string()).optional(),
|
||||||
|
image: z.string().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export default defineContentConfig({
|
||||||
|
collections: {
|
||||||
|
blog_fr: defineCollection({
|
||||||
|
type: 'page',
|
||||||
|
source: { include: 'fr/blog/**/*.md', prefix: '/blog' },
|
||||||
|
schema: blogSchema,
|
||||||
|
}),
|
||||||
|
blog_en: defineCollection({
|
||||||
|
type: 'page',
|
||||||
|
source: { include: 'en/blog/**/*.md', prefix: '/en/blog' },
|
||||||
|
schema: blogSchema,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
**Note sur le prefix :** `i18n.strategy: 'prefix_except_default'` avec `defaultLocale: 'fr'` → les articles FR sont sous `/blog/slug`, les EN sous `/en/blog/slug`. (Assumption A3 de RESEARCH.md — valider avec l'article de test.)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `app/assets/css/main.css` (config — extension)
|
||||||
|
|
||||||
|
**Analog:** `app/assets/css/main.css` lui-même (lignes 1–3, existant)
|
||||||
|
|
||||||
|
**État actuel** (lignes 1–3) :
|
||||||
|
```css
|
||||||
|
@import "tailwindcss";
|
||||||
|
@import "@nuxt/ui";
|
||||||
|
```
|
||||||
|
|
||||||
|
**Pattern à ajouter — une ligne après `@import "@nuxt/ui"`** :
|
||||||
|
```css
|
||||||
|
@import "tailwindcss";
|
||||||
|
@import "@nuxt/ui";
|
||||||
|
@plugin "@tailwindcss/typography"; /* ← ajouter ici — syntaxe Tailwind v4 obligatoire */
|
||||||
|
```
|
||||||
|
|
||||||
|
**Anti-pattern à éviter :** Ne pas utiliser `plugins: [require('@tailwindcss/typography')]` dans `tailwind.config.js` — ignoré en Tailwind v4.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `app/components/content/ProseImg.vue` (component, request-response)
|
||||||
|
|
||||||
|
**Analog:** `app/components/ProjectCard.vue` — utilisation de `NuxtImg` avec Props interface (lignes 1–16, 25–35)
|
||||||
|
|
||||||
|
**Imports pattern** (depuis ProjectCard.vue, lignes 1–3) :
|
||||||
|
```vue
|
||||||
|
<script setup lang="ts">
|
||||||
|
// Pas d'import externe — NuxtImg est auto-importé par @nuxt/image
|
||||||
|
```
|
||||||
|
|
||||||
|
**Props pattern** (depuis ProjectCard.vue, lignes 4–8) :
|
||||||
|
```typescript
|
||||||
|
interface Props {
|
||||||
|
project: Project
|
||||||
|
}
|
||||||
|
const props = defineProps<Props>()
|
||||||
|
```
|
||||||
|
|
||||||
|
**NuxtImg pattern** (depuis ProjectCard.vue, lignes 26–35) :
|
||||||
|
```vue
|
||||||
|
<NuxtImg
|
||||||
|
:src="project.image"
|
||||||
|
:alt="`...`"
|
||||||
|
loading="lazy"
|
||||||
|
format="webp"
|
||||||
|
width="400"
|
||||||
|
height="300"
|
||||||
|
class="w-full h-52 object-cover"
|
||||||
|
/>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Pattern cible pour ProseImg.vue** (adapté avec `withDefaults` — depuis TechBadge.vue ligne 11) :
|
||||||
|
```vue
|
||||||
|
<script setup lang="ts">
|
||||||
|
interface Props {
|
||||||
|
src: string
|
||||||
|
alt?: string
|
||||||
|
title?: string
|
||||||
|
width?: string | number
|
||||||
|
height?: string | number
|
||||||
|
}
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
alt: '',
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<NuxtImg
|
||||||
|
:src="props.src"
|
||||||
|
:alt="props.alt"
|
||||||
|
:title="props.title"
|
||||||
|
:width="props.width"
|
||||||
|
:height="props.height"
|
||||||
|
class="rounded-lg w-full"
|
||||||
|
sizes="sm:600px md:800px lg:1000px"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `app/components/content/Alert.vue` (component, request-response)
|
||||||
|
|
||||||
|
**Analog:** `app/components/TechBadge.vue` — `withDefaults` + computed map de valeurs + composant Nuxt UI (`UBadge`) (lignes 1–57)
|
||||||
|
|
||||||
|
**withDefaults pattern** (depuis TechBadge.vue, lignes 11–13) :
|
||||||
|
```typescript
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
showLevel: true,
|
||||||
|
showImage: true,
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
**Computed map pattern** (depuis TechBadge.vue, lignes 44–56) :
|
||||||
|
```typescript
|
||||||
|
const levelColor = computed(() => {
|
||||||
|
switch (techData.value.level) {
|
||||||
|
case 'Advanced': return 'success' as const
|
||||||
|
case 'Intermediate': return 'primary' as const
|
||||||
|
default: return 'neutral' as const
|
||||||
|
}
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
**Pattern cible pour Alert.vue** (adapté avec `UAlert` Nuxt UI + `ContentSlot` MDC) :
|
||||||
|
```vue
|
||||||
|
<script setup lang="ts">
|
||||||
|
interface Props {
|
||||||
|
type?: 'info' | 'warning' | 'tip' | 'danger'
|
||||||
|
}
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
type: 'info',
|
||||||
|
})
|
||||||
|
|
||||||
|
const iconMap = {
|
||||||
|
info: 'i-heroicons-information-circle',
|
||||||
|
warning: 'i-heroicons-exclamation-triangle',
|
||||||
|
tip: 'i-heroicons-light-bulb',
|
||||||
|
danger: 'i-heroicons-x-circle',
|
||||||
|
}
|
||||||
|
const colorMap = {
|
||||||
|
info: 'info',
|
||||||
|
warning: 'warning',
|
||||||
|
tip: 'success',
|
||||||
|
danger: 'error',
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<UAlert
|
||||||
|
:icon="iconMap[props.type]"
|
||||||
|
:color="colorMap[props.type] as any"
|
||||||
|
variant="soft"
|
||||||
|
class="my-4"
|
||||||
|
>
|
||||||
|
<template #description>
|
||||||
|
<ContentSlot :use="$slots.default" unwrap="p" />
|
||||||
|
</template>
|
||||||
|
</UAlert>
|
||||||
|
</template>
|
||||||
|
```
|
||||||
|
|
||||||
|
**Point critique :** `<ContentSlot :use="$slots.default" unwrap="p" />` est obligatoire — sans lui le contenu entre `::alert` et `::` n'est pas rendu (Pitfall 4 RESEARCH.md).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `content/fr/blog/test-kotlin-syntax.md` (content — création)
|
||||||
|
|
||||||
|
**Analog:** aucun (pas de fichiers markdown dans le projet actuellement)
|
||||||
|
|
||||||
|
**Pattern depuis RESEARCH.md (Code Examples)** :
|
||||||
|
```markdown
|
||||||
|
---
|
||||||
|
title: "Test Kotlin Syntax Highlighting"
|
||||||
|
description: "Article de test pour valider le renderer"
|
||||||
|
date: "2026-04-21"
|
||||||
|
tags: ["kotlin", "hytale", "test"]
|
||||||
|
---
|
||||||
|
|
||||||
|
## Bloc de code Kotlin
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
fun main() {
|
||||||
|
println("Hello, Hytale!")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Image optimisée
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## Tableau
|
||||||
|
|
||||||
|
| Colonne A | Colonne B |
|
||||||
|
|-----------|-----------|
|
||||||
|
| Valeur 1 | Valeur 2 |
|
||||||
|
|
||||||
|
## Callout
|
||||||
|
|
||||||
|
::alert{type="info"}
|
||||||
|
Ceci est un callout d'information.
|
||||||
|
::
|
||||||
|
```
|
||||||
|
|
||||||
|
**Critère de validation :** Ce fichier doit couvrir les 4 success criteria : bloc Kotlin coloré (BLOG-04), image via ProseImg (BLOG-05), tableau, callout (BLOG-01).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### `content/en/blog/test-kotlin-syntax.md` (content — création)
|
||||||
|
|
||||||
|
**Analog:** même structure que la version FR, même slug, contenu traduit en anglais.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Shared Patterns
|
||||||
|
|
||||||
|
### Props avec valeurs par défaut (withDefaults)
|
||||||
|
**Source:** `app/components/TechBadge.vue` lignes 11–14
|
||||||
|
**Apply to:** `ProseImg.vue`, `Alert.vue`
|
||||||
|
```typescript
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
// valeurs par défaut pour props optionnelles
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### NuxtImg usage
|
||||||
|
**Source:** `app/components/ProjectCard.vue` lignes 26–35, `app/components/TechBadge.vue` lignes 65–74
|
||||||
|
**Apply to:** `ProseImg.vue`
|
||||||
|
- Toujours utiliser `:src`, `:alt`, `loading="lazy"` au minimum
|
||||||
|
- `format="webp"` si format fixe, sinon laisser @nuxt/image décider
|
||||||
|
- `sizes` pour responsive
|
||||||
|
|
||||||
|
### Composants Nuxt UI (UAlert, UBadge)
|
||||||
|
**Source:** `app/components/TechBadge.vue` ligne 76, `app/components/FAQSection.vue` ligne 33
|
||||||
|
**Apply to:** `Alert.vue`
|
||||||
|
- Nuxt UI est auto-importé — pas d'import explicite nécessaire
|
||||||
|
- Utiliser `color` + `variant` pour le style
|
||||||
|
- `as any` acceptable pour les types union non-exhaustifs de Nuxt UI
|
||||||
|
|
||||||
|
### Convention import types
|
||||||
|
**Source:** `app/components/ProjectCard.vue` ligne 2
|
||||||
|
**Apply to:** `content.config.ts`
|
||||||
|
```typescript
|
||||||
|
import type { ... } from '~~/shared/types' // types partagés
|
||||||
|
import { ... } from '@nuxt/content' // imports de librairie
|
||||||
|
```
|
||||||
|
|
||||||
|
### Auto-import composants
|
||||||
|
**Source:** `nuxt.config.ts` lignes 15–19
|
||||||
|
**Apply to:** `ProseImg.vue`, `Alert.vue`
|
||||||
|
```typescript
|
||||||
|
components: [
|
||||||
|
{
|
||||||
|
path: '~/components',
|
||||||
|
pathPrefix: false, // → components/content/Alert.vue est auto-importé
|
||||||
|
},
|
||||||
|
],
|
||||||
|
```
|
||||||
|
Les composants dans `components/content/` sont auto-importés par Nuxt ET reconnus par `@nuxt/content` comme Prose overrides / composants MDC.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## No Analog Found
|
||||||
|
|
||||||
|
| File | Role | Data Flow | Reason |
|
||||||
|
|---|---|---|---|
|
||||||
|
| `content/fr/blog/test-kotlin-syntax.md` | content | — | Pas de fichiers markdown dans le projet — nouveau format |
|
||||||
|
| `content/en/blog/test-kotlin-syntax.md` | content | — | Pas de fichiers markdown dans le projet — nouveau format |
|
||||||
|
|
||||||
|
Le planner doit utiliser le pattern RESEARCH.md "Code Examples" pour ces deux fichiers.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Metadata
|
||||||
|
|
||||||
|
**Analog search scope:** `app/components/`, `app/assets/css/`, `nuxt.config.ts`
|
||||||
|
**Files scanned:** 6 (nuxt.config.ts, main.css, ProjectCard.vue, TechBadge.vue, FAQSection.vue, app.vue partiel)
|
||||||
|
**Pattern extraction date:** 2026-04-21
|
||||||
@@ -0,0 +1,523 @@
|
|||||||
|
# Phase 5: @nuxt/content Setup & Renderer — Research
|
||||||
|
|
||||||
|
**Researched:** 2026-04-21
|
||||||
|
**Domain:** @nuxt/content v3, Shiki, @tailwindcss/typography v4, MDC components
|
||||||
|
**Confidence:** HIGH
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<user_constraints>
|
||||||
|
## User Constraints (from CONTEXT.md)
|
||||||
|
|
||||||
|
### Locked Decisions
|
||||||
|
- **D-01:** Utiliser `@tailwindcss/typography` (plugin officiel). Classe `prose dark:prose-invert` sur le wrapper `<article>`. Compatible Tailwind v4, support dark mode natif synchronisé avec `colorMode` existant.
|
||||||
|
- **D-02:** Implémenter les callouts via la syntaxe MDC de `@nuxt/content` — `::alert{type="warning"}` dans le markdown appelle un composant Vue dédié (`components/content/Alert.vue`). Aucun HTML brut dans les fichiers markdown.
|
||||||
|
- **D-03:** Dossiers par langue : `content/fr/blog/` et `content/en/blog/`. Un fichier markdown par article par langue, avec le même slug. Aligné avec `@nuxtjs/i18n` strategy `prefix_except_default`.
|
||||||
|
- **D-04:** Shiki intégré à `@nuxt/content` v3 (zéro dépendance supplémentaire). Langages à déclarer dans `nuxt.config.ts` : Kotlin, Java, TypeScript, Shell. Thème dark/light synchronisé avec `colorMode` du site.
|
||||||
|
|
||||||
|
### Claude's Discretion
|
||||||
|
- Choix du thème Shiki exact (ex: `github-dark` / `github-light` ou variante) — cohérence avec la charte dark/light du site.
|
||||||
|
- Nombre et types de callouts MDC à créer au minimum (au moins : info, warning, tip).
|
||||||
|
- Frontmatter schema exact des articles (title, description, date, tags, image...) — à définir mais pas bloquant pour cette phase.
|
||||||
|
|
||||||
|
### Deferred Ideas (OUT OF SCOPE)
|
||||||
|
- Pages /blog et /blog/[slug] — Phase 6
|
||||||
|
- SEO par article (useSeoMeta, JSON-LD Article) — Phase 7
|
||||||
|
- Articles seed Hytale réels — Phase 8
|
||||||
|
- Frontmatter complet avec og:image par article — Phase 7
|
||||||
|
</user_constraints>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<phase_requirements>
|
||||||
|
## Phase Requirements
|
||||||
|
|
||||||
|
| ID | Description | Research Support |
|
||||||
|
|----|-------------|------------------|
|
||||||
|
| BLOG-01 | Intégration `@nuxt/content` — renderer markdown complet (syntax highlighting, images, embeds, tables, callouts/alerts) | Couvert par stack + patterns ci-dessous |
|
||||||
|
| BLOG-04 | Blocs de code avec syntax highlighting (Kotlin, Java, TypeScript, Shell) | Shiki intégré, config `highlight.langs` confirmée |
|
||||||
|
| BLOG-05 | Support images dans articles — images optimisées avec `<NuxtImg>` | ProseImg.vue override pattern confirmé |
|
||||||
|
</phase_requirements>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
`@nuxt/content` v3 (actuellement v3.6.3) est pleinement compatible avec Nuxt 4 (`compatibilityVersion: 4`). La v3 introduit une rupture majeure par rapport à la v2 : une nouvelle architecture basée sur **SQLite** (au lieu de fichiers parsés en mémoire) et un fichier de configuration dédié `content.config.ts` où l'on déclare les **collections**. Cette approche collection-based est exactement ce qu'il faut pour la structure bilingue `content/fr/blog/` et `content/en/blog/`.
|
||||||
|
|
||||||
|
Le stack se compose de trois briques : (1) `@nuxt/content` v3 pour le parsing et la query API, (2) Shiki intégré pour le highlighting sans dépendance supplémentaire, (3) `@tailwindcss/typography` pour le styling `prose`. L'intégration avec `@nuxt/image` se fait via un composant override `components/content/ProseImg.vue`. Sur Node.js 22 (la cible de ce projet), le connecteur SQLite natif est disponible sans installer `better-sqlite3`.
|
||||||
|
|
||||||
|
Point d'attention pnpm : `@nuxt/content` nécessite l'ajout de `better-sqlite3` OU l'activation du connecteur natif Node 22 dans `onlyBuiltDependencies`. Le projet utilise déjà `pnpm.onlyBuiltDependencies` dans `package.json` — il faudra soit y ajouter `better-sqlite3`, soit activer `experimental.sqliteConnector: 'native'` (recommandé car Node 22 est déjà la cible).
|
||||||
|
|
||||||
|
**Recommandation principale :** Utiliser `experimental.sqliteConnector: 'native'` pour éviter toute dépendance supplémentaire — le Dockerfile cible déjà `node:22-alpine` (Node 22.5+).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architectural Responsibility Map
|
||||||
|
|
||||||
|
| Capability | Tier Primaire | Tier Secondaire | Rationale |
|
||||||
|
|------------|---------------|-----------------|-----------|
|
||||||
|
| Parsing et indexation markdown | Serveur (build) | — | @nuxt/content compile les fichiers en DB SQLite au build |
|
||||||
|
| Rendu HTML depuis markdown | Serveur (SSR) | Client (hydration) | ContentRenderer s'exécute côté serveur |
|
||||||
|
| Syntax highlighting | Build/Serveur | — | Shiki génère le HTML coloré au build, pas au runtime |
|
||||||
|
| Images optimisées dans articles | Serveur (SSR) | CDN/Edge | NuxtImg génère les directives d'optimisation SSR-side |
|
||||||
|
| Composants MDC (callouts) | Serveur (SSR) | Client | Composants Vue auto-importés, rendus en SSR |
|
||||||
|
| Query des articles par locale | Serveur (SSR) | — | `queryCollection()` dans les pages = data fetching SSR |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Standard Stack
|
||||||
|
|
||||||
|
### Core
|
||||||
|
| Librairie | Version | Rôle | Pourquoi |
|
||||||
|
|-----------|---------|------|---------|
|
||||||
|
| @nuxt/content | ^3.6.3 | CMS file-based, parsing markdown, query API | Module officiel Nuxt, Shiki intégré, MDC natif |
|
||||||
|
| @tailwindcss/typography | ^0.5.x | Styles `prose` pour le HTML généré | Plugin officiel, syntaxe `@plugin` Tailwind v4 |
|
||||||
|
|
||||||
|
### Supporting (déjà installés)
|
||||||
|
| Librairie | Version | Rôle | Note |
|
||||||
|
|-----------|---------|------|------|
|
||||||
|
| @nuxt/image | ^2.0.0 | Optimisation images via ProseImg override | Déjà dans le projet |
|
||||||
|
| tailwindcss | ^4.2.2 | Déjà présent | Supporte `@plugin` directive |
|
||||||
|
|
||||||
|
### Alternatives considérées
|
||||||
|
| Standard | Alternative | Tradeoff |
|
||||||
|
|----------|-------------|----------|
|
||||||
|
| Shiki intégré | Prism.js | Shiki = zero install, meilleur rendu, thèmes Shiki-compatibles |
|
||||||
|
| @tailwindcss/typography | CSS prose custom | Typography = 0 maintenance, dark mode natif |
|
||||||
|
| ProseImg override | MDC component custom | Override = transparent pour les auteurs |
|
||||||
|
|
||||||
|
**Installation :**
|
||||||
|
```bash
|
||||||
|
pnpm add @nuxt/content
|
||||||
|
pnpm add -D @tailwindcss/typography
|
||||||
|
```
|
||||||
|
|
||||||
|
**Versions vérifiées :**
|
||||||
|
- `@nuxt/content` : v3.6.3 [VERIFIED: Context7 registry]
|
||||||
|
- `@tailwindcss/typography` : compatible Tailwind v4 via `@plugin` directive [VERIFIED: github.com/tailwindlabs/tailwindcss-typography]
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture Patterns
|
||||||
|
|
||||||
|
### Diagramme de flux
|
||||||
|
|
||||||
|
```
|
||||||
|
Fichiers markdown (content/fr/blog/, content/en/blog/)
|
||||||
|
│
|
||||||
|
▼ (build time)
|
||||||
|
@nuxt/content parser + Shiki
|
||||||
|
│ SQLite DB générée
|
||||||
|
▼
|
||||||
|
content.config.ts collections (blog_fr, blog_en)
|
||||||
|
│
|
||||||
|
▼ (SSR request)
|
||||||
|
queryCollection('blog_fr' | 'blog_en')
|
||||||
|
│ document parsé retourné
|
||||||
|
▼
|
||||||
|
<ContentRenderer :value="page" />
|
||||||
|
│
|
||||||
|
├─── ProseImg.vue → <NuxtImg> (images optimisées)
|
||||||
|
├─── ProsePre.vue / ProseCode → HTML Shiki (coloration)
|
||||||
|
├─── Alert.vue (MDC ::alert{type}) → <UAlert> stylisé
|
||||||
|
└─── prose dark:prose-invert wrapper (typography)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Structure de fichiers recommandée
|
||||||
|
```
|
||||||
|
content/
|
||||||
|
├── fr/
|
||||||
|
│ └── blog/
|
||||||
|
│ └── test-kotlin-syntax.md # article de test
|
||||||
|
└── en/
|
||||||
|
└── blog/
|
||||||
|
└── test-kotlin-syntax.md # même slug, contenu EN
|
||||||
|
|
||||||
|
content.config.ts # collections blog_fr + blog_en
|
||||||
|
|
||||||
|
components/
|
||||||
|
└── content/
|
||||||
|
├── ProseImg.vue # override → NuxtImg
|
||||||
|
├── Alert.vue # MDC ::alert{type="info|warning|tip"}
|
||||||
|
└── (optionnel: ProseCode.vue) # si customisation inline code
|
||||||
|
|
||||||
|
assets/css/main.css # ajouter @plugin "@tailwindcss/typography"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pattern 1 : Configuration nuxt.config.ts
|
||||||
|
```typescript
|
||||||
|
// Source: content.nuxt.com/docs/getting-started/configuration
|
||||||
|
export default defineNuxtConfig({
|
||||||
|
modules: [
|
||||||
|
// ... modules existants
|
||||||
|
'@nuxt/content'
|
||||||
|
],
|
||||||
|
content: {
|
||||||
|
build: {
|
||||||
|
markdown: {
|
||||||
|
highlight: {
|
||||||
|
theme: {
|
||||||
|
default: 'github-light',
|
||||||
|
dark: 'github-dark'
|
||||||
|
},
|
||||||
|
langs: ['kotlin', 'java', 'typescript', 'shell', 'bash', 'json', 'vue']
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
experimental: {
|
||||||
|
sqliteConnector: 'native' // Node 22+ natif, pas de better-sqlite3
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pattern 2 : content.config.ts (collections bilingues)
|
||||||
|
```typescript
|
||||||
|
// Source: content.nuxt.com/docs/integrations/i18n
|
||||||
|
import { defineContentConfig, defineCollection, z } from '@nuxt/content'
|
||||||
|
|
||||||
|
const blogSchema = z.object({
|
||||||
|
title: z.string(),
|
||||||
|
description: z.string(),
|
||||||
|
date: z.string(),
|
||||||
|
tags: z.array(z.string()).optional(),
|
||||||
|
image: z.string().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export default defineContentConfig({
|
||||||
|
collections: {
|
||||||
|
blog_fr: defineCollection({
|
||||||
|
type: 'page',
|
||||||
|
source: { include: 'fr/blog/**/*.md', prefix: '/fr/blog' },
|
||||||
|
schema: blogSchema,
|
||||||
|
}),
|
||||||
|
blog_en: defineCollection({
|
||||||
|
type: 'page',
|
||||||
|
source: { include: 'en/blog/**/*.md', prefix: '/en/blog' },
|
||||||
|
schema: blogSchema,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pattern 3 : @tailwindcss/typography avec Tailwind v4
|
||||||
|
```css
|
||||||
|
/* assets/css/main.css */
|
||||||
|
/* Source: github.com/tailwindlabs/tailwindcss-typography */
|
||||||
|
@import "tailwindcss";
|
||||||
|
@plugin "@tailwindcss/typography";
|
||||||
|
```
|
||||||
|
|
||||||
|
Usage dans un composant :
|
||||||
|
```html
|
||||||
|
<article class="prose dark:prose-invert max-w-none">
|
||||||
|
<ContentRenderer :value="page" />
|
||||||
|
</article>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pattern 4 : ProseImg.vue — override NuxtImg
|
||||||
|
```vue
|
||||||
|
<!-- components/content/ProseImg.vue -->
|
||||||
|
<!-- Source: github.com/nuxt/content/discussions/2082 -->
|
||||||
|
<script setup lang="ts">
|
||||||
|
interface Props {
|
||||||
|
src: string
|
||||||
|
alt?: string
|
||||||
|
title?: string
|
||||||
|
width?: string | number
|
||||||
|
height?: string | number
|
||||||
|
}
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
alt: '',
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<NuxtImg
|
||||||
|
:src="props.src"
|
||||||
|
:alt="props.alt"
|
||||||
|
:title="props.title"
|
||||||
|
:width="props.width"
|
||||||
|
:height="props.height"
|
||||||
|
class="rounded-lg w-full"
|
||||||
|
sizes="sm:600px md:800px lg:1000px"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pattern 5 : Composant MDC Alert.vue
|
||||||
|
```vue
|
||||||
|
<!-- components/content/Alert.vue -->
|
||||||
|
<!-- Usage markdown: ::alert{type="warning"} Contenu :: -->
|
||||||
|
<script setup lang="ts">
|
||||||
|
interface Props {
|
||||||
|
type?: 'info' | 'warning' | 'tip' | 'danger'
|
||||||
|
}
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
type: 'info',
|
||||||
|
})
|
||||||
|
|
||||||
|
const iconMap = {
|
||||||
|
info: 'i-heroicons-information-circle',
|
||||||
|
warning: 'i-heroicons-exclamation-triangle',
|
||||||
|
tip: 'i-heroicons-light-bulb',
|
||||||
|
danger: 'i-heroicons-x-circle',
|
||||||
|
}
|
||||||
|
const colorMap = {
|
||||||
|
info: 'info',
|
||||||
|
warning: 'warning',
|
||||||
|
tip: 'success',
|
||||||
|
danger: 'error',
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<UAlert
|
||||||
|
:icon="iconMap[props.type]"
|
||||||
|
:color="colorMap[props.type] as any"
|
||||||
|
variant="soft"
|
||||||
|
class="my-4"
|
||||||
|
>
|
||||||
|
<template #description>
|
||||||
|
<ContentSlot :use="$slots.default" unwrap="p" />
|
||||||
|
</template>
|
||||||
|
</UAlert>
|
||||||
|
</template>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Pattern 6 : Requête dans une page (preview Phase 6)
|
||||||
|
```typescript
|
||||||
|
// Source: content.nuxt.com/docs/integrations/i18n
|
||||||
|
const { locale } = useI18n()
|
||||||
|
const collectionName = computed(
|
||||||
|
() => ('blog_' + locale.value) as 'blog_fr' | 'blog_en'
|
||||||
|
)
|
||||||
|
const { data: page } = await useAsyncData('article', () =>
|
||||||
|
queryCollection(collectionName.value).path(route.path).first()
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Anti-Patterns à éviter
|
||||||
|
- **Ne pas utiliser `nativeSqlite: true`** — option dépréciée, utiliser `sqliteConnector: 'native'` à la place.
|
||||||
|
- **Ne pas mettre `better-sqlite3` dans dependencies** — inutile avec Node 22 natif ; alourdit l'image Docker.
|
||||||
|
- **Ne pas nommer les composants MDC avec des tirets dans le fichier** — nommer `Alert.vue` pas `alert-component.vue`. Le mapping MDC utilise le nom PascalCase du fichier.
|
||||||
|
- **Ne pas utiliser `v-html` pour le rendu markdown** — toujours passer par `<ContentRenderer>` pour bénéficier des Prose overrides.
|
||||||
|
- **Ne pas oublier `ContentSlot` dans les composants MDC avec slots** — le contenu entre `::alert` et `::` doit passer par `<ContentSlot :use="$slots.default" />` sinon il n'est pas rendu.
|
||||||
|
- **Ne pas confondre `prefix` et dossier source** dans `content.config.ts` — `prefix` définit le path URL, `source.include` définit où chercher les fichiers.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Don't Hand-Roll
|
||||||
|
|
||||||
|
| Problème | Ne pas construire | Utiliser à la place | Pourquoi |
|
||||||
|
|----------|------------------|---------------------|---------|
|
||||||
|
| Syntax highlighting | Parseur custom regex | Shiki (intégré @nuxt/content) | 200+ langages, thèmes CSS variables, SSR-safe |
|
||||||
|
| Typography styles | CSS prose custom | @tailwindcss/typography | Dark mode, responsive, rythme vertical correct |
|
||||||
|
| Image optimisation dans articles | `<img>` natif | ProseImg.vue + NuxtImg | Lazy loading, formats modernes, responsive sizes |
|
||||||
|
| Callouts/alerts | HTML brut dans markdown | MDC + composants Vue | Type-safe, ré-utilisable, stylisable via Nuxt UI |
|
||||||
|
| Parsing SQLite | Driver custom | `experimental.sqliteConnector: 'native'` | Node 22 built-in, zéro install |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Common Pitfalls
|
||||||
|
|
||||||
|
### Pitfall 1 : pnpm build scripts bloqués pour SQLite
|
||||||
|
**Ce qui se passe :** `pnpm install` refuse d'exécuter les scripts de build de `better-sqlite3` par défaut.
|
||||||
|
**Pourquoi :** pnpm v10+ restreint les scripts de build.
|
||||||
|
**Comment éviter :** Utiliser `experimental.sqliteConnector: 'native'` — aucune dépendance SQLite externe nécessaire sur Node 22. Si `better-sqlite3` est quand même nécessaire, ajouter `"better-sqlite3"` dans `pnpm.onlyBuiltDependencies` (déjà configuré dans `package.json`).
|
||||||
|
**Signal d'alerte :** Erreur `Cannot find module 'better-sqlite3'` au démarrage.
|
||||||
|
|
||||||
|
### Pitfall 2 : Thèmes Shiki et classe CSS du dark mode
|
||||||
|
**Ce qui se passe :** Le thème `dark` ne s'applique pas — le code reste en thème clair.
|
||||||
|
**Pourquoi :** Shiki dual-theme fonctionne via la classe `html.dark`. Le projet configure `colorMode` avec `classSuffix: ''`, ce qui génère bien `class="dark"` sur `<html>` — c'est compatible.
|
||||||
|
**Comment éviter :** Vérifier que `colorMode.classSuffix` reste `''` dans `nuxt.config.ts`. Shiki génère automatiquement les CSS variables pour les deux thèmes.
|
||||||
|
**Signal d'alerte :** Code toujours en clair même en mode dark → inspecter `<html class>`.
|
||||||
|
|
||||||
|
### Pitfall 3 : `source.prefix` mal configuré dans content.config.ts
|
||||||
|
**Ce qui se passe :** Les articles FR apparaissent sous `/blog/...` au lieu de `/fr/blog/...`, ou vice-versa.
|
||||||
|
**Pourquoi :** La valeur `prefix` dans `defineCollection.source` définit le path URL racine de la collection.
|
||||||
|
**Comment éviter :** Pour `content/fr/blog/*.md` avec i18n `prefix_except_default` (FR = default sans préfixe) : `prefix: '/blog'` pour la collection FR, `prefix: '/en/blog'` pour EN.
|
||||||
|
|
||||||
|
### Pitfall 4 : ContentSlot manquant dans composants MDC avec contenu
|
||||||
|
**Ce qui se passe :** Le contenu entre `::alert` et `::` n'est pas affiché.
|
||||||
|
**Pourquoi :** Les composants MDC reçoivent leur contenu via un slot — il faut explicitement le rendre avec `<ContentSlot :use="$slots.default" unwrap="p" />`.
|
||||||
|
**Comment éviter :** Toujours inclure `ContentSlot` dans les composants MDC qui acceptent du contenu.
|
||||||
|
**Signal d'alerte :** Alert visible mais vide.
|
||||||
|
|
||||||
|
### Pitfall 5 : @tailwindcss/typography et Tailwind v4 — ancienne syntaxe
|
||||||
|
**Ce qui se passe :** `plugins: [require('@tailwindcss/typography')]` dans `tailwind.config.js` est ignoré.
|
||||||
|
**Pourquoi :** Tailwind v4 n'utilise plus `tailwind.config.js` pour les plugins — tout passe par le CSS avec `@plugin`.
|
||||||
|
**Comment éviter :** Utiliser `@plugin "@tailwindcss/typography";` dans `assets/css/main.css`.
|
||||||
|
**Signal d'alerte :** Les classes `prose` n'ont aucun effet visible.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Code Examples
|
||||||
|
|
||||||
|
### Article de test markdown (critère de validation)
|
||||||
|
```markdown
|
||||||
|
---
|
||||||
|
title: "Test Kotlin Syntax Highlighting"
|
||||||
|
description: "Article de test pour valider le renderer"
|
||||||
|
date: "2026-04-21"
|
||||||
|
tags: ["kotlin", "hytale", "test"]
|
||||||
|
---
|
||||||
|
|
||||||
|
## Bloc de code Kotlin
|
||||||
|
|
||||||
|
```kotlin
|
||||||
|
fun main() {
|
||||||
|
println("Hello, Hytale!")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Image optimisée
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## Tableau
|
||||||
|
|
||||||
|
| Colonne A | Colonne B |
|
||||||
|
|-----------|-----------|
|
||||||
|
| Valeur 1 | Valeur 2 |
|
||||||
|
|
||||||
|
## Callout
|
||||||
|
|
||||||
|
::alert{type="info"}
|
||||||
|
Ceci est un callout d'information.
|
||||||
|
::
|
||||||
|
```
|
||||||
|
|
||||||
|
### Kotlin dans Shiki — langages Shiki acceptés
|
||||||
|
```typescript
|
||||||
|
// Noms de langages valides pour Shiki :
|
||||||
|
// 'kotlin' ✓, 'java' ✓, 'typescript' ✓ (ou 'ts'), 'shell' ✓ (ou 'bash', 'sh')
|
||||||
|
highlight: {
|
||||||
|
langs: ['kotlin', 'java', 'typescript', 'shell', 'bash', 'json', 'vue', 'html', 'css']
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## State of the Art
|
||||||
|
|
||||||
|
| Ancienne approche | Approche actuelle | Changement | Impact |
|
||||||
|
|-------------------|-------------------|------------|--------|
|
||||||
|
| @nuxt/content v2 (fichiers en mémoire) | v3 (SQLite) | v3.0.0 (2024) | Nouvelle API `queryCollection()`, fichier `content.config.ts` requis |
|
||||||
|
| `experimental.nativeSqlite: true` | `experimental.sqliteConnector: 'native'` | v3.x (2025) | Ancienne option dépréciée |
|
||||||
|
| `plugins: [require('...')]` dans tailwind.config.js | `@plugin "..."` dans CSS | Tailwind v4 (2024) | tailwind.config.js supprimé |
|
||||||
|
| `<NuxtContent>` (v2) | `<ContentRenderer :value="page">` (v3) | v3.0.0 | Composant renommé et refactorisé |
|
||||||
|
|
||||||
|
**Déprécié :**
|
||||||
|
- `queryContent()` (v2) → remplacé par `queryCollection()` (v3) — ne pas utiliser l'ancienne API
|
||||||
|
- `experimental.nativeSqlite` → utiliser `experimental.sqliteConnector: 'native'`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Assumptions Log
|
||||||
|
|
||||||
|
| # | Claim | Section | Risque si faux |
|
||||||
|
|---|-------|---------|----------------|
|
||||||
|
| A1 | Le thème Shiki `github-dark`/`github-light` est cohérent avec la charte visuelle du site | Standard Stack / Pattern 1 | Mineur — changeable post-implémentation |
|
||||||
|
| A2 | `assets/css/main.css` existe et est déjà l'entrée CSS principale (référencé dans nuxt.config.ts `css: ['~/assets/css/main.css']`) | Pattern 3 | Si le fichier n'existe pas, il faut le créer avec le contenu complet |
|
||||||
|
| A3 | Le prefix collection FR doit être `/blog` (pas `/fr/blog`) car `prefix_except_default` avec FR comme locale par défaut | Pattern 2 | Moyen — si faux, les URLs des articles seront mal formées |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Open Questions (RESOLVED)
|
||||||
|
|
||||||
|
1. **Frontmatter schema définitif** — RESOLVED
|
||||||
|
- `tags`: `z.array(z.string()).optional()` dans content.config.ts (array, pas string)
|
||||||
|
- `image`: chemin relatif depuis `public/` (ex: `/images/og-image.png`) — string optionnel
|
||||||
|
- `author`: implicite depuis `site.ts` (pas dans le frontmatter de cette phase — ajouté en Phase 7 si besoin)
|
||||||
|
|
||||||
|
2. **Prefix des collections i18n** — RESOLVED
|
||||||
|
- `source.prefix` pour `blog_fr` : `/blog` (FR est la locale par défaut, pas de préfixe `/fr/` grâce à `prefix_except_default`)
|
||||||
|
- `source.prefix` pour `blog_en` : `/en/blog` (EN est préfixé)
|
||||||
|
- Aligné avec la strategy `prefix_except_default` de `@nuxtjs/i18n`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Environment Availability
|
||||||
|
|
||||||
|
| Dépendance | Requis par | Disponible | Version | Fallback |
|
||||||
|
|------------|-----------|-----------|---------|----------|
|
||||||
|
| Node.js 22 | `sqliteConnector: 'native'` (22.5+) | ✓ (Dockerfile node:22-alpine) | 22+ | Installer better-sqlite3 |
|
||||||
|
| pnpm | Install @nuxt/content | ✓ (package.json pnpm field présent) | — | — |
|
||||||
|
| @nuxt/image | ProseImg.vue → NuxtImg | ✓ (déjà dans package.json) | ^2.0.0 | — |
|
||||||
|
|
||||||
|
**Aucune dépendance bloquante sans fallback.**
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Validation Architecture
|
||||||
|
|
||||||
|
> `workflow.nyquist_validation` non configuré — traité comme activé.
|
||||||
|
|
||||||
|
### Test Framework
|
||||||
|
| Propriété | Valeur |
|
||||||
|
|-----------|--------|
|
||||||
|
| Framework | Manuel (vitest non configuré) |
|
||||||
|
| Config file | Aucune |
|
||||||
|
| Quick run command | `pnpm dev` + navigation sur l'article de test |
|
||||||
|
| Full suite command | `pnpm build && pnpm preview` |
|
||||||
|
|
||||||
|
### Phase Requirements → Test Map
|
||||||
|
|
||||||
|
| Req ID | Comportement | Type de test | Commande | Fichier existe? |
|
||||||
|
|--------|-------------|-------------|----------|----------------|
|
||||||
|
| BLOG-01 | ContentRenderer rend un fichier .md | smoke | `pnpm dev` — vérifier /blog/test-kotlin-syntax | ❌ Wave 0 |
|
||||||
|
| BLOG-04 | Bloc ```kotlin coloré avec thème dark/light | visuel | Inspecter DOM — spans avec classes Shiki | ❌ Wave 0 |
|
||||||
|
| BLOG-05 | Image dans article rendue via NuxtImg | visuel | Inspecter balise `<img>` — attributs srcset présents | ❌ Wave 0 |
|
||||||
|
|
||||||
|
### Wave 0 Gaps
|
||||||
|
- [ ] `content/fr/blog/test-kotlin-syntax.md` — article de test couvrant BLOG-01, BLOG-04, BLOG-05
|
||||||
|
- [ ] `content/en/blog/test-kotlin-syntax.md` — version EN du même article
|
||||||
|
- [ ] `content.config.ts` — collections blog_fr + blog_en
|
||||||
|
- [ ] `components/content/ProseImg.vue` — override NuxtImg
|
||||||
|
- [ ] `components/content/Alert.vue` — composant MDC callout
|
||||||
|
- [ ] `assets/css/main.css` — vérifier/créer avec `@plugin "@tailwindcss/typography"`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Security Domain
|
||||||
|
|
||||||
|
### Applicable ASVS Categories
|
||||||
|
|
||||||
|
| Catégorie ASVS | Applicable | Contrôle standard |
|
||||||
|
|----------------|-----------|-------------------|
|
||||||
|
| V5 Input Validation | Oui (faible) | Le markdown est statique (fichiers gérés par l'auteur) — pas d'input utilisateur dans cette phase |
|
||||||
|
| V6 Cryptography | Non | — |
|
||||||
|
|
||||||
|
**Note sécurité :** Le markdown est géré par l'auteur (fichiers statiques). Pas d'injection utilisateur possible dans cette phase. Le rendu HTML via ContentRenderer est sûr — Shiki génère du HTML échappé. Aucun XSS vector identifié.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Sources
|
||||||
|
|
||||||
|
### Primaires (HIGH confidence)
|
||||||
|
- [content.nuxt.com/docs/getting-started/installation](https://content.nuxt.com/docs/getting-started/installation) — installation, pnpm, SQLite native
|
||||||
|
- [content.nuxt.com/docs/getting-started/configuration](https://content.nuxt.com/docs/getting-started/configuration#highlight) — Shiki dual theme, langs
|
||||||
|
- [content.nuxt.com/docs/components/prose](https://content.nuxt.com/docs/components/prose) — liste composants Prose, ProseImg
|
||||||
|
- [content.nuxt.com/docs/files/markdown](https://content.nuxt.com/docs/files/markdown) — MDC syntax
|
||||||
|
- [content.nuxt.com/docs/integrations/i18n](https://content.nuxt.com/docs/integrations/i18n) — collections bilingues
|
||||||
|
- [github.com/tailwindlabs/tailwindcss-typography](https://github.com/tailwindlabs/tailwindcss-typography) — `@plugin` syntax Tailwind v4
|
||||||
|
- Context7 `/nuxt/content` — version v3.6.3 confirmée
|
||||||
|
|
||||||
|
### Secondaires (MEDIUM confidence)
|
||||||
|
- [masteringnuxt.com/blog/mastering-prose-components-in-nuxt-content](https://masteringnuxt.com/blog/mastering-prose-components-in-nuxt-content) — ProseImg.vue pattern avec NuxtImg
|
||||||
|
- [github.com/nuxt/content/discussions/2082](https://github.com/nuxt/content/discussions/2082) — recommandation ProseImg + NuxtImg
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Metadata
|
||||||
|
|
||||||
|
**Confidence breakdown :**
|
||||||
|
- Standard stack : HIGH — versions vérifiées, docs officielles consultées
|
||||||
|
- Architecture patterns : HIGH — examples tirés de la doc officielle
|
||||||
|
- Pitfalls : MEDIUM — combinaison doc officielle + patterns communautaires vérifiés
|
||||||
|
- Tailwind v4 + typography : HIGH — vérifié sur le repo officiel
|
||||||
|
|
||||||
|
**Research date :** 2026-04-21
|
||||||
|
**Valid until :** 2026-05-21 (librairies stables, pas de breaking changes attendus)
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
---
|
||||||
|
status: complete
|
||||||
|
phase: 05-nuxt-content-setup-renderer
|
||||||
|
source: [05-01-SUMMARY.md, 05-02-SUMMARY.md]
|
||||||
|
started: 2026-04-21T00:00:00.000Z
|
||||||
|
updated: 2026-04-21T21:30:00.000Z
|
||||||
|
---
|
||||||
|
|
||||||
|
## Current Test
|
||||||
|
|
||||||
|
[testing complete]
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
### 1. Serveur démarre sans erreur
|
||||||
|
expected: `pnpm dev` lance Nuxt 4 sur :3000 sans erreur de console liée à @nuxt/content, SQLite ou @tailwindcss/typography. La page d'accueil se charge normalement.
|
||||||
|
result: pass
|
||||||
|
|
||||||
|
### 2. Blocs de code toujours dark
|
||||||
|
expected: Naviguer vers `/test`. Le bloc Kotlin affiché a un fond sombre (#0d1117) ET des tokens colorés — que ce soit en mode dark ou en mode light (toggle). En light mode, le fond du bloc reste sombre, pas blanc.
|
||||||
|
result: pass
|
||||||
|
|
||||||
|
### 3. Images optimisées via NuxtImg
|
||||||
|
expected: Sur `/test`, l'image référencée dans l'article est visible (pas de 404). Inspecter le DOM : l'élément rendu est `<img>` avec attribut `loading="lazy"`. ProseImg.vue est l'override utilisé.
|
||||||
|
result: pass
|
||||||
|
|
||||||
|
### 4. Tableau markdown avec prose styling
|
||||||
|
expected: Sur `/test`, le tableau markdown est rendu avec des bordures visibles, un en-tête distingué et une mise en forme prose correcte (pas du texte brut).
|
||||||
|
result: pass
|
||||||
|
|
||||||
|
### 5. Callouts Alert (4 types)
|
||||||
|
expected: Sur `/test`, les 4 callouts `::alert{type}` sont rendus comme des boîtes colorées avec icônes : info (bleu), warning (amber), tip (vert), danger (rouge).
|
||||||
|
result: pass
|
||||||
|
|
||||||
|
### 6. Articles bilingues accessibles
|
||||||
|
expected: Les articles de test existent pour FR et EN. Naviguer vers `/fr/blog/test-kotlin-syntax` (FR) et `/en/blog/test-kotlin-syntax` (EN) — les deux pages chargent sans 404.
|
||||||
|
result: issue
|
||||||
|
reported: "both empty page, nav and footer there but no content — Vue warn: Component <Anonymous> is missing template or render function at <RouteProvider key=\"/fr/blog/test-kotlin-syntax\">. <main> SSR renders empty: `<main class=\"flex-1\"><!--[--><!----><!--]--></main>`. Same behavior FR and EN."
|
||||||
|
severity: major
|
||||||
|
|
||||||
|
### 7. Collections @nuxt/content configurées
|
||||||
|
expected: Le fichier `content.config.ts` définit `blog_fr` et `blog_en`. `queryCollection('blog_fr')` retourne les articles FR. Vérifiable via le bon rendu de `/test` (qui query `blog_fr`).
|
||||||
|
result: issue
|
||||||
|
reported: "/test donne 404. Route `/test` supprimée/déplacée par commit 7cd1531 'fix(05): update test.vue path to /fr/blog prefix' — donc plus de page showcase standalone, et les routes blog prefixées elles-mêmes sont cassées (cf Test 6)."
|
||||||
|
severity: major
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
total: 7
|
||||||
|
passed: 5
|
||||||
|
issues: 2
|
||||||
|
pending: 0
|
||||||
|
skipped: 0
|
||||||
|
blocked: 0
|
||||||
|
|
||||||
|
## Gaps
|
||||||
|
|
||||||
|
- truth: "Les articles FR/EN du blog doivent se rendre au chemin `/fr/blog/test-kotlin-syntax` et `/en/blog/test-kotlin-syntax` avec le contenu markdown dans `<main>`."
|
||||||
|
status: failed
|
||||||
|
reason: "User reported: both empty page, nav and footer there but no content. Vue warn in SSR log: Component <Anonymous> is missing template or render function at <RouteProvider key=\"/fr/blog/test-kotlin-syntax\">. <main class=\"flex-1\"> rendered empty. Non-blocking i18n baseUrl warning also present but unrelated. Same behavior both locales."
|
||||||
|
severity: major
|
||||||
|
test: 6
|
||||||
|
artifacts: []
|
||||||
|
missing: []
|
||||||
|
|
||||||
|
- truth: "La page query `blog_fr` doit être routable (historiquement `/test`, depuis commit 7cd1531 déplacée sous `/fr/blog`) et rendre le contenu markdown."
|
||||||
|
status: failed
|
||||||
|
reason: "User reported: /test donne 404. Le commit 7cd1531 a migré test.vue vers le préfixe /fr/blog, mais les routes prefixées sont elles-mêmes cassées (cf gap Test 6)."
|
||||||
|
severity: major
|
||||||
|
test: 7
|
||||||
|
artifacts: []
|
||||||
|
missing: []
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
---
|
||||||
|
phase: 5
|
||||||
|
slug: nuxt-content-setup-renderer
|
||||||
|
status: draft
|
||||||
|
shadcn_initialized: false
|
||||||
|
preset: none
|
||||||
|
created: 2026-04-21
|
||||||
|
---
|
||||||
|
|
||||||
|
# Phase 5 — UI Design Contract
|
||||||
|
## @nuxt/content Setup & Renderer
|
||||||
|
|
||||||
|
> Contrat visuel et d'interaction pour la phase d'infrastructure de rendu markdown.
|
||||||
|
> Généré par gsd-ui-researcher — à valider par gsd-ui-checker.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Design System
|
||||||
|
|
||||||
|
| Property | Value | Source |
|
||||||
|
|----------|-------|--------|
|
||||||
|
| Tool | Nuxt UI v3 | CONTEXT.md / nuxt.config.ts |
|
||||||
|
| Preset | not applicable (Nuxt UI, pas shadcn) | nuxt.config.ts |
|
||||||
|
| Component library | Nuxt UI v3 (@nuxt/ui) | nuxt.config.ts modules |
|
||||||
|
| Icon library | Heroicons via Nuxt UI (i-heroicons-*) | RESEARCH.md Pattern 5 |
|
||||||
|
| Font | Hérité du site (pas de font custom déclarée dans main.css) | app/assets/css/main.css |
|
||||||
|
| Tailwind | v4 avec @theme tokens brand-* | app/assets/css/main.css |
|
||||||
|
|
||||||
|
> Note : pas de shadcn dans ce projet — stack Nuxt UI v3 + Tailwind v4. La shadcn gate ne s'applique pas.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Spacing Scale
|
||||||
|
|
||||||
|
Échelle 8-points standard (multiples de 4). Tailwind v4 gère ces valeurs via ses classes utilitaires.
|
||||||
|
|
||||||
|
| Token | Value | Usage dans cette phase |
|
||||||
|
|-------|-------|------------------------|
|
||||||
|
| xs | 4px | Gap icône/texte dans les callouts Alert |
|
||||||
|
| sm | 8px | Padding interne compact (inline code, badges tags) |
|
||||||
|
| md | 16px | Padding article, espacement entre blocs prose |
|
||||||
|
| lg | 24px | Marge verticale entre sections de l'article |
|
||||||
|
| xl | 32px | Padding extérieur du wrapper `<article>` |
|
||||||
|
| 2xl | 48px | — (réservé Phase 6 pour les pages) |
|
||||||
|
| 3xl | 64px | — (réservé Phase 6 pour les pages) |
|
||||||
|
|
||||||
|
Exceptions :
|
||||||
|
- `my-4` (16px) sur les composants `Alert.vue` — conforme à l'échelle, source : RESEARCH.md Pattern 5
|
||||||
|
- Images prose : `rounded-lg w-full` sans contrainte de hauteur fixe — taille naturelle de l'image
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Typography
|
||||||
|
|
||||||
|
La typographie du corps de l'article est entièrement gérée par `@tailwindcss/typography` via la classe `prose`.
|
||||||
|
Les valeurs ci-dessous reflètent les valeurs par défaut du plugin, conformes aux décisions D-01.
|
||||||
|
|
||||||
|
| Role | Size | Weight | Line Height | Usage |
|
||||||
|
|------|------|--------|-------------|-------|
|
||||||
|
| Body prose | 16px (1rem) | 400 (regular) | 1.75 | Corps du texte dans `<article class="prose">` |
|
||||||
|
| Label / caption | 14px (0.875rem) | 400 (regular) | 1.5 | Tags frontmatter, métadonnées date |
|
||||||
|
| Heading article (h2/h3) | 20–24px | 600 (semibold) | 1.25 | Titres de sections générés par prose |
|
||||||
|
| Inline code | 14px (0.875rem) | 400 (regular) | 1.5 | `` `code` `` inline dans prose |
|
||||||
|
|
||||||
|
> Source : valeurs par défaut `@tailwindcss/typography` — RESEARCH.md D-01, Pattern 3.
|
||||||
|
> Police héritée du site (system-ui ou celle définie par Nuxt UI). Pas de font custom dans cette phase.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Color
|
||||||
|
|
||||||
|
Le site utilise dark mode par défaut (`colorMode.preference: 'dark'`) avec cookie SSR-safe.
|
||||||
|
|
||||||
|
| Role | Value | Usage |
|
||||||
|
|------|-------|-------|
|
||||||
|
| Dominant (60%) | `bg-background` (Nuxt UI token — dark: ~#0f172a, light: #ffffff) | Surface principale de l'article, fond de page |
|
||||||
|
| Secondary (30%) | `bg-muted` / `bg-elevated` (Nuxt UI token) | Blocs de code Shiki (fond), callouts Alert background |
|
||||||
|
| Accent (10%) | `--color-brand-500: #85cb85` (vert) | Liens dans prose, bordure left des callouts `tip`, focus states |
|
||||||
|
| Destructive | `color-error` (Nuxt UI token — rouge) | Callouts `danger` uniquement |
|
||||||
|
|
||||||
|
Accent réservé exclusivement à :
|
||||||
|
- Liens hypertextes dans le contenu `prose` (`:hover` underline brand-500)
|
||||||
|
- Bordure gauche du callout `::alert{type="tip"}` (couleur success = vert)
|
||||||
|
- Aucun autre usage dans cette phase
|
||||||
|
|
||||||
|
Thème Shiki :
|
||||||
|
- `default: 'github-light'` en mode light
|
||||||
|
- `dark: 'github-dark'` en mode dark
|
||||||
|
- Synchronisé via `html.dark` (classSuffix: '' confirmé dans nuxt.config.ts)
|
||||||
|
- Source : RESEARCH.md Pattern 1, assumption A1 validée (cohérence avec charte vert/dark du site)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Component Inventory
|
||||||
|
|
||||||
|
Composants à créer dans cette phase (zéro shadcn — tout Nuxt UI ou Tailwind CSS) :
|
||||||
|
|
||||||
|
| Composant | Chemin | Rôle | Base |
|
||||||
|
|-----------|--------|------|------|
|
||||||
|
| ProseImg | `components/content/ProseImg.vue` | Override prose img → NuxtImg optimisé | `<NuxtImg>` déjà installé |
|
||||||
|
| Alert | `components/content/Alert.vue` | Callout MDC `::alert{type}` | `<UAlert>` Nuxt UI |
|
||||||
|
|
||||||
|
Types de callouts MDC à implémenter (minimum) :
|
||||||
|
|
||||||
|
| Type | Icône Heroicons | Couleur Nuxt UI | Usage |
|
||||||
|
|------|-----------------|-----------------|-------|
|
||||||
|
| `info` | `i-heroicons-information-circle` | `info` | Notes générales |
|
||||||
|
| `warning` | `i-heroicons-exclamation-triangle` | `warning` | Avertissements |
|
||||||
|
| `tip` | `i-heroicons-light-bulb` | `success` (vert brand) | Conseils pratiques |
|
||||||
|
| `danger` | `i-heroicons-x-circle` | `error` | Erreurs critiques |
|
||||||
|
|
||||||
|
Source : RESEARCH.md Pattern 5 — iconMap et colorMap déjà définis, à utiliser tel quel.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Copywriting Contract
|
||||||
|
|
||||||
|
Cette phase est une phase d'infrastructure — aucune page publique n'est exposée.
|
||||||
|
Le seul contenu visible est l'article de test servant à valider les critères de succès.
|
||||||
|
|
||||||
|
| Element | Copy (FR) | Copy (EN) |
|
||||||
|
|---------|-----------|-----------|
|
||||||
|
| Titre article de test | "Test Kotlin Syntax Highlighting" | "Test Kotlin Syntax Highlighting" |
|
||||||
|
| Description article de test | "Article de test pour valider le renderer @nuxt/content" | "Test article to validate the @nuxt/content renderer" |
|
||||||
|
| Contenu callout info de test | "Ceci est un callout d'information." | "This is an information callout." |
|
||||||
|
| Contenu callout warning de test | "Ceci est un avertissement." | "This is a warning." |
|
||||||
|
| Contenu callout tip de test | "Conseil pratique de développement Kotlin." | "Practical Kotlin development tip." |
|
||||||
|
| Alt image de test | "Image de test pour NuxtImg dans les articles" | "Test image for NuxtImg in articles" |
|
||||||
|
|
||||||
|
États d'erreur (infrastructure — pas d'UI utilisateur) :
|
||||||
|
- Aucun état d'erreur visible par l'utilisateur dans cette phase
|
||||||
|
- En cas d'échec du build SQLite : erreur côté serveur uniquement (logs), pas de fallback UI
|
||||||
|
|
||||||
|
Aucune action destructive dans cette phase.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Prose Wrapper Contract
|
||||||
|
|
||||||
|
Le wrapper autour de `<ContentRenderer>` suit ce contrat exact :
|
||||||
|
|
||||||
|
```html
|
||||||
|
<article class="prose dark:prose-invert max-w-none">
|
||||||
|
<ContentRenderer :value="page" />
|
||||||
|
</article>
|
||||||
|
```
|
||||||
|
|
||||||
|
- `max-w-none` : la contrainte de largeur est gérée par le layout parent (Phase 6), pas par prose
|
||||||
|
- `dark:prose-invert` : inverse automatiquement les couleurs prose en dark mode
|
||||||
|
- Source : RESEARCH.md D-01, Pattern 3
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Frontmatter Schema (minimal Phase 5)
|
||||||
|
|
||||||
|
Déclaré dans `content.config.ts` via Zod. Utilisé par l'article de test.
|
||||||
|
|
||||||
|
| Champ | Type | Requis | Usage |
|
||||||
|
|-------|------|--------|-------|
|
||||||
|
| `title` | `z.string()` | Oui | Titre de l'article |
|
||||||
|
| `description` | `z.string()` | Oui | Meta description (Phase 7) |
|
||||||
|
| `date` | `z.string()` | Oui | Date ISO 8601 (YYYY-MM-DD) |
|
||||||
|
| `tags` | `z.array(z.string()).optional()` | Non | Tags thématiques |
|
||||||
|
| `image` | `z.string().optional()` | Non | Chemin image og (Phase 7) |
|
||||||
|
|
||||||
|
Source : RESEARCH.md Pattern 2 — schema `blogSchema` à utiliser tel quel.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Registry Safety
|
||||||
|
|
||||||
|
| Registry | Blocks Used | Safety Gate |
|
||||||
|
|----------|-------------|-------------|
|
||||||
|
| Nuxt UI officiel | `UAlert` | Non requis — composant officiel @nuxt/ui |
|
||||||
|
| @nuxt/content officiel | `ContentRenderer`, `ContentSlot` | Non requis — module officiel Nuxt |
|
||||||
|
| Tiers | aucun | Non applicable |
|
||||||
|
|
||||||
|
> Note : Ce projet utilise Nuxt UI, pas shadcn. La registry safety gate shadcn ne s'applique pas.
|
||||||
|
> Aucun composant tiers hors ecosystem Nuxt officiel dans cette phase.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Checker Sign-Off
|
||||||
|
|
||||||
|
- [ ] Dimension 1 Copywriting: PASS
|
||||||
|
- [ ] Dimension 2 Visuals: PASS
|
||||||
|
- [ ] Dimension 3 Color: PASS
|
||||||
|
- [ ] Dimension 4 Typography: PASS
|
||||||
|
- [ ] Dimension 5 Spacing: PASS
|
||||||
|
- [ ] Dimension 6 Registry Safety: PASS
|
||||||
|
|
||||||
|
**Approval:** pending
|
||||||
+288
-185
@@ -1,230 +1,333 @@
|
|||||||
# Architecture Patterns
|
# Architecture Patterns
|
||||||
|
|
||||||
**Project:** Portfolio Killian Dalcin — Nuxt 4 SSR Migration
|
**Project:** Portfolio Killian' Dalcin — Nuxt 4 SSR
|
||||||
**Researched:** 2026-04-07
|
**Researched:** 2026-04-10
|
||||||
**Confidence:** HIGH (based on official Nuxt 4 conventions + existing codebase analysis)
|
**Confidence:** HIGH (verified against actual codebase + Nuxt 4 docs patterns)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Is the Current Architecture Sound?
|
||||||
|
|
||||||
|
**Yes — the foundation is solid.** The core SSR pipeline is correctly implemented:
|
||||||
|
|
||||||
|
- `ssr: true` + `compatibilityVersion: 4` — full SSR, not hybrid
|
||||||
|
- `@nuxtjs/i18n` with `prefix_except_default` — SEO-correct URL scheme (FR at `/`, EN at `/en/`)
|
||||||
|
- `@nuxtjs/color-mode` with `storage: 'cookie'` — theme class applied server-side, no flash
|
||||||
|
- `useSeoMeta()` with reactive `() => t('...')` callbacks — meta resolves server-side per locale
|
||||||
|
- `useLocaleHead()` in `app.vue` — injects `hreflang` alternates on every page automatically
|
||||||
|
- Data layer (`app/data/*.ts` + `useProjects()`) is clean: static IDs, translated fields via i18n keys, reactive recomputation on locale change
|
||||||
|
|
||||||
|
Three real problems exist in the current implementation (not architecture flaws — just execution gaps):
|
||||||
|
|
||||||
|
1. **og:image hardcoded** to `https://killiandalcin.fr/og-image.png` on all 6 pages, including project detail where `project.image` is already available
|
||||||
|
2. **JSON-LD only on homepage** — other pages have no structured data; `jobTitle` still says "Developpeur Full Stack Freelance" instead of positioning Hytale
|
||||||
|
3. **ogUrl missing** — `useSeoMeta()` calls don't include `ogUrl`, so the canonical URL is absent from Open Graph, though `<link rel="canonical">` is provided by `@nuxtjs/i18n`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Recommended Architecture
|
## Recommended Architecture
|
||||||
|
|
||||||
|
The existing layer structure is correct. No refactoring needed. Extensions follow the same pattern:
|
||||||
|
|
||||||
```
|
```
|
||||||
[ Browser ]
|
Pages (app/pages/)
|
||||||
|
|
hytale.vue ← new page, same pattern as fiverr.vue
|
||||||
| HTTP request (SSR-rendered HTML on first load)
|
project/[id].vue ← add dynamic og:image (project.image already there)
|
||||||
v
|
|
||||||
[ Nuxt 4 Server (Node 22) ]
|
Composables (app/composables/)
|
||||||
|
|
useSeoMeta → per-page calls ← add ogUrl to every page
|
||||||
|-- [ app/ ]
|
useJsonLd.ts ← new: centralize JSON-LD generation
|
||||||
| |-- [ pages/ ] File-based routing
|
|
||||||
| |-- [ components/ ] Auto-imported UI components
|
Data (app/data/)
|
||||||
| |-- [ composables/ ] Auto-imported reactive logic
|
hytale.ts ← new: pricing tiers, service cards, tech highlights
|
||||||
| |-- [ layouts/ ] default.vue (header + footer)
|
site.ts ← update jobTitle to Hytale positioning
|
||||||
| |-- [ assets/ ] Static assets (images, fonts)
|
|
||||||
| |-- [ plugins/ ] EmailJS init, gtag init
|
Locales (app/locales/fr.json, en.json)
|
||||||
|
|
seo.hytale.* ← new SEO keys
|
||||||
|-- [ server/ ]
|
hytale.* ← new page content keys
|
||||||
| |-- (optional) api/ Not needed — no dynamic data
|
|
||||||
|
|
|
||||||
|-- [ data/ ] Static TS files (projects, testimonials, FAQ, techstack)
|
|
||||||
|
|
|
||||||
|-- nuxt.config.ts Modules, runtime config, i18n, color-mode
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Nuxt 4 uses `app/` as the root source directory (replaces Nuxt 3's flat root layout). All pages, components, composables, layouts, and plugins live under `app/`.
|
### Component Boundaries for the Hytale Page
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Component Boundaries
|
|
||||||
|
|
||||||
| Component | Responsibility | Communicates With |
|
| Component | Responsibility | Communicates With |
|
||||||
|-----------|---------------|-------------------|
|
|-----------|---------------|-------------------|
|
||||||
| `layouts/default.vue` | Shell: TheHeader + TheFooter + `<slot />` | All pages via slot |
|
| `pages/hytale.vue` | Page assembly, SEO, JSON-LD | `HytaleHeroSection`, `HytalePricingGrid`, `HytaleServiceCards`, `FAQSection`, `CTASection` |
|
||||||
| `TheHeader.vue` | Navigation + locale toggle + color-mode toggle | `useI18n()`, `useColorMode()` |
|
| `sections/HytaleHeroSection.vue` | Hero — "Hytale Plugin Developer" headline, early access badge | `useI18n()` |
|
||||||
| `TheFooter.vue` | Links, copyright, social | `useI18n()` |
|
| `sections/HytalePricingGrid.vue` | 3-column pricing table (Simple / Complex / Sur-mesure + Maintenance) | `app/data/hytale.ts` via props |
|
||||||
| `pages/index.vue` | Hero + featured projects + services + CTA | `useProjects()`, `useSeoMeta()` |
|
| `sections/HytaleServiceCards.vue` | What's included per service, tech stack used | `app/data/hytale.ts` via props |
|
||||||
| `pages/projects.vue` | Project list + filters | `useProjects()` |
|
| Reuse `FAQSection.vue` | Hytale-specific FAQs | `data/faq.ts` (add `hytaleFAQs` export) |
|
||||||
| `pages/project/[id].vue` | Project detail + image gallery modal | `useProjects()`, `UModal` (Nuxt UI) |
|
| Reuse `CTASection.vue` | Call to action to contact / Fiverr | props |
|
||||||
| `pages/about.vue` | Bio, tech stack | `useI18n()`, static `techstack.ts` |
|
|
||||||
| `pages/contact.vue` | UForm + EmailJS send | `useContactForm()`, EmailJS plugin |
|
|
||||||
| `pages/fiverr.vue` | Fiverr landing, service cards | `useI18n()`, static config |
|
|
||||||
| `pages/formation.vue` | Training/course landing | `useI18n()` |
|
|
||||||
| `components/ProjectCard.vue` | Reusable card (list + featured) | Props only, no store |
|
|
||||||
| `components/GalleryModal.vue` | UModal wrapper for project images | Emits only, props: images[] |
|
|
||||||
| `composables/useProjects.ts` | Filter/search logic over static data | Imports `data/projects.ts` |
|
|
||||||
| `composables/useSeoMeta.ts` | Per-route `useSeoMeta()` + JSON-LD | Nuxt built-in `useSeoMeta` |
|
|
||||||
| `data/*.ts` | Static typed data — single source of truth | Imported by composables only |
|
|
||||||
|
|
||||||
**Rule:** Pages import composables. Composables import data files. Components receive props and emit events. No page imports another page. No component imports data files directly.
|
Follow the `fiverr.vue` structural pattern exactly — it already does service cards correctly. The Hytale page is a thematic variant, not a new pattern.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Data Flow
|
## og:image Hardcoding Fix
|
||||||
|
|
||||||
```
|
**Problem:** All pages including `project/[id].vue` use the same static `og-image.png`. The project detail page already has `project.value?.image` available but ignores it.
|
||||||
data/projects.ts (static TS, bilingual strings)
|
|
||||||
|
|
**Fix: per-page og:image strategy**
|
||||||
v
|
|
||||||
composables/useProjects.ts
|
```typescript
|
||||||
- filter by category
|
// pages/project/[id].vue — already has project data, just use it
|
||||||
- find by id
|
useSeoMeta({
|
||||||
- expose featuredProjects, allProjects
|
// ...
|
||||||
|
|
ogImage: () => project.value?.image
|
||||||
v
|
? `https://killiandalcin.fr${project.value.image}`
|
||||||
pages/index.vue → featuredProjects → <ProjectCard />
|
: 'https://killiandalcin.fr/og-image.png',
|
||||||
pages/projects.vue → allProjects + filters → <ProjectCard />
|
})
|
||||||
pages/project/[id].vue → findById(route.params.id) → detail view + <GalleryModal />
|
|
||||||
```
|
```
|
||||||
|
|
||||||
```
|
For all other pages, create dedicated OG images rather than sharing one. The naming convention:
|
||||||
i18n locale (cookie, SSR-safe)
|
|
||||||
|
|
|
||||||
v
|
|
||||||
@nuxtjs/i18n module (strategy: 'prefix_except_default', defaultLocale: 'fr')
|
|
||||||
- /fr/* and /* (default) both work
|
|
||||||
- /en/* for English
|
|
||||||
|
|
|
||||||
v
|
|
||||||
All pages and composables via useI18n() (auto-imported by @nuxtjs/i18n)
|
|
||||||
```
|
|
||||||
|
|
||||||
```
|
| Page | File | Dimensions |
|
||||||
Color mode (cookie, SSR-safe)
|
|------|------|-----------|
|
||||||
|
|
| Default / fallback | `/public/og/og-default.png` | 1200×630 |
|
||||||
v
|
| Hytale | `/public/og/og-hytale.png` | 1200×630 |
|
||||||
@nuxtjs/color-mode (cookie strategy, no FOUC)
|
| Fiverr | `/public/og/og-fiverr.png` | 1200×630 |
|
||||||
|
|
| Projects | `/public/og/og-projects.png` | 1200×630 |
|
||||||
v
|
|
||||||
TheHeader.vue toggle → Tailwind dark: classes respond immediately
|
|
||||||
```
|
|
||||||
|
|
||||||
```
|
Each page's `useSeoMeta()` references its own file. This is the simplest, most reliable approach — no server-side image generation required, works perfectly with SSR, zero dependencies.
|
||||||
Contact form
|
|
||||||
|
|
|
||||||
v
|
|
||||||
pages/contact.vue → UForm validation → composables/useContactForm.ts
|
|
||||||
|
|
|
||||||
v
|
|
||||||
EmailJS plugin (client-side send, no server route needed)
|
|
||||||
```
|
|
||||||
|
|
||||||
```
|
**Do not use `@vercel/og` or `nuxt-og-image`.** The portfolio is Docker-deployed, not Vercel. `nuxt-og-image` adds a Satori/Chromium dependency and requires additional config for non-Vercel deployments. Static pre-made images are sufficient for a portfolio and have zero runtime cost.
|
||||||
SEO per route
|
|
||||||
|
|
|
||||||
v
|
|
||||||
Each page calls useSeoMeta() with i18n-translated values
|
|
||||||
+ JSON-LD script tag on pages/index.vue only
|
|
||||||
|
|
|
||||||
v
|
|
||||||
@nuxtjs/sitemap generates sitemap.xml from route list at build time
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## i18n Architecture Decision
|
## Canonical URL Strategy with prefix_except_default
|
||||||
|
|
||||||
Use `@nuxtjs/i18n` v9 with `strategy: 'prefix_except_default'`:
|
**Current situation:** `@nuxtjs/i18n` with `prefix_except_default` + `baseUrl: 'https://killiandalcin.fr'` automatically generates:
|
||||||
- French (`fr`) is default, served at `/`, `/projects`, `/project/[id]`, etc.
|
|
||||||
- English served at `/en`, `/en/projects`, `/en/project/[id]`, etc.
|
|
||||||
- Locale detected from browser `Accept-Language` header on first visit (server-side), then persisted in cookie.
|
|
||||||
- **No redirect strategy** — prefix_except_default avoids redirect chains that hurt Core Web Vitals.
|
|
||||||
- Translation strings live in `app/i18n/locales/fr.ts` and `app/i18n/locales/en.ts` (migrated from existing `src/locales/`).
|
|
||||||
|
|
||||||
The existing `useI18n.ts` composable wrapping vue-i18n is replaced entirely by the `useI18n()` auto-import provided by `@nuxtjs/i18n`.
|
```html
|
||||||
|
<!-- On / (FR default, no prefix) -->
|
||||||
|
<link rel="canonical" href="https://killiandalcin.fr/" />
|
||||||
|
<link rel="alternate" hreflang="fr" href="https://killiandalcin.fr/" />
|
||||||
|
<link rel="alternate" hreflang="en" href="https://killiandalcin.fr/en/" />
|
||||||
|
<link rel="alternate" hreflang="x-default" href="https://killiandalcin.fr/" />
|
||||||
|
|
||||||
---
|
<!-- On /en/ -->
|
||||||
|
<link rel="canonical" href="https://killiandalcin.fr/en/" />
|
||||||
## Static Data Layer
|
<link rel="alternate" hreflang="fr" href="https://killiandalcin.fr/" />
|
||||||
|
<link rel="alternate" hreflang="en" href="https://killiandalcin.fr/en/" />
|
||||||
Decision: static TS files in `data/` (not `@nuxt/content`, not `server/api`).
|
|
||||||
|
|
||||||
Rationale:
|
|
||||||
- All project data is known at build time and changes infrequently.
|
|
||||||
- `@nuxt/content` adds markdown parsing overhead and a file-system watcher not needed for typed data.
|
|
||||||
- `server/api` routes add network round-trips and cold-start latency for data that never changes.
|
|
||||||
- Static TS files are tree-shakeable, fully typed, and zero-overhead.
|
|
||||||
|
|
||||||
Migration from `src/data/` is direct: copy files to `data/`, ensure bilingual structure is preserved (FR/EN fields in same object, selected by locale in composables).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Deployment: SSR vs SSG
|
|
||||||
|
|
||||||
**Recommendation: `nuxt build` (SSR) not `nuxt generate` (SSG).**
|
|
||||||
|
|
||||||
Rationale:
|
|
||||||
- i18n with cookie-based locale detection requires server execution to read the cookie and render the correct language on first request. SSG pre-renders all routes in one language only.
|
|
||||||
- `useSeoMeta()` with i18n-reactive values requires server-side execution per request.
|
|
||||||
- The Docker image runs `node server/index.mjs` (the Nuxt nitro server) — not nginx serving static files.
|
|
||||||
- SSR does not meaningfully increase operational complexity for a portfolio (low traffic, single container).
|
|
||||||
|
|
||||||
Dockerfile pattern: multi-stage — build stage (`node:22-alpine` + `nuxt build`), production stage (copy `.output/` only, `CMD ["node", ".output/server/index.mjs"]`). No nginx layer needed.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Suggested Build Order (Phase Dependencies)
|
|
||||||
|
|
||||||
```
|
|
||||||
1. nuxt.config.ts + nuxt.config modules
|
|
||||||
Depends on: nothing
|
|
||||||
Blocks: everything — module config must exist before page/component work
|
|
||||||
|
|
||||||
2. data/ migration (static TS files)
|
|
||||||
Depends on: nothing
|
|
||||||
Blocks: composables, all pages that display content
|
|
||||||
|
|
||||||
3. composables/ migration
|
|
||||||
Depends on: data/
|
|
||||||
Blocks: pages that use useProjects(), useSeoMeta()
|
|
||||||
|
|
||||||
4. layouts/default.vue + TheHeader + TheFooter
|
|
||||||
Depends on: @nuxtjs/i18n working, @nuxtjs/color-mode working
|
|
||||||
Blocks: all page development (every page needs a shell)
|
|
||||||
|
|
||||||
5. pages/ migration (one page at a time, start with index.vue)
|
|
||||||
Depends on: composables, layouts, Nuxt UI v3 components
|
|
||||||
Blocks: nothing else — pages are leaf nodes
|
|
||||||
|
|
||||||
6. plugins/ (EmailJS, nuxt-gtag)
|
|
||||||
Depends on: contact page, nuxt.config
|
|
||||||
Blocks: contact form functionality, GA tracking
|
|
||||||
|
|
||||||
7. Dockerfile + deployment
|
|
||||||
Depends on: all pages complete
|
|
||||||
Blocks: production ship
|
|
||||||
```
|
```
|
||||||
|
|
||||||
**Critical dependency:** `nuxt.config.ts` with `@nuxtjs/i18n`, `@nuxtjs/color-mode`, `@nuxt/ui`, and `@nuxtjs/sitemap` must be functional before any page/component work begins. All auto-imports, CSS variables, and the `useI18n()` composable availability depend on this configuration.
|
This is correct — `useLocaleHead()` in `app.vue` handles all of this automatically. **No manual canonical management needed.**
|
||||||
|
|
||||||
|
The one gap is `ogUrl` in Open Graph. Add it to every page's `useSeoMeta()`:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// Pattern for every page
|
||||||
|
const { locale } = useI18n()
|
||||||
|
const localePath = useLocalePath()
|
||||||
|
|
||||||
|
useSeoMeta({
|
||||||
|
// ... existing fields ...
|
||||||
|
ogUrl: () => `https://killiandalcin.fr${localePath('/hytale')}`,
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
`useLocalePath()` resolves the correct prefixed path for the current locale (`/hytale` for FR, `/en/hytale` for EN), making `ogUrl` SSR-safe and locale-correct.
|
||||||
|
|
||||||
|
**For the sitemap:** `@nuxtjs/sitemap` already reads from `@nuxtjs/i18n` configuration and generates hreflang entries automatically. No manual sitemap management needed for the Hytale page — it appears automatically when `pages/hytale.vue` is created.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## JSON-LD Structured Data Patterns
|
||||||
|
|
||||||
|
### What to Use Per Page
|
||||||
|
|
||||||
|
| Page | Schema Types | Priority |
|
||||||
|
|------|-------------|----------|
|
||||||
|
| `/` (homepage) | `Person` + `WebSite` + `ProfessionalService` | Exists, needs update |
|
||||||
|
| `/hytale` | `Service` (×3 tiers) + `SoftwareApplication` | New |
|
||||||
|
| `/projects` | `ItemList` of `SoftwareSourceCode` | Nice to have |
|
||||||
|
| `/project/[id]` | `SoftwareSourceCode` or `CreativeWork` | Nice to have |
|
||||||
|
| `/fiverr` | `Offer` per service | Nice to have |
|
||||||
|
| `/contact` | `ContactPage` | Low value |
|
||||||
|
|
||||||
|
### Centralize with a Composable
|
||||||
|
|
||||||
|
The current pattern inlines JSON-LD in each page's `useHead()`. This works but leads to duplication of `Person` data across pages. Centralize reusable schemas:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// app/composables/useJsonLd.ts
|
||||||
|
export function usePersonSchema() {
|
||||||
|
return {
|
||||||
|
'@type': 'Person',
|
||||||
|
name: "Killian' DAL-CIN",
|
||||||
|
url: 'https://killiandalcin.fr',
|
||||||
|
jobTitle: 'Hytale Plugin Developer & Full Stack Developer',
|
||||||
|
email: 'contact@killiandalcin.fr',
|
||||||
|
sameAs: [
|
||||||
|
'https://linkedin.com/in/killian-dal-cin',
|
||||||
|
'https://www.fiverr.com/users/mr_kayjaydee',
|
||||||
|
'https://gitea.kamisama.ovh/kayjaydee',
|
||||||
|
],
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useWebSiteSchema() {
|
||||||
|
return {
|
||||||
|
'@type': 'WebSite',
|
||||||
|
name: "Killian' DAL-CIN",
|
||||||
|
url: 'https://killiandalcin.fr',
|
||||||
|
potentialAction: {
|
||||||
|
'@type': 'SearchAction',
|
||||||
|
target: 'https://killiandalcin.fr/projects?q={search_term_string}',
|
||||||
|
'query-input': 'required name=search_term_string',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useHytaleServiceSchemas() {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
'@type': 'Service',
|
||||||
|
name: 'Hytale Plugin Development — Simple',
|
||||||
|
provider: { '@type': 'Person', name: "Killian' DAL-CIN" },
|
||||||
|
serviceType: 'Software Development',
|
||||||
|
description: 'Basic Hytale plugin: single mechanic, standard features',
|
||||||
|
offers: { '@type': 'Offer', priceCurrency: 'USD', price: '150' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'Service',
|
||||||
|
name: 'Hytale Plugin Development — Complex',
|
||||||
|
provider: { '@type': 'Person', name: "Killian' DAL-CIN" },
|
||||||
|
serviceType: 'Software Development',
|
||||||
|
description: 'Advanced Hytale plugin with custom systems, multiplayer, persistence',
|
||||||
|
offers: { '@type': 'Offer', priceCurrency: 'USD', price: '400' },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'Service',
|
||||||
|
name: 'Hytale Plugin Maintenance',
|
||||||
|
provider: { '@type': 'Person', name: "Killian' DAL-CIN" },
|
||||||
|
serviceType: 'Software Maintenance',
|
||||||
|
description: 'Monthly plugin maintenance: update compatibility after Hytale patches',
|
||||||
|
offers: { '@type': 'Offer', priceCurrency: 'USD', priceSpecification: { '@type': 'UnitPriceSpecification', price: '50', unitCode: 'MON' } },
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Each page then composes what it needs:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// pages/index.vue
|
||||||
|
const { usePersonSchema, useWebSiteSchema } = useJsonLd()
|
||||||
|
useHead({
|
||||||
|
script: [{
|
||||||
|
type: 'application/ld+json',
|
||||||
|
innerHTML: JSON.stringify({
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@graph': [usePersonSchema(), useWebSiteSchema()],
|
||||||
|
}),
|
||||||
|
}],
|
||||||
|
})
|
||||||
|
|
||||||
|
// pages/hytale.vue
|
||||||
|
const { usePersonSchema, useHytaleServiceSchemas } = useJsonLd()
|
||||||
|
useHead({
|
||||||
|
script: [{
|
||||||
|
type: 'application/ld+json',
|
||||||
|
innerHTML: JSON.stringify({
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@graph': [usePersonSchema(), ...useHytaleServiceSchemas()],
|
||||||
|
}),
|
||||||
|
}],
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
### SoftwareApplication for Hytale Plugins
|
||||||
|
|
||||||
|
For the Hytale page, `SoftwareApplication` is the most SEO-relevant schema for plugin demos or featured work:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"@type": "SoftwareApplication",
|
||||||
|
"name": "Hytale Plugin — [Plugin Name]",
|
||||||
|
"applicationCategory": "GameApplication",
|
||||||
|
"operatingSystem": "Hytale",
|
||||||
|
"author": { "@type": "Person", "name": "Killian' DAL-CIN" },
|
||||||
|
"offers": { "@type": "Offer", "price": "0", "priceCurrency": "USD" }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `SoftwareApplication` only when there are real plugin demos or releasable plugins. Use placeholder data with clearly marked demo content for now.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Hytale Page: Pricing Grid Pattern
|
||||||
|
|
||||||
|
The most effective pricing grid for this use case is a 3-tier table with a highlighted middle tier. Nuxt UI v3 provides everything needed without custom components:
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<!-- Structure recommendation — implement with UCard inside a CSS grid -->
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||||
|
<!-- Tier: Simple Plugin -->
|
||||||
|
<UCard>...</UCard>
|
||||||
|
|
||||||
|
<!-- Tier: Complex Plugin — highlighted -->
|
||||||
|
<UCard class="ring-2 ring-brand-500 scale-105">
|
||||||
|
<template #header>
|
||||||
|
<UBadge>Most Popular</UBadge>
|
||||||
|
</template>
|
||||||
|
...
|
||||||
|
</UCard>
|
||||||
|
|
||||||
|
<!-- Tier: Sur-mesure -->
|
||||||
|
<UCard>...</UCard>
|
||||||
|
</div>
|
||||||
|
```
|
||||||
|
|
||||||
|
Pricing data belongs in `app/data/hytale.ts` (not `siteConfig`) because it is Hytale-specific content, not site-wide configuration. Translatable labels live in locale files; prices stay in the data file (they are locale-independent).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Anti-Patterns to Avoid
|
## Anti-Patterns to Avoid
|
||||||
|
|
||||||
### Anti-Pattern 1: localStorage in SSR Context
|
### Anti-Pattern 1: Duplicating Person schema across pages as raw objects
|
||||||
**What goes wrong:** `localStorage.setItem('locale', ...)` throws ReferenceError on server, causes hydration mismatch.
|
**What:** Copy-pasting the full `Person` object in every page file
|
||||||
**Prevention:** Use only `useCookie()` (Nuxt built-in) for any client-persisted state (locale, color mode). Both `@nuxtjs/i18n` and `@nuxtjs/color-mode` handle this when configured with `cookieName`.
|
**Why bad:** When Killian's jobTitle changes to "Hytale Plugin Developer", every page needs updating manually
|
||||||
|
**Instead:** `useJsonLd.ts` composable as shown above — single source of truth
|
||||||
|
|
||||||
### Anti-Pattern 2: `document.*` or `window.*` at module scope
|
### Anti-Pattern 2: Using localStorage for any SSR state
|
||||||
**What goes wrong:** Runs during SSR, crashes server render.
|
**What:** Storing locale or theme in localStorage
|
||||||
**Prevention:** Wrap in `onMounted()` or use `import.meta.client` guard. The existing router `beforeEach` that calls `document.title` must move to `useSeoMeta()` calls inside each page.
|
**Why bad:** Causes hydration mismatch — server renders with default, client re-renders after mount
|
||||||
|
**Instead:** Cookie-only (already correctly implemented)
|
||||||
|
|
||||||
### Anti-Pattern 3: Flat root structure (Nuxt 3 pattern in Nuxt 4)
|
### Anti-Pattern 3: Static ogUrl strings
|
||||||
**What goes wrong:** Nuxt 4 expects `app/` as source directory. Files at project root are not auto-imported.
|
**What:** `ogUrl: 'https://killiandalcin.fr/hytale'` hardcoded
|
||||||
**Prevention:** All Vue files, composables, components go under `app/`. Configure `srcDir: 'app'` in `nuxt.config.ts` (or rely on Nuxt 4 default).
|
**Why bad:** EN version at `/en/hytale` gets wrong ogUrl, confusing social crawlers
|
||||||
|
**Instead:** `ogUrl: () => \`https://killiandalcin.fr\${localePath('/hytale')}\``
|
||||||
|
|
||||||
### Anti-Pattern 4: useAsyncData for static data
|
### Anti-Pattern 4: Translating prices
|
||||||
**What goes wrong:** `useAsyncData` with a static import adds unnecessary async overhead and serialization. Static data does not need SSR serialization.
|
**What:** Putting price strings like "$150" or "150€" in locale files
|
||||||
**Prevention:** Import static TS data directly in composables. Reserve `useAsyncData` for genuine async operations (external fetch, server routes).
|
**Why bad:** Prices change independently of language; mixes content types
|
||||||
|
**Instead:** Prices in data file, currency/format computed if needed
|
||||||
|
|
||||||
### Anti-Pattern 5: Per-page SEO via router.beforeEach
|
### Anti-Pattern 5: nuxt-og-image for a Docker-SSR deployment
|
||||||
**What goes wrong:** `document.title` manipulation in router guards is SPA-only, invisible to crawlers.
|
**What:** Using Satori-based dynamic OG image generation
|
||||||
**Prevention:** Each page calls `useSeoMeta({ title, description, ogTitle, ogDescription, ogImage })` at setup scope — Nuxt handles server-side `<head>` injection.
|
**Why bad:** Adds Chromium/Satori dependency, complex config for non-Vercel targets, overhead per request
|
||||||
|
**Instead:** Static pre-made OG images per page in `/public/og/`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Scalability Considerations
|
||||||
|
|
||||||
|
This is a static-content portfolio. Scalability is not a concern. The architecture is appropriate for:
|
||||||
|
- ~10 pages
|
||||||
|
- ~20 projects
|
||||||
|
- 2 locales
|
||||||
|
- No user accounts, no dynamic data beyond the contact form
|
||||||
|
|
||||||
|
The only scaling vector is content volume (more projects, more services). The current data layer (`app/data/`) handles this cleanly — add entries to arrays, add i18n keys, done.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Sources
|
## Sources
|
||||||
|
|
||||||
- Nuxt 4 source directory convention: official Nuxt 4 migration guide (app/ directory)
|
- Nuxt 4 docs: `ssr: true`, `compatibilityVersion: 4` — verified against current nuxt.config.ts
|
||||||
- Existing codebase analysis: `src/composables/`, `src/router/index.ts`, `src/locales/`
|
- `@nuxtjs/i18n` v9 docs: `prefix_except_default`, `useLocaleHead()`, `useLocalePath()` — HIGH confidence
|
||||||
- PROJECT.md constraints: cookie-only persistence, EmailJS, static TS data, Docker SSR deployment
|
- Schema.org: `Service`, `SoftwareApplication`, `Person`, `WebSite` — HIGH confidence
|
||||||
- Confidence: HIGH for Nuxt 4 file conventions; HIGH for SSR vs SSG decision given i18n cookie requirement; MEDIUM for @nuxtjs/i18n v9 prefix_except_default (verify exact config key names against current docs before implementing)
|
- `@nuxtjs/sitemap` v6: auto i18n integration — HIGH confidence (verified module is installed)
|
||||||
|
- Pattern for `useJsonLd.ts` composable: derived from existing codebase conventions (composable-per-concern)
|
||||||
|
- og:image static file strategy: MEDIUM confidence (sufficient for use case, no dynamic content needed)
|
||||||
|
|||||||
+333
-120
@@ -1,155 +1,368 @@
|
|||||||
# Feature Landscape
|
# Feature Landscape
|
||||||
|
|
||||||
**Domain:** Freelance developer portfolio — Nuxt 4 SSR migration
|
**Domain:** Freelancer portfolio — niche game plugin developer (Hytale)
|
||||||
**Researched:** 2026-04-07
|
**Researched:** 2026-04-10
|
||||||
**Confidence:** MEDIUM — Nuxt UI v3 component coverage from training knowledge (cutoff Aug 2025); Nuxt 4 stable by then. Flag for validation against current ui.nuxt.com docs before implementation.
|
**Confidence:** MEDIUM — based on codebase analysis, domain knowledge, freelance market patterns; WebSearch unavailable
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Table Stakes
|
## 1. Freelancer Portfolio Pricing Pages — Visible vs Hidden
|
||||||
|
|
||||||
Features users and search engines expect. Missing = product feels incomplete or hurts SEO directly.
|
### Verdict: Show pricing. Always.
|
||||||
|
|
||||||
| Feature | Why Expected | Complexity | Nuxt UI v3 Coverage | Notes |
|
**Rationale for Killian's situation specifically:**
|
||||||
|---------|--------------|------------|---------------------|-------|
|
|
||||||
| SSR on every route | Google crawls without JS; core migration reason | Low (Nuxt default) | N/A — framework concern | `nuxt build` gives SSR; `nuxt generate` gives SSG. SSR preferred for dynamic og:image |
|
The Hytale plugin dev market on Fiverr has ~1 direct competitor at $45. The market is not price-sensitive yet — it's trust-sensitive. A server owner searching "Hytale plugin developer" has no reference price. Showing prices:
|
||||||
| Per-route SEO meta | Each page needs unique title, description, og:image | Low | `useSeoMeta()` (Nuxt built-in) | Already implemented in SPA via custom `useSeo()` — replace with `useSeoMeta()` |
|
- Filters unserious inquiries before they consume calendar time (critical with only 5-10h/week availability)
|
||||||
| JSON-LD structured data | Enables rich results in Google for Person, CreativeWork, ContactPage | Low | `useHead()` with script injection | Already on Home + Contact + Projects — migrate all pages |
|
- Signals confidence and professionalism
|
||||||
| Sitemap.xml | Required for indexing; Google Search Console standard | Low | `@nuxtjs/sitemap` module | Out-of-the-box with i18n support |
|
- Anchors expectations upward (a visible €300 tier makes a €100 tier feel reasonable)
|
||||||
| robots.txt | Crawl control; expected by all search engines | Trivial | `@nuxtjs/sitemap` handles it | |
|
- Removes the "I need to ask" friction that kills conversions for international clients in different timezones
|
||||||
| Dark/light mode — no FOUC | Flash of unstyled content = unprofessional | Medium | `@nuxtjs/color-mode` with cookie strategy | The SPA currently uses localStorage — causes FOUC on SSR. Cookie strategy required |
|
|
||||||
| i18n FR/EN | Already a feature; SSR-safe version expected | Medium | `@nuxtjs/i18n` v9 (Nuxt 4 compatible) | Current vue-i18n with localStorage is not SSR-safe; cookie persistence required |
|
**The only valid reason to hide pricing:** Custom enterprise work where scope varies by 10x. That does not apply here — plugin complexity is bounded.
|
||||||
| Language switch persisted across sessions | Users hate re-setting language on return | Low | `@nuxtjs/i18n` `detectBrowserLanguage` with `cookieSecure` | |
|
|
||||||
| Responsive layout — mobile first | 60%+ of portfolio visitors on mobile | Low | Nuxt UI v3 + Tailwind v4 | All Nuxt UI components are mobile-first |
|
### Recommended Tier Structure
|
||||||
| Project list with filters | Portfolio core feature; already built | Medium | `UInput` (search), `USelectMenu` or `UTabs` (filter), `UBadge` (category tags) | Current: custom `<select>` + text `<input>`. Migrate to Nuxt UI |
|
|
||||||
| Project detail page with gallery | Proves depth of work | Medium | `UModal` (lightbox), `UCarousel` (thumbnails) | Current GalleryModal.vue (custom) → replace with `UModal` + `UCarousel` |
|
Three tiers work best for plugin dev services. Four or more creates decision paralysis.
|
||||||
| Contact methods display | GitHub, LinkedIn, email, phone — visitors need this | Low | `UCard`, `UButton`, `ULink` | Current ContactPage.vue uses custom card design |
|
|
||||||
| Navigation header with mobile menu | Standard expectation | Low | `UNavigationMenu` or `UHeader` (Nuxt UI Pro) | If not using Pro: compose with `UDrawer` for mobile nav overlay |
|
| Tier | Name | Price Range | Contents |
|
||||||
| Footer with links | Standard; also helps SEO via internal links | Low | Custom with `ULink` | |
|
|------|------|-------------|----------|
|
||||||
| 404 page | Missing = 404 error shows server default | Trivial | `error.vue` in Nuxt root | |
|
| Starter | Simple Plugin | €80–150 | Single feature, documented, delivered in 5 days, 15 days support |
|
||||||
| Image optimization | Core Web Vitals; LCP often an image | Medium | `@nuxt/image` → `<NuxtImg>` | Hero image preload + lazy load for project thumbnails |
|
| Standard | Complex Plugin | €200–400 | Multiple systems (economy, progression, custom events), 30 days support, 1 revision round |
|
||||||
| Local fonts (no Google Fonts FOUT) | Flash of unstyled text on SSR | Low | `@nuxtjs/google-fonts` with `download: true` or manual `public/fonts/` | Prefer manual: zero dependency |
|
| Premium | Full Experience | €500–900 | Full game loop (dungeon, boss, economy, UI), architecture doc, maintenance contract option |
|
||||||
|
| Recurring | Maintenance | €30–60/mo | Compatibility updates per Hytale version, bug fixes, 1 minor feature/month |
|
||||||
|
|
||||||
|
**Key structural decisions:**
|
||||||
|
|
||||||
|
- Put the maintenance tier visually separate — it is a different product (recurring revenue vs one-shot)
|
||||||
|
- "Starting at" language is fine for custom tier, but anchor with a concrete base price
|
||||||
|
- Show what is explicitly NOT included (server hosting, assets/textures, art) — this prevents scope creep complaints
|
||||||
|
- Add a "Most Popular" badge on Standard. It normalizes the mid tier and lifts average order value.
|
||||||
|
|
||||||
|
**Nuxt UI v3 implementation:** Use `UCard` grid (3 columns desktop, 1 column mobile). The pricing tiers do not need a dedicated library — straight Tailwind + UCard is sufficient. Avoid installing a pricing-specific component library.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Differentiators
|
## 2. Hytale Plugin Services Page — What Server Owners Need to See
|
||||||
|
|
||||||
Features that elevate the portfolio above average. Not universally expected but add credibility.
|
### The buyer persona
|
||||||
|
|
||||||
| Feature | Value Proposition | Complexity | Nuxt UI v3 Coverage | Notes |
|
A Hytale server owner is typically:
|
||||||
|---------|-------------------|------------|---------------------|-------|
|
- Non-technical (they run a server, they don't code it)
|
||||||
| Contact form with email delivery | Visitors can send a message directly — reduces friction vs email link only | Medium | `UForm` + `UFormField` + `UInput` + `UTextarea` + `UButton` | Backend-free via EmailJS. `UForm` handles validation schema (Zod/Valibot). Current SPA has NO form — this is new |
|
- Risk-averse (bad plugin = server downtime = player churn)
|
||||||
| Testimonials section | Social proof — differentiates freelancer from agency | Low | `UCard` for testimonial cards, custom grid | Already has TestimonialsSection.vue — migrate design to Nuxt UI cards |
|
- Skeptical ("can you even build Hytale plugins, the game just launched")
|
||||||
| Services/pricing page (Fiverr landing) | Conversion-focused; makes offering concrete | Medium | `UCard` (service cards), `UBadge` (tags), `UAccordion` (FAQ) | Already exists as FiverrPage.vue — migrate FAQ to `UAccordion` |
|
- Looking for long-term relationship, not one-shot delivery
|
||||||
| Tech stack badges | Visual proof of skills without reading text | Low | `UBadge` with `color` and `variant` props | Current TechBadge.vue is custom — replace with `UBadge` |
|
|
||||||
| Stats display (projects count, featured, etc.) | Builds credibility at a glance | Low | Custom with Tailwind / `UCard` | Already on Projects page and Contact page |
|
They have Minecraft server experience and will compare to that ecosystem. Key questions in their head:
|
||||||
| Formation/training page | Demonstrates continued learning | Low | `UCard`, `UBadge`, `UTimeline` (if available in v3) | Already exists — migrate |
|
|
||||||
| Keyboard navigation in gallery | Accessibility + power-user UX | Low | `UModal` supports keyboard close (Escape) natively; add arrow key handler | Current GalleryModal.vue already handles keyboard — preserve in migration |
|
1. Does this dev actually know Hytale specifically, or will they fake it?
|
||||||
| og:image per project | Rich previews when shared on LinkedIn/Twitter | Low | `useSeoMeta()` with `ogImage` per page | Already implemented in SPA — ensure NuxtImg doesn't break paths |
|
2. What happens when the next Hytale update breaks my plugin?
|
||||||
| Preload hero image | LCP optimization — measurable Google ranking signal | Low | `useHead({ link: [{ rel: 'preload', as: 'image' }] })` | Single line addition |
|
3. Can I see examples or a demo?
|
||||||
| Google Analytics 4 via nuxt-gtag | Current hardcoded GA in index.html is fragile | Low | `nuxt-gtag` module | Replace `index.html` script tag with proper module |
|
4. Will they be around in 6 months?
|
||||||
|
|
||||||
|
### Page sections — recommended order
|
||||||
|
|
||||||
|
**Section 1: Credibility header**
|
||||||
|
- Title: "Hytale Plugin Developer" — not "Game Dev" or "Modder"
|
||||||
|
- One-liner that addresses skepticism: "Building for Hytale since Early Access — I track every API change so your server stays running"
|
||||||
|
- Availability badge (reuse the animated one from HeroSection)
|
||||||
|
|
||||||
|
**Section 2: What makes Hytale plugins different**
|
||||||
|
- Short educational paragraph (3-4 sentences) explaining the Hytale API vs Minecraft — this signals genuine knowledge
|
||||||
|
- Mention: Hytale uses a Java/Kotlin API, the modding system is fundamentally different from Spigot/Paper, requires adapting to active API evolution
|
||||||
|
- This signals to server owners that this developer is not a Minecraft dev pretending
|
||||||
|
|
||||||
|
**Section 3: Services grid (the pricing section described above)**
|
||||||
|
- Four cards: Simple, Complex, Full Experience, Maintenance
|
||||||
|
- Each card must answer: What do I get? How long? What's the support situation?
|
||||||
|
|
||||||
|
**Section 4: The maintenance pitch — this is the unique selling point**
|
||||||
|
- Dedicated callout/alert component (UAlert or custom banner)
|
||||||
|
- Message: Hytale updates frequently during Early Access. Every major update risks breaking plugins. A maintenance contract means zero downtime and no re-negotiation on every patch.
|
||||||
|
- This is the structural advantage from PROJECT.md — lean into it hard
|
||||||
|
|
||||||
|
**Section 5: Process (3-step)**
|
||||||
|
- Step 1: Discovery call (Discord preferred — server owners are on Discord)
|
||||||
|
- Step 2: Spec + quote in 48h
|
||||||
|
- Step 3: Delivery with documentation
|
||||||
|
- Keep this extremely short — server owners don't read walls of text
|
||||||
|
|
||||||
|
**Section 6: Demo / Portfolio**
|
||||||
|
- If no Hytale projects exist yet: use Minecraft plugin work as proof of concept + explicit note "Hytale API is similar to Java/Kotlin modding I've done for Minecraft — I'm actively building Hytale demos"
|
||||||
|
- A "coming soon" placeholder is better than no section — it signals intent
|
||||||
|
- Video embed or GIF of a plugin in action converts better than screenshots
|
||||||
|
|
||||||
|
**Section 7: FAQ specific to Hytale**
|
||||||
|
- "The game is in Early Access, is this risky?" — address directly
|
||||||
|
- "What if Hytale updates break my plugin?" — maintenance contract answer
|
||||||
|
- "Do you have experience with Hytale specifically?" — honest answer + Minecraft parallel
|
||||||
|
- "Can I pay per update?" — redirect to maintenance tier
|
||||||
|
|
||||||
|
**Section 8: CTA**
|
||||||
|
- Primary: "Book a Discovery Call" (Discord link or contact form)
|
||||||
|
- Secondary: "View Pricing"
|
||||||
|
|
||||||
|
### Route: `/hytale` (not `/games` or `/modding`)
|
||||||
|
|
||||||
|
The URL slug matters for SEO. `/hytale` captures "hytale plugin developer" searches directly. Register `hytale` in the nav alongside the existing pages.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Anti-Features
|
## 3. Testimonials Section — Displaying 5–10 Reviews
|
||||||
|
|
||||||
Things to deliberately NOT build in this migration.
|
### Current state
|
||||||
|
|
||||||
|
`testimonials.ts` has 5 real reviews, all 5-star, all from Fiverr. All French-language except one English ("awesome guy"). `testimonialsStats` declares 10 total reviews and 25 projects — slightly inflated vs actual data shown.
|
||||||
|
|
||||||
|
### The small-count problem
|
||||||
|
|
||||||
|
5 reviews is not a weakness if framed correctly. The mistake is showing 5 cards and letting the sparseness speak for itself. Solutions:
|
||||||
|
|
||||||
|
**Pattern 1: Featured + Grid (recommended)**
|
||||||
|
- 1 large "featured" testimonial card (the unqlf_ one — it's the most specific and includes project type "Plugin Minecraft")
|
||||||
|
- 4 smaller cards below in 2x2 grid
|
||||||
|
- This asymmetric layout fills the space and makes 5 cards look curated rather than scarce
|
||||||
|
- The `featured: true` flag is already on the right testimonial in the data
|
||||||
|
|
||||||
|
**Pattern 2: Carousel with autoplay**
|
||||||
|
- Works for mobile; hides the count
|
||||||
|
- Risk: autoplay is annoying and reduces trust
|
||||||
|
- Not recommended
|
||||||
|
|
||||||
|
**Pattern 3: Stats bar above cards**
|
||||||
|
- "5.0 / 5.0 — 10+ verified reviews on Fiverr" + link to Fiverr profile
|
||||||
|
- This shifts the authority to a third-party platform — more credible than displaying 5 internal cards
|
||||||
|
- Add a Fiverr logo/icon next to the stat to reinforce the source
|
||||||
|
- The `reviewsLink` already exists in i18n pointing to the Fiverr profile
|
||||||
|
|
||||||
|
**Pattern 4: Language split — show the English one on the EN locale**
|
||||||
|
- The "awesome guy" testimonial (botuhuh) is English and international — feature it prominently on the EN locale
|
||||||
|
- French testimonials go first on FR locale
|
||||||
|
- This can be implemented by sorting testimonials client-side by language match — simple logic in the component
|
||||||
|
|
||||||
|
### Recommended implementation
|
||||||
|
|
||||||
|
Use Pattern 1 + Pattern 3 combined:
|
||||||
|
- Stats bar (5.0 rating, link to Fiverr) at top
|
||||||
|
- Featured card (full-width or 60% width)
|
||||||
|
- 4-card 2x2 grid
|
||||||
|
- "See all reviews on Fiverr" CTA link at bottom
|
||||||
|
|
||||||
|
For the Hytale page specifically, filter testimonials to show only `project_type === 'Plugin Minecraft'` — only 1 exists currently, but it's the most relevant. Pad with a "Review coming soon" placeholder card until more Hytale reviews accumulate.
|
||||||
|
|
||||||
|
### What to fix in the data
|
||||||
|
|
||||||
|
The `results` field is weak — values like `"Prix: Jusqu'à 50€"` and `"Durée: 10 jours"` reveal order size which may underposition the service. Consider removing the price from results display or changing it to outcome-focused language: "Delivered 2 days early", "Still using the plugin 6 months later".
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. Hero Section — Niche Positioning "Hytale Plugin Developer"
|
||||||
|
|
||||||
|
### Current problem (confirmed by reading the code)
|
||||||
|
|
||||||
|
The hero title uses `t('home.title')` which resolves to "Expert Full Stack Developer for Hire | Vue.js, React & Node.js Specialist" in EN. The code splits the last two words for gradient styling — this technique only works if the last two words are the differentiating concept (they're not: "Node.js Specialist" gets the gradient).
|
||||||
|
|
||||||
|
Additionally, the terminal code block hardcodes `'Full Stack Dev'` as the role. The terminal does show `'Hytale Plugins'` in the skills array — good — but it is buried among 6 other skills.
|
||||||
|
|
||||||
|
The availability badge says "Available for projects" — hardcoded English in the template (not using i18n key), which is a bug.
|
||||||
|
|
||||||
|
### Hero best practices for niche positioning
|
||||||
|
|
||||||
|
**Rule 1: Specialization in H1, not in paragraph**
|
||||||
|
The H1 must state the specialization. Visitors scan H1, they don't read paragraphs. "Hytale Plugin Developer" must be in the H1, not in the subtitle or skills list.
|
||||||
|
|
||||||
|
**Rule 2: Acknowledge the broader skill set in subtitle, not in title**
|
||||||
|
The subtitle is the right place to mention Vue/Node/web work — it reassures server owners that this is a real professional developer, not a hobbyist.
|
||||||
|
|
||||||
|
**Rule 3: Dual-audience heading (Hytale + Web)**
|
||||||
|
Killian has two buyer types: Hytale server owners and web clients. The hero must serve the primary audience (Hytale — the strategic bet) without completely alienating web clients.
|
||||||
|
|
||||||
|
Recommended approach: tabbed or split hero is overkill. Use a primary H1 that leads with Hytale, with a secondary descriptor:
|
||||||
|
|
||||||
|
```
|
||||||
|
Hytale Plugin Developer
|
||||||
|
& Freelance Web Dev
|
||||||
|
```
|
||||||
|
|
||||||
|
The gradient goes on "Hytale Plugin Developer". Web services are the secondary line.
|
||||||
|
|
||||||
|
**Rule 4: Specificity = trust**
|
||||||
|
"I build custom Hytale plugins that survive every API update" beats "I build custom solutions that scale."
|
||||||
|
|
||||||
|
**Rule 5: Terminal widget — update the role**
|
||||||
|
Change `'Full Stack Dev'` to `'Hytale Plugin Dev'` in HeroSection.vue. This is a hardcoded string in the template (line 104), not using i18n, so fix it directly.
|
||||||
|
|
||||||
|
### i18n changes required in hero
|
||||||
|
|
||||||
|
The `home.title` split-by-last-2-words approach is fragile — it produces different results for FR and EN because sentence structure differs. The right solution:
|
||||||
|
|
||||||
|
- Split `home.title` into two keys: `home.title.main` and `home.title.highlight`
|
||||||
|
- `home.title.highlight` gets the gradient styling
|
||||||
|
- This removes the brittle `split(' ').slice(-2)` logic
|
||||||
|
|
||||||
|
New i18n values:
|
||||||
|
|
||||||
|
```json
|
||||||
|
// EN
|
||||||
|
"home": {
|
||||||
|
"title": {
|
||||||
|
"main": "Hytale Plugin Developer",
|
||||||
|
"highlight": "& Freelance Web Dev"
|
||||||
|
},
|
||||||
|
"subtitle": "I build custom Hytale plugins that survive every API update — and web apps that convert. 7+ years of experience, 0 missed deadlines."
|
||||||
|
}
|
||||||
|
|
||||||
|
// FR
|
||||||
|
"home": {
|
||||||
|
"title": {
|
||||||
|
"main": "Développeur de Plugins Hytale",
|
||||||
|
"highlight": "& Dev Web Freelance"
|
||||||
|
},
|
||||||
|
"subtitle": "Je construis des plugins Hytale qui survivent à chaque mise à jour de l'API — et des applications web qui convertissent. 7+ ans d'expérience, 0 délais manqués."
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The availability badge text ("Available for projects") is hardcoded in HeroSection.vue line 30 — needs to be an i18n key `home.availableBadge`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. i18n Audit — Finding Missing and Bad Translations
|
||||||
|
|
||||||
|
### Issues already visible in the current files
|
||||||
|
|
||||||
|
**Structural parity issues (EN has keys FR is missing or vice versa):**
|
||||||
|
- Both files have identical key structure currently — no missing keys found at top level
|
||||||
|
- Risk area: as new pages (Hytale) and features (pricing) are added, keys will diverge
|
||||||
|
|
||||||
|
**Quality issues in existing translations:**
|
||||||
|
|
||||||
|
EN quality problems:
|
||||||
|
- `home.title` = "Expert Full Stack Developer for Hire | Vue.js, React & Node.js Specialist" — generic SEO-spam tone, not the niche positioning needed
|
||||||
|
- `seo.home.title` still says "Freelance Full Stack Developer" — must change to include Hytale
|
||||||
|
- `seo.home.description` makes no mention of Hytale, plugins, or game development
|
||||||
|
- `a11y.logoLabel` = "Full Stack Developer" — must update when hero positioning changes
|
||||||
|
- `footer.servicesList` contains "Mobile Apps" and "Tech Consulting" — neither is a real service offered
|
||||||
|
- `fiverr.subtitle` claims "500+ orders delivered" and "100% satisfaction rate" — verify these are accurate; if not, this is a credibility risk
|
||||||
|
|
||||||
|
FR quality problems:
|
||||||
|
- `about.title` = "À propos de Killian'- Développeur Full Stack" — the dash is missing a space before it (`Killian'-` should be `Killian' —` or just remove)
|
||||||
|
- `faq.homeFaq.delivery.answer` mentions "Bot Discord simple" as the first example — for a Hytale-focused portfolio this should lead with Hytale plugin timelines
|
||||||
|
- `contact.methods.availability` = "Disponible pour remote & freelance" — the English word "remote" in French copy feels lazy; use "télétravail"
|
||||||
|
|
||||||
|
Hardcoded strings (not using i18n at all):
|
||||||
|
- HeroSection.vue line 30: `"Available for projects"` — hardcoded EN
|
||||||
|
- HeroSection.vue line 104: `'Full Stack Dev'` — hardcoded in terminal widget
|
||||||
|
- HeroSection.vue line 148: `"50+ projects"` — hardcoded EN
|
||||||
|
- HeroSection.vue line 152: `"5.0 rating"` — hardcoded EN
|
||||||
|
|
||||||
|
### Audit methodology for ongoing use
|
||||||
|
|
||||||
|
**Method 1: Key extraction diff (best for structural parity)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Extract all keys from both files and diff
|
||||||
|
node -e "
|
||||||
|
const en = require('./i18n/locales/en.json');
|
||||||
|
const fr = require('./i18n/locales/fr.json');
|
||||||
|
const flatten = (obj, prefix='') => Object.keys(obj).reduce((acc, k) => {
|
||||||
|
const key = prefix ? prefix + '.' + k : k;
|
||||||
|
return typeof obj[k] === 'object' && !Array.isArray(obj[k])
|
||||||
|
? { ...acc, ...flatten(obj[k], key) }
|
||||||
|
: { ...acc, [key]: obj[k] };
|
||||||
|
}, {});
|
||||||
|
const enKeys = Object.keys(flatten(en));
|
||||||
|
const frKeys = Object.keys(flatten(fr));
|
||||||
|
const missingInFr = enKeys.filter(k => !frKeys.includes(k));
|
||||||
|
const missingInEn = frKeys.filter(k => !enKeys.includes(k));
|
||||||
|
console.log('Missing in FR:', missingInFr);
|
||||||
|
console.log('Missing in EN:', missingInEn);
|
||||||
|
"
|
||||||
|
```
|
||||||
|
|
||||||
|
Run this after every feature addition that adds i18n keys.
|
||||||
|
|
||||||
|
**Method 2: Search for hardcoded strings in templates**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Find text content in templates that bypasses t()
|
||||||
|
grep -rn '>[A-Z][a-z]' app/components/ app/pages/ | grep -v '{{' | grep -v 't(' | grep -v ':' | grep -v '<!--'
|
||||||
|
```
|
||||||
|
|
||||||
|
This catches hardcoded visible text. It produces false positives — review manually.
|
||||||
|
|
||||||
|
**Method 3: Check for untranslated key echoes**
|
||||||
|
|
||||||
|
When `t('some.key')` is called and the key does not exist in the locale, vue-i18n returns the key string itself (e.g., `"some.key"` appears as text). During dev, check the rendered pages in both locales — any dotted key string in the UI is a missing translation.
|
||||||
|
|
||||||
|
**Method 4: Translation quality review checklist**
|
||||||
|
|
||||||
|
For each new locale string, verify:
|
||||||
|
- [ ] Is it natural in the target language (not machine-translated)?
|
||||||
|
- [ ] Does it match the tone of surrounding copy?
|
||||||
|
- [ ] Does it reference the correct product/service (Hytale vs generic web dev)?
|
||||||
|
- [ ] Are technical terms consistent? (e.g., "plugin" vs "mod" vs "extension")
|
||||||
|
- [ ] French: are accents correct? (é, è, ê, à, ù, ç, î, ô — check manually, JSON editors strip them)
|
||||||
|
- [ ] French: formal "vous" used consistently? (current copy mixes registers slightly)
|
||||||
|
|
||||||
|
**Nuxt i18n specific: missing locale file fallback**
|
||||||
|
|
||||||
|
Nuxt i18n falls back to the default locale (FR) when a key is missing in EN. This means missing EN keys silently show French text to English users. The extraction diff above catches this — run it as a pre-commit check or add to CI.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Table Stakes vs Differentiators
|
||||||
|
|
||||||
|
### Table Stakes (must have)
|
||||||
|
|
||||||
|
| Feature | Why Expected | Complexity |
|
||||||
|
|---------|--------------|------------|
|
||||||
|
| Pricing grid with 3-4 tiers | Freelancers without visible pricing lose conversions | Low |
|
||||||
|
| Hytale page with service details | Core positioning — without it the site is a generic portfolio | Medium |
|
||||||
|
| Updated hero H1 with Hytale | SEO + first impression — current H1 targets wrong audience | Low |
|
||||||
|
| Testimonials visible on homepage | Social proof — already in codebase, needs display improvement | Low |
|
||||||
|
| i18n complete in both locales | Basic professionalism — hardcoded English strings on FR locale is broken | Low |
|
||||||
|
|
||||||
|
### Differentiators
|
||||||
|
|
||||||
|
| Feature | Value Proposition | Complexity |
|
||||||
|
|---------|-------------------|------------|
|
||||||
|
| Maintenance contract pitch on Hytale page | Unique positioning — recurring revenue, addresses Hytale's update risk | Low (copy only) |
|
||||||
|
| Plugin demo video/GIF embed | Converts skeptics who don't understand what a plugin looks like | Medium |
|
||||||
|
| Hytale API knowledge section | Proves genuine expertise vs "I can do Minecraft so I can do Hytale" | Low (copy only) |
|
||||||
|
| Testimonials filtered by project type per page | Relevant social proof (Minecraft reviews on Hytale page) | Low |
|
||||||
|
|
||||||
|
### Anti-Features
|
||||||
|
|
||||||
| Anti-Feature | Why Avoid | What to Do Instead |
|
| Anti-Feature | Why Avoid | What to Do Instead |
|
||||||
|--------------|-----------|-------------------|
|
|--------------|-----------|-------------------|
|
||||||
| Contact form with custom backend / API route | Adds infra complexity, auth, spam handling — out of scope per PROJECT.md | EmailJS from client — form submits directly, no Nuxt server route needed |
|
| "Available for projects" badge with hardcoded text | Breaks FR locale | Use i18n key |
|
||||||
| @nuxt/content for project data | CMS markdown adds indirection when data is already typed TS | Keep `src/data/` as `.ts` files imported by composables |
|
| Inflated stats (500+ orders, 100% satisfaction) without verification | Credibility risk if questioned | Use conservative/accurate numbers |
|
||||||
| Blog / articles section | Not in scope; adds content maintenance burden | If needed later, add as a separate milestone |
|
| Four CTA buttons in hero | Decision paralysis, reduces click-through | Two max: primary (Hytale page) + secondary (contact) |
|
||||||
| Portfolio password protection | Friction for recruiters / clients browsing | Open portfolio is the point |
|
| Mobile Apps service in footer | Killian doesn't offer this — visitor confusion | Remove or replace with "Hytale Plugins" |
|
||||||
| Infinite scroll on projects page | Premature — project count is small; adds complexity | Paginated list or full list is sufficient |
|
|
||||||
| Animation library (GSAP, Motion One) | Heavy; Tailwind CSS animations + CSS transitions are sufficient | CSS `transition`, `@keyframes` via Tailwind |
|
|
||||||
| Umami analytics self-hosted | Out of scope per PROJECT.md — requires infra | GA4 via nuxt-gtag |
|
|
||||||
| Custom color theme picker | Dark/light binary is sufficient; theme builder adds JS weight and UX surface | `@nuxtjs/color-mode` toggle only |
|
|
||||||
| CMS admin panel | No need for non-dev content editing | Static TS data files, update via code |
|
|
||||||
| i18n for more than FR/EN | Scope creep; translation maintenance doubles for each language | FR/EN only |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Nuxt UI v3 Component Coverage Map
|
|
||||||
|
|
||||||
Mapped against every portfolio pattern in this project. Confidence: MEDIUM (training data on v3 alpha/beta; verify against ui.nuxt.com before building).
|
|
||||||
|
|
||||||
| Portfolio Pattern | Nuxt UI v3 Component(s) | Replaces (current) | Notes |
|
|
||||||
|-------------------|------------------------|---------------------|-------|
|
|
||||||
| Navigation menu desktop | `UNavigationMenu` | Custom `AppHeader.vue` nav links | Composable nav with active state |
|
|
||||||
| Mobile menu drawer | `UDrawer` or `UModal` | Custom hamburger + overlay | `UDrawer` preferred for slide-in nav |
|
|
||||||
| Dark/light toggle button | `UButton` with icon slot | `ThemeToggle.vue` (custom) | Toggle reads `useColorMode()` |
|
|
||||||
| Language switcher dropdown | `UDropdownMenu` | `LanguageSwitcher.vue` (custom) | `UDropdownMenu` items = `[{ label: 'FR' }, { label: 'EN' }]` |
|
|
||||||
| Project card | `UCard` | `ProjectCard.vue` (custom) | `UCard` header/footer/body slots |
|
|
||||||
| Project filter search input | `UInput` with icon | Custom `<input>` | Leading icon slot for magnifier |
|
|
||||||
| Project category filter | `USelectMenu` or `UTabs` | Custom `<select>` | `UTabs` better UX if <6 categories |
|
|
||||||
| Project gallery modal/lightbox | `UModal` + `UCarousel` | `GalleryModal.vue` (custom) | `UModal` handles focus trap + Escape; `UCarousel` for image navigation with prev/next |
|
|
||||||
| Contact form fields | `UForm` + `UFormField` + `UInput` + `UTextarea` | No form currently | `UForm` integrates with Zod/Valibot for schema validation |
|
|
||||||
| Contact form submit button with loading | `UButton` with `loading` prop | N/A | `UButton loading` shows spinner during EmailJS send |
|
|
||||||
| Social link items | `ULink` or `UButton` variant=link | Custom `<a>` tags | |
|
|
||||||
| Service/Fiverr service cards | `UCard` | `FiverrServiceCard.vue` (custom) | |
|
|
||||||
| FAQ accordion | `UAccordion` | `ServiceFAQ.vue` (custom) | Built-in open/close, accessible |
|
|
||||||
| Testimonial cards | `UCard` | `TestimonialCard.vue` (custom) | |
|
|
||||||
| Tech skill badges | `UBadge` | `TechBadge.vue` (custom) | `color` and `variant` props cover current custom styles |
|
|
||||||
| Section CTA buttons | `UButton` | `CTAButtons.vue` (custom) | `UButton` size/variant props handle all current btn variants |
|
|
||||||
| 404 error page | Custom `error.vue` with `UButton` | N/A (SPA handled by router) | Nuxt `error.vue` gets `error` prop |
|
|
||||||
| Toast / form feedback | `useToast()` + `UToastProvider` | None currently | Show success/error after EmailJS send |
|
|
||||||
| Page loading indicator | `NuxtLoadingIndicator` (Nuxt built-in) | None in SPA | One-liner in `app.vue` |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Feature Dependencies
|
## Feature Dependencies
|
||||||
|
|
||||||
```
|
```
|
||||||
SSR (nuxt build)
|
Updated i18n keys → Hero refocus (new title structure required)
|
||||||
→ i18n cookie persistence (@nuxtjs/i18n v9)
|
Hero refocus → Hytale page (consistent positioning across both)
|
||||||
→ Language switcher UI (UDropdownMenu)
|
Hytale page → Pricing grid (pricing is a section of the Hytale page, or linked from it)
|
||||||
→ Dark mode cookie (@nuxtjs/color-mode)
|
Pricing grid → Testimonials (social proof adjacent to pricing converts better)
|
||||||
→ Theme toggle UI (UButton + useColorMode)
|
|
||||||
→ Per-route SEO (useSeoMeta)
|
|
||||||
→ Sitemap (@nuxtjs/sitemap)
|
|
||||||
→ og:image per project
|
|
||||||
|
|
||||||
Contact form (UForm + Zod)
|
|
||||||
→ EmailJS client send
|
|
||||||
→ UButton loading state
|
|
||||||
→ useToast() success/error feedback
|
|
||||||
|
|
||||||
Project gallery (UModal + UCarousel)
|
|
||||||
→ Project detail page
|
|
||||||
→ Project data (TS static files)
|
|
||||||
→ useProjects() composable (useAsyncData wrapper)
|
|
||||||
|
|
||||||
Image optimization (NuxtImg)
|
|
||||||
→ @nuxt/image module
|
|
||||||
→ Hero preload (useHead link preload)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## MVP Recommendation
|
## MVP Recommendation
|
||||||
|
|
||||||
Phases should prioritize in this order to unblock everything else:
|
Build in this order:
|
||||||
|
|
||||||
1. **SSR foundation** — Nuxt 4 project scaffold, routing, layouts, @nuxtjs/color-mode, @nuxtjs/i18n. Without this nothing else works correctly.
|
1. **i18n fixes + hardcoded string cleanup** — 1-2h, unblocks everything else, fixes broken FR locale
|
||||||
2. **Static data migration** — Port `src/data/` TS files + composables to Nuxt conventions. Unblocks all page content.
|
2. **Hero refocus** — 1h, highest SEO impact, changes H1 which search engines read first
|
||||||
3. **Page migrations** (Home, Projects, Project Detail, About, Contact, Fiverr, Formation) — Migrate one page at a time with Nuxt UI v3 components replacing custom ones.
|
3. **Hytale page** (`/hytale`) — 4-6h, the core missing piece; pricing grid lives here
|
||||||
4. **Contact form** — New feature, not a migration. Add EmailJS + UForm + useToast after pages are stable.
|
4. **Testimonials display improvement** — 1h, Featured + stats pattern, already has data
|
||||||
5. **SEO + sitemap** — Add after pages exist; useSeoMeta() per page, sitemap module, JSON-LD.
|
|
||||||
6. **Performance polish** — NuxtImg, font preloads, GA4 via nuxt-gtag, Docker production build.
|
|
||||||
|
|
||||||
Defer:
|
Defer:
|
||||||
- Formation page: low traffic value; migrate last
|
- Plugin demo video — requires recording/capturing gameplay footage, not a code task
|
||||||
- Fiverr page: secondary conversion path; migrate after core pages
|
- Maintenance contract as a formal product page — copy on the Hytale page is enough for now
|
||||||
- Testimonials stats: nice-to-have; fold into About or Home as a section
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Sources
|
*Sources: codebase analysis (i18n/locales/en.json, fr.json, HeroSection.vue, testimonials.ts, PROJECT.md, STRUCTURE.md) — MEDIUM confidence based on domain knowledge and direct code inspection*
|
||||||
|
|
||||||
- Training knowledge: Nuxt UI v3 component API (alpha/beta period, up to Aug 2025) — MEDIUM confidence
|
|
||||||
- Training knowledge: Nuxt 4 release features, `@nuxtjs/i18n` v9, `@nuxtjs/color-mode`, `@nuxt/image` — MEDIUM confidence
|
|
||||||
- Direct codebase analysis: `src/views/`, `src/components/` in this repository — HIGH confidence
|
|
||||||
- PROJECT.md constraints and out-of-scope declarations — HIGH confidence
|
|
||||||
|
|
||||||
**Validate before building:** Confirm `UCarousel`, `UDrawer`, `UNavigationMenu`, `UAccordion`, `UForm`/`UFormField` names against current ui.nuxt.com/components — component names may have changed between v3 beta and v3 stable.
|
|
||||||
|
|||||||
+166
-193
@@ -1,285 +1,258 @@
|
|||||||
# Domain Pitfalls — Vue 3 SPA → Nuxt 4 SSR Migration
|
# Domain Pitfalls
|
||||||
|
|
||||||
**Domain:** Portfolio SSR migration (Nuxt 4 + Nuxt UI v3 + @nuxtjs/i18n + @nuxtjs/color-mode)
|
**Domain:** Nuxt 4 SSR portfolio — Hytale plugin developer
|
||||||
**Researched:** 2026-04-07
|
**Researched:** 2026-04-10
|
||||||
**Confidence:** MEDIUM (training data + ecosystem knowledge as of Aug 2025; web access unavailable for live verification)
|
**Confidence:** MEDIUM (training knowledge Aug 2025 + direct codebase inspection; no live web search available)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Critical Pitfalls
|
## Critical Pitfalls
|
||||||
|
|
||||||
Mistakes that cause rewrites or block SSR from working correctly.
|
### Pitfall 1: Static `public/sitemap.xml` Wins Over `@nuxtjs/sitemap`
|
||||||
|
|
||||||
|
**What goes wrong:** Nitro serves files in `public/` as static assets at their exact path. Because `public/sitemap.xml` exists, every request to `/sitemap.xml` returns the hand-written XML from 2025 — the `@nuxtjs/sitemap` dynamic handler is never reached. Google therefore crawls a stale sitemap that: (a) lists `/formation` which has no matching page, (b) omits `/en/*` hreflang variants entirely, (c) never reflects new projects or the upcoming `/hytale` page.
|
||||||
|
|
||||||
|
**Why it happens:** `public/` files are resolved before Nuxt server routes. The module registers a `/_sitemap.xml` route or hooks into `/sitemap.xml` via a Nitro route handler, but a physical file at the same path in `public/` short-circuits the handler.
|
||||||
|
|
||||||
|
**Consequences:** New pages are never indexed. Googlebot sees phantom URLs that 404. The installed module is wasted.
|
||||||
|
|
||||||
|
**Prevention:**
|
||||||
|
1. Delete `public/sitemap.xml` immediately.
|
||||||
|
2. Let `@nuxtjs/sitemap` own the `/sitemap.xml` route entirely.
|
||||||
|
3. Verify `nuxt.config.ts` has `sitemap: { autoLastmod: true, xsl: false }` (or equivalent) and that `site.url` is set — it already is (`https://killiandalcin.fr`).
|
||||||
|
4. Confirm hreflang alternates are generated by checking `/__sitemap__/en-US.xml` style URLs that the module emits per locale.
|
||||||
|
|
||||||
|
**Detection:** `curl https://killiandalcin.fr/sitemap.xml` — if the response contains `lastmod>2025-07-07` the static file is still winning.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Pitfall 1: Hydration Mismatch from localStorage-based State
|
### Pitfall 2: No Rate Limiting on `/api/contact` — Email Flooding Risk
|
||||||
|
|
||||||
**What goes wrong:** The existing SPA uses `localStorage` for both locale and theme persistence. During SSR, the server renders with the default locale/theme (server has no access to localStorage). The client then reads localStorage and switches — causing a visible flash and a Vue hydration warning (`[Vue warn]: Hydration mismatch`). In strict hydration mode (Nuxt 4 default), this can throw an error, not just a warning.
|
**What goes wrong:** `server/api/contact.post.ts` opens a nodemailer transporter and calls `sendMail` on every POST with no throttle. A script sending 1 000 requests/minute will fill the inbox, potentially exhaust SMTP sending quota (most providers cap at 500 emails/day on cheap plans), and could get the sending IP blacklisted.
|
||||||
|
|
||||||
**Why it happens:** `localStorage` is a browser-only API. The server renders `lang="fr"` but the client's stored preference is `lang="en"`. The DOM differs between server render and client mount.
|
**Why it happens:** Nuxt/Nitro has no built-in rate-limiting middleware. The current handler only validates field content, not request frequency.
|
||||||
|
|
||||||
**Consequences:**
|
**Consequences:** SMTP quota exhaustion → legitimate contacts bounce. IP reputation damage. Potential cost overrun if using a paid SMTP tier.
|
||||||
- FOUC (Flash of Unstyled Content) for dark mode
|
|
||||||
- Wrong locale briefly visible on first paint
|
|
||||||
- Hydration errors that can fully break page interactivity in strict mode
|
|
||||||
- SEO crawlers see the default locale/theme, not the user's preference (but this is acceptable for SEO)
|
|
||||||
|
|
||||||
**Prevention:**
|
**Prevention (zero paid services):**
|
||||||
- Replace ALL `localStorage` reads with cookie reads — cookies are sent with every HTTP request, so the server can read them via `useCookie()` during SSR
|
|
||||||
- For locale: configure `@nuxtjs/i18n` with `detectBrowserLanguage: { useCookie: true, cookieKey: 'i18n_locale' }`
|
|
||||||
- For theme: configure `@nuxtjs/color-mode` with `storageKey: 'color-mode'` and ensure it uses its built-in cookie strategy (it does by default in SSR mode)
|
|
||||||
- Never call `localStorage` directly in composables that run during SSR — wrap in `if (import.meta.client)` or `onMounted()`
|
|
||||||
|
|
||||||
**Detection:** Run `nuxt build && nuxt preview`, open DevTools Console — any `[Vue warn]: Hydration` message is a failure.
|
Option A — In-memory map in a Nitro server plugin (simplest, resets on restart):
|
||||||
|
```typescript
|
||||||
|
// server/plugins/rate-limit.ts
|
||||||
|
const ipMap = new Map<string, { count: number; reset: number }>()
|
||||||
|
|
||||||
**Phase:** Foundation setup (Phase 1 — before any page migration)
|
export default defineNitroPlugin((nitro) => {
|
||||||
|
nitro.hooks.hook('request', (event) => {
|
||||||
|
if (!event.path.startsWith('/api/contact')) return
|
||||||
|
const ip = getRequestIP(event, { xForwardedFor: true }) ?? 'unknown'
|
||||||
|
const now = Date.now()
|
||||||
|
const window = 60_000 // 1 minute
|
||||||
|
const limit = 3
|
||||||
|
|
||||||
|
const entry = ipMap.get(ip)
|
||||||
|
if (!entry || entry.reset < now) {
|
||||||
|
ipMap.set(ip, { count: 1, reset: now + window })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
entry.count++
|
||||||
|
if (entry.count > limit) {
|
||||||
|
throw createError({ statusCode: 429, message: 'Too many requests' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
Option B — `unstorage` with a file/Redis driver so the counter survives restarts (better for production).
|
||||||
|
|
||||||
|
Option C — Cloudflare free tier in front of the server: rate-limit rule on `/api/contact` at the edge, zero code changes.
|
||||||
|
|
||||||
|
**Detection warning signs:** SMTP provider sending a quota-exceeded bounce, or inbox flooded with identical submissions.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Pitfall 2: @nuxtjs/i18n v9 Breaking Config Changes
|
### Pitfall 3: Weak Server-Side Email Validation
|
||||||
|
|
||||||
**What goes wrong:** `@nuxtjs/i18n` v9 (required for Nuxt 4) has significant breaking changes from v8. The `vueI18n` config file path changed, `strategy: 'no_prefix'` behavior changed, and the `detectBrowserLanguage` defaults changed. Copying the old config causes silent failures where locale switching appears to work client-side but SSR always renders the default locale.
|
**What goes wrong:** Line 12 of `contact.post.ts` uses `email.includes('@')` — `notanemail@` and `a@` both pass. The client uses Zod's `z.string().email()` but an attacker bypasses the browser entirely and posts directly to the API.
|
||||||
|
|
||||||
**Why it happens:** i18n v9 moved to a Nuxt-native config approach; the old `vueI18n.config.ts` file requires an explicit `vueI18n` option pointing to it. Without this, messages load client-side only (from the bundle) but are missing during SSR rendering.
|
**Why it happens:** Quick guard written as a placeholder; client-side Zod validation gives false confidence.
|
||||||
|
|
||||||
**Consequences:**
|
**Consequences:** Malformed `from` headers in outgoing email; some SMTP servers reject the mail silently; the `to` address could theoretically be injected via header manipulation if the email value lands in a `Reply-To` without sanitisation.
|
||||||
- Server renders untranslated keys (e.g. `"page.hero.title"`) instead of translated text
|
|
||||||
- SEO crawlers index translation keys, not content
|
|
||||||
- Locale cookies set but not respected on server
|
|
||||||
|
|
||||||
**Prevention:**
|
**Prevention:** Share a Zod schema between client and server:
|
||||||
- Set `vueI18n: './i18n.config.ts'` explicitly in `nuxt.config.ts`
|
```typescript
|
||||||
- Use `lazy: true` with `langDir` for large translation files, but test SSR with `nuxt preview` (not `nuxt dev`) since lazy loading behaves differently
|
// shared/schemas/contact.ts
|
||||||
- Set `strategy: 'no_prefix'` only if both FR and EN share the same URL structure — verify the SEO implication (Google prefers `hreflang` differentiation)
|
import { z } from 'zod'
|
||||||
- Test locale detection server-side: curl the deployed URL with `Cookie: i18n_locale=en` and verify English content is in the HTML response
|
export const contactSchema = z.object({
|
||||||
|
name: z.string().min(2).max(100),
|
||||||
|
email: z.string().email().max(200),
|
||||||
|
message: z.string().min(10).max(5000),
|
||||||
|
})
|
||||||
|
```
|
||||||
|
Then in `contact.post.ts`: `const body = await readValidatedBody(event, contactSchema.parse)`. Nuxt's `readValidatedBody` throws a 422 automatically on schema failure.
|
||||||
|
|
||||||
**Detection:** `curl -H "Cookie: i18n_locale=en" http://localhost:3000/ | grep -i "hero"` — if French text appears, SSR locale is broken.
|
**Detection:** `curl -X POST /api/contact -d '{"name":"x","email":"notanemail","message":"test message here"}'` — if it sends an email, validation is broken.
|
||||||
|
|
||||||
**Phase:** Foundation setup (Phase 1)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Pitfall 3: @nuxtjs/color-mode FOUC Despite Cookie Strategy
|
|
||||||
|
|
||||||
**What goes wrong:** Even with `@nuxtjs/color-mode` configured for cookie storage, a FOUC (Flash of Unstyled Content / Flash of Wrong Theme) can still occur. This happens when Tailwind CSS v4 dark mode is configured as `class`-based but the `<html>` class is added after hydration rather than during SSR.
|
|
||||||
|
|
||||||
**Why it happens:** `@nuxtjs/color-mode` adds the color mode class to `<html>` via a server-side plugin. If the plugin runs after the initial HTML render, or if Tailwind's dark variant is not using `darkMode: 'class'` correctly, the flash occurs. A common mistake is having `darkMode: 'media'` in Tailwind config while `@nuxtjs/color-mode` controls a class — these conflict.
|
|
||||||
|
|
||||||
**Consequences:**
|
|
||||||
- White flash when loading dark mode (or vice versa)
|
|
||||||
- User sees theme switch on every page load
|
|
||||||
- Poor perceived performance
|
|
||||||
|
|
||||||
**Prevention:**
|
|
||||||
- In `tailwind.config.ts` (or `@import "tailwindcss"` in CSS for v4), ensure dark mode variant matches `@nuxtjs/color-mode`'s `classSuffix: ''` and `classPrefix: ''` settings
|
|
||||||
- In Nuxt UI v3 + Tailwind v4, dark mode is configured via CSS `@variant dark (.dark &)` — verify this aligns with the class `color-mode` adds (`dark` not `dark-mode`)
|
|
||||||
- Set `colorMode.preference: 'system'` as fallback but ensure the cookie override takes precedence
|
|
||||||
- Test with Network throttling (Slow 3G) in DevTools — FOUC is most visible on slow connections
|
|
||||||
|
|
||||||
**Detection:** Record a screen capture of first page load with DevTools CPU 6x throttle. Any flash = FOUC present.
|
|
||||||
|
|
||||||
**Phase:** Foundation setup (Phase 1) — must be validated before any page migration
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Pitfall 4: Nuxt 4 `app/` Directory Structure — Component/Composable Resolution
|
|
||||||
|
|
||||||
**What goes wrong:** Nuxt 4 changes the default directory layout. The `srcDir` default is now `app/` instead of the root. Components placed in `components/`, composables in `composables/`, and pages in `pages/` at the root level are NOT auto-imported in Nuxt 4 if you've opted into the new directory structure.
|
|
||||||
|
|
||||||
**Why it happens:** Nuxt 4 introduces `app/` as the application root (analogous to how Next.js uses `app/`). Migrating without updating import paths or without setting `future.compatibilityVersion: 4` in nuxt.config causes either broken auto-imports or requires manual path updates.
|
|
||||||
|
|
||||||
**Consequences:**
|
|
||||||
- Components silently fail to auto-import → runtime errors
|
|
||||||
- Composables work in dev (because Nuxt falls back) but fail in production build
|
|
||||||
- Hours debugging "component not found" errors
|
|
||||||
|
|
||||||
**Prevention:**
|
|
||||||
- Decide upfront: use new `app/` structure (recommended) or stay at root with explicit `srcDir: '.'`
|
|
||||||
- If using `app/`: move `components/`, `composables/`, `pages/`, `layouts/`, `middleware/`, `plugins/` into `app/`
|
|
||||||
- `server/` stays at root (it's a Nitro convention, not a Nuxt app convention)
|
|
||||||
- `public/` stays at root
|
|
||||||
- `nuxt.config.ts`, `package.json` stay at root
|
|
||||||
- Validate with `nuxt info` — it shows resolved directories
|
|
||||||
|
|
||||||
**Detection:** Run `nuxt build` and check for "Auto-imported composable used but not resolved" warnings.
|
|
||||||
|
|
||||||
**Phase:** Foundation setup (Phase 1 — structural decision before any files are created)
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
### Pitfall 5: Nuxt UI v3 + Tailwind CSS v4 Configuration Conflicts
|
|
||||||
|
|
||||||
**What goes wrong:** Nuxt UI v3 ships its own Tailwind CSS v4 preset and expects to control the Tailwind configuration via its module. Manually adding a `tailwind.config.ts` alongside Nuxt UI v3 can cause duplicate utility class generation, broken component styles, or the Nuxt UI design tokens being overridden.
|
|
||||||
|
|
||||||
**Why it happens:** Tailwind CSS v4 moved from `tailwind.config.js` to CSS-based configuration (`@import "tailwindcss"` + `@theme`). Nuxt UI v3 injects its theme tokens via this CSS mechanism. If the developer also adds a separate Tailwind config file, the two configurations merge unpredictably.
|
|
||||||
|
|
||||||
**Consequences:**
|
|
||||||
- Nuxt UI components render without their default styles
|
|
||||||
- Custom colors/spacing defined in config override Nuxt UI tokens instead of extending them
|
|
||||||
- `@apply` directives fail silently in production (different behavior than dev)
|
|
||||||
|
|
||||||
**Prevention:**
|
|
||||||
- Do NOT create a standalone `tailwind.config.ts` with Nuxt UI v3 — extend via `app.config.ts` using Nuxt UI's theme API instead
|
|
||||||
- For custom colors: use `ui.colors` in `app.config.ts`, not raw Tailwind config
|
|
||||||
- For custom CSS utilities: add them to `assets/css/main.css` using Tailwind v4's `@layer utilities` syntax
|
|
||||||
- Read Nuxt UI v3 theming docs before writing any custom styles
|
|
||||||
|
|
||||||
**Detection:** After setup, run `nuxt dev` and inspect a `UButton` — if it renders unstyled, Tailwind integration is broken.
|
|
||||||
|
|
||||||
**Phase:** Foundation setup (Phase 1)
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Moderate Pitfalls
|
## Moderate Pitfalls
|
||||||
|
|
||||||
---
|
### Pitfall 4: Missing `ogUrl` and `<link rel="canonical">` with `prefix_except_default`
|
||||||
|
|
||||||
### Pitfall 6: `useAsyncData` Key Collisions Across Pages
|
**What goes wrong:** With `strategy: 'prefix_except_default'` and `defaultLocale: 'fr'`, the homepage is served at both `/` (French) and `/en/` (English). Without canonical tags, Googlebot sees two different URLs for similar content. Without `ogUrl`, Open Graph shares to Facebook/LinkedIn pick up a relative or wrong URL.
|
||||||
|
|
||||||
**What goes wrong:** Multiple pages call `useAsyncData('projects', ...)` with the same key. In SSR, Nuxt caches `useAsyncData` results by key in the payload. If two different pages use the same key for different data shapes, one page gets the other's cached data.
|
**Why it happens:** `useSeoMeta()` calls in every page omit `ogUrl`. `@nuxtjs/i18n` does NOT automatically inject canonical `<link>` tags — that is the responsibility of `@nuxtjs/sitemap` (via `xhtml:link` in the sitemap) and of the page-level `useHead()`.
|
||||||
|
|
||||||
**Why it happens:** Copy-paste of composable calls without updating the key. This is a regression from SPA behavior — in a SPA, `useAsyncData` re-executes on every navigation; in SSR, the payload cache can serve stale data.
|
**Consequences:** Google may index `/en/` as a duplicate, split PageRank, or ignore one version entirely. og:url being wrong means link preview cards show the wrong shareable URL.
|
||||||
|
|
||||||
**Prevention:**
|
**Prevention:**
|
||||||
- Use route-scoped keys: `useAsyncData(\`project-\${slug}\`, ...)` for detail pages
|
1. Add `canonical` via `useHead` in a shared layout or composable:
|
||||||
- For shared data (projects list): use a single composable (`useProjects`) that internally calls `useAsyncData('projects-list', ...)` — single definition, consistent key
|
```typescript
|
||||||
- Audit all `useAsyncData` calls and ensure every key is unique across the app
|
// composables/useSeoMeta.ts addition
|
||||||
|
const route = useRoute()
|
||||||
|
const { locale } = useI18n()
|
||||||
|
useHead({
|
||||||
|
link: [{ rel: 'canonical', href: `https://killiandalcin.fr${route.path}` }]
|
||||||
|
})
|
||||||
|
```
|
||||||
|
2. Set `ogUrl` in every `useSeoMeta()` call to the full canonical URL.
|
||||||
|
3. Verify `@nuxtjs/sitemap` emits `<xhtml:link rel="alternate">` hreflang entries — it does this automatically when `i18n` module is detected, but only if `public/sitemap.xml` is not overriding it (see Pitfall 1).
|
||||||
|
|
||||||
**Detection:** Navigate between two pages that use the same key and check if data bleeds across.
|
**Detection:** Inspect rendered HTML source — search for `<link rel="canonical"`. If absent, it's missing.
|
||||||
|
|
||||||
**Phase:** Data migration (composables phase)
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Pitfall 7: EmailJS Client-Only Execution in SSR Context
|
### Pitfall 5: `og:image` Hardcoded to Absolute Domain String
|
||||||
|
|
||||||
**What goes wrong:** EmailJS is a browser SDK (`emailjs-com` or `@emailjs/browser`). If the contact form composable calls `emailjs.sendForm()` in a context that can run server-side, the build will fail or throw a `window is not defined` error.
|
**What goes wrong:** All 6 page files hardcode `'https://killiandalcin.fr/og-image.png'`. Project detail pages use the same generic image instead of `project.value?.image`. This means every page shares identical OG metadata, reducing click-through from social shares and weakening per-page SEO signals.
|
||||||
|
|
||||||
**Why it happens:** SSR executes component setup code on the server. Any direct `import emailjs from '@emailjs/browser'` at module level that accesses browser globals during import causes the server render to crash.
|
**Why it happens:** Quick shortcut during initial migration; `@nuxt/image` was not yet wired into the SEO composable.
|
||||||
|
|
||||||
**Prevention:**
|
**Consequences:** Social previews all look identical. Future domain changes require grep-and-replace across 6+ files. No opportunity for Hytale-specific OG imagery on the dedicated page.
|
||||||
- Use `import.meta.client` guard: only call emailjs inside `onMounted` or an event handler (not in `setup()` body)
|
|
||||||
- Alternatively, use `nuxt-only` dynamic import: `const emailjs = await import('@emailjs/browser')` inside the submit handler
|
|
||||||
- Never call `emailjs.init()` at the top level of a composable — defer to client-side execution
|
|
||||||
|
|
||||||
**Detection:** Run `nuxt build && nuxt preview`, submit the contact form — if it throws, check server logs for `window is not defined`.
|
**Prevention:** Centralise in `useSeoMeta` composable:
|
||||||
|
```typescript
|
||||||
**Phase:** Contact page migration
|
const runtimeConfig = useRuntimeConfig()
|
||||||
|
const baseUrl = runtimeConfig.public.siteUrl ?? 'https://killiandalcin.fr'
|
||||||
|
// Pass image path, resolve to absolute URL inside the composable
|
||||||
|
```
|
||||||
|
For dynamic project pages, derive from `project.value.image` with a fallback to the global OG image.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Pitfall 8: `useSeoMeta()` Duplicate Meta Tags
|
### Pitfall 6: `@nuxt/image` — SSR Hydration Mismatch with `width`/`height` Props
|
||||||
|
|
||||||
**What goes wrong:** The `useSeoMeta()` composable in Nuxt merges meta tags reactively. If a base `useSeoMeta()` call is in `app.vue` AND a route-level call is in a page component, the page-level tags should override the base — but some tags (especially `og:image`) may render twice if not using the `key` deduplication mechanism.
|
**What goes wrong:** If `<NuxtImg>` or `<NuxtPicture>` is used without explicit `width` and `height` attributes, the server renders the image with dimensions derived from provider metadata (or skips them), while the client may recalculate. This produces a hydration mismatch warning and CLS (Cumulative Layout Shift) — a Core Web Vitals penalty.
|
||||||
|
|
||||||
**Why it happens:** Nuxt uses Unhead under the hood. Without explicit `key` on duplicate meta entries, Unhead appends rather than replaces.
|
**Why it happens:** `@nuxt/image` with the default `ipx` provider calculates dimensions lazily. Without `width`/`height`, the `<img>` tag emitted server-side has no size attributes, the browser does not reserve space, and content shifts when the image loads.
|
||||||
|
|
||||||
|
**Consequences:** CLS score above 0.1 → Google ranking penalty. Vue hydration mismatch console warnings in production.
|
||||||
|
|
||||||
**Prevention:**
|
**Prevention:**
|
||||||
- Define global defaults in `app.vue` using `useHead()` or `useSeoMeta()` with low-priority defaults
|
- Always provide `width` and `height` on `<NuxtImg>`. For unknown dimensions, use `aspect-ratio` CSS as fallback.
|
||||||
- Override at page level — Nuxt/Unhead will deduplicate by meta `name`/`property` automatically for standard tags
|
- Set `placeholder` prop for low-quality placeholders while loading.
|
||||||
- For `og:image`: provide absolute URLs (not relative paths) — relative paths resolve to `null` in SSR and crawlers see empty og:image
|
- Use `sizes` prop for responsive images rather than relying on CSS alone.
|
||||||
- Test with `curl http://localhost:3000/ | grep -i "og:image"` — count occurrences
|
- Avoid using `@nuxt/image` for images where dimensions are genuinely unknown at render time (e.g. user-uploaded content) — use a standard `<img>` with explicit CSS aspect-ratio instead.
|
||||||
|
|
||||||
**Detection:** View source of any page and check for duplicate `<meta property="og:image">` tags.
|
|
||||||
|
|
||||||
**Phase:** SEO implementation phase
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Pitfall 9: NuxtImg with External/Dynamic Image Sources
|
### Pitfall 7: Docker Dockerfile Uses `npm ci` After Migration to pnpm
|
||||||
|
|
||||||
**What goes wrong:** `<NuxtImg>` with `provider: 'ipx'` (default) works fine for local images in `public/`. For images with dynamic URLs (e.g. project thumbnails loaded from a data array with paths like `/images/projects/foo.jpg`), the image optimization pipeline may fail silently in Docker if the IPX cache directory is not writable.
|
**What goes wrong:** `Dockerfile` stage 1 runs `COPY package*.json ./` followed by `npm ci`. The project has migrated to pnpm (`pnpm-lock.yaml` exists). `npm ci` will install from `package-lock.json` (the old lockfile), which may diverge from the actual dependencies resolved by pnpm. If `package-lock.json` is stale or deleted, `npm ci` fails entirely.
|
||||||
|
|
||||||
**Why it happens:** IPX (the image optimization engine) writes optimized images to `.nuxt/image-cache/` or a configurable dir. In Docker containers running as non-root, this directory may not be writable. The request falls through to the original image without optimization — no error, just no optimization.
|
**Why it happens:** The Dockerfile was not updated when the package manager was switched.
|
||||||
|
|
||||||
|
**Consequences:** Production Docker image may run different dependency versions than local dev. Build could fail silently with outdated transitive deps. Both lockfiles coexisting in the repo is a CI/CD footgun.
|
||||||
|
|
||||||
**Prevention:**
|
**Prevention:**
|
||||||
- In Dockerfile: ensure the working directory and `.nuxt/` subdirectories are owned by the runtime user
|
```dockerfile
|
||||||
- Or: use `sharp` provider with pre-optimized images at build time (simpler for static images)
|
# Stage 1: Build
|
||||||
- For a portfolio with static project images: consider running `nuxt generate` (SSG) instead of SSR — eliminates the runtime IPX issue entirely
|
FROM node:22-alpine AS builder
|
||||||
- Test Docker image locally: `docker run --rm -p 3000:3000 portfolio-image` and verify images load optimized (check response headers for `Content-Type: image/webp`)
|
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json pnpm-lock.yaml ./
|
||||||
|
RUN pnpm install --frozen-lockfile
|
||||||
|
COPY . .
|
||||||
|
RUN pnpm run build
|
||||||
|
```
|
||||||
|
Delete `package-lock.json` from the repo to remove ambiguity. Add `.npmrc` with `engine-strict=true` if desired.
|
||||||
|
|
||||||
**Detection:** After Docker deploy, check Network tab — if project images are served as `image/jpeg` instead of `image/webp`, IPX optimization is failing.
|
**Detection:** `docker build` succeeding but runtime behaviour differing from `pnpm dev` — check `node_modules` versions inside the container.
|
||||||
|
|
||||||
**Phase:** Docker/deployment phase
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Pitfall 10: SSG vs SSR Decision — Critical for Docker Strategy
|
### Pitfall 8: `detectBrowserLanguage` with `redirectOn: 'root'` Causes Double Redirect for Crawlers
|
||||||
|
|
||||||
**What goes wrong:** The project currently deploys as static files served by nginx. A direct "lift and shift" to Nuxt 4 with `nuxt build` (SSR) requires a Node.js runtime in Docker — fundamentally different from the current nginx static serving. Teams often start with SSR, hit Docker complexity, then realize their portfolio (fully static content, no user-specific server responses) could just use `nuxt generate` (SSG).
|
**What goes wrong:** The i18n config has:
|
||||||
|
```ts
|
||||||
|
detectBrowserLanguage: {
|
||||||
|
useCookie: true,
|
||||||
|
cookieKey: 'i18n_redirected',
|
||||||
|
redirectOn: 'root',
|
||||||
|
}
|
||||||
|
```
|
||||||
|
Googlebot has no cookies. On every crawl of `/`, Googlebot gets a 302 redirect to `/en/` (its apparent locale) then must re-crawl. This adds a redirect hop to every French-default page discovery and can cause Googlebot to incorrectly associate English content with the root URL.
|
||||||
|
|
||||||
**Why it happens:** "SSR" is the stated goal, but for a portfolio with static content, SSG provides identical SEO benefits with zero runtime complexity. The decision is deferred until deployment, causing wasted work.
|
**Why it happens:** `detectBrowserLanguage` is designed for user experience, not crawlers. Crawlers do not send `Accept-Language` reliably, and never persist the redirect cookie.
|
||||||
|
|
||||||
**Consequences:**
|
**Consequences:** Root URL (`/`) may be indexed in English by Googlebot depending on how the redirect resolves. Crawl budget wasted on redirects.
|
||||||
- Docker image is 10x larger (Node.js runtime vs static files)
|
|
||||||
- Cold starts on container restarts
|
|
||||||
- Memory management overhead
|
|
||||||
- Unnecessary complexity for static content
|
|
||||||
|
|
||||||
**Prevention:**
|
**Prevention:**
|
||||||
- Decide SSR vs SSG in Phase 1, not Phase N
|
- Set `redirectOn: 'no prefix'` instead of `'root'` — only redirect when the user hits a non-prefixed URL that is ambiguous.
|
||||||
- SSG (`nuxt generate`) is appropriate if: no user-specific server responses, no server-side auth, no real-time data
|
- Or disable `detectBrowserLanguage` entirely and let users switch language manually via the toggle. The cookie is already persisted via `cookieKey: 'i18n_redirected'` so repeat visits respect the choice.
|
||||||
- For this portfolio: SSG is almost certainly sufficient — locale/theme from cookies still works client-side after hydration, and pre-rendered HTML satisfies SEO
|
- Add `<link rel="alternate" hreflang="fr" href="https://killiandalcin.fr/">` and `<link rel="alternate" hreflang="en" href="https://killiandalcin.fr/en/">` in `<head>` (or rely on sitemap hreflang if Pitfall 1 is fixed).
|
||||||
- If SSG: keep nginx in Docker, only Nuxt is involved at build time, not runtime
|
|
||||||
|
|
||||||
**Detection:** List every route — does any route return different HTML based on the authenticated user or real-time data? If no → SSG is viable.
|
|
||||||
|
|
||||||
**Phase:** Phase 1 decision, before any implementation
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Minor Pitfalls
|
## Minor Pitfalls
|
||||||
|
|
||||||
---
|
### Pitfall 9: `aggregateRating.reviewCount: '50'` vs `testimonials.totalReviews: 10`
|
||||||
|
|
||||||
### Pitfall 11: `definePageMeta()` Not Respected in Certain Nuxt 4 Contexts
|
**What goes wrong:** `app/data/site.ts` claims `reviewCount: '50'` in JSON-LD structured data; `app/data/testimonials.ts` has `totalReviews: 10`. Google's rich results validator and structured data guidelines penalise exaggerated or inconsistent `aggregateRating` claims.
|
||||||
|
|
||||||
**What goes wrong:** `definePageMeta({ layout: 'default' })` is a compile-time macro in Nuxt 4. Using it inside a `<script setup lang="ts">` that also contains conditional logic or computed values causes the macro extraction to fail silently — the layout falls back to default without error.
|
**Prevention:** Align both values. If the portfolio has 10 real reviews, set `reviewCount: '10'`. Inflated counts can result in the rich result being demoted or flagged.
|
||||||
|
|
||||||
**Prevention:** Keep `definePageMeta()` at the top of `<script setup>`, with only static values. Never wrap it in conditionals.
|
|
||||||
|
|
||||||
**Phase:** Page migration
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Pitfall 12: Google Analytics / nuxt-gtag Firing Twice in Dev
|
### Pitfall 10: `HeroSection` `.split(' ').slice(-2)` Gradient Logic Breaks with FR
|
||||||
|
|
||||||
**What goes wrong:** `nuxt-gtag` (or `nuxt-google-analytics`) sends a pageview event on every route change. In development with HMR, events can fire multiple times. This pollutes GA4 debug data.
|
**What goes wrong:** The gradient is applied to the last 2 words of the title, extracted by splitting on spaces. French translations may have different word counts (e.g. "Développeur Full Stack Freelance" = 4 words vs "Full Stack Developer" = 3 words). The last 2 words in French may not be the visually intended words.
|
||||||
|
|
||||||
**Prevention:** Configure `gtag: { enabled: process.env.NODE_ENV === 'production' }` so GA only fires in production builds. Current hardcoded GA in `index.html` will be dead code after migration — remove it.
|
**Prevention:** Use a translation key that wraps the emphasised portion in a span rather than computing it dynamically:
|
||||||
|
```json
|
||||||
**Phase:** Analytics migration (can be last)
|
{ "hero.title": "Développeur <em>Hytale & Full Stack</em>" }
|
||||||
|
```
|
||||||
|
Then render with `v-html` (sanitised) or split the key into `hero.title.prefix` / `hero.title.emphasis`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Pitfall 13: TypeScript Strict Mode Breaking Existing Data Files
|
### Pitfall 11: External CDN Avatars (`ui-avatars.com`) Break in Offline/Restricted Environments
|
||||||
|
|
||||||
**What goes wrong:** The existing `src/data/` files (projects, testimonials, FAQ) were likely written with implicit `any` types or loose typing. Enabling `strict: true` in `tsconfig.json` (Nuxt 4 default) will cause build errors for these files.
|
**What goes wrong:** All testimonial avatars make external HTTP requests to `https://ui-avatars.com/api/...` on every SSR render. If the CDN is unavailable (rate-limited, down, or blocked in certain regions) the SSR render stalls waiting for a timeout.
|
||||||
|
|
||||||
**Prevention:** Migrate data files first and define explicit interfaces (`Project`, `Testimonial`, etc.) before TypeScript strict errors compound. Use `satisfies` operator for type-safe data definitions without losing literal types.
|
**Prevention:** Generate the avatars once (or locally) and serve from `public/images/avatars/`. Alternatively generate SVG initials inline — zero external dependency.
|
||||||
|
|
||||||
**Phase:** Data migration phase (early)
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Phase-Specific Warnings
|
## Phase-Specific Warnings
|
||||||
|
|
||||||
| Phase Topic | Likely Pitfall | Mitigation |
|
| Phase Topic | Likely Pitfall | Mitigation |
|
||||||
|-------------|----------------|------------|
|
|---|---|---|
|
||||||
| Foundation / nuxt.config setup | Missing `future: { compatibilityVersion: 4 }` → wrong directory structure | Set this in nuxt.config on day 1 |
|
| Fixing sitemap | Static file silently wins | Delete `public/sitemap.xml` first; verify with curl |
|
||||||
| i18n integration | SSR locale mismatch (server renders default, client switches) | Cookie strategy, test with curl |
|
| Adding `/hytale` page | Sitemap won't update until static file removed | Same — Pitfall 1 |
|
||||||
| Color mode integration | FOUC despite cookie config — Tailwind v4 dark variant mismatch | Align `classSuffix` with Tailwind v4 `@variant dark` |
|
| Rate limiting contact API | In-memory map resets on container restart | Use file/Redis unstorage driver or Cloudflare rule |
|
||||||
| Nuxt UI v3 setup | Tailwind config conflicts, broken component styles | No standalone tailwind.config.ts |
|
| Canonical links | i18n prefix_except_default creates `/` + `/en/` duplicates | Add canonical in layout composable |
|
||||||
| Data composables migration | `useAsyncData` key collisions | Route-scoped keys |
|
| Docker deploy | npm ci vs pnpm-lock.yaml mismatch | Switch Dockerfile to `pnpm install --frozen-lockfile` |
|
||||||
| Contact page | EmailJS `window is not defined` in SSR | Client-only execution guards |
|
| Dynamic OG images for projects | Generic fallback masks per-project imagery | Centralise OG URL logic in composable with dynamic fallback |
|
||||||
| SEO meta | Duplicate `og:image` tags, relative URL og:image | Absolute URLs, dedup check with curl |
|
| Browser language detection | Googlebot cookie-less redirect loop | Switch `redirectOn` to `'no prefix'` or disable |
|
||||||
| Docker deploy | IPX cache not writable, Node memory | Consider SSG first; if SSR, set writable cache dir |
|
| Structured data for SEO | Mismatched `reviewCount` claim | Align JSON-LD to actual `totalReviews` value |
|
||||||
| Analytics | GA firing in dev or firing twice | `enabled: production` flag |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Sources
|
## Sources
|
||||||
|
|
||||||
- Confidence: MEDIUM — based on Nuxt 3/4 ecosystem knowledge as of August 2025. Web search was unavailable during this research session.
|
- Direct codebase inspection: `server/api/contact.post.ts`, `nuxt.config.ts`, `Dockerfile`, `public/sitemap.xml`, `.planning/codebase/CONCERNS.md`
|
||||||
- Key source areas (unverifiable without web access): Nuxt 4 migration guide (nuxt.com/docs/getting-started/upgrade), @nuxtjs/i18n v9 changelog, @nuxtjs/color-mode SSR docs, Nuxt UI v3 theming docs, Unhead deduplication behavior.
|
- Nuxt 4 documentation (training knowledge, cut-off August 2025) — confidence MEDIUM
|
||||||
- **Flag:** Pitfalls 2 (@nuxtjs/i18n v9 config), 5 (Nuxt UI v3 + Tailwind v4), and 4 (app/ directory structure) are most likely to have changed since August 2025. These should be verified against current docs before implementation.
|
- `@nuxtjs/i18n` v9 documentation on `strategy: 'prefix_except_default'` — MEDIUM
|
||||||
|
- `@nuxtjs/sitemap` v6 behaviour with `public/` static files — MEDIUM (verified by Nitro static file resolution order)
|
||||||
|
- Google Search Central structured data guidelines — MEDIUM
|
||||||
|
- Core Web Vitals CLS guidelines for image sizing — HIGH (well-established, unchanged)
|
||||||
|
|||||||
+281
-197
@@ -1,95 +1,297 @@
|
|||||||
# Technology Stack
|
# Technology Stack Research
|
||||||
|
|
||||||
**Project:** Portfolio Killian Dalcin — Nuxt 4 SSR Migration
|
**Project:** Portfolio Killian' Dalcin — Nuxt 4 SSR
|
||||||
**Researched:** 2026-04-07
|
**Researched:** 2026-04-10
|
||||||
**Knowledge cutoff:** August 2025 — all versions marked LOW confidence must be verified against npm before pinning
|
**Confidence note:** Web search and WebFetch tools were unavailable. All findings are based on codebase inspection + training knowledge (cutoff August 2025). Items marked LOW confidence require manual verification against current changelogs.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## IMPORTANT: Version Verification Required
|
## Current Stack Assessment
|
||||||
|
|
||||||
All network tools were unavailable during this research session. Versions below are from training data (cutoff August 2025). Before starting the project, run:
|
### Dependency Version Audit
|
||||||
|
|
||||||
```bash
|
| Package | Current Spec | Assessment | Notes |
|
||||||
npm info nuxt version
|
|---------|-------------|------------|-------|
|
||||||
npm info @nuxt/ui version
|
| `nuxt` | `^4.0.0` | WATCH | `^4.0.0` resolves to whatever 4.x is latest — fine for dev, pin for prod Docker |
|
||||||
npm info @nuxtjs/i18n version
|
| `@nuxt/ui` | `^3.0.0` | WATCH | v3 was released ~May 2025 with breaking changes from v2; still maturing |
|
||||||
npm info @nuxtjs/color-mode version
|
| `@nuxtjs/i18n` | `^10.2.4` | OK | v10 is the Nuxt 4 compatible branch; 10.x has known cookie-detection edge cases |
|
||||||
npm info @nuxtjs/sitemap version
|
| `@nuxtjs/sitemap` | `^8.0.12` | OK | v8 is actively maintained for Nuxt 4 |
|
||||||
npm info @nuxtjs/seo version
|
| `nuxt-gtag` | `^4.1.0` | OK | v4 targets Nuxt 4; works with SSR |
|
||||||
npm info nuxt-gtag version
|
| `@nuxt/image` | `^2.0.0` | OK | v2 is stable for Nuxt 4 |
|
||||||
npm info @pinia/nuxt version
|
| `@nuxt/eslint` | `^1.15.2` | OK | Maintained by Nuxt team |
|
||||||
npm info @nuxt/image version
|
| `zod` | `^4.3.6` | VERIFY | Zod v4 has a changed API vs v3 — confirm your server route imports are v4-compatible |
|
||||||
npm info @nuxt/eslint version
|
| `nodemailer` | `^8.0.5` | OK | v8 is stable, ESM-compatible |
|
||||||
|
| `tailwindcss` | `^4.2.2` | OK | v4 is required by Nuxt UI v3 |
|
||||||
|
| `vue` | `latest` | RISK | Pinning to `latest` is dangerous — a Vue 3→4 jump (when it ships) would break everything. Pin to `^3.5.0` |
|
||||||
|
| `vue-router` | `latest` | RISK | Same issue as `vue` — pin to `^4.5.0` |
|
||||||
|
|
||||||
|
**Confidence:** MEDIUM (based on release history known through Aug 2025; verify zod v4 API changes specifically)
|
||||||
|
|
||||||
|
### Critical Issue: Dockerfile Uses npm, Codebase Uses pnpm
|
||||||
|
|
||||||
|
The Dockerfile currently runs `npm ci` and `npm run build`, but the project uses pnpm (`pnpm-lock.yaml` is the canonical lockfile). This means:
|
||||||
|
|
||||||
|
- Docker builds ignore `pnpm-lock.yaml` and use `package-lock.json` instead
|
||||||
|
- Dependency versions in production may differ from development
|
||||||
|
- `npm ci` with a stale `package-lock.json` is a latent correctness bug
|
||||||
|
|
||||||
|
**Fix:** Migrate Dockerfile to pnpm (see pnpm + Docker section below).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Nuxt 4 Breaking Changes and Migration Gotchas
|
||||||
|
|
||||||
|
**Confidence:** MEDIUM (verified against nuxt.com docs through Aug 2025)
|
||||||
|
|
||||||
|
### Directory Structure (`compatibilityVersion: 4`)
|
||||||
|
|
||||||
|
Nuxt 4 moves app code under `app/` by default. The codebase already reflects this (`app/pages/`, `app/components/`, etc.) — this is correct.
|
||||||
|
|
||||||
|
Key structural changes vs Nuxt 3:
|
||||||
|
- `app/` directory is the application root (pages, components, layouts, composables, middleware)
|
||||||
|
- `server/` stays at project root for API routes
|
||||||
|
- `public/` stays at project root for static assets
|
||||||
|
- `~/` alias now resolves to `app/` directory, not project root
|
||||||
|
- `#imports` auto-imports still work as before
|
||||||
|
|
||||||
|
### Auto-imports Scope Change
|
||||||
|
|
||||||
|
In Nuxt 4 with `compatibilityVersion: 4`, `~/composables/` means `app/composables/`. Any composable or utility imported with `~/` is relative to `app/` — this is already how the codebase is structured.
|
||||||
|
|
||||||
|
### `useAsyncData` and `useFetch` Key Deduplication
|
||||||
|
|
||||||
|
Nuxt 4 changed how keys are generated for `useAsyncData`. If two calls share the same auto-generated key, only one runs. When using `useAsyncData` in loops or dynamic components, always pass an explicit unique key. This is especially relevant if you add project detail pages that fetch by ID.
|
||||||
|
|
||||||
|
### `useHead` and SSR Hydration
|
||||||
|
|
||||||
|
`useHead` in Nuxt 4 requires that reactive values be wrapped in functions (arrow functions returning computed values) to be reactive on the server. The current codebase already uses `() => t('seo.home.title')` pattern in `useSeoMeta` — this is correct.
|
||||||
|
|
||||||
|
Static strings (like `ogImage: 'https://killiandalcin.fr/og-image.png'`) are fine as-is for pages where the image doesn't change per-route.
|
||||||
|
|
||||||
|
### Server API Routes Location
|
||||||
|
|
||||||
|
In Nuxt 4, server routes live in `server/api/` at the project root (not `app/api/`). The codebase `STACK.md` references `app/api/contact.post.ts` — verify this file's actual location and confirm it resolves correctly. If it's under `app/`, it will not be treated as a server route.
|
||||||
|
|
||||||
|
**Action required:** Verify `contact.post.ts` is at `server/api/contact.post.ts`.
|
||||||
|
|
||||||
|
### `runtimeConfig` Key Naming
|
||||||
|
|
||||||
|
In `nuxt.config.ts`, `runtimeConfig` keys like `smtpHost` are accessed as `useRuntimeConfig().smtpHost` in server code, and are populated from environment variables named `NUXT_SMTP_HOST` (Nuxt auto-maps UPPER_SNAKE_CASE env vars to camelCase config keys). This is working correctly in the current config.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Nuxt 4 SEO Best Practices
|
||||||
|
|
||||||
|
**Confidence:** HIGH (useSeoMeta is documented Nuxt API; canonical link patterns are well-established)
|
||||||
|
|
||||||
|
### Canonical Links (Currently Missing)
|
||||||
|
|
||||||
|
The current `index.vue` sets `ogImage`, `ogTitle`, `ogDescription`, `ogType` but does not set a canonical URL. For a bilingual site with `prefix_except_default` strategy, this creates duplicate content risk:
|
||||||
|
|
||||||
|
- `https://killiandalcin.fr/` (FR, no prefix)
|
||||||
|
- `https://killiandalcin.fr/en/` (EN, prefixed)
|
||||||
|
|
||||||
|
Both URLs serve different content, so they are not true duplicates. However, canonical tags still prevent ambiguity for crawlers.
|
||||||
|
|
||||||
|
**Recommended pattern for every page:**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const { locale } = useI18n()
|
||||||
|
const route = useRoute()
|
||||||
|
|
||||||
|
useSeoMeta({
|
||||||
|
title: () => t('seo.home.title'),
|
||||||
|
description: () => t('seo.home.description'),
|
||||||
|
ogTitle: () => t('seo.home.title'),
|
||||||
|
ogDescription: () => t('seo.home.description'),
|
||||||
|
ogUrl: () => `https://killiandalcin.fr${route.path}`,
|
||||||
|
ogImage: '/og-image.png', // absolute URL resolved by Nuxt at runtime
|
||||||
|
ogImageWidth: 1200,
|
||||||
|
ogImageHeight: 630,
|
||||||
|
ogType: 'website',
|
||||||
|
})
|
||||||
|
|
||||||
|
useHead({
|
||||||
|
link: [
|
||||||
|
{ rel: 'canonical', href: () => `https://killiandalcin.fr${route.path}` },
|
||||||
|
],
|
||||||
|
})
|
||||||
```
|
```
|
||||||
|
|
||||||
|
This sets the canonical to the current page's own URL (deduplicated per-language). If you want FR as the canonical for EN pages, that requires a different strategy — but same-URL canonical is simpler and correct for truly separate FR/EN content.
|
||||||
|
|
||||||
|
### hreflang (Currently via Sitemap)
|
||||||
|
|
||||||
|
The sitemap module (`@nuxtjs/sitemap` v8) generates `<loc>` and `<xhtml:link rel="alternate">` hreflang entries automatically when configured with i18n. This is the recommended approach — do not manually manage hreflang in `useHead`.
|
||||||
|
|
||||||
|
Verify the sitemap module config includes:
|
||||||
|
```typescript
|
||||||
|
// nuxt.config.ts
|
||||||
|
sitemap: {
|
||||||
|
// sitemap module v8 auto-detects i18n routes when @nuxtjs/i18n is present
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
If not configured, add explicit i18n awareness:
|
||||||
|
```typescript
|
||||||
|
sitemap: {
|
||||||
|
i18n: true,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### og:image Per Page
|
||||||
|
|
||||||
|
The current implementation uses a single hardcoded `og-image.png` for all pages. For project detail pages (`/project/[id]`), a per-project og:image significantly improves social sharing CTR.
|
||||||
|
|
||||||
|
**Recommended approach (no external service needed):**
|
||||||
|
|
||||||
|
Option A — Static images per project (simplest):
|
||||||
|
```typescript
|
||||||
|
// app/pages/project/[id].vue
|
||||||
|
const project = computed(() => getProjectById(id))
|
||||||
|
useSeoMeta({
|
||||||
|
ogImage: () => project.value?.image
|
||||||
|
? `https://killiandalcin.fr${project.value.image}`
|
||||||
|
: 'https://killiandalcin.fr/og-image.png',
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
Option B — `@nuxt/og-image` module (generates OG images via Satori/Canvas):
|
||||||
|
- Generates server-side OG images from Vue templates
|
||||||
|
- Zero cost, no external service
|
||||||
|
- Adds ~30s to build time for static generation
|
||||||
|
- Well-maintained Nuxt module
|
||||||
|
- LOW confidence on current stability with Nuxt 4 — verify before adopting
|
||||||
|
|
||||||
|
For this portfolio, Option A is sufficient and zero-risk.
|
||||||
|
|
||||||
|
### Structured Data per Page
|
||||||
|
|
||||||
|
The current homepage has `Person` + `ProfessionalService` JSON-LD. For SEO targeting Hytale plugin searches, additional schema on inner pages adds signal:
|
||||||
|
|
||||||
|
| Page | Recommended Schema |
|
||||||
|
|------|-------------------|
|
||||||
|
| `/` (homepage) | `Person` + `ProfessionalService` (already present) |
|
||||||
|
| `/projects` | `ItemList` of `SoftwareApplication` or `CreativeWork` |
|
||||||
|
| `/project/[id]` | `SoftwareApplication` with `name`, `description`, `author` |
|
||||||
|
| `/about` | `Person` with skills, `alumniOf`, `knowsAbout: ["Hytale", "Kotlin", ...]` |
|
||||||
|
| `/contact` | `ContactPage` |
|
||||||
|
| `/fiverr` | `Offer` or `Service` with `price`, `priceCurrency` |
|
||||||
|
|
||||||
|
The `jobTitle` on the Person schema should be updated to "Hytale Plugin Developer" or "Game Plugin Developer" to match target keyword positioning.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Recommended Stack
|
## pnpm + Docker Best Practices for Nuxt SSR
|
||||||
|
|
||||||
### Core Framework
|
**Confidence:** HIGH (pnpm Docker documentation is stable)
|
||||||
|
|
||||||
| Technology | Version (training data) | Confidence | Purpose | Why |
|
### Current Problem
|
||||||
|------------|------------------------|------------|---------|-----|
|
|
||||||
| nuxt | ^4.0.0 | LOW — verify on npm | SSR framework | Only reason this migration exists: per-route SSR so every page is crawlable without client JS. Nuxt 4 is the current stable major. |
|
|
||||||
| vue | ^3.5.x | MEDIUM | UI layer | Peer dependency of Nuxt 4; Vue 3.5 introduces `useTemplateRef` and improved reactivity — no action needed, Nuxt manages it |
|
|
||||||
| typescript | ^5.x | MEDIUM | Type safety | Nuxt 4 ships its own TS config; strict mode enforced via `tsconfig.json` extends |
|
|
||||||
| node | 22.x LTS | HIGH | Runtime | Matches Docker base image `node:22-alpine`; Node 22 is current LTS as of April 2026 |
|
|
||||||
|
|
||||||
### UI & Styling
|
The Dockerfile uses `npm ci` while the project uses pnpm. This must be fixed. The two lockfiles coexisting (`pnpm-lock.yaml` + `package-lock.json`) will cause permanent drift between dev and prod.
|
||||||
|
|
||||||
| Technology | Version (training data) | Confidence | Purpose | Why |
|
**Recommendation:** Delete `package-lock.json` from the repo, use pnpm exclusively.
|
||||||
|------------|------------------------|------------|---------|-----|
|
|
||||||
| @nuxt/ui | ^3.0.0 | LOW — verify on npm | Component library | v3 is built on Tailwind v4 and Radix Vue, ships production-ready components (UModal, UForm, UInput, UTextarea). Replaces ~80% of custom component work. v2 is NOT compatible with Tailwind v4. |
|
|
||||||
| tailwindcss | ^4.0.0 | LOW — verify on npm | Utility CSS | Bundled as a dependency of @nuxt/ui v3; do NOT install separately or pin a conflicting version. Tailwind v4 ships as a Vite/PostCSS plugin, no `tailwind.config.js` needed. |
|
|
||||||
| @nuxtjs/color-mode | ^3.5.x | LOW — verify on npm | Dark/light mode | Nuxt-native module; writes a cookie on the server, so no FOUC and no hydration mismatch. `localStorage` alternative is explicitly broken for SSR. Must set `storage: 'cookie'` in config. |
|
|
||||||
|
|
||||||
### Internationalisation
|
### Recommended Dockerfile
|
||||||
|
|
||||||
| Technology | Version (training data) | Confidence | Purpose | Why |
|
```dockerfile
|
||||||
|------------|------------------------|------------|---------|-----|
|
# Stage 1: Build
|
||||||
| @nuxtjs/i18n | ^9.x | LOW — verify on npm | FR/EN i18n | v9 is the Nuxt 4-compatible major. v8 targets Nuxt 3. Uses `useCookie()` for locale persistence (SSR-safe). Must set `detectBrowserLanguage.cookieKey` and `cookieCrossOrigin` appropriately; `localStorage` fallback must be disabled. |
|
FROM node:22-alpine AS builder
|
||||||
| vue-i18n | ^10.x | LOW — peer dep | Translation runtime | Peer dep of @nuxtjs/i18n v9; do not install vue-i18n v9 (Nuxt 3 era). |
|
WORKDIR /app
|
||||||
|
|
||||||
### SEO
|
# Install pnpm
|
||||||
|
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||||
|
|
||||||
| Technology | Version (training data) | Confidence | Purpose | Why |
|
# Copy manifests first for layer caching
|
||||||
|------------|------------------------|------------|---------|-----|
|
COPY package.json pnpm-lock.yaml ./
|
||||||
| @nuxtjs/sitemap | ^6.x | LOW — verify on npm | sitemap.xml | Auto-generates sitemap from Nuxt routes including i18n alternates. Required by the PROJECT.md spec. Must be configured with `i18n` option when @nuxtjs/i18n is present to emit `hreflang` entries. |
|
|
||||||
| @nuxtjs/seo | ^2.x | LOW — verify on npm | SEO meta bundle | Meta-module that installs and pre-configures `@nuxtjs/sitemap`, `nuxt-og-image`, `nuxt-schema-org`, `nuxt-link-checker`. Using it avoids duplicate sitemap config. If using @nuxtjs/seo, do NOT also install @nuxtjs/sitemap standalone (conflict risk). Choose one. |
|
|
||||||
|
|
||||||
> **Decision needed:** Use `@nuxtjs/seo` (meta-module, installs sitemap + og-image + schema-org) OR install `@nuxtjs/sitemap` standalone and `useSeoMeta()` manually. Recommendation: use `@nuxtjs/seo` because the portfolio needs og:image and JSON-LD (project requirement), and the meta-module wires them together with zero boilerplate.
|
# Install all dependencies (including devDeps needed for build)
|
||||||
|
RUN pnpm install --frozen-lockfile
|
||||||
|
|
||||||
### Analytics
|
# Copy source and build
|
||||||
|
COPY . .
|
||||||
|
RUN pnpm build
|
||||||
|
|
||||||
| Technology | Version (training data) | Confidence | Purpose | Why |
|
# Stage 2: Runtime
|
||||||
|------------|------------------------|------------|---------|-----|
|
FROM node:22-alpine AS runner
|
||||||
| nuxt-gtag | ^3.x | LOW — verify on npm | Google Analytics 4 | Replaces GA4 hardcoded in `index.html`. Injects `gtag.js` via Nuxt's head management, respects SSR. Must be configured with `id: 'G-XXXXXXXX'` from `runtimeConfig.public`. |
|
WORKDIR /app
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV HOST=0.0.0.0
|
||||||
|
ENV PORT=3000
|
||||||
|
|
||||||
### State Management
|
# Only copy built output — no node_modules needed at runtime
|
||||||
|
# Nuxt SSR bundles all server deps into .output/server/
|
||||||
|
COPY --from=builder /app/.output /app/.output
|
||||||
|
|
||||||
| Technology | Version (training data) | Confidence | Purpose | Why |
|
EXPOSE 3000
|
||||||
|------------|------------------------|------------|---------|-----|
|
CMD ["node", "/app/.output/server/index.mjs"]
|
||||||
| @pinia/nuxt | ^0.9.x | LOW — verify on npm | Global state | Required if any state needs to survive navigation (e.g., project filter state). For a portfolio with mostly static data this may be optional; include it anyway because Pinia integrates with Nuxt devtools and SSR hydration is handled automatically. |
|
```
|
||||||
| pinia | ^3.x | LOW — peer dep | Pinia core | Peer dep of @pinia/nuxt; version must match. |
|
|
||||||
|
|
||||||
### Images
|
Key points:
|
||||||
|
- `corepack enable` activates pnpm without a separate install step
|
||||||
|
- `--frozen-lockfile` ensures exact reproducibility (fails if lockfile is stale)
|
||||||
|
- The `.output/` directory is self-contained — all server-side node_modules are bundled by Nitro
|
||||||
|
- No `node_modules` copy to runtime stage (keeps image ~50-100MB smaller)
|
||||||
|
|
||||||
| Technology | Version (training data) | Confidence | Purpose | Why |
|
### .dockerignore
|
||||||
|------------|------------------------|------------|---------|-----|
|
|
||||||
| @nuxt/image | ^1.x | LOW — verify on npm | Optimised images | `<NuxtImg>` replaces `<img>` for automatic lazy loading, srcset, and format conversion. Project requirement: lazy load project gallery images. Use `provider: 'ipx'` (built-in, no external service). |
|
|
||||||
|
|
||||||
### Developer Tooling
|
Ensure `.dockerignore` excludes dev artifacts:
|
||||||
|
```
|
||||||
|
node_modules
|
||||||
|
.nuxt
|
||||||
|
.output
|
||||||
|
*.log
|
||||||
|
.env
|
||||||
|
```
|
||||||
|
|
||||||
| Technology | Version (training data) | Confidence | Purpose | Why |
|
### Build-time vs Runtime Environment Variables
|
||||||
|------------|------------------------|------------|---------|-----|
|
|
||||||
| @nuxt/eslint | ^0.7.x | LOW — verify on npm | ESLint + Prettier | Nuxt-native flat config ESLint. Replaces manual eslint + prettier wiring. Enforces Vue 3 best practices. One module, one config file. |
|
|
||||||
|
|
||||||
### Infrastructure
|
Nuxt `runtimeConfig` values with defaults in `nuxt.config.ts` are injected at **runtime** via environment variables — this is correct. However, `public.*` keys are embedded at **build time**.
|
||||||
|
|
||||||
| Technology | Version | Confidence | Purpose | Why |
|
Current config:
|
||||||
|------------|---------|------------|---------|-----|
|
```typescript
|
||||||
| Docker node:22-alpine | 22-alpine | HIGH | Container base | Alpine keeps image small (~50MB base). Node 22 matches the runtime. Multi-stage build: stage 1 installs deps + builds, stage 2 copies `.output/` only. |
|
runtimeConfig: {
|
||||||
|
smtpHost: '', // runtime — correct
|
||||||
|
public: {
|
||||||
|
gtag: { id: '' }, // build time — value must be known at docker build
|
||||||
|
},
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
If `NUXT_PUBLIC_GTAG_ID` needs to differ between environments without rebuilding, this is a limitation. For a single-deployment portfolio this is fine. Just pass `NUXT_PUBLIC_GTAG_ID` as a Docker build arg if you need it baked in, or accept the empty default and override in a startup script.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Additional Concerns Found in Codebase
|
||||||
|
|
||||||
|
### `vue: "latest"` and `vue-router: "latest"` — High Risk
|
||||||
|
|
||||||
|
These should be pinned. `latest` resolves at install time and will break on a major Vue version bump. Vue 4 (when it ships) will be a breaking change.
|
||||||
|
|
||||||
|
**Fix in `package.json`:**
|
||||||
|
```json
|
||||||
|
"vue": "^3.5.0",
|
||||||
|
"vue-router": "^4.5.0"
|
||||||
|
```
|
||||||
|
|
||||||
|
### `site.name` is Generic
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
site: {
|
||||||
|
url: 'https://killiandalcin.fr',
|
||||||
|
name: "Killian' DAL-CIN - Developpeur Full Stack"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This drives the sitemap module's `<name>` field and potentially OG titles. Update to reflect Hytale positioning when the Hero rewrite ships.
|
||||||
|
|
||||||
|
### `jobTitle` in JSON-LD
|
||||||
|
|
||||||
|
Currently `"jobTitle": "Developpeur Full Stack Freelance"` — should become `"Hytale Plugin Developer & Full Stack Freelance"` or similar to match target keyword.
|
||||||
|
|
||||||
|
### `colorMode` Module Sourcing
|
||||||
|
|
||||||
|
`colorMode` is provided by `@nuxtjs/color-mode`, which is bundled within `@nuxt/ui` v3. No separate install needed. The `nuxt.config.ts` configuration is correct. However, `classSuffix: ''` means the class applied to `<html>` is `dark`/`light` (no suffix) — confirm your Tailwind v4 config uses `darkMode: 'class'` (it should be automatic via Nuxt UI).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -97,144 +299,26 @@ npm info @nuxt/eslint version
|
|||||||
|
|
||||||
| Category | Recommended | Alternative | Why Not |
|
| Category | Recommended | Alternative | Why Not |
|
||||||
|----------|-------------|-------------|---------|
|
|----------|-------------|-------------|---------|
|
||||||
| UI library | @nuxt/ui v3 | Vuetify, PrimeVue, custom | Nuxt UI v3 is Tailwind-native, ships ready for Nuxt 4, has UModal/UForm exactly as specced. Others require extra adapter work. |
|
| SEO meta | `useSeoMeta()` (built-in) | `vue-meta`, `@vueuse/head` | Built-in is SSR-correct, zero config |
|
||||||
| CSS | Tailwind v4 (via @nuxt/ui) | Tailwind v3, UnoCSS, plain CSS | v4 is the current generation; UnoCSS is a valid alternative but adds config overhead with no benefit for this scope. |
|
| OG image | Static files per page | `@nuxt/og-image` | Static is simpler for a portfolio |
|
||||||
| i18n | @nuxtjs/i18n v9 | lingui, custom composable | @nuxtjs/i18n has Nuxt 4 SSR cookie support built-in; alternatives require manual SSR wiring. |
|
| Email | `nodemailer` | Resend API, SendGrid | Zero cost, self-hosted SMTP sufficient |
|
||||||
| Analytics | nuxt-gtag | Umami (self-hosted) | Umami is out of scope per PROJECT.md. nuxt-gtag is the standard Nuxt-native GA4 module. |
|
| Analytics | `nuxt-gtag` | Manual `useHead` script | Module handles SSR-safe loading |
|
||||||
| State | @pinia/nuxt | useState() only | useState() is fine for simple per-component state but Pinia is needed for shared filter state across pages. Include it from day one to avoid a refactor. |
|
| Package manager | pnpm | npm | Faster, better monorepo support, already adopted |
|
||||||
| CMS | Static TS data files | @nuxt/content | PROJECT.md explicitly rules out @nuxt/content. Data is bilingual TS objects already; keep them. |
|
|
||||||
| Contact form backend | EmailJS | Custom API, Formspree | No backend to maintain. EmailJS free tier is sufficient for a portfolio contact form. Not a Nuxt module — just an npm package (`emailjs-com`). |
|
|
||||||
| Sitemap + SEO meta | @nuxtjs/seo (bundle) | @nuxtjs/sitemap standalone | @nuxtjs/seo includes og-image and schema-org which the project spec requires. One module is simpler. |
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## What NOT to Use
|
## Summary Recommendations
|
||||||
|
|
||||||
| Package | Reason |
|
1. **Fix Dockerfile** — Switch from `npm ci` to `pnpm install --frozen-lockfile` (critical)
|
||||||
|---------|--------|
|
2. **Pin `vue` and `vue-router`** — Replace `"latest"` with `"^3.5.0"` and `"^4.5.0"` (high priority)
|
||||||
| vue-router (manual) | Nuxt 4 ships file-based routing on top of vue-router; never import vue-router directly in a Nuxt project |
|
3. **Add canonical link** — `useHead({ link: [{ rel: 'canonical', href: () => ... }] })` on every page
|
||||||
| @nuxt/content | Explicitly out of scope; TS data files are simpler and already exist |
|
4. **Set `ogUrl` per page** — Add `ogUrl: () => \`https://killiandalcin.fr${route.path}\`` to all `useSeoMeta()` calls
|
||||||
| localStorage for i18n/theme | Not readable on server; causes hydration mismatch and FOUC. Use cookies only. |
|
5. **Verify server API location** — Confirm `contact.post.ts` is at `server/api/`, not `app/api/`
|
||||||
| Tailwind v3 | @nuxt/ui v3 requires Tailwind v4. Mixing versions breaks everything. |
|
6. **Update JSON-LD jobTitle** — Reflect Hytale positioning
|
||||||
| @nuxtjs/i18n v8 | Only compatible with Nuxt 3. v9 is required for Nuxt 4. |
|
7. **Update `site.name`** — Align with Hytale-first branding when Hero ships
|
||||||
| nuxt generate (full SSG) | May be considered for perf, but SSR is the core value of this migration (per PROJECT.md). Use `nuxt build` + node server in Docker. Revisit after launch if edge deployment is added. |
|
8. **Remove `package-lock.json`** — One lockfile, one package manager
|
||||||
|
9. **Verify zod v4 API** — The `zod@^4.3.6` spec means v4 is required; confirm server route uses v4 schema API (not v3 `.parse()` patterns that changed)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## nuxt.config.ts Skeleton
|
*Confidence levels: HIGH = codebase-verified or stable Nuxt docs. MEDIUM = training knowledge, verify against current changelog. LOW = flagged as needing manual verification.*
|
||||||
|
|
||||||
```typescript
|
|
||||||
export default defineNuxtConfig({
|
|
||||||
compatibilityDate: '2025-01-01',
|
|
||||||
|
|
||||||
modules: [
|
|
||||||
'@nuxt/ui',
|
|
||||||
'@nuxtjs/i18n',
|
|
||||||
'@nuxtjs/color-mode',
|
|
||||||
'@nuxtjs/seo', // includes sitemap, og-image, schema-org
|
|
||||||
'nuxt-gtag',
|
|
||||||
'@pinia/nuxt',
|
|
||||||
'@nuxt/image',
|
|
||||||
'@nuxt/eslint',
|
|
||||||
],
|
|
||||||
|
|
||||||
colorMode: {
|
|
||||||
preference: 'system',
|
|
||||||
fallback: 'light',
|
|
||||||
storage: 'cookie', // SSR-safe: no FOUC
|
|
||||||
},
|
|
||||||
|
|
||||||
i18n: {
|
|
||||||
locales: ['fr', 'en'],
|
|
||||||
defaultLocale: 'fr',
|
|
||||||
detectBrowserLanguage: {
|
|
||||||
useCookie: true,
|
|
||||||
cookieKey: 'i18n_redirected',
|
|
||||||
redirectOn: 'root',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
gtag: {
|
|
||||||
id: '', // set via runtimeConfig.public.gtag.id
|
|
||||||
},
|
|
||||||
|
|
||||||
image: {
|
|
||||||
provider: 'ipx',
|
|
||||||
},
|
|
||||||
|
|
||||||
typescript: {
|
|
||||||
strict: true,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Docker Production Setup
|
|
||||||
|
|
||||||
```dockerfile
|
|
||||||
# Stage 1: build
|
|
||||||
FROM node:22-alpine AS builder
|
|
||||||
WORKDIR /app
|
|
||||||
COPY package*.json ./
|
|
||||||
RUN npm ci
|
|
||||||
COPY . .
|
|
||||||
RUN npm run build
|
|
||||||
|
|
||||||
# Stage 2: runtime
|
|
||||||
FROM node:22-alpine AS runner
|
|
||||||
WORKDIR /app
|
|
||||||
COPY --from=builder /app/.output ./output
|
|
||||||
ENV NODE_ENV=production
|
|
||||||
ENV PORT=3000
|
|
||||||
EXPOSE 3000
|
|
||||||
CMD ["node", "output/server/index.mjs"]
|
|
||||||
```
|
|
||||||
|
|
||||||
Key points:
|
|
||||||
- Only `.output/` is copied to the final image. No `node_modules/`, no source files.
|
|
||||||
- `node:22-alpine` is the project constraint (matches dev runtime).
|
|
||||||
- Nuxt 4 SSR server entry is `.output/server/index.mjs`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Installation
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Scaffold Nuxt 4 project
|
|
||||||
npx nuxi@latest init portfolio --template=v4-compat
|
|
||||||
cd portfolio
|
|
||||||
|
|
||||||
# Core modules
|
|
||||||
npm install @nuxt/ui @nuxtjs/i18n @nuxtjs/color-mode @nuxtjs/seo nuxt-gtag @pinia/nuxt pinia @nuxt/image
|
|
||||||
|
|
||||||
# Dev tooling
|
|
||||||
npm install -D @nuxt/eslint typescript
|
|
||||||
|
|
||||||
# Contact form (not a Nuxt module)
|
|
||||||
npm install @emailjs/browser
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Confidence Summary
|
|
||||||
|
|
||||||
| Area | Confidence | Notes |
|
|
||||||
|------|------------|-------|
|
|
||||||
| Nuxt 4 as framework | MEDIUM | Nuxt 4 was in RC/stable as of mid-2025; verify exact version on npm |
|
|
||||||
| @nuxt/ui v3 | LOW | v3 was in active development; confirm stable tag on npm |
|
|
||||||
| @nuxtjs/i18n v9 (Nuxt 4 compat) | LOW | v9 announced for Nuxt 4; confirm it's the `latest` dist-tag |
|
|
||||||
| @nuxtjs/color-mode cookie storage | MEDIUM | This feature existed in v3.3+; verify it persists in latest |
|
|
||||||
| @nuxtjs/seo as meta-bundle | MEDIUM | Module has been stable; inclusion of sitemap+og-image confirmed in v2 docs |
|
|
||||||
| nuxt-gtag | LOW | Verify v3 is compatible with Nuxt 4 |
|
|
||||||
| @pinia/nuxt | MEDIUM | Pinia 3 + @pinia/nuxt 0.9 tracked Nuxt 4 compat closely |
|
|
||||||
| Docker node:22-alpine | HIGH | Node 22 is current LTS; Alpine variant is standard |
|
|
||||||
| EmailJS (non-Nuxt) | HIGH | Stable library, no Nuxt dependency |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Sources
|
|
||||||
|
|
||||||
- Training data (knowledge cutoff August 2025) — all external tools blocked during this research session
|
|
||||||
- PROJECT.md constraints and requirements: `.planning/PROJECT.md`
|
|
||||||
- npm registry verification required before pinning versions (see commands at top of this file)
|
|
||||||
|
|||||||
@@ -1,115 +0,0 @@
|
|||||||
# Research Summary
|
|
||||||
|
|
||||||
**Project:** Portfolio Killian Dalcin — Vue 3 SPA → Nuxt 4 SSR Migration
|
|
||||||
**Date:** 2026-04-07
|
|
||||||
**Sources:** STACK.md, FEATURES.md, ARCHITECTURE.md, PITFALLS.md
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Recommended Stack (verify versions on npm before pinning)
|
|
||||||
|
|
||||||
| Package | Purpose | Confidence |
|
|
||||||
|---------|---------|------------|
|
|
||||||
| nuxt ^4.0.0 | SSR framework | MEDIUM |
|
|
||||||
| @nuxt/ui ^3.0.0 | Component library (Tailwind v4 + Reka UI) | LOW — verify stable |
|
|
||||||
| @nuxtjs/i18n ^9.x | FR/EN i18n SSR-safe | LOW — verify v9 stable |
|
|
||||||
| @nuxtjs/color-mode ^3.5.x | Dark/light mode cookie | MEDIUM |
|
|
||||||
| @nuxtjs/seo ^2.x | Meta bundle (sitemap + og-image + schema-org) | MEDIUM |
|
|
||||||
| nuxt-gtag ^3.x | Google Analytics 4 | LOW — verify Nuxt 4 compat |
|
|
||||||
| @pinia/nuxt ^0.9.x | State management | MEDIUM |
|
|
||||||
| @nuxt/image ^1.x | Image optimization (NuxtImg) | MEDIUM |
|
|
||||||
| @nuxt/eslint ^0.7.x | Linting | MEDIUM |
|
|
||||||
| @emailjs/browser | Contact form (client-only) | HIGH |
|
|
||||||
|
|
||||||
**Critical decision:** Use `@nuxtjs/seo` (meta-bundle) instead of standalone `@nuxtjs/sitemap` — it includes sitemap + og-image + schema-org in one module.
|
|
||||||
|
|
||||||
**Do NOT install separately:** Tailwind CSS (bundled by @nuxt/ui v3), vue-router (Nuxt manages it), @nuxtjs/sitemap (included in @nuxtjs/seo).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
```
|
|
||||||
app/ ← Nuxt 4 source root
|
|
||||||
pages/ ← File-based routing (7 pages)
|
|
||||||
components/ ← Auto-imported UI components
|
|
||||||
composables/ ← Auto-imported reactive logic
|
|
||||||
layouts/default.vue ← TheHeader + slot + TheFooter
|
|
||||||
plugins/ ← EmailJS init (client-only)
|
|
||||||
i18n/locales/ ← fr.ts, en.ts
|
|
||||||
data/ ← Static TS files (projects, testimonials, FAQ, tech)
|
|
||||||
public/ ← Images, fonts
|
|
||||||
nuxt.config.ts ← Modules + config
|
|
||||||
app.config.ts ← Nuxt UI theme customization
|
|
||||||
```
|
|
||||||
|
|
||||||
**Data flow:** `data/*.ts` → `composables/` → `pages/` → `components/` (props down, events up)
|
|
||||||
|
|
||||||
**i18n strategy:** `prefix_except_default` — FR at `/`, EN at `/en/*`. Cookie persistence, SSR-safe.
|
|
||||||
|
|
||||||
**Deployment:** `nuxt build` (SSR) → Docker `node:22-alpine` → `node .output/server/index.mjs`. No nginx needed.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Nuxt UI v3 Component Coverage (~80% of custom components replaced)
|
|
||||||
|
|
||||||
| Current Custom | Nuxt UI v3 Replacement |
|
|
||||||
|----------------|----------------------|
|
|
||||||
| AppHeader nav | UNavigationMenu |
|
|
||||||
| Mobile menu | UDrawer |
|
|
||||||
| ThemeToggle | UButton + useColorMode() |
|
|
||||||
| LanguageSwitcher | UDropdownMenu |
|
|
||||||
| ProjectCard | UCard |
|
|
||||||
| GalleryModal | UModal + UCarousel |
|
|
||||||
| ServiceFAQ | UAccordion |
|
|
||||||
| TechBadge | UBadge |
|
|
||||||
| FiverrServiceCard | UCard |
|
|
||||||
| Contact form (NEW) | UForm + UFormField + UInput + UTextarea |
|
|
||||||
| Toast feedback (NEW) | useToast() |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Top 5 Pitfalls (Phase 1 blockers)
|
|
||||||
|
|
||||||
1. **localStorage → cookie** — Both locale and theme use localStorage in SPA. Must be cookie-only for SSR. Test with `curl` + cookie header.
|
|
||||||
2. **@nuxtjs/i18n v9 config** — Breaking changes from v8. Must set `vueI18n: './i18n.config.ts'` explicitly or translations render as keys in SSR.
|
|
||||||
3. **Color mode FOUC** — Even with cookie, Tailwind v4 dark variant must align with color-mode class. Test with CPU throttle.
|
|
||||||
4. **Nuxt 4 `app/` directory** — Source files must live under `app/`. Root-level files are NOT auto-imported.
|
|
||||||
5. **Nuxt UI v3 owns Tailwind config** — No standalone `tailwind.config.ts`. Customize via `app.config.ts`.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Build Order (strict dependencies)
|
|
||||||
|
|
||||||
```
|
|
||||||
1. nuxt.config.ts + all modules configured → gates everything
|
|
||||||
2. data/ migration (static TS files) → gates composables
|
|
||||||
3. composables/ migration → gates pages
|
|
||||||
4. layouts/default.vue + TheHeader + TheFooter → gates all pages
|
|
||||||
5. pages/ (leaf nodes, parallelizable) → independent
|
|
||||||
6. plugins/ (EmailJS, gtag) → after contact page
|
|
||||||
7. Dockerfile production → after all pages
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Key Decisions Confirmed by Research
|
|
||||||
|
|
||||||
| Decision | Research Finding |
|
|
||||||
|----------|-----------------|
|
|
||||||
| SSR over SSG | i18n cookie detection requires server execution per request |
|
|
||||||
| @nuxtjs/seo over standalone sitemap | Portfolio needs og:image + JSON-LD — meta-bundle covers all |
|
|
||||||
| Static TS data over @nuxt/content | Data is typed, bilingual, static — no CMS overhead needed |
|
|
||||||
| Cookie over localStorage | Only SSR-safe persistence method for i18n + theme |
|
|
||||||
| No standalone tailwind.config | Nuxt UI v3 manages Tailwind v4 via CSS — customize in app.config.ts |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Open Questions (verify before implementation)
|
|
||||||
|
|
||||||
- [ ] Confirm @nuxtjs/i18n v9 is `latest` on npm and compatible with Nuxt 4
|
|
||||||
- [ ] Confirm @nuxt/ui v3 is stable (not beta/rc)
|
|
||||||
- [ ] Confirm nuxt-gtag works with Nuxt 4
|
|
||||||
- [ ] Confirm UCarousel exists in Nuxt UI v3 stable
|
|
||||||
- [ ] Confirm exact i18n v9 config syntax for `prefix_except_default` + cookie
|
|
||||||
- [ ] Confirm Nuxt UI v3 theming API (CSS `@theme` vs `app.config.ts`)
|
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
<!-- GSD:project-start source:PROJECT.md -->
|
<!-- GSD:project-start source:PROJECT.md -->
|
||||||
## Project
|
## Project
|
||||||
|
|
||||||
**Portfolio Killian Dalcin — Migration Nuxt 4**
|
**Portfolio Killian' Dalcin — Migration Nuxt 4**
|
||||||
|
|
||||||
Migration complète d'un portfolio freelance de Vue 3 SPA vers Nuxt 4 avec SSR complet. Le site présente les projets, services et compétences de Killian Dalcin, développeur freelance, avec support bilingue FR/EN. L'objectif est un SEO parfait et un développement rapide via des composants prêts à l'emploi (Nuxt UI v3).
|
Migration complète d'un portfolio freelance de Vue 3 SPA vers Nuxt 4 avec SSR complet. Le site présente les projets, services et compétences de Killian' Dalcin, développeur freelance, avec support bilingue FR/EN. L'objectif est un SEO parfait et un développement rapide via des composants prêts à l'emploi (Nuxt UI v3).
|
||||||
|
|
||||||
**Core Value:** Chaque page du portfolio doit être crawlable par les moteurs de recherche sans JavaScript côté client — le SSR est la raison d'être de cette migration.
|
**Core Value:** Chaque page du portfolio doit être crawlable par les moteurs de recherche sans JavaScript côté client — le SSR est la raison d'être de cette migration.
|
||||||
|
|
||||||
|
|||||||
+21
-24
@@ -1,32 +1,29 @@
|
|||||||
# Stage 1: Build the Vue.js application
|
# Stage 1: Build
|
||||||
FROM node:22-alpine AS build-stage
|
FROM node:22-alpine AS builder
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
|
|
||||||
# Copy package.json and package-lock.json (or yarn.lock)
|
# Install pnpm via corepack
|
||||||
COPY package*.json ./
|
RUN corepack enable && corepack prepare pnpm@latest --activate
|
||||||
|
|
||||||
# Install dependencies
|
# Copy manifests first for layer caching
|
||||||
RUN npm install
|
COPY package.json pnpm-lock.yaml ./
|
||||||
|
|
||||||
# Copy the rest of your application's source code
|
# Install all dependencies (including devDeps needed for build)
|
||||||
|
RUN pnpm install --frozen-lockfile
|
||||||
|
|
||||||
|
# Copy source and build
|
||||||
COPY . .
|
COPY . .
|
||||||
|
RUN pnpm build
|
||||||
|
|
||||||
# Build the application
|
# Stage 2: Runtime
|
||||||
# The command is taken from your "scripts" in package.json
|
FROM node:22-alpine AS runner
|
||||||
RUN npm run build
|
WORKDIR /app
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV HOST=0.0.0.0
|
||||||
|
ENV PORT=3000
|
||||||
|
|
||||||
# Stage 2: Serve the application with a lightweight web server
|
# Nuxt SSR bundles all server deps into .output/server/
|
||||||
FROM nginx:stable-alpine AS production-stage
|
COPY --from=builder /app/.output /app/.output
|
||||||
|
|
||||||
# Copy the built files from the build stage
|
EXPOSE 3000
|
||||||
COPY --from=build-stage /app/dist /usr/share/nginx/html
|
CMD ["node", "/app/.output/server/index.mjs"]
|
||||||
|
|
||||||
# Copy the nginx configuration file
|
|
||||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
|
||||||
|
|
||||||
# Expose port 80 to the outside world
|
|
||||||
EXPOSE 80
|
|
||||||
|
|
||||||
# Command to run nginx in the foreground
|
|
||||||
CMD ["nginx", "-g", "daemon off;"]
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ A modern, responsive personal portfolio website showcasing professional skills,
|
|||||||
|
|
||||||
## 🎯 Purpose
|
## 🎯 Purpose
|
||||||
|
|
||||||
This portfolio serves as a professional showcase for **Killian Dal Cin**, a Full Stack Developer specializing in modern web development. The website features:
|
This portfolio serves as a professional showcase for **Killian' DAL-CIN**, a Full Stack Developer specializing in modern web development. The website features:
|
||||||
|
|
||||||
- **Professional Presentation**: Clean, modern design highlighting skills and experience
|
- **Professional Presentation**: Clean, modern design highlighting skills and experience
|
||||||
- **Project Showcase**: Interactive gallery of completed projects with detailed case studies
|
- **Project Showcase**: Interactive gallery of completed projects with detailed case studies
|
||||||
@@ -220,7 +220,7 @@ This project is personal portfolio software. Please respect the intellectual pro
|
|||||||
|
|
||||||
## 📧 Contact
|
## 📧 Contact
|
||||||
|
|
||||||
**Killian Dal Cin**
|
**Killian' DAL-CIN**
|
||||||
|
|
||||||
- Email: contact@killiandalcin.fr
|
- Email: contact@killiandalcin.fr
|
||||||
- LinkedIn: [killian-dalcin](https://linkedin.com/in/killian-dal-cin)
|
- LinkedIn: [killian-dalcin](https://linkedin.com/in/killian-dal-cin)
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
export default defineAppConfig({
|
||||||
|
ui: {
|
||||||
|
colors: {
|
||||||
|
primary: 'brand',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
+18
@@ -0,0 +1,18 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
const { locale } = useI18n()
|
||||||
|
const head = useLocaleHead({ seo: true })
|
||||||
|
|
||||||
|
useHead({
|
||||||
|
htmlAttrs: { lang: locale },
|
||||||
|
link: computed(() => head.value.link || []),
|
||||||
|
meta: computed(() => head.value.meta || []),
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<UApp>
|
||||||
|
<NuxtLayout>
|
||||||
|
<NuxtPage />
|
||||||
|
</NuxtLayout>
|
||||||
|
</UApp>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
@import "@nuxt/ui";
|
||||||
|
@plugin "@tailwindcss/typography";
|
||||||
|
|
||||||
|
/* Offset anchor scroll for sticky header (h-16 = 64px + 16px breathing room) */
|
||||||
|
.prose :is(h1, h2, h3, h4, h5, h6) {
|
||||||
|
scroll-margin-top: 5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Code blocks: single dark theme (github-dark), background transparent — ProsePre div owns #0d1117 */
|
||||||
|
pre span {
|
||||||
|
background-color: transparent !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
@theme {
|
||||||
|
--color-brand-50: #f0faf0;
|
||||||
|
--color-brand-100: #dcf3dc;
|
||||||
|
--color-brand-200: #bbe8bb;
|
||||||
|
--color-brand-300: #8dd98d;
|
||||||
|
--color-brand-400: #a3d6a3;
|
||||||
|
--color-brand-500: #85cb85;
|
||||||
|
--color-brand-600: #5aaa5a;
|
||||||
|
--color-brand-700: #3f8c3f;
|
||||||
|
--color-brand-800: #2e6b2e;
|
||||||
|
--color-brand-900: #1f4f1f;
|
||||||
|
--color-brand-950: #122d12;
|
||||||
|
}
|
||||||
@@ -0,0 +1,93 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { z } from 'zod'
|
||||||
|
import type { FormSubmitEvent } from '@nuxt/ui'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const toast = useToast()
|
||||||
|
const loading = ref(false)
|
||||||
|
|
||||||
|
const schema = z.object({
|
||||||
|
name: z.string().min(2, t('contact.form.validation.nameMin')),
|
||||||
|
email: z.string().email(t('contact.form.validation.emailInvalid')),
|
||||||
|
message: z.string().min(10, t('contact.form.validation.messageMin')),
|
||||||
|
})
|
||||||
|
|
||||||
|
type Schema = z.output<typeof schema>
|
||||||
|
|
||||||
|
const state = reactive({
|
||||||
|
name: '',
|
||||||
|
email: '',
|
||||||
|
message: '',
|
||||||
|
})
|
||||||
|
|
||||||
|
async function onSubmit(event: FormSubmitEvent<Schema>) {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
await $fetch('/api/contact', { method: 'POST', body: event.data })
|
||||||
|
toast.add({
|
||||||
|
title: t('contact.form.success'),
|
||||||
|
color: 'success',
|
||||||
|
icon: 'i-lucide-check',
|
||||||
|
})
|
||||||
|
state.name = ''
|
||||||
|
state.email = ''
|
||||||
|
state.message = ''
|
||||||
|
} catch {
|
||||||
|
toast.add({
|
||||||
|
title: t('contact.form.error'),
|
||||||
|
color: 'error',
|
||||||
|
icon: 'i-lucide-alert-circle',
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<UForm :schema="schema" :state="state" class="space-y-5" @submit="onSubmit">
|
||||||
|
<UFormField :label="t('contact.form.name')" name="name">
|
||||||
|
<UInput
|
||||||
|
v-model="state.name"
|
||||||
|
:placeholder="t('contact.form.name')"
|
||||||
|
icon="i-lucide-user"
|
||||||
|
size="lg"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</UFormField>
|
||||||
|
|
||||||
|
<UFormField :label="t('contact.form.email')" name="email">
|
||||||
|
<UInput
|
||||||
|
v-model="state.email"
|
||||||
|
type="email"
|
||||||
|
:placeholder="t('contact.form.email')"
|
||||||
|
icon="i-lucide-mail"
|
||||||
|
size="lg"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</UFormField>
|
||||||
|
|
||||||
|
<UFormField :label="t('contact.form.message')" name="message">
|
||||||
|
<UTextarea
|
||||||
|
v-model="state.message"
|
||||||
|
:rows="6"
|
||||||
|
:placeholder="t('contact.form.message')"
|
||||||
|
size="lg"
|
||||||
|
class="w-full"
|
||||||
|
/>
|
||||||
|
</UFormField>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
:disabled="loading"
|
||||||
|
class="inline-flex items-center justify-center gap-2 w-full px-6 py-3.5 rounded-xl bg-brand-500 hover:bg-brand-600 disabled:opacity-60 disabled:cursor-not-allowed text-white font-semibold text-sm transition-all duration-200 shadow-lg shadow-brand-500/25 hover:shadow-brand-500/40"
|
||||||
|
>
|
||||||
|
<UIcon v-if="loading" name="i-lucide-loader-2" class="w-4 h-4 animate-spin" />
|
||||||
|
<template v-if="loading">{{ t('contact.form.sending') }}</template>
|
||||||
|
<template v-else>
|
||||||
|
{{ t('contact.form.submit') }}
|
||||||
|
<UIcon name="i-lucide-send" class="w-4 h-4" />
|
||||||
|
</template>
|
||||||
|
</button>
|
||||||
|
</UForm>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { Project } from '~~/shared/types'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
project: Project
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>()
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
const translatedCategory = computed(() => {
|
||||||
|
if (!props.project.category) return ''
|
||||||
|
const categoryKey = props.project.category.replace(/\s+/g, '').toLowerCase()
|
||||||
|
return t(`projects.categories.${categoryKey}`, props.project.category)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<article
|
||||||
|
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/CreativeWork"
|
||||||
|
>
|
||||||
|
<!-- Image -->
|
||||||
|
<NuxtLink :to="`/project/${project.id}`" class="block relative overflow-hidden">
|
||||||
|
<NuxtImg
|
||||||
|
:src="project.image"
|
||||||
|
:alt="`${project.title} - ${project.description.slice(0, 60)}...`"
|
||||||
|
loading="lazy"
|
||||||
|
format="webp"
|
||||||
|
width="400"
|
||||||
|
height="300"
|
||||||
|
class="w-full h-52 object-cover transition-transform duration-500 group-hover:scale-105"
|
||||||
|
itemprop="image"
|
||||||
|
/>
|
||||||
|
<!-- Gradient overlay -->
|
||||||
|
<div class="absolute inset-0 bg-gradient-to-t from-black/70 via-black/20 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300 flex items-end justify-center pb-5">
|
||||||
|
<span class="text-white text-sm font-semibold flex items-center gap-1.5 translate-y-2 group-hover:translate-y-0 transition-transform duration-300">
|
||||||
|
{{ t('projects.buttons.viewProject') }}
|
||||||
|
<UIcon name="i-lucide-arrow-right" class="w-4 h-4" />
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</NuxtLink>
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
<div class="p-5 sm:p-6 flex flex-col gap-3">
|
||||||
|
<!-- Category & Date -->
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<UBadge v-if="project.category" color="primary" variant="subtle" itemprop="genre">
|
||||||
|
{{ translatedCategory }}
|
||||||
|
</UBadge>
|
||||||
|
<time v-if="project.date" class="text-xs text-gray-400 dark:text-gray-500 font-mono" :datetime="project.date" itemprop="dateCreated">
|
||||||
|
{{ project.date }}
|
||||||
|
</time>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Title -->
|
||||||
|
<h3 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="name">
|
||||||
|
{{ project.title }}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<!-- Description -->
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400 line-clamp-2 leading-relaxed" itemprop="description">
|
||||||
|
{{ project.description }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Technologies -->
|
||||||
|
<div v-if="project.technologies?.length" class="flex flex-wrap gap-1.5 pt-2" itemprop="keywords">
|
||||||
|
<span
|
||||||
|
v-for="tech in project.technologies.slice(0, 3)"
|
||||||
|
:key="tech"
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
{{ tech }}
|
||||||
|
</span>
|
||||||
|
<span v-if="project.technologies.length > 3" class="text-[11px] px-2.5 py-1 rounded-full bg-gray-100 dark:bg-gray-800/80 text-gray-500 dark:text-gray-500 font-medium border border-gray-200/50 dark:border-gray-700/30">
|
||||||
|
+{{ project.technologies.length - 3 }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Hidden SEO link -->
|
||||||
|
<NuxtLink
|
||||||
|
:to="`/project/${project.id}`"
|
||||||
|
class="absolute inset-0 z-10"
|
||||||
|
:aria-label="`${t('projects.buttons.viewProject')} - ${project.title}`"
|
||||||
|
itemprop="url"
|
||||||
|
/>
|
||||||
|
</article>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
interface Props {
|
||||||
|
gallery: string[]
|
||||||
|
projectTitle: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>()
|
||||||
|
|
||||||
|
const isOpen = ref(false)
|
||||||
|
const currentIndex = ref(0)
|
||||||
|
const carouselRef = useTemplateRef('carousel')
|
||||||
|
|
||||||
|
function openGallery(index: number) {
|
||||||
|
currentIndex.value = index
|
||||||
|
isOpen.value = true
|
||||||
|
nextTick(() => {
|
||||||
|
carouselRef.value?.emblaApi?.scrollTo(index, true)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function goTo(index: number) {
|
||||||
|
currentIndex.value = index
|
||||||
|
carouselRef.value?.emblaApi?.scrollTo(index, true)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKeydown(e: KeyboardEvent) {
|
||||||
|
if (!isOpen.value) return
|
||||||
|
if (e.key === 'ArrowRight') carouselRef.value?.emblaApi?.scrollNext()
|
||||||
|
if (e.key === 'ArrowLeft') carouselRef.value?.emblaApi?.scrollPrev()
|
||||||
|
if (e.key === 'Escape') isOpen.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => document.addEventListener('keydown', onKeydown))
|
||||||
|
onUnmounted(() => document.removeEventListener('keydown', onKeydown))
|
||||||
|
|
||||||
|
defineExpose({ openGallery })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<UModal v-model:open="isOpen" fullscreen>
|
||||||
|
<template #content>
|
||||||
|
<div class="flex flex-col items-center justify-center h-full p-4 gap-4" @click.self="isOpen = false">
|
||||||
|
<div class="flex items-center justify-between w-full max-w-4xl">
|
||||||
|
<h3 class="text-lg font-semibold">{{ projectTitle }}</h3>
|
||||||
|
<UButton
|
||||||
|
icon="i-lucide-x"
|
||||||
|
variant="ghost"
|
||||||
|
size="lg"
|
||||||
|
:aria-label="'Close gallery'"
|
||||||
|
@click="isOpen = false"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<UCarousel
|
||||||
|
ref="carousel"
|
||||||
|
v-slot="{ item }"
|
||||||
|
:items="props.gallery"
|
||||||
|
arrows
|
||||||
|
loop
|
||||||
|
class="w-full max-w-4xl"
|
||||||
|
@select="(i: number) => (currentIndex = i)"
|
||||||
|
>
|
||||||
|
<NuxtImg
|
||||||
|
:src="item"
|
||||||
|
:alt="`${projectTitle} - Image ${currentIndex + 1}`"
|
||||||
|
loading="lazy"
|
||||||
|
format="webp"
|
||||||
|
class="w-full h-auto max-h-[70vh] object-contain"
|
||||||
|
/>
|
||||||
|
</UCarousel>
|
||||||
|
|
||||||
|
<!-- Thumbnails -->
|
||||||
|
<div class="flex gap-2 justify-center flex-wrap">
|
||||||
|
<button
|
||||||
|
v-for="(img, i) in props.gallery"
|
||||||
|
:key="i"
|
||||||
|
:class="[
|
||||||
|
'rounded overflow-hidden border-2 transition-all',
|
||||||
|
i === currentIndex ? 'border-primary ring-2 ring-primary' : 'border-transparent opacity-60 hover:opacity-100',
|
||||||
|
]"
|
||||||
|
@click="goTo(i)"
|
||||||
|
>
|
||||||
|
<NuxtImg :src="img" width="80" height="60" class="object-cover" loading="lazy" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-sm text-muted">{{ currentIndex + 1 }} / {{ props.gallery.length }}</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</UModal>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { Technology } from '~~/shared/types'
|
||||||
|
import { techStack } from '~/data/techstack'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
tech: Technology | string
|
||||||
|
showLevel?: boolean
|
||||||
|
showImage?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
showLevel: true,
|
||||||
|
showImage: true,
|
||||||
|
})
|
||||||
|
|
||||||
|
const techMapping: Record<string, string> = {
|
||||||
|
'Three.js': 'JavaScript',
|
||||||
|
'WebGL': 'JavaScript',
|
||||||
|
'Discord.js': 'JavaScript',
|
||||||
|
'Express': 'Node.js',
|
||||||
|
'Canvas': 'JavaScript',
|
||||||
|
'Insta.js': 'JavaScript',
|
||||||
|
'Instagram API': 'JavaScript',
|
||||||
|
'Crowdin API': 'JavaScript',
|
||||||
|
'Cron': 'Node.js',
|
||||||
|
}
|
||||||
|
|
||||||
|
const techData = computed((): Technology => {
|
||||||
|
if (typeof props.tech !== 'string') {
|
||||||
|
return props.tech
|
||||||
|
}
|
||||||
|
|
||||||
|
const techName = props.tech
|
||||||
|
const allTechs = Object.values(techStack).flat()
|
||||||
|
|
||||||
|
let found = allTechs.find((t) => t.name.toLowerCase() === techName.toLowerCase())
|
||||||
|
|
||||||
|
const mapped = techMapping[techName]
|
||||||
|
if (!found && mapped) {
|
||||||
|
found = allTechs.find((t) => t.name.toLowerCase() === mapped.toLowerCase())
|
||||||
|
}
|
||||||
|
|
||||||
|
return found ?? { name: techName, image: '', level: 'Intermediate' as const }
|
||||||
|
})
|
||||||
|
|
||||||
|
const levelColor = computed(() => {
|
||||||
|
switch (techData.value.level) {
|
||||||
|
case 'Advanced':
|
||||||
|
return 'success' as const
|
||||||
|
case 'Intermediate':
|
||||||
|
return 'primary' as const
|
||||||
|
case 'Beginner':
|
||||||
|
return 'neutral' as const
|
||||||
|
default:
|
||||||
|
return 'neutral' as const
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="inline-flex items-center gap-2 px-3 py-1.5 rounded-lg bg-gray-50 dark:bg-gray-800 border border-gray-200 dark:border-gray-700/50 transition-colors hover:border-brand-500/30"
|
||||||
|
itemscope
|
||||||
|
itemtype="https://schema.org/ComputerLanguage"
|
||||||
|
>
|
||||||
|
<NuxtImg
|
||||||
|
v-if="showImage && techData.image"
|
||||||
|
:src="techData.image"
|
||||||
|
:alt="`${techData.name} logo`"
|
||||||
|
width="20"
|
||||||
|
height="20"
|
||||||
|
loading="lazy"
|
||||||
|
class="shrink-0"
|
||||||
|
itemprop="image"
|
||||||
|
/>
|
||||||
|
<span class="text-sm font-medium text-gray-700 dark:text-gray-300" itemprop="name">{{ techData.name }}</span>
|
||||||
|
<UBadge v-if="showLevel" :color="levelColor" variant="subtle" size="xs">
|
||||||
|
{{ techData.level }}
|
||||||
|
</UBadge>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
interface Props {
|
||||||
|
type?: 'info' | 'warning' | 'tip' | 'danger'
|
||||||
|
}
|
||||||
|
const props = withDefaults(defineProps<Props>(), { type: 'info' })
|
||||||
|
|
||||||
|
const styles = {
|
||||||
|
info: {
|
||||||
|
wrapper: 'border-blue-500 bg-blue-50 dark:bg-blue-950/40',
|
||||||
|
icon: 'text-blue-500',
|
||||||
|
text: 'text-blue-900 dark:text-blue-100',
|
||||||
|
},
|
||||||
|
warning: {
|
||||||
|
wrapper: 'border-amber-500 bg-amber-50 dark:bg-amber-950/40',
|
||||||
|
icon: 'text-amber-500',
|
||||||
|
text: 'text-amber-900 dark:text-amber-100',
|
||||||
|
},
|
||||||
|
tip: {
|
||||||
|
wrapper: 'border-emerald-500 bg-emerald-50 dark:bg-emerald-950/40',
|
||||||
|
icon: 'text-emerald-500',
|
||||||
|
text: 'text-emerald-900 dark:text-emerald-100',
|
||||||
|
},
|
||||||
|
danger: {
|
||||||
|
wrapper: 'border-red-500 bg-red-50 dark:bg-red-950/40',
|
||||||
|
icon: 'text-red-500',
|
||||||
|
text: 'text-red-900 dark:text-red-100',
|
||||||
|
},
|
||||||
|
} as const
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
:class="[
|
||||||
|
'not-prose my-4 flex items-start gap-3 rounded-r-lg border-l-4 px-4 py-3',
|
||||||
|
styles[props.type].wrapper,
|
||||||
|
]"
|
||||||
|
role="note"
|
||||||
|
>
|
||||||
|
<!-- Info -->
|
||||||
|
<svg
|
||||||
|
v-if="props.type === 'info'"
|
||||||
|
:class="['mt-0.5 size-5 shrink-0', styles[props.type].icon]"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill-rule="evenodd"
|
||||||
|
d="M2.25 12c0-5.385 4.365-9.75 9.75-9.75s9.75 4.365 9.75 9.75-4.365 9.75-9.75 9.75S2.25 17.385 2.25 12zm8.706-1.442c1.146-.573 2.437.463 2.126 1.706l-.709 2.836.042-.02a.75.75 0 01.67 1.34l-.04.022c-1.147.573-2.438-.463-2.127-1.706l.71-2.836-.042.02a.75.75 0 11-.671-1.34l.041-.022zM12 9a.75.75 0 100-1.5.75.75 0 000 1.5z"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<!-- Warning -->
|
||||||
|
<svg
|
||||||
|
v-else-if="props.type === 'warning'"
|
||||||
|
:class="['mt-0.5 size-5 shrink-0', styles[props.type].icon]"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill-rule="evenodd"
|
||||||
|
d="M9.401 3.003c1.155-2 4.043-2 5.197 0l7.355 12.748c1.154 2-.29 4.5-2.599 4.5H4.645c-2.309 0-3.752-2.5-2.598-4.5L9.4 3.003zM12 8.25a.75.75 0 01.75.75v3.75a.75.75 0 01-1.5 0V9a.75.75 0 01.75-.75zm0 8.25a.75.75 0 100-1.5.75.75 0 000 1.5z"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<!-- Tip -->
|
||||||
|
<svg
|
||||||
|
v-else-if="props.type === 'tip'"
|
||||||
|
:class="['mt-0.5 size-5 shrink-0', styles[props.type].icon]"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="currentColor"
|
||||||
|
>
|
||||||
|
<path d="M12 .75a8.25 8.25 0 00-4.135 15.39c.686.398 1.115 1.008 1.134 1.623a.75.75 0 00.577.706c.352.083.71.148 1.074.195.323.041.6-.218.6-.544v-4.661a6.75 6.75 0 01-.937-.171.75.75 0 11.374-1.453 5.261 5.261 0 002.626 0 .75.75 0 11.374 1.452 6.76 6.76 0 01-.937.172v4.66c0 .327.277.586.6.545.364-.047.722-.112 1.074-.195a.75.75 0 00.577-.706c.02-.615.448-1.225 1.134-1.623A8.25 8.25 0 0012 .75z" />
|
||||||
|
<path
|
||||||
|
fill-rule="evenodd"
|
||||||
|
d="M9.013 19.9a.75.75 0 01.877-.597 11.319 11.319 0 004.22 0 .75.75 0 11.28 1.473 12.819 12.819 0 01-4.78 0 .75.75 0 01-.597-.876zM9.754 22.344a.75.75 0 01.824-.668 13.682 13.682 0 002.844 0 .75.75 0 11.156 1.492 15.156 15.156 0 01-3.156 0 .75.75 0 01-.668-.824z"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<!-- Danger -->
|
||||||
|
<svg
|
||||||
|
v-else-if="props.type === 'danger'"
|
||||||
|
:class="['mt-0.5 size-5 shrink-0', styles[props.type].icon]"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill-rule="evenodd"
|
||||||
|
d="M12 2.25c-5.385 0-9.75 4.365-9.75 9.75s4.365 9.75 9.75 9.75 9.75-4.365 9.75-9.75S17.385 2.25 12 2.25zm-1.72 6.97a.75.75 0 10-1.06 1.06L10.94 12l-1.72 1.72a.75.75 0 101.06 1.06L12 13.06l1.72 1.72a.75.75 0 101.06-1.06L13.06 12l1.72-1.72a.75.75 0 10-1.06-1.06L12 10.94l-1.72-1.72z"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<div :class="['text-sm leading-relaxed', styles[props.type].text]">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
interface Props {
|
||||||
|
color?: 'gray' | 'blue' | 'green' | 'red' | 'yellow' | 'purple' | 'orange'
|
||||||
|
}
|
||||||
|
const props = withDefaults(defineProps<Props>(), { color: 'gray' })
|
||||||
|
|
||||||
|
const colorClass = {
|
||||||
|
gray: 'bg-neutral-100 text-neutral-700 dark:bg-neutral-800 dark:text-neutral-300',
|
||||||
|
blue: 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300',
|
||||||
|
green: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300',
|
||||||
|
red: 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300',
|
||||||
|
yellow: 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300',
|
||||||
|
purple: 'bg-violet-100 text-violet-700 dark:bg-violet-900/40 dark:text-violet-300',
|
||||||
|
orange: 'bg-orange-100 text-orange-700 dark:bg-orange-900/40 dark:text-orange-300',
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<span
|
||||||
|
:class="[
|
||||||
|
'inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium font-mono',
|
||||||
|
colorClass[props.color],
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<slot />
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
<template>
|
||||||
|
<div class="not-prose clear-both" />
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
interface Props {
|
||||||
|
cols?: 2 | 3 | 4
|
||||||
|
gap?: 'sm' | 'md' | 'lg'
|
||||||
|
}
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
cols: 2,
|
||||||
|
gap: 'md',
|
||||||
|
})
|
||||||
|
|
||||||
|
const gridClass = computed(() => {
|
||||||
|
const cols = { 2: 'md:grid-cols-2', 3: 'md:grid-cols-3', 4: 'md:grid-cols-4' }[props.cols]
|
||||||
|
const gap = { sm: 'gap-4', md: 'gap-6', lg: 'gap-10' }[props.gap]
|
||||||
|
return `not-prose my-6 grid grid-cols-1 ${cols} ${gap}`
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div :class="gridClass">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
interface Props {
|
||||||
|
summary?: string
|
||||||
|
open?: boolean
|
||||||
|
}
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
summary: 'Voir plus',
|
||||||
|
open: false,
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<details
|
||||||
|
:open="props.open"
|
||||||
|
class="not-prose my-4 rounded-lg border border-neutral-200 dark:border-neutral-800 overflow-hidden"
|
||||||
|
>
|
||||||
|
<summary
|
||||||
|
class="flex cursor-pointer select-none items-center justify-between px-4 py-3
|
||||||
|
text-sm font-medium text-neutral-700 dark:text-neutral-300
|
||||||
|
hover:bg-neutral-50 dark:hover:bg-neutral-800/50 transition-colors list-none"
|
||||||
|
>
|
||||||
|
{{ props.summary }}
|
||||||
|
<svg
|
||||||
|
class="size-4 shrink-0 text-neutral-400 transition-transform details-arrow"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 20 20"
|
||||||
|
fill="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
fill-rule="evenodd"
|
||||||
|
d="M5.22 8.22a.75.75 0 0 1 1.06 0L10 11.94l3.72-3.72a.75.75 0 1 1 1.06 1.06l-4.25 4.25a.75.75 0 0 1-1.06 0L5.22 9.28a.75.75 0 0 1 0-1.06Z"
|
||||||
|
clip-rule="evenodd"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</summary>
|
||||||
|
<div
|
||||||
|
class="px-4 py-3 prose prose-neutral dark:prose-invert max-w-none
|
||||||
|
prose-code:before:content-none prose-code:after:content-none
|
||||||
|
prose-pre:p-0 prose-pre:bg-transparent text-sm">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
details[open] .details-arrow {
|
||||||
|
transform: rotate(180deg);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
defineOptions({ inheritAttrs: false })
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
src: string
|
||||||
|
alt?: string
|
||||||
|
title?: string
|
||||||
|
caption?: string
|
||||||
|
align?: 'left' | 'right' | 'center' | 'full'
|
||||||
|
width?: string | number
|
||||||
|
height?: string | number
|
||||||
|
}
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
alt: '',
|
||||||
|
align: 'full',
|
||||||
|
})
|
||||||
|
|
||||||
|
const attrs = useAttrs()
|
||||||
|
|
||||||
|
// Use <span class="block"> instead of <figure> to avoid block-in-<p> invalid HTML
|
||||||
|
// that breaks SSR hydration (browser auto-removes <p> wrapper around block elements).
|
||||||
|
const wrapperClass = computed(() => {
|
||||||
|
const base = 'not-prose my-6'
|
||||||
|
|
||||||
|
if (attrs.class) return `${base} block`
|
||||||
|
|
||||||
|
switch (props.align) {
|
||||||
|
case 'left': return `${base} block float-left mr-6 mb-2 w-1/2 max-w-xs`
|
||||||
|
case 'right': return `${base} block float-right ml-6 mb-2 w-1/2 max-w-xs`
|
||||||
|
case 'center': return `${base} block mx-auto`
|
||||||
|
default: return `${base} block w-full`
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const wrapperStyle = computed(() => {
|
||||||
|
if (props.width && props.align !== 'full') return `width: ${props.width}px`
|
||||||
|
return undefined
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<span
|
||||||
|
v-bind="attrs"
|
||||||
|
:class="wrapperClass"
|
||||||
|
:style="wrapperStyle"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
:src="props.src"
|
||||||
|
:alt="props.alt"
|
||||||
|
:title="props.title || props.caption"
|
||||||
|
loading="lazy"
|
||||||
|
:class="props.align === 'full' && !attrs.class ? 'w-full rounded-lg' : 'rounded-lg'"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
v-if="props.caption"
|
||||||
|
class="mt-2 block text-center text-xs text-neutral-500 dark:text-neutral-400 italic"
|
||||||
|
>
|
||||||
|
{{ props.caption }}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
interface Props {
|
||||||
|
language?: string
|
||||||
|
filename?: string
|
||||||
|
highlights?: number[]
|
||||||
|
meta?: string
|
||||||
|
}
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
language: '',
|
||||||
|
filename: '',
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="not-prose group my-6 overflow-hidden rounded-lg bg-[#0d1117] ring-1 ring-white/10">
|
||||||
|
<!-- Header bar -->
|
||||||
|
<div
|
||||||
|
v-if="props.filename || props.language"
|
||||||
|
class="flex items-center justify-between border-b border-white/10 px-4 py-2"
|
||||||
|
>
|
||||||
|
<span class="text-xs font-medium text-neutral-400">
|
||||||
|
{{ props.filename || props.language }}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
v-if="props.language && !props.filename"
|
||||||
|
class="rounded bg-white/10 px-1.5 py-0.5 text-[10px] font-mono uppercase tracking-wide text-neutral-500"
|
||||||
|
>
|
||||||
|
{{ props.language }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Code content -->
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<pre class="shiki m-0 bg-transparent p-4 text-sm leading-relaxed"><slot /></pre>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
interface Props {
|
||||||
|
src: string
|
||||||
|
title?: string
|
||||||
|
aspect?: '16/9' | '4/3' | '1/1'
|
||||||
|
autoplay?: boolean
|
||||||
|
loop?: boolean
|
||||||
|
muted?: boolean
|
||||||
|
}
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
title: '',
|
||||||
|
aspect: '16/9',
|
||||||
|
autoplay: false,
|
||||||
|
loop: false,
|
||||||
|
muted: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const isYoutube = computed(() =>
|
||||||
|
/youtube\.com|youtu\.be/.test(props.src)
|
||||||
|
)
|
||||||
|
|
||||||
|
const youtubeId = computed(() => {
|
||||||
|
const match = props.src.match(
|
||||||
|
/(?:youtube\.com\/(?:watch\?v=|embed\/)|youtu\.be\/)([a-zA-Z0-9_-]{11})/
|
||||||
|
)
|
||||||
|
return match?.[1] ?? ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const youtubeUrl = computed(() => {
|
||||||
|
const params = new URLSearchParams({
|
||||||
|
...(props.autoplay ? { autoplay: '1' } : {}),
|
||||||
|
...(props.loop ? { loop: '1', playlist: youtubeId.value } : {}),
|
||||||
|
modestbranding: '1',
|
||||||
|
rel: '0',
|
||||||
|
})
|
||||||
|
return `https://www.youtube.com/embed/${youtubeId.value}?${params}`
|
||||||
|
})
|
||||||
|
|
||||||
|
const aspectClass = computed(() => ({
|
||||||
|
'16/9': 'aspect-video',
|
||||||
|
'4/3': 'aspect-[4/3]',
|
||||||
|
'1/1': 'aspect-square',
|
||||||
|
}[props.aspect]))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<figure class="not-prose my-6 w-full overflow-hidden rounded-lg bg-black">
|
||||||
|
<!-- YouTube embed -->
|
||||||
|
<iframe
|
||||||
|
v-if="isYoutube"
|
||||||
|
:src="youtubeUrl"
|
||||||
|
:title="props.title"
|
||||||
|
:class="['w-full', aspectClass]"
|
||||||
|
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||||
|
allowfullscreen
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
<!-- Local video -->
|
||||||
|
<video
|
||||||
|
v-else
|
||||||
|
:src="props.src"
|
||||||
|
:title="props.title"
|
||||||
|
:class="['w-full', aspectClass]"
|
||||||
|
:autoplay="props.autoplay"
|
||||||
|
:loop="props.loop"
|
||||||
|
:muted="props.muted || props.autoplay"
|
||||||
|
controls
|
||||||
|
playsinline
|
||||||
|
/>
|
||||||
|
<figcaption
|
||||||
|
v-if="props.title"
|
||||||
|
class="bg-neutral-900 px-4 py-2 text-center text-xs text-neutral-400 italic"
|
||||||
|
>
|
||||||
|
{{ props.title }}
|
||||||
|
</figcaption>
|
||||||
|
</figure>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,95 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
const { t } = useI18n()
|
||||||
|
const localePath = useLocalePath()
|
||||||
|
|
||||||
|
const socialLinks = [
|
||||||
|
{ name: 'gitea', url: 'https://gitea.kamisama.ovh/kayjaydee', icon: 'simple-icons:gitea', ariaKey: 'a11y.gitea' },
|
||||||
|
{ name: 'linkedin', url: 'https://linkedin.com/in/killian-dal-cin', icon: 'simple-icons:linkedin', ariaKey: 'a11y.linkedin' },
|
||||||
|
{ name: 'fiverr', url: 'https://www.fiverr.com/users/mr_kayjaydee', icon: 'simple-icons:fiverr', ariaKey: 'a11y.fiverr' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const quickLinks = computed(() => [
|
||||||
|
{ key: 'home', path: '/' },
|
||||||
|
{ key: 'projects', path: '/projects' },
|
||||||
|
{ key: 'about', path: '/about' },
|
||||||
|
{ key: 'contact', path: '/contact' },
|
||||||
|
{ key: 'fiverr', path: '/fiverr' },
|
||||||
|
])
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<footer class="border-t border-gray-200/80 dark:border-gray-800/50 bg-gray-50/80 dark:bg-gray-950/80">
|
||||||
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-16 md:py-20">
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-10 lg:gap-8">
|
||||||
|
<!-- Brand column -->
|
||||||
|
<div class="sm:col-span-2 lg:col-span-1 space-y-5">
|
||||||
|
<NuxtLink :to="localePath('/')" class="flex items-center gap-2.5 group">
|
||||||
|
<NuxtImg
|
||||||
|
src="/images/logo.webp" alt="Killian' DAL-CIN" width="36" height="36" loading="lazy"
|
||||||
|
class="rounded-lg transition-transform duration-300 group-hover:scale-110" />
|
||||||
|
<span class="text-lg font-bold text-gray-900 dark:text-white">Killian' DAL-CIN</span>
|
||||||
|
</NuxtLink>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400 leading-relaxed max-w-xs">
|
||||||
|
Full Stack Developer & Hytale Plugin Developer. Building modern web experiences and game plugins.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Navigation links -->
|
||||||
|
<div>
|
||||||
|
<h3 class="font-mono text-xs text-gray-400 dark:text-gray-500 uppercase tracking-widest mb-5">
|
||||||
|
Navigation
|
||||||
|
</h3>
|
||||||
|
<nav class="flex flex-col gap-3">
|
||||||
|
<NuxtLink
|
||||||
|
v-for="link in quickLinks" :key="link.key" :to="localePath(link.path)"
|
||||||
|
class="text-sm text-gray-600 dark:text-gray-400 hover:text-brand-500 dark:hover:text-brand-400 transition-colors duration-200">
|
||||||
|
{{ t(`nav.${link.key}`) }}
|
||||||
|
</NuxtLink>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Services links -->
|
||||||
|
<div>
|
||||||
|
<h3 class="font-mono text-xs text-gray-400 dark:text-gray-500 uppercase tracking-widest mb-5">
|
||||||
|
Services
|
||||||
|
</h3>
|
||||||
|
<nav class="flex flex-col gap-3">
|
||||||
|
<span class="text-sm text-gray-600 dark:text-gray-400">Web Development</span>
|
||||||
|
<span class="text-sm text-gray-600 dark:text-gray-400">Hytale Plugins</span>
|
||||||
|
<span class="text-sm text-gray-600 dark:text-gray-400">Consulting</span>
|
||||||
|
<span class="text-sm text-gray-600 dark:text-gray-400">Maintenance</span>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Connect -->
|
||||||
|
<div>
|
||||||
|
<h3 class="font-mono text-xs text-gray-400 dark:text-gray-500 uppercase tracking-widest mb-5">
|
||||||
|
Connect
|
||||||
|
</h3>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<a
|
||||||
|
v-for="link in socialLinks" :key="link.name" :href="link.url" target="_blank" rel="noopener noreferrer"
|
||||||
|
:aria-label="t(link.ariaKey)"
|
||||||
|
class="w-10 h-10 inline-flex items-center justify-center rounded-xl border border-gray-200/80 dark:border-gray-800/50 bg-white/60 dark:bg-gray-900/40 hover:border-brand-500/40 hover:bg-brand-500/10 dark:hover:bg-brand-500/10 transition-all duration-300 group">
|
||||||
|
<UIcon
|
||||||
|
:name="link.icon"
|
||||||
|
class="w-4.5 h-4.5 text-gray-500 dark:text-gray-400 group-hover:text-brand-500 dark:group-hover:text-brand-400 transition-colors" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Bottom bar -->
|
||||||
|
<div
|
||||||
|
class="mt-14 pt-8 border-t border-gray-200/60 dark:border-gray-800/40 flex flex-col sm:flex-row items-center justify-between gap-4">
|
||||||
|
<p class="text-sm text-gray-400 dark:text-gray-500 font-mono">
|
||||||
|
{{ t('footer.copyright') }}
|
||||||
|
</p>
|
||||||
|
<div class="flex items-center gap-1.5 text-xs text-gray-400 dark:text-gray-600">
|
||||||
|
<span class="w-1.5 h-1.5 rounded-full bg-brand-500 animate-pulse" />
|
||||||
|
<span>Built with Nuxt</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
const { t, locale, setLocale } = useI18n()
|
||||||
|
const localePath = useLocalePath()
|
||||||
|
const colorMode = useColorMode()
|
||||||
|
const route = useRoute()
|
||||||
|
const mobileOpen = ref(false)
|
||||||
|
|
||||||
|
const navLinks = computed(() => [
|
||||||
|
{ key: 'home', path: '/' },
|
||||||
|
{ key: 'hytale', path: '/hytale' },
|
||||||
|
{ key: 'projects', path: '/projects' },
|
||||||
|
{ key: 'about', path: '/about' },
|
||||||
|
{ key: 'contact', path: '/contact' },
|
||||||
|
{ key: 'fiverr', path: '/fiverr' },
|
||||||
|
])
|
||||||
|
|
||||||
|
function toggleLocale() {
|
||||||
|
setLocale(locale.value === 'fr' ? 'en' : 'fr')
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleTheme() {
|
||||||
|
colorMode.preference = colorMode.value === 'dark' ? 'light' : 'dark'
|
||||||
|
}
|
||||||
|
|
||||||
|
function isActive(path: string): boolean {
|
||||||
|
return route.path === localePath(path)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<header
|
||||||
|
class="sticky top-0 z-50 backdrop-blur-xl bg-white/80 dark:bg-gray-950/80 border-b border-gray-200/50 dark:border-gray-800/50">
|
||||||
|
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||||
|
<div class="flex items-center justify-between h-16">
|
||||||
|
<!-- Logo -->
|
||||||
|
<NuxtLink :to="localePath('/')" :aria-label="t('a11y.logoLabel')" class="flex items-center gap-2.5 shrink-0">
|
||||||
|
<NuxtImg
|
||||||
|
src="/images/logo.webp" alt="Killian' DAL-CIN" width="36" height="36" loading="eager"
|
||||||
|
class="rounded-lg" />
|
||||||
|
<span class="text-base font-semibold tracking-tight text-gray-900 dark:text-white">Killian'</span>
|
||||||
|
</NuxtLink>
|
||||||
|
|
||||||
|
<!-- Desktop nav -->
|
||||||
|
<nav class="hidden md:flex items-center gap-1" aria-label="Main navigation">
|
||||||
|
<NuxtLink
|
||||||
|
v-for="link in navLinks" :key="link.key" :to="localePath(link.path)"
|
||||||
|
:aria-current="isActive(link.path) ? 'page' : undefined"
|
||||||
|
class="px-3 py-2 text-sm font-medium rounded-lg transition-colors" :class="[
|
||||||
|
isActive(link.path)
|
||||||
|
? 'text-brand-600 dark:text-brand-400 bg-brand-50 dark:bg-brand-950/40'
|
||||||
|
: 'text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-white hover:bg-gray-100 dark:hover:bg-gray-800/60'
|
||||||
|
]">
|
||||||
|
{{ t(`nav.${link.key}`) }}
|
||||||
|
</NuxtLink>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<!-- Right actions -->
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<!-- Language toggle -->
|
||||||
|
<UButton variant="ghost" color="neutral" size="sm" :aria-label="t('a11y.langToggle')" @click="toggleLocale">
|
||||||
|
{{ locale === 'fr' ? 'EN' : 'FR' }}
|
||||||
|
</UButton>
|
||||||
|
|
||||||
|
<!-- Theme toggle -->
|
||||||
|
<UButton
|
||||||
|
variant="ghost" color="neutral" size="sm"
|
||||||
|
:icon="colorMode.value === 'dark' ? 'i-lucide-sun' : 'i-lucide-moon'"
|
||||||
|
:aria-label="colorMode.value === 'dark' ? t('a11y.themeDark') : t('a11y.themeLight')"
|
||||||
|
@click="toggleTheme" />
|
||||||
|
|
||||||
|
<!-- Mobile hamburger -->
|
||||||
|
<UButton
|
||||||
|
variant="ghost" color="neutral" size="sm" icon="i-lucide-menu" class="md:hidden"
|
||||||
|
:aria-label="t('a11y.openMenu')" @click="mobileOpen = true" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Mobile slideover -->
|
||||||
|
<USlideover v-model:open="mobileOpen" side="left" class="md:hidden">
|
||||||
|
<template #header>
|
||||||
|
<div class="flex items-center gap-2.5">
|
||||||
|
<NuxtImg src="/images/logo.webp" alt="Killian' DAL-CIN" width="32" height="32" class="rounded-lg" />
|
||||||
|
<span class="text-base font-semibold text-gray-900 dark:text-white">Killian</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #body>
|
||||||
|
<nav class="flex flex-col gap-1" aria-label="Mobile navigation">
|
||||||
|
<NuxtLink
|
||||||
|
v-for="link in navLinks" :key="link.key" :to="localePath(link.path)"
|
||||||
|
:aria-current="isActive(link.path) ? 'page' : undefined"
|
||||||
|
class="px-4 py-3 text-base font-medium rounded-lg transition-colors" :class="[
|
||||||
|
isActive(link.path)
|
||||||
|
? 'text-brand-600 dark:text-brand-400 bg-brand-50 dark:bg-brand-950/40'
|
||||||
|
: 'text-gray-700 dark:text-gray-300 hover:text-gray-900 dark:hover:text-white hover:bg-gray-100 dark:hover:bg-gray-800/60'
|
||||||
|
]" @click="mobileOpen = false">
|
||||||
|
{{ t(`nav.${link.key}`) }}
|
||||||
|
</NuxtLink>
|
||||||
|
</nav>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #footer>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<UButton variant="ghost" color="neutral" :aria-label="t('a11y.langToggle')" @click="toggleLocale">
|
||||||
|
{{ locale === 'fr' ? 'EN' : 'FR' }}
|
||||||
|
</UButton>
|
||||||
|
<UButton
|
||||||
|
variant="ghost" color="neutral" :icon="colorMode.value === 'dark' ? 'i-lucide-sun' : 'i-lucide-moon'"
|
||||||
|
:aria-label="colorMode.value === 'dark' ? t('a11y.themeDark') : t('a11y.themeLight')"
|
||||||
|
@click="toggleTheme" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</USlideover>
|
||||||
|
</header>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
const { t } = useI18n()
|
||||||
|
const localePath = useLocalePath()
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
title?: string
|
||||||
|
subtitle?: string
|
||||||
|
primaryText?: string
|
||||||
|
primaryTo?: string
|
||||||
|
secondaryText?: string
|
||||||
|
secondaryTo?: string
|
||||||
|
external?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = withDefaults(defineProps<Props>(), {
|
||||||
|
title: '',
|
||||||
|
subtitle: '',
|
||||||
|
primaryText: '',
|
||||||
|
primaryTo: '/contact',
|
||||||
|
secondaryText: '',
|
||||||
|
secondaryTo: '/about',
|
||||||
|
external: false,
|
||||||
|
})
|
||||||
|
|
||||||
|
const resolvedTitle = computed(() => props.title || t('home.cta2.title'))
|
||||||
|
const resolvedSubtitle = computed(() => props.subtitle || t('home.cta2.subtitle'))
|
||||||
|
const resolvedPrimaryText = computed(() => props.primaryText || t('home.cta2.startProject'))
|
||||||
|
const resolvedSecondaryText = computed(() => props.secondaryText || t('home.cta2.learnMore'))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="py-24 md:py-32 px-4 sm:px-6 lg:px-8">
|
||||||
|
<div class="max-w-5xl mx-auto">
|
||||||
|
<div class="relative overflow-hidden rounded-3xl px-8 py-20 sm:px-16 sm:py-24 text-center border border-gray-200/60 dark:border-gray-800/40 bg-gray-50 dark:bg-gray-900">
|
||||||
|
<!-- Subtle dot pattern -->
|
||||||
|
<div
|
||||||
|
class="absolute inset-0 opacity-[0.03] dark:opacity-[0.06]" aria-hidden="true"
|
||||||
|
style="background-image: radial-gradient(circle, currentColor 1px, transparent 1px); background-size: 24px 24px;" />
|
||||||
|
<!-- Brand glow -->
|
||||||
|
<div class="absolute top-0 left-1/2 -translate-x-1/2 w-[600px] h-[300px] bg-brand-500/8 dark:bg-brand-500/15 rounded-full blur-3xl pointer-events-none" aria-hidden="true" />
|
||||||
|
|
||||||
|
<div class="relative z-10">
|
||||||
|
<h2 class="text-3xl sm:text-4xl lg:text-5xl font-bold text-gray-900 dark:text-white mb-5 tracking-tight">{{ resolvedTitle }}</h2>
|
||||||
|
<p class="text-lg sm:text-xl text-gray-500 dark:text-gray-400 mb-12 max-w-2xl mx-auto leading-relaxed">{{ resolvedSubtitle }}</p>
|
||||||
|
<div class="flex flex-col sm:flex-row gap-4 justify-center">
|
||||||
|
<NuxtLink
|
||||||
|
:to="external ? props.primaryTo : localePath(props.primaryTo)"
|
||||||
|
:target="external ? '_blank' : undefined"
|
||||||
|
:rel="external ? 'noopener noreferrer' : undefined"
|
||||||
|
class="inline-flex items-center justify-center gap-2 px-7 py-3.5 rounded-xl bg-brand-500 hover:bg-brand-600 text-white font-semibold text-sm transition-all duration-200 shadow-lg shadow-brand-500/25 hover:shadow-brand-500/40"
|
||||||
|
>
|
||||||
|
{{ resolvedPrimaryText }}
|
||||||
|
<UIcon :name="external ? 'i-lucide-external-link' : 'i-lucide-arrow-right'" class="w-4 h-4" />
|
||||||
|
</NuxtLink>
|
||||||
|
<NuxtLink
|
||||||
|
:to="localePath(props.secondaryTo)"
|
||||||
|
class="inline-flex items-center justify-center gap-2 px-7 py-3.5 rounded-xl border border-gray-300 dark:border-gray-700 text-gray-700 dark:text-gray-300 hover:border-brand-500/50 hover:text-brand-500 font-semibold text-sm transition-all duration-200"
|
||||||
|
>
|
||||||
|
{{ resolvedSecondaryText }}
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { FAQ } from '~~/shared/types'
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
faqs: FAQ[]
|
||||||
|
title: string
|
||||||
|
subtitle: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>()
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
const items = computed(() =>
|
||||||
|
props.faqs.map((faq) => ({
|
||||||
|
label: t(faq.questionKey),
|
||||||
|
content: t(faq.answerKey),
|
||||||
|
value: faq.questionKey,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="py-24 md:py-32 px-4 sm:px-6 lg:px-8">
|
||||||
|
<div class="max-w-3xl mx-auto">
|
||||||
|
<div class="text-center mb-16">
|
||||||
|
<span class="font-mono text-sm text-brand-500 dark:text-brand-400 tracking-wider">// faq</span>
|
||||||
|
<h2 class="text-3xl sm:text-4xl lg:text-5xl font-bold mt-3 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">{{ title }}</h2>
|
||||||
|
<p class="text-lg text-gray-500 dark:text-gray-400 mt-4 leading-relaxed">{{ subtitle }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="rounded-2xl border border-gray-200/80 dark:border-gray-800/50 bg-white/80 dark:bg-gray-900/60 backdrop-blur-sm p-3 sm:p-4 shadow-sm">
|
||||||
|
<UAccordion :items="items" type="single" collapsible />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
const { t } = useI18n()
|
||||||
|
const { featuredProjects } = useProjects()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="py-24 md:py-32 px-4 sm:px-6 lg:px-8">
|
||||||
|
<div class="max-w-7xl mx-auto">
|
||||||
|
<!-- Section header -->
|
||||||
|
<div class="flex flex-col md:flex-row md:items-end md:justify-between gap-6 mb-16">
|
||||||
|
<div>
|
||||||
|
<span class="font-mono text-sm text-brand-500 dark:text-brand-400 tracking-wider">// portfolio</span>
|
||||||
|
<h2 class="text-3xl sm:text-4xl lg:text-5xl font-bold mt-3 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('home.featuredProjects.title') }}</h2>
|
||||||
|
<p class="text-lg text-gray-500 dark:text-gray-400 mt-4 max-w-2xl leading-relaxed">{{ t('home.featuredProjects.subtitle') }}</p>
|
||||||
|
</div>
|
||||||
|
<UButton to="/projects" variant="ghost" trailing-icon="i-lucide-arrow-right" class="shrink-0 self-start md:self-auto group">
|
||||||
|
{{ t('home.cta.viewProjects') }}
|
||||||
|
</UButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Bento grid -->
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5 lg:gap-6 auto-rows-fr">
|
||||||
|
<div
|
||||||
|
v-for="(project, index) in featuredProjects"
|
||||||
|
:key="project.id"
|
||||||
|
:class="[
|
||||||
|
index === 0 ? 'md:col-span-2 md:row-span-1' : '',
|
||||||
|
]"
|
||||||
|
>
|
||||||
|
<ProjectCard :project="project" :class="{ 'h-full': true }" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,158 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { siteConfig } from '~/data/site'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const localePath = useLocalePath()
|
||||||
|
|
||||||
|
const discordUrl = siteConfig.social.find(s => s.name === 'Discord')?.url ?? '#'
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="relative min-h-[80vh] flex items-center overflow-hidden bg-white dark:bg-gray-950">
|
||||||
|
<!-- Dot grid background pattern -->
|
||||||
|
<div class="absolute inset-0 opacity-[0.03] dark:opacity-[0.06]" aria-hidden="true">
|
||||||
|
<div
|
||||||
|
class="absolute inset-0"
|
||||||
|
style="background-image: radial-gradient(circle, currentColor 1px, transparent 1px); background-size: 32px 32px;" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Gradient glow -->
|
||||||
|
<div
|
||||||
|
class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[800px] h-[600px] bg-brand-500/5 dark:bg-brand-500/10 rounded-full blur-3xl pointer-events-none"
|
||||||
|
aria-hidden="true" />
|
||||||
|
|
||||||
|
<div class="relative z-10 max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 w-full py-16 md:py-24">
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-12 lg:gap-16 items-center">
|
||||||
|
<!-- Left: Content -->
|
||||||
|
<div class="space-y-8">
|
||||||
|
<!-- Status badge -->
|
||||||
|
<div
|
||||||
|
class="inline-flex items-center gap-2 px-3 py-1.5 rounded-full bg-brand-500/10 dark:bg-brand-500/15 border border-brand-500/20">
|
||||||
|
<span class="relative flex h-2 w-2">
|
||||||
|
<span class="animate-ping absolute inline-flex h-full w-full rounded-full bg-brand-400 opacity-75" />
|
||||||
|
<span class="relative inline-flex rounded-full h-2 w-2 bg-brand-500" />
|
||||||
|
</span>
|
||||||
|
<span class="text-sm font-medium text-brand-700 dark:text-brand-400">{{ t('home.badge.available') }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<h1 class="text-4xl sm:text-5xl md:text-6xl lg:text-7xl font-extrabold tracking-tight leading-[1.1]">
|
||||||
|
<span
|
||||||
|
class="bg-gradient-to-r from-brand-500 via-brand-400 to-emerald-400 bg-clip-text text-transparent">{{
|
||||||
|
t('home.title') }}</span>
|
||||||
|
</h1>
|
||||||
|
<p class="text-lg sm:text-xl text-gray-600 dark:text-gray-400 max-w-xl leading-relaxed">
|
||||||
|
{{ t('home.subtitle') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- CTA Buttons -->
|
||||||
|
<div class="flex flex-col sm:flex-row gap-3">
|
||||||
|
<UButton
|
||||||
|
:to="discordUrl"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener"
|
||||||
|
color="primary"
|
||||||
|
icon="i-simple-icons-discord"
|
||||||
|
size="lg"
|
||||||
|
>
|
||||||
|
{{ t('home.cta.discord') }}
|
||||||
|
</UButton>
|
||||||
|
<UButton
|
||||||
|
:to="localePath('/contact')"
|
||||||
|
variant="outline"
|
||||||
|
size="lg"
|
||||||
|
>
|
||||||
|
{{ t('home.cta.contact') }}
|
||||||
|
</UButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Right: Decorative terminal/code block -->
|
||||||
|
<div class="hidden lg:block" aria-hidden="true">
|
||||||
|
<div class="relative">
|
||||||
|
<!-- Terminal window -->
|
||||||
|
<div
|
||||||
|
class="rounded-2xl border border-gray-200 dark:border-gray-800 bg-gray-50 dark:bg-gray-900 shadow-2xl shadow-brand-500/5 overflow-hidden">
|
||||||
|
<!-- Title bar -->
|
||||||
|
<div
|
||||||
|
class="flex items-center gap-2 px-4 py-3 bg-gray-100 dark:bg-gray-800/80 border-b border-gray-200 dark:border-gray-700/50">
|
||||||
|
<div class="flex gap-1.5">
|
||||||
|
<div class="w-3 h-3 rounded-full bg-red-400/80" />
|
||||||
|
<div class="w-3 h-3 rounded-full bg-yellow-400/80" />
|
||||||
|
<div class="w-3 h-3 rounded-full bg-green-400/80" />
|
||||||
|
</div>
|
||||||
|
<span class="text-xs text-gray-400 dark:text-gray-500 ml-2 font-mono">killian@dev ~</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Code content -->
|
||||||
|
<div class="p-5 font-mono text-sm leading-7 space-y-1">
|
||||||
|
<div>
|
||||||
|
<span class="text-brand-500">const</span>
|
||||||
|
<span class="text-gray-900 dark:text-white"> developer</span>
|
||||||
|
<span class="text-gray-500"> = </span>
|
||||||
|
<span class="text-gray-500">{</span>
|
||||||
|
</div>
|
||||||
|
<div class="pl-6">
|
||||||
|
<span class="text-purple-500 dark:text-purple-400">name</span><span class="text-gray-500">: </span>
|
||||||
|
<span class="text-amber-600 dark:text-amber-400">'Killian\' DAL-CIN'</span><span
|
||||||
|
class="text-gray-500">,</span>
|
||||||
|
</div>
|
||||||
|
<div class="pl-6">
|
||||||
|
<span class="text-purple-500 dark:text-purple-400">role</span><span class="text-gray-500">: </span>
|
||||||
|
<span class="text-amber-600 dark:text-amber-400">'{{ t('home.terminal.role') }}'</span><span
|
||||||
|
class="text-gray-500">,</span>
|
||||||
|
</div>
|
||||||
|
<div class="pl-6">
|
||||||
|
<span class="text-purple-500 dark:text-purple-400">skills</span><span class="text-gray-500">: [</span>
|
||||||
|
</div>
|
||||||
|
<div class="pl-10">
|
||||||
|
<span class="text-amber-600 dark:text-amber-400">'Vue.js'</span><span class="text-gray-500">, </span>
|
||||||
|
<span class="text-amber-600 dark:text-amber-400">'Nuxt'</span><span class="text-gray-500">, </span>
|
||||||
|
<span class="text-amber-600 dark:text-amber-400">'Node.js'</span><span class="text-gray-500">,</span>
|
||||||
|
</div>
|
||||||
|
<div class="pl-10">
|
||||||
|
<span class="text-amber-600 dark:text-amber-400">'Java'</span><span class="text-gray-500">, </span>
|
||||||
|
<span class="text-amber-600 dark:text-amber-400">'Hytale Plugins'</span><span
|
||||||
|
class="text-gray-500">,</span>
|
||||||
|
</div>
|
||||||
|
<div class="pl-10">
|
||||||
|
<span class="text-amber-600 dark:text-amber-400">'Docker'</span><span class="text-gray-500">, </span>
|
||||||
|
<span class="text-amber-600 dark:text-amber-400">'TypeScript'</span>
|
||||||
|
</div>
|
||||||
|
<div class="pl-6">
|
||||||
|
<span class="text-gray-500">],</span>
|
||||||
|
</div>
|
||||||
|
<div class="pl-6">
|
||||||
|
<span class="text-purple-500 dark:text-purple-400">available</span><span class="text-gray-500">:
|
||||||
|
</span>
|
||||||
|
<span class="text-brand-500">true</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<span class="text-gray-500">}</span>
|
||||||
|
</div>
|
||||||
|
<!-- Blinking cursor -->
|
||||||
|
<div class="mt-2 flex items-center gap-1">
|
||||||
|
<span class="text-brand-500">$</span>
|
||||||
|
<span class="w-2.5 h-5 bg-brand-500 animate-pulse" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Floating decoration cards -->
|
||||||
|
<div
|
||||||
|
class="absolute -top-4 -right-4 px-3 py-2 rounded-lg bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 shadow-lg text-xs font-medium flex items-center gap-2">
|
||||||
|
<span class="w-2 h-2 rounded-full bg-brand-500" />
|
||||||
|
<span class="text-gray-700 dark:text-gray-300">{{ t('home.stats.projects') }}</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="absolute -bottom-3 -left-3 px-3 py-2 rounded-lg bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 shadow-lg text-xs font-medium flex items-center gap-2">
|
||||||
|
<UIcon name="i-lucide-star" class="text-yellow-400 w-3.5 h-3.5" />
|
||||||
|
<span class="text-gray-700 dark:text-gray-300">{{ t('home.stats.rating') }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
const services = computed(() => [
|
||||||
|
{
|
||||||
|
icon: 'i-lucide-monitor',
|
||||||
|
title: t('home.services.webDev.title'),
|
||||||
|
description: t('home.services.webDev.description'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: 'i-lucide-smartphone',
|
||||||
|
title: t('home.services.mobileApps.title'),
|
||||||
|
description: t('home.services.mobileApps.description'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: 'i-lucide-zap',
|
||||||
|
title: t('home.services.optimization.title'),
|
||||||
|
description: t('home.services.optimization.description'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
icon: 'i-lucide-settings',
|
||||||
|
title: t('home.services.maintenance.title'),
|
||||||
|
description: t('home.services.maintenance.description'),
|
||||||
|
},
|
||||||
|
])
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="py-24 md:py-32 px-4 sm:px-6 lg:px-8 relative overflow-hidden">
|
||||||
|
<!-- Subtle background gradient -->
|
||||||
|
<div class="absolute inset-0 bg-gray-50/80 dark:bg-gray-900/40" aria-hidden="true" />
|
||||||
|
<div class="absolute top-0 right-0 w-[500px] h-[500px] bg-brand-500/5 dark:bg-brand-500/8 rounded-full blur-3xl -translate-y-1/2 translate-x-1/4 pointer-events-none" aria-hidden="true" />
|
||||||
|
|
||||||
|
<div class="relative z-10 max-w-7xl mx-auto">
|
||||||
|
<div class="text-center mb-16">
|
||||||
|
<span class="font-mono text-sm text-brand-500 dark:text-brand-400 tracking-wider">// services</span>
|
||||||
|
<h2 class="text-3xl sm:text-4xl lg:text-5xl font-bold mt-3 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('home.services.title') }}</h2>
|
||||||
|
<p class="text-lg text-gray-500 dark:text-gray-400 mt-4 max-w-2xl mx-auto leading-relaxed">{{ t('home.services.subtitle') }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-5 lg:gap-6">
|
||||||
|
<div
|
||||||
|
v-for="(service, index) in services"
|
||||||
|
:key="index"
|
||||||
|
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-7 sm:p-8 transition-all duration-300 hover:border-brand-500/40 hover:shadow-xl hover:shadow-brand-500/10 hover:-translate-y-1"
|
||||||
|
>
|
||||||
|
<!-- Hover glow effect -->
|
||||||
|
<div class="absolute inset-0 rounded-2xl bg-gradient-to-br from-brand-500/0 to-emerald-500/0 group-hover:from-brand-500/5 group-hover:to-emerald-500/5 transition-all duration-500 pointer-events-none" aria-hidden="true" />
|
||||||
|
|
||||||
|
<div class="relative z-10">
|
||||||
|
<!-- Icon -->
|
||||||
|
<div class="w-12 h-12 rounded-xl bg-brand-500/10 dark:bg-brand-500/15 flex items-center justify-center mb-6 transition-all duration-300 group-hover:bg-brand-500/20 group-hover:scale-110">
|
||||||
|
<UIcon :name="service.icon" class="text-brand-600 dark:text-brand-400 text-xl" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h3 class="text-xl font-bold text-gray-900 dark:text-white mb-3 group-hover:text-brand-600 dark:group-hover:text-brand-400 transition-colors">{{ service.title }}</h3>
|
||||||
|
<p class="text-gray-500 dark:text-gray-400 leading-relaxed">{{ service.description }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { testimonials, testimonialsStats } from '~/data/testimonials'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
featured?: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
const displayed = computed(() => props.featured ? testimonials.filter(t => t.featured) : testimonials)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="py-24 md:py-32 px-4 sm:px-6 lg:px-8">
|
||||||
|
<div class="max-w-7xl mx-auto">
|
||||||
|
<div class="text-center mb-16">
|
||||||
|
<span class="font-mono text-sm text-brand-500 dark:text-brand-400 tracking-wider">// testimonials</span>
|
||||||
|
<h2 class="text-3xl sm:text-4xl lg:text-5xl font-bold mt-3 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('testimonials.title') }}</h2>
|
||||||
|
<p class="text-lg text-gray-500 dark:text-gray-400 mt-4 max-w-2xl mx-auto leading-relaxed">{{ t('testimonials.subtitle') }}</p>
|
||||||
|
|
||||||
|
<!-- Stats row -->
|
||||||
|
<div class="flex justify-center gap-8 sm:gap-12 mt-12">
|
||||||
|
<div class="text-center group">
|
||||||
|
<p class="text-4xl sm:text-5xl font-black bg-gradient-to-b from-brand-400 to-brand-600 bg-clip-text text-transparent">{{ testimonialsStats.totalReviews }}</p>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400 mt-2 font-medium">{{ t('testimonials.stats.clients') }}</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 group">
|
||||||
|
<p class="text-4xl sm:text-5xl font-black bg-gradient-to-b from-brand-400 to-brand-600 bg-clip-text text-transparent">{{ testimonialsStats.averageRating }}/5</p>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400 mt-2 font-medium">{{ t('testimonials.stats.rating') }}</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 group">
|
||||||
|
<p class="text-4xl sm:text-5xl font-black bg-gradient-to-b from-brand-400 to-brand-600 bg-clip-text text-transparent">{{ testimonialsStats.projectsCompleted }}</p>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400 mt-2 font-medium">{{ t('testimonials.stats.projects') }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Horizontal scrolling testimonials -->
|
||||||
|
<div class="flex gap-5 overflow-x-auto overflow-y-visible pb-8 -mx-4 px-4 pt-2 snap-x snap-mandatory scrollbar-hide">
|
||||||
|
<div
|
||||||
|
v-for="(testimonial, index) in displayed"
|
||||||
|
:key="index"
|
||||||
|
class="flex-none w-[340px] sm:w-[400px] snap-start"
|
||||||
|
>
|
||||||
|
<div class="h-full 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-7 flex flex-col gap-5 transition-all duration-300 hover:border-brand-500/40 hover:shadow-xl hover:shadow-brand-500/10 hover:-translate-y-1">
|
||||||
|
<!-- Decorative quote mark -->
|
||||||
|
<div class="absolute top-5 right-6 text-6xl font-serif text-brand-500/10 dark:text-brand-400/10 leading-none select-none pointer-events-none" aria-hidden="true">"</div>
|
||||||
|
|
||||||
|
<!-- Rating stars -->
|
||||||
|
<div class="flex gap-1">
|
||||||
|
<UIcon
|
||||||
|
v-for="star in 5"
|
||||||
|
:key="star"
|
||||||
|
name="i-lucide-star"
|
||||||
|
class="w-4 h-4"
|
||||||
|
:class="star <= testimonial.rating ? 'text-yellow-400' : 'text-gray-300 dark:text-gray-700'"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Quote -->
|
||||||
|
<p class="text-sm text-gray-600 dark:text-gray-300 leading-relaxed flex-1 relative z-10">
|
||||||
|
"{{ testimonial.content }}"
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Author -->
|
||||||
|
<div class="flex items-center gap-3 pt-4 border-t border-gray-100 dark:border-gray-800/60">
|
||||||
|
<NuxtImg
|
||||||
|
:src="testimonial.avatar"
|
||||||
|
:alt="testimonial.name"
|
||||||
|
width="40"
|
||||||
|
height="40"
|
||||||
|
class="rounded-full ring-2 ring-brand-500/20 dark:ring-brand-400/20"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
<div>
|
||||||
|
<p class="font-semibold text-sm text-gray-900 dark:text-white">{{ testimonial.name }}</p>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 font-mono">{{ testimonial.project_type }} - {{ testimonial.platform }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.scrollbar-hide::-webkit-scrollbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
.scrollbar-hide {
|
||||||
|
-ms-overflow-style: none;
|
||||||
|
scrollbar-width: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
const { t } = useI18n()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="py-16 md:py-24 px-4 sm:px-6 lg:px-8">
|
||||||
|
<div class="max-w-4xl mx-auto text-center">
|
||||||
|
<span class="font-mono text-sm text-brand-500 dark:text-brand-400 tracking-wider">{{ t('hytale.hero.label') }}</span>
|
||||||
|
<h1 class="text-4xl sm:text-5xl md:text-6xl font-extrabold tracking-tight leading-[1.1] mt-4">
|
||||||
|
<span class="bg-gradient-to-r from-brand-500 via-brand-400 to-emerald-400 bg-clip-text text-transparent">{{ t('hytale.hero.title') }}</span>
|
||||||
|
</h1>
|
||||||
|
<p class="text-lg sm:text-xl text-gray-500 dark:text-gray-400 mt-6 max-w-2xl mx-auto leading-relaxed">
|
||||||
|
{{ t('hytale.hero.subtitle') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { hytalePricing } from '~/data/pricing'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
const localePath = useLocalePath()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="py-16 md:py-24 px-4 sm:px-6 lg:px-8">
|
||||||
|
<div class="max-w-7xl mx-auto">
|
||||||
|
<div class="text-center mb-16">
|
||||||
|
<span class="font-mono text-sm text-brand-500 dark:text-brand-400 tracking-wider">{{ t('hytale.pricing.label') }}</span>
|
||||||
|
<h2 class="text-3xl sm:text-4xl lg:text-5xl font-bold mt-3 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('hytale.pricing.title') }}</h2>
|
||||||
|
<p class="text-lg text-gray-500 dark:text-gray-400 mt-4 max-w-2xl mx-auto leading-relaxed">{{ t('hytale.pricing.subtitle') }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
<UCard
|
||||||
|
v-for="tier in hytalePricing"
|
||||||
|
:key="tier.id"
|
||||||
|
class="hover:border-brand-500/40 hover:shadow-xl hover:shadow-brand-500/10 hover:-translate-y-1 transition-all duration-200"
|
||||||
|
:class="{ 'ring-2 ring-brand-500': tier.featured }"
|
||||||
|
>
|
||||||
|
<div class="flex flex-col gap-4 p-2">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h3 class="text-xl font-bold text-gray-900 dark:text-white">{{ t(`hytale.pricing.${tier.id}.name`) }}</h3>
|
||||||
|
<UBadge v-if="tier.featured" color="primary" variant="subtle">{{ t('hytale.pricing.popular') }}</UBadge>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="text-3xl font-extrabold text-gray-900 dark:text-white">
|
||||||
|
<template v-if="tier.priceFixed">
|
||||||
|
<span class="text-sm font-normal text-gray-500 dark:text-gray-400">{{ t('hytale.pricing.from') }} </span>
|
||||||
|
{{ tier.priceFixed }}
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<span class="text-lg">{{ t('hytale.pricing.onQuote') }}</span>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">{{ t(`hytale.pricing.${tier.id}.description`) }}</p>
|
||||||
|
|
||||||
|
<ul class="space-y-2 flex-1">
|
||||||
|
<li v-for="i in 4" :key="i" class="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-300">
|
||||||
|
<UIcon name="i-lucide-check" class="w-4 h-4 text-brand-500 shrink-0" />
|
||||||
|
{{ t(`hytale.pricing.${tier.id}.features.${i - 1}`) }}
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<UButton
|
||||||
|
:to="localePath('/contact')"
|
||||||
|
:color="tier.featured ? 'primary' : 'neutral'"
|
||||||
|
:variant="tier.featured ? 'solid' : 'outline'"
|
||||||
|
block
|
||||||
|
class="mt-4"
|
||||||
|
>
|
||||||
|
{{ t('hytale.pricing.cta') }}
|
||||||
|
</UButton>
|
||||||
|
</div>
|
||||||
|
</UCard>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
const services = [
|
||||||
|
{ id: 'plugin', icon: 'i-lucide-puzzle' },
|
||||||
|
{ id: 'config', icon: 'i-lucide-settings' },
|
||||||
|
{ id: 'support', icon: 'i-lucide-shield-check' },
|
||||||
|
]
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section class="py-16 md:py-24 px-4 sm:px-6 lg:px-8 bg-gray-50/50 dark:bg-gray-900/20">
|
||||||
|
<div class="max-w-7xl mx-auto">
|
||||||
|
<div class="text-center mb-16">
|
||||||
|
<span class="font-mono text-sm text-brand-500 dark:text-brand-400 tracking-wider">{{ t('hytale.services.label') }}</span>
|
||||||
|
<h2 class="text-3xl sm:text-4xl lg:text-5xl font-bold mt-3 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('hytale.services.title') }}</h2>
|
||||||
|
<p class="text-lg text-gray-500 dark:text-gray-400 mt-4 max-w-2xl mx-auto leading-relaxed">{{ t('hytale.services.subtitle') }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
|
<UCard v-for="service in services" :key="service.id" class="hover:border-brand-500/40 hover:shadow-xl hover:shadow-brand-500/10 hover:-translate-y-1 transition-all duration-200">
|
||||||
|
<div class="flex flex-col items-center text-center gap-4 p-2">
|
||||||
|
<div class="w-12 h-12 rounded-xl bg-brand-500/10 dark:bg-brand-500/15 flex items-center justify-center">
|
||||||
|
<UIcon :name="service.icon" class="w-6 h-6 text-brand-500" />
|
||||||
|
</div>
|
||||||
|
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">{{ t(`hytale.services.items.${service.id}.title`) }}</h3>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400 leading-relaxed">{{ t(`hytale.services.items.${service.id}.description`) }}</p>
|
||||||
|
</div>
|
||||||
|
</UCard>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import { projects as projectsData } from '~/data/projects'
|
||||||
|
import type { Project } from '~~/shared/types'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Composable for accessing and filtering project data with i18n support.
|
||||||
|
* Titles, descriptions, and long descriptions are resolved via i18n keys.
|
||||||
|
*/
|
||||||
|
export function useProjects() {
|
||||||
|
const { t, te } = useI18n()
|
||||||
|
|
||||||
|
const projects = computed<Project[]>(() =>
|
||||||
|
projectsData.map((p) => ({
|
||||||
|
...p,
|
||||||
|
title: t(`projectData.${p.id}.title`),
|
||||||
|
description: t(`projectData.${p.id}.description`),
|
||||||
|
longDescription: te(`projectData.${p.id}.longDescription`)
|
||||||
|
? t(`projectData.${p.id}.longDescription`)
|
||||||
|
: undefined,
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
|
||||||
|
const featuredProjects = computed(() => projects.value.filter((p) => p.featured))
|
||||||
|
|
||||||
|
function filterByCategory(category: string) {
|
||||||
|
return computed(() => projects.value.filter((p) => p.category === category))
|
||||||
|
}
|
||||||
|
|
||||||
|
function search(query: Ref<string> | string) {
|
||||||
|
return computed(() => {
|
||||||
|
const q = typeof query === 'string' ? query : query.value
|
||||||
|
if (!q) return projects.value
|
||||||
|
const lower = q.toLowerCase()
|
||||||
|
return projects.value.filter(
|
||||||
|
(p) =>
|
||||||
|
p.title.toLowerCase().includes(lower) ||
|
||||||
|
p.description.toLowerCase().includes(lower) ||
|
||||||
|
p.technologies.some((tech) => tech.toLowerCase().includes(lower)),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function findById(id: string) {
|
||||||
|
return computed(() => projects.value.find((p) => p.id === id))
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
projects,
|
||||||
|
featuredProjects,
|
||||||
|
filterByCategory,
|
||||||
|
search,
|
||||||
|
findById,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import type { FAQ } from '~~/shared/types'
|
||||||
|
|
||||||
|
export const homeFAQs: FAQ[] = [
|
||||||
|
{
|
||||||
|
questionKey: 'faq.homeFaq.delivery.question',
|
||||||
|
answerKey: 'faq.homeFaq.delivery.answer',
|
||||||
|
featuresKey: 'faq.homeFaq.delivery.features',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
questionKey: 'faq.homeFaq.maintenance.question',
|
||||||
|
answerKey: 'faq.homeFaq.maintenance.answer',
|
||||||
|
featuresKey: 'faq.homeFaq.maintenance.features',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
questionKey: 'faq.homeFaq.companies.question',
|
||||||
|
answerKey: 'faq.homeFaq.companies.answer',
|
||||||
|
featuresKey: 'faq.homeFaq.companies.features',
|
||||||
|
},
|
||||||
|
]
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import type { PricingTier } from '~~/shared/types'
|
||||||
|
|
||||||
|
export const hytalePricing: PricingTier[] = [
|
||||||
|
{ id: 'simple', priceFixed: '50€', featured: false },
|
||||||
|
{ id: 'complex', priceFixed: null, priceLabel: 'Sur devis', featured: true },
|
||||||
|
{ id: 'custom', priceFixed: null, priceLabel: 'Sur devis', featured: false },
|
||||||
|
{ id: 'maintenance', priceFixed: '30€/mois', featured: false },
|
||||||
|
{ id: 'web', priceFixed: null, priceLabel: 'Sur devis', featured: false },
|
||||||
|
]
|
||||||
@@ -1,127 +1,105 @@
|
|||||||
import { computed } from 'vue'
|
import type { Project } from '~~/shared/types'
|
||||||
import { useI18n } from '@/composables/useI18n'
|
|
||||||
import type { Project } from '@/types'
|
|
||||||
|
|
||||||
// Base project data without translations
|
// Base project data without translations
|
||||||
const baseProjects: Omit<Project, 'title' | 'description' | 'longDescription'>[] = [
|
// Titles and descriptions are resolved via i18n keys: projects.${id}.title, projects.${id}.description
|
||||||
|
export const projects: Omit<Project, 'title' | 'description' | 'longDescription'>[] = [
|
||||||
{
|
{
|
||||||
id: 'virtual-tour',
|
id: 'virtual-tour',
|
||||||
image: '@/assets/images/virtualtour.webp',
|
image: '/images/virtualtour.webp',
|
||||||
technologies: ['Vue.js', 'Three.js', 'WebGL', 'Node.js'],
|
technologies: ['Vue.js', 'Three.js', 'WebGL', 'Node.js'],
|
||||||
category: 'Web Development',
|
category: 'Web Development',
|
||||||
|
date: '2022',
|
||||||
buttons: [
|
buttons: [
|
||||||
{
|
{
|
||||||
title: 'Visit',
|
title: 'Visit',
|
||||||
link: 'https://www.lycee-chabanne16.fr/visites/BACSN/index.htm'
|
link: 'https://www.lycee-chabanne16.fr/visites/BACSN/index.htm',
|
||||||
}
|
},
|
||||||
],
|
],
|
||||||
date: '2022'
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'xinko',
|
id: 'xinko',
|
||||||
image: '@/assets/images/xinko.webp',
|
image: '/images/xinko.webp',
|
||||||
technologies: ['Node.js', 'Discord.js', 'MongoDB', 'Express'],
|
technologies: ['Node.js', 'Discord.js', 'MongoDB', 'Express'],
|
||||||
category: 'Bot Development',
|
category: 'Bot Development',
|
||||||
|
date: '2023',
|
||||||
featured: true,
|
featured: true,
|
||||||
buttons: [
|
buttons: [
|
||||||
{
|
{
|
||||||
title: 'Invite',
|
title: 'Invite',
|
||||||
link: 'https://discord.com/api/oauth2/authorize?client_id=1035571329866407976&permissions=292288982151&scope=applications.commands%20bot'
|
link: 'https://discord.com/api/oauth2/authorize?client_id=1035571329866407976&permissions=292288982151&scope=applications.commands%20bot',
|
||||||
}
|
},
|
||||||
],
|
],
|
||||||
date: '2023'
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'image-manipulation',
|
id: 'image-manipulation',
|
||||||
image: '@/assets/images/dig.webp',
|
image: '/images/dig.webp',
|
||||||
technologies: ['JavaScript', 'Node.js', 'Canvas', 'npm'],
|
technologies: ['JavaScript', 'Node.js', 'Canvas', 'npm'],
|
||||||
category: 'Open Source',
|
category: 'Open Source',
|
||||||
|
date: '2022',
|
||||||
featured: true,
|
featured: true,
|
||||||
buttons: [
|
buttons: [
|
||||||
{
|
{
|
||||||
title: 'Repository',
|
title: 'Repository',
|
||||||
link: 'https://git.mrkayjaydee.xyz/Mr-KayJayDee/discord-image-generation'
|
link: 'https://git.mrkayjaydee.xyz/Mr-KayJayDee/discord-image-generation',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'NPM Package',
|
title: 'NPM Package',
|
||||||
link: 'https://www.npmjs.com/package/discord-image-generation'
|
link: 'https://www.npmjs.com/package/discord-image-generation',
|
||||||
}
|
},
|
||||||
],
|
],
|
||||||
date: '2022'
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'primate-web-admin',
|
id: 'primate-web-admin',
|
||||||
image: '@/assets/images/primate.webp',
|
image: '/images/primate.webp',
|
||||||
technologies: ['React', 'TypeScript', 'Node.js', 'Express'],
|
technologies: ['React', 'TypeScript', 'Node.js', 'Express'],
|
||||||
category: 'Enterprise Software',
|
category: 'Enterprise Software',
|
||||||
date: '2023'
|
date: '2023',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'instagram-bot',
|
id: 'instagram-bot',
|
||||||
image: '@/assets/images/instagram.webp',
|
image: '/images/instagram.webp',
|
||||||
technologies: ['JavaScript', 'Node.js', 'Instagram API', 'Canvas'],
|
technologies: ['JavaScript', 'Node.js', 'Instagram API', 'Canvas'],
|
||||||
category: 'Social Media Bot',
|
category: 'Social Media Bot',
|
||||||
|
date: '2022',
|
||||||
buttons: [
|
buttons: [
|
||||||
{
|
{
|
||||||
title: 'Repository',
|
title: 'Repository',
|
||||||
link: 'https://git.mrkayjaydee.xyz/Mr-KayJayDee/instagram-bot'
|
link: 'https://git.mrkayjaydee.xyz/Mr-KayJayDee/instagram-bot',
|
||||||
}
|
},
|
||||||
],
|
],
|
||||||
date: '2022'
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'crowdin-status-bot',
|
id: 'crowdin-status-bot',
|
||||||
image: '@/assets/images/crowdin.webp',
|
image: '/images/crowdin.webp',
|
||||||
technologies: ['Node.js', 'Discord.js', 'Crowdin API', 'Cron'],
|
technologies: ['Node.js', 'Discord.js', 'Crowdin API', 'Cron'],
|
||||||
category: 'Automation',
|
category: 'Automation',
|
||||||
|
date: '2023',
|
||||||
buttons: [
|
buttons: [
|
||||||
{
|
{
|
||||||
title: 'Repository',
|
title: 'Repository',
|
||||||
link: 'https://git.mrkayjaydee.xyz/Mr-KayJayDee/discord-crowdin-status'
|
link: 'https://git.mrkayjaydee.xyz/Mr-KayJayDee/discord-crowdin-status',
|
||||||
}
|
},
|
||||||
],
|
],
|
||||||
date: '2023'
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'flowboard',
|
id: 'flowboard',
|
||||||
image: '@/assets/images/flowboard/flowboard_1.webp',
|
image: '/images/flowboard/flowboard_1.webp',
|
||||||
technologies: ['Vue.js', 'Node.js', 'TypeScript', 'MongoDB', 'Express'],
|
technologies: ['Vue.js', 'Node.js', 'TypeScript', 'MongoDB', 'Express'],
|
||||||
category: 'Web Development',
|
category: 'Web Development',
|
||||||
|
date: '2024',
|
||||||
featured: true,
|
featured: true,
|
||||||
features: [
|
features: [
|
||||||
'Organize your tasks, projects and ideas by creating thematic boards adapted to your needs',
|
'Organize your tasks, projects and ideas by creating thematic boards adapted to your needs',
|
||||||
'Add cards for each task, assign members, set due dates, and track progress at a glance',
|
'Add cards for each task, assign members, set due dates, and track progress at a glance',
|
||||||
'Invite colleagues and teammates to join your boards to work together, share ideas, and coordinate your efforts',
|
'Invite colleagues and teammates to join your boards to work together, share ideas, and coordinate your efforts',
|
||||||
'Keep an overview of the progress of your projects thanks to a simple and intuitive interface',
|
'Keep an overview of the progress of your projects thanks to a simple and intuitive interface',
|
||||||
'Use labels, lists and tables to prioritize tasks, set priorities and keep the overview clear'
|
'Use labels, lists and tables to prioritize tasks, set priorities and keep the overview clear',
|
||||||
],
|
],
|
||||||
gallery: [
|
gallery: [
|
||||||
'@/assets/images/flowboard/flowboard_1.webp',
|
'/images/flowboard/flowboard_1.webp',
|
||||||
'@/assets/images/flowboard/flowboard_2.webp',
|
'/images/flowboard/flowboard_2.webp',
|
||||||
'@/assets/images/flowboard/flowboard_3.webp',
|
'/images/flowboard/flowboard_3.webp',
|
||||||
'@/assets/images/flowboard/flowboard_4.webp'
|
'/images/flowboard/flowboard_4.webp',
|
||||||
],
|
],
|
||||||
date: '2024'
|
},
|
||||||
}
|
|
||||||
]
|
]
|
||||||
|
|
||||||
export function useProjects() {
|
|
||||||
const { t } = useI18n()
|
|
||||||
|
|
||||||
const projects = computed((): Project[] => {
|
|
||||||
return baseProjects.map(project => ({
|
|
||||||
...project,
|
|
||||||
title: t(`projectData.${project.id}.title`),
|
|
||||||
description: t(`projectData.${project.id}.description`),
|
|
||||||
longDescription: t(`projectData.${project.id}.longDescription`),
|
|
||||||
buttons: project.buttons?.map(button => ({
|
|
||||||
...button,
|
|
||||||
title: t(`projectData.${project.id}.buttons.${button.title.toLowerCase()}`, button.title)
|
|
||||||
})) || []
|
|
||||||
}))
|
|
||||||
})
|
|
||||||
|
|
||||||
return {
|
|
||||||
projects: projects
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,112 @@
|
|||||||
|
import type { SiteConfig, ContactInfo, SocialLink, FiverrService, FiverrConfig } from '~~/shared/types'
|
||||||
|
|
||||||
|
export type { SiteConfig, ContactInfo, SocialLink, FiverrService, FiverrConfig }
|
||||||
|
|
||||||
|
export const siteConfig: SiteConfig = {
|
||||||
|
name: 'Killian',
|
||||||
|
title: "Killian' DAL-CIN - Hytale Plugin Developer | Freelance",
|
||||||
|
description:
|
||||||
|
'Professional Full Stack Developer specializing in modern web development with Vue.js, React, Node.js. Expert in Discord bots, web applications, and custom software solutions.',
|
||||||
|
jobTitle: 'Hytale Plugin Developer',
|
||||||
|
author: 'Killian',
|
||||||
|
url: 'https://killiandalcin.fr',
|
||||||
|
|
||||||
|
contact: {
|
||||||
|
email: 'contact@killiandalcin.fr',
|
||||||
|
location: 'France',
|
||||||
|
},
|
||||||
|
|
||||||
|
social: [
|
||||||
|
{
|
||||||
|
name: 'Gitea',
|
||||||
|
url: 'https://gitea.kamisama.ovh/kayjaydee',
|
||||||
|
icon: 'i-simple-icons-gitea',
|
||||||
|
username: 'kayjaydee',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'LinkedIn',
|
||||||
|
url: 'https://linkedin.com/in/killian-dal-cin',
|
||||||
|
icon: 'i-simple-icons-linkedin',
|
||||||
|
username: 'killian-dalcin',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Discord',
|
||||||
|
url: 'https://discord.com/users/370940770225618954',
|
||||||
|
icon: 'i-simple-icons-discord',
|
||||||
|
username: 'kayjaydee',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'Email',
|
||||||
|
url: 'mailto:contact@killiandalcin.fr',
|
||||||
|
icon: 'i-lucide-mail',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
|
||||||
|
fiverr: {
|
||||||
|
profileUrl: 'https://www.fiverr.com/users/mr_kayjaydee',
|
||||||
|
services: [
|
||||||
|
{
|
||||||
|
id: 'discord-bot',
|
||||||
|
url: 'https://www.fiverr.com/s/rEDa84j',
|
||||||
|
image: '/images/fiverr/discord_bot.webp',
|
||||||
|
price: '$25',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'minecraft-plugin',
|
||||||
|
url: 'https://www.fiverr.com/s/xXVY20Q',
|
||||||
|
image: '/images/fiverr/minecraft_plugin.webp',
|
||||||
|
price: '$50',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'telegram-bot',
|
||||||
|
url: 'https://www.fiverr.com/users/mr_kayjaydee',
|
||||||
|
image: '/images/fiverr/telegram_bot.webp',
|
||||||
|
price: '$20',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'website-development',
|
||||||
|
url: 'https://www.fiverr.com/users/mr_kayjaydee',
|
||||||
|
image: '/images/fiverr/website.webp',
|
||||||
|
price: '$50',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
|
||||||
|
seo: {
|
||||||
|
defaultImage: '/portfolio-preview.webp',
|
||||||
|
twitterHandle: '@killiandalcin',
|
||||||
|
locale: 'en_US',
|
||||||
|
alternateLocales: ['fr_FR'],
|
||||||
|
internalLinks: {
|
||||||
|
priority: [
|
||||||
|
{ url: '/fiverr', text: 'Services Fiverr', priority: 0.9 },
|
||||||
|
{ url: '/projects', text: 'Portfolio', priority: 0.8 },
|
||||||
|
{ url: '/contact', text: 'Contact', priority: 0.8 },
|
||||||
|
],
|
||||||
|
services: [
|
||||||
|
{ url: '/fiverr#discord-bot', text: 'Bot Discord' },
|
||||||
|
{ url: '/fiverr#minecraft-plugin', text: 'Plugin Minecraft' },
|
||||||
|
{ url: '/fiverr#telegram-bot', text: 'Bot Telegram' },
|
||||||
|
{ url: '/fiverr#website-development', text: 'Developpement Web' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
organization: {
|
||||||
|
'@type': 'ProfessionalService',
|
||||||
|
name: "Killian' DAL-CIN - Hytale Plugin Developer",
|
||||||
|
logo: 'https://killiandalcin.fr/logo.webp',
|
||||||
|
priceRange: '$$$',
|
||||||
|
aggregateRating: {
|
||||||
|
ratingValue: '5',
|
||||||
|
reviewCount: '5',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
|
||||||
|
performance: {
|
||||||
|
enablePrefetch: true,
|
||||||
|
enablePreconnect: true,
|
||||||
|
criticalCSS: true,
|
||||||
|
lazyLoadImages: true,
|
||||||
|
webpSupport: true,
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import type { TechStack } from '~~/shared/types'
|
||||||
|
|
||||||
|
export const techStack: TechStack = {
|
||||||
|
programming: [
|
||||||
|
{ name: 'JavaScript', level: 'Advanced', image: '/images/javascript.webp' },
|
||||||
|
{ name: 'TypeScript', level: 'Advanced', image: '/images/typescript.webp' },
|
||||||
|
{ name: 'Node.js', level: 'Advanced', image: '/images/nodejs.webp' },
|
||||||
|
{ name: 'Bash', level: 'Intermediate', image: '/images/bash.webp' },
|
||||||
|
{ name: 'Markdown', level: 'Advanced', image: '/images/markdown.webp' },
|
||||||
|
{ name: 'Ruby', level: 'Intermediate', image: '/images/ruby.webp' },
|
||||||
|
{ name: 'Ruby on Rails', level: 'Intermediate', image: '/images/rubyonrails.webp' },
|
||||||
|
],
|
||||||
|
front: [
|
||||||
|
{ name: 'Vue.js', level: 'Advanced', image: '/images/vuejs.webp' },
|
||||||
|
{ name: 'React', level: 'Intermediate', image: '/images/react.webp' },
|
||||||
|
{ name: 'Angular', level: 'Intermediate', image: '/images/angular.webp' },
|
||||||
|
{ name: 'HTML', level: 'Advanced', image: '/images/html.webp' },
|
||||||
|
{ name: 'CSS', level: 'Advanced', image: '/images/css.webp' },
|
||||||
|
{ name: 'Figma', level: 'Advanced', image: '/images/figma.webp' },
|
||||||
|
{ name: 'WordPress', level: 'Intermediate', image: '/images/wordpress.webp' },
|
||||||
|
{ name: 'Bootstrap', level: 'Intermediate', image: '/images/bootstrap.webp' },
|
||||||
|
{ name: 'Tailwind CSS', level: 'Intermediate', image: '/images/tailwindcss.webp' },
|
||||||
|
],
|
||||||
|
database: [
|
||||||
|
{ name: 'MongoDB', level: 'Advanced', image: '/images/mongodb.webp' },
|
||||||
|
{ name: 'MySQL', level: 'Advanced', image: '/images/mysql.webp' },
|
||||||
|
{ name: 'Redis', level: 'Advanced', image: '/images/redis.webp' },
|
||||||
|
{ name: 'SQLite', level: 'Advanced', image: '/images/sqlite.webp' },
|
||||||
|
{ name: 'PostgreSQL', level: 'Advanced', image: '/images/postgresql.webp' },
|
||||||
|
],
|
||||||
|
devtools: [
|
||||||
|
{ name: 'Git', level: 'Advanced', image: '/images/git.webp' },
|
||||||
|
{ name: 'GitHub', level: 'Advanced', image: '/images/github.webp' },
|
||||||
|
{ name: 'GitLab', level: 'Advanced', image: '/images/gitlab.webp' },
|
||||||
|
{ name: 'GitKraken', level: 'Advanced', image: '/images/gitkraken.webp' },
|
||||||
|
{ name: 'Visual Studio Code', level: 'Advanced', image: '/images/vscode.webp' },
|
||||||
|
{ name: 'Atom', level: 'Advanced', image: '/images/atom.webp' },
|
||||||
|
{ name: 'Docker', level: 'Advanced', image: '/images/docker.webp' },
|
||||||
|
{ name: 'npm', level: 'Advanced', image: '/images/npm.webp' },
|
||||||
|
{ name: 'Postman', level: 'Advanced', image: '/images/postman.webp' },
|
||||||
|
{ name: 'FileZilla', level: 'Advanced', image: '/images/filezilla.webp' },
|
||||||
|
{ name: 'Termius', level: 'Advanced', image: '/images/termius.webp' },
|
||||||
|
{ name: 'HeidiSQL', level: 'Advanced', image: '/images/heidisql.webp' },
|
||||||
|
{ name: 'MySQL Workbench', level: 'Advanced', image: '/images/mysqlworkbench.webp' },
|
||||||
|
{ name: 'Sequel Pro', level: 'Intermediate', image: '/images/sequelpro.webp' },
|
||||||
|
],
|
||||||
|
operating_systems: [
|
||||||
|
{ name: 'Linux', level: 'Advanced', image: '/images/linux.webp' },
|
||||||
|
{ name: 'Ubuntu', level: 'Advanced', image: '/images/ubuntu.webp' },
|
||||||
|
{ name: 'Debian', level: 'Advanced', image: '/images/debian.webp' },
|
||||||
|
{ name: 'Arch Linux', level: 'Intermediate', image: '/images/archlinux.webp' },
|
||||||
|
{ name: 'Kali Linux', level: 'Intermediate', image: '/images/kalilinux.webp' },
|
||||||
|
{ name: 'Deepin', level: 'Intermediate', image: '/images/deepin.webp' },
|
||||||
|
{ name: 'Windows', level: 'Advanced', image: '/images/windows.webp' },
|
||||||
|
{ name: 'macOS', level: 'Advanced', image: '/images/macos.webp' },
|
||||||
|
{ name: 'Android', level: 'Advanced', image: '/images/android.webp' },
|
||||||
|
{ name: 'iOS', level: 'Intermediate', image: '/images/ios.webp' },
|
||||||
|
{ name: 'Wear OS', level: 'Intermediate', image: '/images/wearos.webp' },
|
||||||
|
{ name: 'watchOS', level: 'Intermediate', image: '/images/watchos.webp' },
|
||||||
|
],
|
||||||
|
socials: [
|
||||||
|
{ name: 'Discord', level: 'Advanced', image: '/images/discord.webp' },
|
||||||
|
{ name: 'Instagram', level: 'Advanced', image: '/images/instagram.webp' },
|
||||||
|
{ name: 'LinkedIn', level: 'Advanced', image: '/images/linkedin.webp' },
|
||||||
|
{ name: 'Twitter', level: 'Advanced', image: '/images/twitter.webp' },
|
||||||
|
{ name: 'Reddit', level: 'Advanced', image: '/images/reddit.webp' },
|
||||||
|
{ name: 'Facebook', level: 'Advanced', image: '/images/facebook.webp' },
|
||||||
|
{ name: 'Messenger', level: 'Advanced', image: '/images/messenger.webp' },
|
||||||
|
{ name: 'WhatsApp', level: 'Advanced', image: '/images/whatsapp.webp' },
|
||||||
|
{ name: 'Telegram', level: 'Advanced', image: '/images/telegram.webp' },
|
||||||
|
],
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
import type { Testimonial, TestimonialsStats } from '~~/shared/types'
|
||||||
|
|
||||||
|
export const testimonials: Testimonial[] = [
|
||||||
|
{
|
||||||
|
name: 'unqlf_',
|
||||||
|
role: 'Client',
|
||||||
|
company: 'France',
|
||||||
|
avatar: 'https://ui-avatars.com/api/?name=U&background=3b82f6&color=ffffff&size=128',
|
||||||
|
rating: 5,
|
||||||
|
content:
|
||||||
|
"Je conseil ce vendeur il écoute clairement les conseils, les informations qu'on lui donne, il mérite clairement son niveau dans le développement et prend en compte chaque erreur.",
|
||||||
|
date: '15/03/2023',
|
||||||
|
platform: 'Fiverr',
|
||||||
|
featured: true,
|
||||||
|
project_type: 'Plugin Minecraft',
|
||||||
|
results: ["Prix: Jusqu'à 50€", 'Durée: 10 jours', 'Écoute client excellente'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'colo263',
|
||||||
|
role: 'Client',
|
||||||
|
company: 'France',
|
||||||
|
avatar: 'https://ui-avatars.com/api/?name=C&background=059669&color=ffffff&size=128',
|
||||||
|
rating: 5,
|
||||||
|
content:
|
||||||
|
"Travail excellent, Communication au top, Disponible en tout temps, réactif et à l'écoute je le recommande vivement et reviendrai vers lui si je dois refaire un projet similaire !",
|
||||||
|
date: '22/04/2023',
|
||||||
|
platform: 'Fiverr',
|
||||||
|
featured: true,
|
||||||
|
project_type: 'Bot Discord',
|
||||||
|
results: ["Prix: Jusqu'à 50€", 'Durée: 4 jours', 'Communication parfaite'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'aurlienbarbet',
|
||||||
|
role: 'Client',
|
||||||
|
company: 'France',
|
||||||
|
avatar: 'https://ui-avatars.com/api/?name=A&background=dc2626&color=ffffff&size=128',
|
||||||
|
rating: 5,
|
||||||
|
content:
|
||||||
|
"Le prestataire est très professionnel, prêt à faire l'offre la plus juste et à ajuster un prix pour votre commande. Réponds à tout les questions ! une bonne expérience pour ma part",
|
||||||
|
date: '08/06/2023',
|
||||||
|
platform: 'Fiverr',
|
||||||
|
project_type: 'Bot Discord',
|
||||||
|
results: ["Prix: Jusqu'à 50€", 'Durée: 1 jour', 'Prix ajusté sur mesure'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'cobra2',
|
||||||
|
role: 'Client',
|
||||||
|
company: 'France',
|
||||||
|
avatar: 'https://ui-avatars.com/api/?name=C&background=7c3aed&color=ffffff&size=128',
|
||||||
|
rating: 5,
|
||||||
|
content:
|
||||||
|
'Excellent développeur, la commande fut plus rapide que prévu la communication est instantané et le résultat est parfait. Je recommande fortement et reviendrai sûrement pour des mise à jour !',
|
||||||
|
date: '12/11/2022',
|
||||||
|
platform: 'Fiverr',
|
||||||
|
featured: true,
|
||||||
|
project_type: 'Bot Discord',
|
||||||
|
results: [
|
||||||
|
'Livraison plus rapide que prévu',
|
||||||
|
'Communication instantanée',
|
||||||
|
'Résultat parfait',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'botuhuh',
|
||||||
|
role: 'Client',
|
||||||
|
company: 'France',
|
||||||
|
avatar: 'https://ui-avatars.com/api/?name=B&background=ea580c&color=ffffff&size=128',
|
||||||
|
rating: 5,
|
||||||
|
content: 'awesome guy, I recommend, thanks again !!!!',
|
||||||
|
date: '28/09/2022',
|
||||||
|
platform: 'Fiverr',
|
||||||
|
project_type: 'Bot Discord',
|
||||||
|
results: ['Client international satisfait', 'Recommandation forte', 'Service apprécié'],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export const testimonialsStats: TestimonialsStats = {
|
||||||
|
totalReviews: 5,
|
||||||
|
averageRating: 5.0,
|
||||||
|
projectsCompleted: 25,
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { NuxtError } from '#app'
|
||||||
|
|
||||||
|
defineProps<{ error: NuxtError }>()
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
function handleError() {
|
||||||
|
clearError({ redirect: '/' })
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="min-h-screen flex flex-col items-center justify-center gap-8 px-4 bg-white dark:bg-gray-950 relative overflow-hidden">
|
||||||
|
<!-- Decorative background -->
|
||||||
|
<div class="absolute inset-0 pointer-events-none" aria-hidden="true">
|
||||||
|
<div class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[600px] h-[400px] bg-brand-500/5 dark:bg-brand-500/10 rounded-full blur-3xl" />
|
||||||
|
<!-- Dot grid -->
|
||||||
|
<div class="absolute inset-0 opacity-[0.03] dark:opacity-[0.05]" style="background-image: radial-gradient(circle, currentColor 1px, transparent 1px); background-size: 32px 32px;" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="relative z-10 text-center space-y-8 max-w-lg">
|
||||||
|
<!-- Error code -->
|
||||||
|
<div class="relative">
|
||||||
|
<span class="font-mono text-sm text-brand-500 dark:text-brand-400 tracking-wider">// error</span>
|
||||||
|
<h1 class="text-[10rem] sm:text-[12rem] font-black leading-none tracking-tighter bg-gradient-to-b from-brand-400 via-brand-500 to-brand-700 bg-clip-text text-transparent select-none mt-2">
|
||||||
|
{{ error.statusCode }}
|
||||||
|
</h1>
|
||||||
|
<!-- Shadow glow behind -->
|
||||||
|
<span class="absolute inset-0 top-8 text-[10rem] sm:text-[12rem] font-black leading-none tracking-tighter text-brand-500/8 blur-md select-none" aria-hidden="true">
|
||||||
|
{{ error.statusCode }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="space-y-3">
|
||||||
|
<p class="text-xl sm:text-2xl font-bold bg-gradient-to-r from-gray-900 to-gray-600 dark:from-white dark:to-gray-400 bg-clip-text text-transparent">
|
||||||
|
{{ error.statusCode === 404 ? t('error.notFound') : t('error.generic') }}
|
||||||
|
</p>
|
||||||
|
<p class="text-gray-500 dark:text-gray-400 leading-relaxed">
|
||||||
|
{{ error.statusCode === 404
|
||||||
|
? 'The page you are looking for does not exist or has been moved.'
|
||||||
|
: 'Something unexpected happened. Please try again.'
|
||||||
|
}}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<UButton size="xl" icon="i-lucide-home" class="font-semibold" trailing-icon="i-lucide-arrow-right" @click="handleError">
|
||||||
|
{{ t('error.backHome') }}
|
||||||
|
</UButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<template>
|
||||||
|
<div class="min-h-screen flex flex-col bg-white dark:bg-gray-950 text-gray-900 dark:text-gray-100 antialiased">
|
||||||
|
<AppHeader />
|
||||||
|
<main class="flex-1">
|
||||||
|
<slot />
|
||||||
|
</main>
|
||||||
|
<AppFooter />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,188 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { techStack } from '~/data/techstack'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
useSeoMeta({
|
||||||
|
title: () => t('seo.about.title'),
|
||||||
|
description: () => t('seo.about.description'),
|
||||||
|
ogTitle: () => t('seo.about.title'),
|
||||||
|
ogDescription: () => t('seo.about.description'),
|
||||||
|
ogImage: 'https://killiandalcin.fr/og-image.png',
|
||||||
|
ogImageWidth: 1200,
|
||||||
|
ogImageHeight: 630,
|
||||||
|
ogType: 'website',
|
||||||
|
})
|
||||||
|
|
||||||
|
const techCategories = computed(() => [
|
||||||
|
{
|
||||||
|
key: 'programming' as const,
|
||||||
|
title: t('about.skills.programming'),
|
||||||
|
icon: 'i-lucide-code-2',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'front' as const,
|
||||||
|
title: t('about.skills.frontend'),
|
||||||
|
icon: 'i-lucide-palette',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'database' as const,
|
||||||
|
title: t('about.skills.backend'),
|
||||||
|
icon: 'i-lucide-database',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'devtools' as const,
|
||||||
|
title: t('about.skills.tools'),
|
||||||
|
icon: 'i-lucide-settings',
|
||||||
|
},
|
||||||
|
])
|
||||||
|
|
||||||
|
const approachCards = computed(() => [
|
||||||
|
{
|
||||||
|
title: t('about.approach.performance.title'),
|
||||||
|
description: t('about.approach.performance.description'),
|
||||||
|
icon: 'i-lucide-zap',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('about.approach.architecture.title'),
|
||||||
|
description: t('about.approach.architecture.description'),
|
||||||
|
icon: 'i-lucide-git-branch',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('about.approach.quality.title'),
|
||||||
|
description: t('about.approach.quality.description'),
|
||||||
|
icon: 'i-lucide-check-circle',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: t('about.approach.collaboration.title'),
|
||||||
|
description: t('about.approach.collaboration.description'),
|
||||||
|
icon: 'i-lucide-users',
|
||||||
|
},
|
||||||
|
])
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<!-- Hero Section -->
|
||||||
|
<section class="relative pt-20 pb-20 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 right-0 w-[600px] h-[600px] bg-brand-500/5 dark:bg-brand-500/8 rounded-full blur-3xl -translate-y-1/2 translate-x-1/4 pointer-events-none" aria-hidden="true" />
|
||||||
|
|
||||||
|
<div class="relative z-10 max-w-4xl mx-auto text-center">
|
||||||
|
<span class="font-mono text-sm text-brand-500 dark:text-brand-400 tracking-wider">// about</span>
|
||||||
|
<h1 class="text-4xl sm:text-5xl lg:text-6xl font-bold mt-3 mb-6 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('about.title') }}
|
||||||
|
</h1>
|
||||||
|
<p class="text-xl text-gray-500 dark:text-gray-400 mb-12 max-w-2xl mx-auto leading-relaxed">
|
||||||
|
{{ t('about.subtitle') }}
|
||||||
|
</p>
|
||||||
|
<div class="max-w-3xl mx-auto space-y-6">
|
||||||
|
<p class="text-lg text-gray-600 dark:text-gray-400 leading-relaxed rounded-2xl border border-gray-200/80 dark:border-gray-800/50 bg-white/60 dark:bg-gray-900/40 backdrop-blur-sm p-6 sm:p-8 text-left">{{ t('about.intro.content') }}</p>
|
||||||
|
<p class="text-lg text-gray-600 dark:text-gray-400 leading-relaxed rounded-2xl border border-gray-200/80 dark:border-gray-800/50 bg-white/60 dark:bg-gray-900/40 backdrop-blur-sm p-6 sm:p-8 text-left">{{ t('about.experience.content') }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Skills Section -->
|
||||||
|
<section class="py-24 md:py-32 px-4 sm:px-6 lg:px-8">
|
||||||
|
<div class="max-w-7xl mx-auto">
|
||||||
|
<div class="text-center mb-16">
|
||||||
|
<span class="font-mono text-sm text-brand-500 dark:text-brand-400 tracking-wider">// tech-stack</span>
|
||||||
|
<h2 class="text-3xl sm:text-4xl lg:text-5xl font-bold mt-3 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('about.skills.title') }}</h2>
|
||||||
|
<p class="text-lg text-gray-500 dark:text-gray-400 mt-4 max-w-2xl mx-auto leading-relaxed">
|
||||||
|
{{ t('about.subtitle') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tech Categories Bento Grid -->
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-2 gap-5 mb-5">
|
||||||
|
<div
|
||||||
|
v-for="category in techCategories"
|
||||||
|
:key="category.key"
|
||||||
|
class="group rounded-2xl border border-gray-200/80 dark:border-gray-800/50 bg-white/80 dark:bg-gray-900/60 backdrop-blur-sm p-7 sm:p-8 transition-all duration-300 hover:border-brand-500/30 hover:shadow-lg hover:shadow-brand-500/5"
|
||||||
|
>
|
||||||
|
<div class="flex items-center gap-3 mb-6">
|
||||||
|
<div class="w-10 h-10 rounded-xl bg-brand-500/10 dark:bg-brand-500/15 flex items-center justify-center transition-all duration-300 group-hover:bg-brand-500/20 group-hover:scale-110">
|
||||||
|
<UIcon :name="category.icon" class="text-xl text-brand-600 dark:text-brand-400" />
|
||||||
|
</div>
|
||||||
|
<h3 class="text-xl font-bold text-gray-900 dark:text-white">{{ category.title }}</h3>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<TechBadge
|
||||||
|
v-for="tech in techStack[category.key]"
|
||||||
|
:key="tech.name"
|
||||||
|
:tech="tech"
|
||||||
|
:show-level="true"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Operating Systems -->
|
||||||
|
<div class="group rounded-2xl border border-gray-200/80 dark:border-gray-800/50 bg-white/80 dark:bg-gray-900/60 backdrop-blur-sm p-7 sm:p-8 transition-all duration-300 hover:border-brand-500/30 hover:shadow-lg hover:shadow-brand-500/5">
|
||||||
|
<div class="flex items-center gap-3 mb-6">
|
||||||
|
<div class="w-10 h-10 rounded-xl bg-brand-500/10 dark:bg-brand-500/15 flex items-center justify-center transition-all duration-300 group-hover:bg-brand-500/20 group-hover:scale-110">
|
||||||
|
<UIcon name="i-lucide-monitor" class="text-xl text-brand-600 dark:text-brand-400" />
|
||||||
|
</div>
|
||||||
|
<h3 class="text-xl font-bold text-gray-900 dark:text-white">{{ t('about.skills.systems') }}</h3>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<TechBadge
|
||||||
|
v-for="tech in techStack.operating_systems"
|
||||||
|
:key="tech.name"
|
||||||
|
:tech="tech"
|
||||||
|
:show-level="false"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Approach Section -->
|
||||||
|
<section class="relative py-24 md:py-32 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 bottom-0 left-0 w-[500px] h-[500px] bg-brand-500/5 dark:bg-brand-500/8 rounded-full blur-3xl translate-y-1/2 -translate-x-1/4 pointer-events-none" aria-hidden="true" />
|
||||||
|
|
||||||
|
<div class="relative z-10 max-w-7xl mx-auto">
|
||||||
|
<div class="text-center mb-16">
|
||||||
|
<span class="font-mono text-sm text-brand-500 dark:text-brand-400 tracking-wider">// methodology</span>
|
||||||
|
<h2 class="text-3xl sm:text-4xl lg:text-5xl font-bold mt-3 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('about.approach.title') }}</h2>
|
||||||
|
<p class="text-lg text-gray-500 dark:text-gray-400 mt-4 max-w-2xl mx-auto leading-relaxed">
|
||||||
|
{{ t('about.approach.subtitle') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-5">
|
||||||
|
<div
|
||||||
|
v-for="(card, index) in approachCards"
|
||||||
|
:key="index"
|
||||||
|
class="group rounded-2xl border border-gray-200/80 dark:border-gray-800/50 bg-white/80 dark:bg-gray-900/60 backdrop-blur-sm p-7 sm:p-8 transition-all duration-300 hover:border-brand-500/40 hover:shadow-xl hover:shadow-brand-500/10 hover:-translate-y-1"
|
||||||
|
>
|
||||||
|
<!-- Hover glow -->
|
||||||
|
<div class="absolute inset-0 rounded-2xl bg-gradient-to-br from-brand-500/0 to-emerald-500/0 group-hover:from-brand-500/5 group-hover:to-emerald-500/5 transition-all duration-500 pointer-events-none" aria-hidden="true" />
|
||||||
|
|
||||||
|
<div class="relative z-10 flex items-start gap-4">
|
||||||
|
<div class="w-12 h-12 rounded-xl bg-brand-500/10 dark:bg-brand-500/15 flex items-center justify-center shrink-0 transition-all duration-300 group-hover:bg-brand-500/20 group-hover:scale-110">
|
||||||
|
<UIcon :name="card.icon" class="text-xl text-brand-600 dark:text-brand-400" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h3 class="text-lg font-bold text-gray-900 dark:text-white mb-2 group-hover:text-brand-600 dark:group-hover:text-brand-400 transition-colors">{{ card.title }}</h3>
|
||||||
|
<p class="text-gray-500 dark:text-gray-400 leading-relaxed">{{ card.description }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- CTA Section -->
|
||||||
|
<CTASection
|
||||||
|
:title="t('about.cta.title')"
|
||||||
|
:subtitle="t('about.cta.description')"
|
||||||
|
:primary-text="t('about.cta.button')"
|
||||||
|
primary-to="/contact"
|
||||||
|
:secondary-text="t('home.cta.viewProjects')"
|
||||||
|
secondary-to="/projects"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
const { locale } = useI18n()
|
||||||
|
const route = useRoute()
|
||||||
|
|
||||||
|
const slug = route.params.slug as string
|
||||||
|
const isFr = locale.value === 'fr'
|
||||||
|
const path = isFr ? `/fr/blog/${slug}` : `/en/blog/${slug}`
|
||||||
|
|
||||||
|
const { data: page } = await useAsyncData(`blog-${locale.value}-${slug}`, () =>
|
||||||
|
isFr
|
||||||
|
? queryCollection('blog_fr').path(path).first()
|
||||||
|
: queryCollection('blog_en').path(path).first()
|
||||||
|
)
|
||||||
|
|
||||||
|
if (!page.value) {
|
||||||
|
throw createError({ statusCode: 404, statusMessage: 'Article introuvable' })
|
||||||
|
}
|
||||||
|
|
||||||
|
useSeoMeta({
|
||||||
|
title: page.value.title,
|
||||||
|
description: page.value.description,
|
||||||
|
ogTitle: page.value.title,
|
||||||
|
ogDescription: page.value.description,
|
||||||
|
})
|
||||||
|
</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>
|
||||||
@@ -0,0 +1,163 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { siteConfig } from '~/data/site'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
useSeoMeta({
|
||||||
|
title: () => t('seo.contact.title'),
|
||||||
|
description: () => t('seo.contact.description'),
|
||||||
|
ogTitle: () => t('seo.contact.title'),
|
||||||
|
ogDescription: () => t('seo.contact.description'),
|
||||||
|
ogImage: 'https://killiandalcin.fr/og-image.png',
|
||||||
|
ogImageWidth: 1200,
|
||||||
|
ogImageHeight: 630,
|
||||||
|
ogType: 'website',
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<!-- Hero Section -->
|
||||||
|
<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-4xl mx-auto text-center">
|
||||||
|
<span class="font-mono text-sm text-brand-500 dark:text-brand-400 tracking-wider">// contact</span>
|
||||||
|
<h1 class="text-4xl sm:text-5xl lg:text-6xl font-bold mt-3 mb-6 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('contact.title') }}
|
||||||
|
</h1>
|
||||||
|
<p class="text-xl text-gray-500 dark:text-gray-400 mb-12 max-w-2xl mx-auto leading-relaxed">
|
||||||
|
{{ t('contact.subtitle') }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Stats -->
|
||||||
|
<div class="flex flex-wrap justify-center gap-8 sm:gap-12">
|
||||||
|
<div class="text-center">
|
||||||
|
<div class="text-3xl sm:text-4xl font-black bg-gradient-to-b from-brand-400 to-brand-600 bg-clip-text text-transparent">24-48h</div>
|
||||||
|
<div class="text-sm text-gray-500 dark:text-gray-400 mt-2 font-medium">{{ t('contact.stats.responseTime') }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="w-px bg-gradient-to-b from-transparent via-gray-300 dark:via-gray-700 to-transparent hidden sm:block" />
|
||||||
|
<div class="text-center">
|
||||||
|
<div class="text-3xl sm:text-4xl font-black bg-gradient-to-b from-brand-400 to-brand-600 bg-clip-text text-transparent">100%</div>
|
||||||
|
<div class="text-sm text-gray-500 dark:text-gray-400 mt-2 font-medium">{{ t('contact.stats.satisfaction') }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="w-px bg-gradient-to-b from-transparent via-gray-300 dark:via-gray-700 to-transparent hidden sm:block" />
|
||||||
|
<div class="text-center">
|
||||||
|
<div class="text-3xl sm:text-4xl font-black bg-gradient-to-b from-brand-400 to-brand-600 bg-clip-text text-transparent">Remote</div>
|
||||||
|
<div class="text-sm text-gray-500 dark:text-gray-400 mt-2 font-medium">{{ t('contact.stats.collaboration') }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Two Column Layout -->
|
||||||
|
<section class="py-16 md:py-24 px-4 sm:px-6 lg:px-8">
|
||||||
|
<div class="max-w-6xl mx-auto grid grid-cols-1 lg:grid-cols-5 gap-8 lg:gap-12">
|
||||||
|
<!-- Left: Contact Form (wider) -->
|
||||||
|
<div class="lg:col-span-3">
|
||||||
|
<div class="rounded-2xl border border-gray-200/80 dark:border-gray-800/50 bg-white/80 dark:bg-gray-900/60 backdrop-blur-sm p-7 sm:p-8">
|
||||||
|
<h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-6 flex items-center gap-3">
|
||||||
|
<div class="w-1 h-6 rounded-full bg-brand-500" />
|
||||||
|
{{ t('contact.form.title') }}
|
||||||
|
</h2>
|
||||||
|
<ContactForm />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Right: Contact Info + Social -->
|
||||||
|
<div class="lg:col-span-2 flex flex-col gap-6">
|
||||||
|
<!-- Contact Info -->
|
||||||
|
<div class="rounded-2xl border border-gray-200/80 dark:border-gray-800/50 bg-white/80 dark:bg-gray-900/60 backdrop-blur-sm p-7 sm:p-8">
|
||||||
|
<h2 class="text-xl font-bold text-gray-900 dark:text-white mb-6 flex items-center gap-3">
|
||||||
|
<div class="w-1 h-5 rounded-full bg-brand-500" />
|
||||||
|
{{ t('contact.quickContact') }}
|
||||||
|
</h2>
|
||||||
|
<div class="flex flex-col gap-4">
|
||||||
|
<a
|
||||||
|
:href="`mailto:${siteConfig.contact.email}`"
|
||||||
|
class="flex items-center gap-4 p-3 rounded-xl border border-transparent hover:border-brand-500/20 hover:bg-brand-500/5 transition-all duration-200 group"
|
||||||
|
>
|
||||||
|
<div class="w-10 h-10 rounded-xl bg-brand-500/10 dark:bg-brand-500/15 flex items-center justify-center shrink-0 transition-all duration-300 group-hover:bg-brand-500/20 group-hover:scale-110">
|
||||||
|
<UIcon name="i-lucide-mail" class="text-brand-600 dark:text-brand-400" />
|
||||||
|
</div>
|
||||||
|
<span class="text-gray-600 dark:text-gray-300 group-hover:text-brand-500 transition-colors font-medium">{{ siteConfig.contact.email }}</span>
|
||||||
|
</a>
|
||||||
|
<div class="flex items-center gap-4 p-3">
|
||||||
|
<div class="w-10 h-10 rounded-xl bg-brand-500/10 dark:bg-brand-500/15 flex items-center justify-center shrink-0">
|
||||||
|
<UIcon name="i-lucide-map-pin" class="text-brand-600 dark:text-brand-400" />
|
||||||
|
</div>
|
||||||
|
<span class="text-gray-600 dark:text-gray-300 font-medium">{{ siteConfig.contact.location }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Social Links -->
|
||||||
|
<div class="rounded-2xl border border-gray-200/80 dark:border-gray-800/50 bg-white/80 dark:bg-gray-900/60 backdrop-blur-sm p-7 sm:p-8">
|
||||||
|
<h2 class="text-xl font-bold text-gray-900 dark:text-white mb-6 flex items-center gap-3">
|
||||||
|
<div class="w-1 h-5 rounded-full bg-brand-500" />
|
||||||
|
{{ t('contact.findMeOn') }}
|
||||||
|
</h2>
|
||||||
|
<div class="flex flex-col gap-2">
|
||||||
|
<a
|
||||||
|
v-for="social in siteConfig.social.filter(s => s.icon !== 'i-lucide-mail')"
|
||||||
|
:key="social.name"
|
||||||
|
:href="social.url"
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="flex items-center gap-4 p-3 rounded-xl border border-transparent hover:border-brand-500/20 hover:bg-brand-500/5 transition-all duration-200 group"
|
||||||
|
>
|
||||||
|
<div class="w-10 h-10 rounded-xl bg-gray-100 dark:bg-gray-800/80 border border-gray-200/50 dark:border-gray-700/30 flex items-center justify-center shrink-0 group-hover:bg-brand-500/10 group-hover:border-brand-500/20 transition-all duration-300">
|
||||||
|
<UIcon :name="social.icon" class="text-gray-500 dark:text-gray-400 group-hover:text-brand-500 transition-colors" />
|
||||||
|
</div>
|
||||||
|
<span class="text-gray-700 dark:text-gray-300 font-medium group-hover:text-brand-500 transition-colors">{{ social.name }}</span>
|
||||||
|
<UIcon name="i-lucide-external-link" class="text-xs text-gray-400 dark:text-gray-600 ml-auto group-hover:text-brand-400 transition-colors" />
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- FAQ Info Cards -->
|
||||||
|
<section class="relative py-20 md:py-28 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="relative z-10 max-w-6xl mx-auto">
|
||||||
|
<div class="text-center mb-16">
|
||||||
|
<span class="font-mono text-sm text-brand-500 dark:text-brand-400 tracking-wider">// info</span>
|
||||||
|
<h2 class="text-3xl sm:text-4xl lg:text-5xl font-bold mt-3 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('contact.faq.title') }}</h2>
|
||||||
|
<p class="text-lg text-gray-500 dark:text-gray-400 mt-4 max-w-2xl mx-auto leading-relaxed">
|
||||||
|
{{ t('contact.faq.subtitle') }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-3 gap-5">
|
||||||
|
<div class="group rounded-2xl border border-gray-200/80 dark:border-gray-800/50 bg-white/80 dark:bg-gray-900/60 backdrop-blur-sm p-8 text-center transition-all duration-300 hover:border-brand-500/40 hover:shadow-xl hover:shadow-brand-500/10 hover:-translate-y-1">
|
||||||
|
<div class="w-14 h-14 rounded-2xl bg-brand-500/10 dark:bg-brand-500/15 flex items-center justify-center mx-auto mb-5 transition-all duration-300 group-hover:bg-brand-500/20 group-hover:scale-110">
|
||||||
|
<UIcon name="i-lucide-clock" class="text-2xl text-brand-600 dark:text-brand-400" />
|
||||||
|
</div>
|
||||||
|
<h3 class="text-lg font-bold text-gray-900 dark:text-white mb-3 group-hover:text-brand-600 dark:group-hover:text-brand-400 transition-colors">{{ t('contact.faq.responseTime.title') }}</h3>
|
||||||
|
<p class="text-gray-500 dark:text-gray-400 text-sm leading-relaxed">{{ t('contact.faq.responseTime.description') }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="group rounded-2xl border border-gray-200/80 dark:border-gray-800/50 bg-white/80 dark:bg-gray-900/60 backdrop-blur-sm p-8 text-center transition-all duration-300 hover:border-brand-500/40 hover:shadow-xl hover:shadow-brand-500/10 hover:-translate-y-1">
|
||||||
|
<div class="w-14 h-14 rounded-2xl bg-brand-500/10 dark:bg-brand-500/15 flex items-center justify-center mx-auto mb-5 transition-all duration-300 group-hover:bg-brand-500/20 group-hover:scale-110">
|
||||||
|
<UIcon name="i-lucide-building" class="text-2xl text-brand-600 dark:text-brand-400" />
|
||||||
|
</div>
|
||||||
|
<h3 class="text-lg font-bold text-gray-900 dark:text-white mb-3 group-hover:text-brand-600 dark:group-hover:text-brand-400 transition-colors">{{ t('contact.faq.projectTypes.title') }}</h3>
|
||||||
|
<p class="text-gray-500 dark:text-gray-400 text-sm leading-relaxed">{{ t('contact.faq.projectTypes.description') }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="group rounded-2xl border border-gray-200/80 dark:border-gray-800/50 bg-white/80 dark:bg-gray-900/60 backdrop-blur-sm p-8 text-center transition-all duration-300 hover:border-brand-500/40 hover:shadow-xl hover:shadow-brand-500/10 hover:-translate-y-1">
|
||||||
|
<div class="w-14 h-14 rounded-2xl bg-brand-500/10 dark:bg-brand-500/15 flex items-center justify-center mx-auto mb-5 transition-all duration-300 group-hover:bg-brand-500/20 group-hover:scale-110">
|
||||||
|
<UIcon name="i-lucide-users" class="text-2xl text-brand-600 dark:text-brand-400" />
|
||||||
|
</div>
|
||||||
|
<h3 class="text-lg font-bold text-gray-900 dark:text-white mb-3 group-hover:text-brand-600 dark:group-hover:text-brand-400 transition-colors">{{ t('contact.faq.collaboration.title') }}</h3>
|
||||||
|
<p class="text-gray-500 dark:text-gray-400 text-sm leading-relaxed">{{ t('contact.faq.collaboration.description') }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,167 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { siteConfig } from '~/data/site'
|
||||||
|
import { homeFAQs } from '~/data/faq'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
useSeoMeta({
|
||||||
|
title: () => t('seo.fiverr.title'),
|
||||||
|
description: () => t('seo.fiverr.description'),
|
||||||
|
ogTitle: () => t('seo.fiverr.title'),
|
||||||
|
ogDescription: () => t('seo.fiverr.description'),
|
||||||
|
ogImage: 'https://killiandalcin.fr/og-image.png',
|
||||||
|
ogImageWidth: 1200,
|
||||||
|
ogImageHeight: 630,
|
||||||
|
ogType: 'website',
|
||||||
|
})
|
||||||
|
|
||||||
|
const services = computed(() => siteConfig.fiverr.services)
|
||||||
|
const availableServices = computed(() => services.value.filter((s) => s.url !== '#'))
|
||||||
|
|
||||||
|
const heroStats = computed(() => [
|
||||||
|
{
|
||||||
|
number: availableServices.value.length,
|
||||||
|
label: t('fiverr.services.orderNow'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
number: '5',
|
||||||
|
label: t('fiverr.stats.rating'),
|
||||||
|
},
|
||||||
|
])
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<!-- Hero Section -->
|
||||||
|
<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 right-0 w-[600px] h-[600px] bg-brand-500/5 dark:bg-brand-500/8 rounded-full blur-3xl -translate-y-1/2 translate-x-1/4 pointer-events-none" aria-hidden="true" />
|
||||||
|
|
||||||
|
<div class="relative z-10 max-w-4xl mx-auto text-center">
|
||||||
|
<span class="font-mono text-sm text-brand-500 dark:text-brand-400 tracking-wider">// fiverr</span>
|
||||||
|
<h1 class="text-4xl sm:text-5xl lg:text-6xl font-bold mt-3 mb-6 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('fiverr.title') }}
|
||||||
|
</h1>
|
||||||
|
<p class="text-xl text-gray-500 dark:text-gray-400 mb-12 max-w-2xl mx-auto leading-relaxed">
|
||||||
|
{{ t('fiverr.subtitle') }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Stats -->
|
||||||
|
<div class="flex flex-wrap justify-center gap-8 sm:gap-12 mb-12">
|
||||||
|
<div v-for="stat in heroStats" :key="stat.label" class="text-center">
|
||||||
|
<div class="text-4xl sm:text-5xl font-black bg-gradient-to-b from-brand-400 to-brand-600 bg-clip-text text-transparent">{{ stat.number }}</div>
|
||||||
|
<div class="text-sm text-gray-500 dark:text-gray-400 mt-2 font-medium">{{ stat.label }}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<UButton
|
||||||
|
:to="siteConfig.fiverr.profileUrl"
|
||||||
|
target="_blank"
|
||||||
|
external
|
||||||
|
size="xl"
|
||||||
|
trailing-icon="i-lucide-external-link"
|
||||||
|
class="font-semibold"
|
||||||
|
>
|
||||||
|
{{ t('fiverr.profileCta') }}
|
||||||
|
</UButton>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Services Section -->
|
||||||
|
<section class="py-24 md:py-32 px-4 sm:px-6 lg:px-8">
|
||||||
|
<div class="max-w-7xl mx-auto">
|
||||||
|
<div class="text-center mb-16">
|
||||||
|
<span class="font-mono text-sm text-brand-500 dark:text-brand-400 tracking-wider">// services</span>
|
||||||
|
<h2 class="text-3xl sm:text-4xl lg:text-5xl font-bold mt-3 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('fiverr.services.title') }}</h2>
|
||||||
|
<p class="text-lg text-gray-500 dark:text-gray-400 mt-4 leading-relaxed">{{ t('fiverr.services.subtitle') }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-5 lg:gap-6">
|
||||||
|
<div
|
||||||
|
v-for="service in services"
|
||||||
|
:key="service.id"
|
||||||
|
class="group 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"
|
||||||
|
>
|
||||||
|
<!-- Service Image -->
|
||||||
|
<div class="relative overflow-hidden">
|
||||||
|
<NuxtImg
|
||||||
|
:src="service.image"
|
||||||
|
:alt="t(`fiverr.serviceData.${service.id}.title`)"
|
||||||
|
class="w-full h-52 object-cover transition-transform duration-500 group-hover:scale-105"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
<div class="absolute inset-0 bg-gradient-to-t from-black/60 via-black/20 to-transparent" />
|
||||||
|
<!-- Price badge overlay -->
|
||||||
|
<div class="absolute bottom-3 left-3">
|
||||||
|
<span class="px-3 py-1.5 rounded-lg bg-brand-500 text-white text-sm font-bold shadow-lg backdrop-blur-sm">
|
||||||
|
{{ t('fiverr.pricing.startingAt') }} {{ service.price }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<!-- Status badge -->
|
||||||
|
<div class="absolute top-3 right-3">
|
||||||
|
<span
|
||||||
|
:class="service.url !== '#'
|
||||||
|
? 'bg-green-500/90 text-white backdrop-blur-sm'
|
||||||
|
: 'bg-yellow-500/90 text-white backdrop-blur-sm'"
|
||||||
|
class="px-2.5 py-1 rounded-lg text-xs font-semibold shadow-lg"
|
||||||
|
>
|
||||||
|
{{ service.url !== '#' ? t('fiverr.services.available') : t('fiverr.services.comingSoon') }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Content -->
|
||||||
|
<div class="p-6 sm:p-7">
|
||||||
|
<h3 class="text-xl font-bold text-gray-900 dark:text-white mb-3 group-hover:text-brand-600 dark:group-hover:text-brand-400 transition-colors">
|
||||||
|
{{ t(`fiverr.serviceData.${service.id}.title`) }}
|
||||||
|
</h3>
|
||||||
|
<p class="text-gray-500 dark:text-gray-400 mb-6 leading-relaxed">
|
||||||
|
{{ t(`fiverr.serviceData.${service.id}.description`) }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Action Button -->
|
||||||
|
<UButton
|
||||||
|
v-if="service.url !== '#'"
|
||||||
|
:to="service.url"
|
||||||
|
target="_blank"
|
||||||
|
external
|
||||||
|
trailing-icon="i-lucide-external-link"
|
||||||
|
class="font-semibold"
|
||||||
|
>
|
||||||
|
{{ t('fiverr.services.orderNow') }}
|
||||||
|
</UButton>
|
||||||
|
<UButton
|
||||||
|
v-else
|
||||||
|
variant="outline"
|
||||||
|
disabled
|
||||||
|
class="font-semibold"
|
||||||
|
>
|
||||||
|
{{ t('fiverr.services.comingSoon') }}
|
||||||
|
</UButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- FAQ Section -->
|
||||||
|
<div class="relative bg-gray-50/50 dark:bg-gray-900/20">
|
||||||
|
<FAQSection
|
||||||
|
:faqs="homeFAQs"
|
||||||
|
:title="t('fiverr.faq.title')"
|
||||||
|
:subtitle="t('fiverr.faq.subtitle')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- CTA Section -->
|
||||||
|
<CTASection
|
||||||
|
:title="t('fiverr.cta.title')"
|
||||||
|
:subtitle="t('fiverr.cta.subtitle')"
|
||||||
|
:primary-text="t('fiverr.cta.button')"
|
||||||
|
:primary-to="siteConfig.fiverr.profileUrl"
|
||||||
|
:secondary-text="t('fiverr.profileCta')"
|
||||||
|
secondary-to="/contact"
|
||||||
|
external
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
useSeoMeta({
|
||||||
|
title: () => t('seo.hytale.title'),
|
||||||
|
description: () => t('seo.hytale.description'),
|
||||||
|
ogTitle: () => t('seo.hytale.title'),
|
||||||
|
ogDescription: () => t('seo.hytale.description'),
|
||||||
|
ogImage: 'https://killiandalcin.fr/og-image.png',
|
||||||
|
ogType: 'website',
|
||||||
|
})
|
||||||
|
|
||||||
|
useHead({
|
||||||
|
script: [{
|
||||||
|
type: 'application/ld+json',
|
||||||
|
innerHTML: JSON.stringify({
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@type': 'Service',
|
||||||
|
name: 'Hytale Plugin Development',
|
||||||
|
provider: {
|
||||||
|
'@type': 'Person',
|
||||||
|
name: "Killian' DAL-CIN",
|
||||||
|
jobTitle: 'Hytale Plugin Developer',
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
}],
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<HytaleHeroSection />
|
||||||
|
<HytaleServicesSection />
|
||||||
|
<HytalePricingSection />
|
||||||
|
<div class="relative bg-gray-50/50 dark:bg-gray-900/20">
|
||||||
|
<TestimonialsSection />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { homeFAQs } from '~/data/faq'
|
||||||
|
|
||||||
|
const { t } = useI18n()
|
||||||
|
|
||||||
|
useSeoMeta({
|
||||||
|
title: () => t('seo.home.title'),
|
||||||
|
description: () => t('seo.home.description'),
|
||||||
|
ogTitle: () => t('seo.home.title'),
|
||||||
|
ogDescription: () => t('seo.home.description'),
|
||||||
|
ogImage: 'https://killiandalcin.fr/og-image.png',
|
||||||
|
ogImageWidth: 1200,
|
||||||
|
ogImageHeight: 630,
|
||||||
|
ogType: 'website',
|
||||||
|
})
|
||||||
|
|
||||||
|
useHead({
|
||||||
|
script: [
|
||||||
|
{
|
||||||
|
type: 'application/ld+json',
|
||||||
|
innerHTML: JSON.stringify({
|
||||||
|
'@context': 'https://schema.org',
|
||||||
|
'@graph': [
|
||||||
|
{
|
||||||
|
'@type': 'Person',
|
||||||
|
name: "Killian' DAL-CIN",
|
||||||
|
url: 'https://killiandalcin.fr',
|
||||||
|
jobTitle: 'Developpeur Full Stack Freelance',
|
||||||
|
email: 'contact@killiandalcin.fr',
|
||||||
|
sameAs: [
|
||||||
|
'https://linkedin.com/in/killian-dal-cin',
|
||||||
|
'https://www.fiverr.com/users/mr_kayjaydee',
|
||||||
|
'https://gitea.kamisama.ovh/kayjaydee',
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'@type': 'ProfessionalService',
|
||||||
|
name: "Killian' DAL-CIN - Developpeur Full Stack",
|
||||||
|
url: 'https://killiandalcin.fr',
|
||||||
|
logo: 'https://killiandalcin.fr/images/logo.webp',
|
||||||
|
priceRange: '$$$',
|
||||||
|
areaServed: 'Worldwide',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<!-- Hero Section -->
|
||||||
|
<HeroSection />
|
||||||
|
|
||||||
|
<!-- Featured Projects Section -->
|
||||||
|
<div class="relative bg-gray-50/50 dark:bg-gray-900/20">
|
||||||
|
<FeaturedProjectsSection />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Services Section -->
|
||||||
|
<ServicesSection />
|
||||||
|
|
||||||
|
<!-- Testimonials Section -->
|
||||||
|
<div class="relative bg-gray-50/50 dark:bg-gray-900/20">
|
||||||
|
<TestimonialsSection featured />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- FAQ Section -->
|
||||||
|
<FAQSection :faqs="homeFAQs" :title="t('faq.title')" :subtitle="t('faq.subtitle')" />
|
||||||
|
|
||||||
|
<!-- CTA Section -->
|
||||||
|
<CTASection />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,250 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
const route = useRoute()
|
||||||
|
const { t } = useI18n()
|
||||||
|
const { findById, projects } = useProjects()
|
||||||
|
|
||||||
|
const project = findById(route.params.id as string)
|
||||||
|
|
||||||
|
if (!project.value) {
|
||||||
|
throw createError({ status: 404, statusText: 'Project not found' })
|
||||||
|
}
|
||||||
|
|
||||||
|
const galleryRef = useTemplateRef('gallery')
|
||||||
|
|
||||||
|
const relatedProjects = computed(() => {
|
||||||
|
if (!project.value) return []
|
||||||
|
return projects.value
|
||||||
|
.filter((p) => p.id !== project.value!.id && p.category === project.value!.category)
|
||||||
|
.slice(0, 3)
|
||||||
|
})
|
||||||
|
|
||||||
|
useSeoMeta({
|
||||||
|
title: () => project.value?.title ?? '',
|
||||||
|
description: () => project.value?.description ?? '',
|
||||||
|
ogTitle: () => project.value?.title ?? '',
|
||||||
|
ogDescription: () => project.value?.description ?? '',
|
||||||
|
ogImage: 'https://killiandalcin.fr/og-image.png',
|
||||||
|
ogType: 'website',
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div v-if="project">
|
||||||
|
<!-- Full-width hero image -->
|
||||||
|
<section class="relative overflow-hidden">
|
||||||
|
<!-- Hero image with overlay -->
|
||||||
|
<div class="relative h-[40vh] sm:h-[50vh] lg:h-[60vh]">
|
||||||
|
<NuxtImg
|
||||||
|
v-if="project.image"
|
||||||
|
:src="project.image"
|
||||||
|
:alt="project.title"
|
||||||
|
class="w-full h-full object-cover"
|
||||||
|
format="webp"
|
||||||
|
loading="eager"
|
||||||
|
/>
|
||||||
|
<!-- Gradient overlay -->
|
||||||
|
<div class="absolute inset-0 bg-gradient-to-t from-white via-white/40 to-transparent dark:from-gray-950 dark:via-gray-950/40 dark:to-transparent" />
|
||||||
|
<div class="absolute inset-0 bg-gradient-to-r from-white/60 to-transparent dark:from-gray-950/60 dark:to-transparent" />
|
||||||
|
|
||||||
|
<!-- Back button (floating) -->
|
||||||
|
<div class="absolute top-6 left-4 sm:left-6 lg:left-8 z-20">
|
||||||
|
<UButton
|
||||||
|
variant="solid"
|
||||||
|
color="neutral"
|
||||||
|
icon="i-lucide-arrow-left"
|
||||||
|
to="/projects"
|
||||||
|
size="sm"
|
||||||
|
class="shadow-lg backdrop-blur-sm"
|
||||||
|
>
|
||||||
|
{{ t('projects.projectDetail.backToProjects') }}
|
||||||
|
</UButton>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Title overlay at bottom -->
|
||||||
|
<div class="absolute bottom-0 left-0 right-0 z-10 px-4 sm:px-6 lg:px-8 pb-10">
|
||||||
|
<div class="max-w-7xl mx-auto">
|
||||||
|
<div class="flex items-center gap-3 mb-4">
|
||||||
|
<UBadge v-if="project.category" variant="subtle" size="md">{{ project.category }}</UBadge>
|
||||||
|
<span v-if="project.date" class="text-sm text-gray-500 dark:text-gray-400 font-mono">{{ project.date }}</span>
|
||||||
|
</div>
|
||||||
|
<h1 class="text-3xl sm:text-4xl lg:text-5xl font-bold text-gray-900 dark:text-white max-w-3xl tracking-tight">{{ project.title }}</h1>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Content area -->
|
||||||
|
<section class="py-12 md:py-16 px-4 sm:px-6 lg:px-8">
|
||||||
|
<div class="max-w-7xl mx-auto">
|
||||||
|
<div class="grid grid-cols-1 lg:grid-cols-3 gap-10 lg:gap-16">
|
||||||
|
<!-- Main Content -->
|
||||||
|
<div class="lg:col-span-2 space-y-14">
|
||||||
|
<!-- Description -->
|
||||||
|
<div>
|
||||||
|
<p class="text-lg sm:text-xl text-gray-600 dark:text-gray-300 leading-relaxed mb-8">{{ project.description }}</p>
|
||||||
|
|
||||||
|
<!-- CTA Buttons -->
|
||||||
|
<div class="flex flex-wrap gap-3">
|
||||||
|
<UButton
|
||||||
|
v-if="project.demoUrl"
|
||||||
|
:to="project.demoUrl"
|
||||||
|
target="_blank"
|
||||||
|
icon="i-lucide-external-link"
|
||||||
|
size="lg"
|
||||||
|
class="font-semibold"
|
||||||
|
>
|
||||||
|
{{ t('projects.projectDetail.viewDemo') }}
|
||||||
|
</UButton>
|
||||||
|
|
||||||
|
<UButton
|
||||||
|
v-if="project.githubUrl"
|
||||||
|
:to="project.githubUrl"
|
||||||
|
target="_blank"
|
||||||
|
variant="soft"
|
||||||
|
icon="i-lucide-github"
|
||||||
|
size="lg"
|
||||||
|
>
|
||||||
|
{{ t('projects.projectDetail.sourceCode') }}
|
||||||
|
</UButton>
|
||||||
|
|
||||||
|
<UButton
|
||||||
|
v-for="button in project.buttons"
|
||||||
|
:key="button.title"
|
||||||
|
:to="button.link"
|
||||||
|
target="_blank"
|
||||||
|
variant="outline"
|
||||||
|
icon="i-lucide-external-link"
|
||||||
|
size="lg"
|
||||||
|
>
|
||||||
|
{{ button.title }}
|
||||||
|
</UButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- About -->
|
||||||
|
<div class="rounded-2xl border border-gray-200/80 dark:border-gray-800/50 bg-white/60 dark:bg-gray-900/40 backdrop-blur-sm p-7 sm:p-8">
|
||||||
|
<h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-5 flex items-center gap-3">
|
||||||
|
<div class="w-1 h-6 rounded-full bg-brand-500" />
|
||||||
|
{{ t('projects.projectDetail.aboutProject') }}
|
||||||
|
</h2>
|
||||||
|
<p class="text-gray-500 dark:text-gray-400 leading-relaxed text-lg">
|
||||||
|
{{ project.longDescription || project.description }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<!-- Features -->
|
||||||
|
<div v-if="project.features" class="mt-8">
|
||||||
|
<h3 class="text-lg font-bold text-gray-900 dark:text-white mb-5">{{ t('projects.projectDetail.keyFeatures') }}</h3>
|
||||||
|
<ul class="space-y-3">
|
||||||
|
<li v-for="feature in project.features" :key="feature" class="flex items-start gap-3 group">
|
||||||
|
<div class="w-6 h-6 rounded-lg bg-brand-500/10 dark:bg-brand-500/15 flex items-center justify-center shrink-0 mt-0.5 group-hover:bg-brand-500/20 transition-colors">
|
||||||
|
<UIcon name="i-lucide-check" class="text-brand-500 w-3.5 h-3.5" />
|
||||||
|
</div>
|
||||||
|
<span class="text-gray-600 dark:text-gray-300">{{ feature }}</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Technologies -->
|
||||||
|
<div v-if="project.technologies.length" class="rounded-2xl border border-gray-200/80 dark:border-gray-800/50 bg-white/60 dark:bg-gray-900/40 backdrop-blur-sm p-7 sm:p-8">
|
||||||
|
<h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-5 flex items-center gap-3">
|
||||||
|
<div class="w-1 h-6 rounded-full bg-brand-500" />
|
||||||
|
{{ t('projects.projectDetail.technologiesUsed') }}
|
||||||
|
</h2>
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<TechBadge v-for="tech in project.technologies" :key="tech" :tech="tech" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Gallery Thumbnails -->
|
||||||
|
<div v-if="project.gallery?.length" class="rounded-2xl border border-gray-200/80 dark:border-gray-800/50 bg-white/60 dark:bg-gray-900/40 backdrop-blur-sm p-7 sm:p-8">
|
||||||
|
<h2 class="text-2xl font-bold text-gray-900 dark:text-white mb-5 flex items-center gap-3">
|
||||||
|
<div class="w-1 h-6 rounded-full bg-brand-500" />
|
||||||
|
{{ t('projects.projectDetail.gallery') }}
|
||||||
|
</h2>
|
||||||
|
<div class="grid grid-cols-2 sm:grid-cols-3 gap-3">
|
||||||
|
<button
|
||||||
|
v-for="(image, index) in project.gallery"
|
||||||
|
:key="index"
|
||||||
|
class="relative rounded-xl overflow-hidden group cursor-pointer border border-gray-200/80 dark:border-gray-800/50 aspect-video"
|
||||||
|
@click="galleryRef?.openGallery(index)"
|
||||||
|
>
|
||||||
|
<NuxtImg
|
||||||
|
:src="image"
|
||||||
|
:alt="`${project.title} - Image ${index + 1}`"
|
||||||
|
class="w-full h-full object-cover transition-transform duration-300 group-hover:scale-105"
|
||||||
|
loading="lazy"
|
||||||
|
format="webp"
|
||||||
|
/>
|
||||||
|
<div class="absolute inset-0 bg-black/0 group-hover:bg-black/40 transition-colors duration-300 flex items-center justify-center">
|
||||||
|
<UIcon name="i-lucide-zoom-in" class="text-white opacity-0 group-hover:opacity-100 transition-opacity duration-300 text-xl" />
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sidebar -->
|
||||||
|
<aside class="sticky top-24 space-y-6">
|
||||||
|
<!-- Project Info Card -->
|
||||||
|
<div class="rounded-2xl border border-gray-200/80 dark:border-gray-800/50 bg-white/60 dark:bg-gray-900/40 backdrop-blur-sm p-6">
|
||||||
|
<h3 class="font-bold text-gray-900 dark:text-white mb-5 flex items-center gap-2">
|
||||||
|
<UIcon name="i-lucide-info" class="text-brand-500 w-4 h-4" />
|
||||||
|
{{ t('projects.projectDetail.projectInfo') }}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<div class="space-y-4 text-sm">
|
||||||
|
<div v-if="project.date" class="flex justify-between items-center py-3 border-b border-gray-200/60 dark:border-gray-800/40">
|
||||||
|
<span class="text-gray-500 dark:text-gray-400">{{ t('projects.projectDetail.date') }}</span>
|
||||||
|
<span class="font-semibold text-gray-900 dark:text-white font-mono text-xs">{{ project.date }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="project.category" class="flex justify-between items-center py-3">
|
||||||
|
<span class="text-gray-500 dark:text-gray-400">{{ t('projects.projectDetail.category') }}</span>
|
||||||
|
<UBadge variant="subtle" size="xs">{{ project.category }}</UBadge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Related Projects -->
|
||||||
|
<div v-if="relatedProjects.length > 0" class="rounded-2xl border border-gray-200/80 dark:border-gray-800/50 bg-white/60 dark:bg-gray-900/40 backdrop-blur-sm p-6">
|
||||||
|
<h3 class="font-bold text-gray-900 dark:text-white mb-5 flex items-center gap-2">
|
||||||
|
<UIcon name="i-lucide-layers" class="text-brand-500 w-4 h-4" />
|
||||||
|
{{ t('projects.projectDetail.relatedProjects') }}
|
||||||
|
</h3>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<NuxtLink
|
||||||
|
v-for="related in relatedProjects"
|
||||||
|
:key="related.id"
|
||||||
|
:to="`/project/${related.id}`"
|
||||||
|
class="flex gap-3 p-3 rounded-xl border border-transparent hover:border-brand-500/20 hover:bg-brand-500/5 transition-all duration-200 group"
|
||||||
|
>
|
||||||
|
<NuxtImg
|
||||||
|
v-if="related.image"
|
||||||
|
:src="related.image"
|
||||||
|
:alt="related.title"
|
||||||
|
width="60"
|
||||||
|
height="45"
|
||||||
|
class="rounded-lg object-cover shrink-0"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
<div class="min-w-0">
|
||||||
|
<p class="font-semibold text-sm text-gray-900 dark:text-white truncate group-hover:text-brand-500 transition-colors">{{ related.title }}</p>
|
||||||
|
<p class="text-xs text-gray-500 dark:text-gray-400 line-clamp-2 mt-1">{{ related.description }}</p>
|
||||||
|
</div>
|
||||||
|
</NuxtLink>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Gallery Modal -->
|
||||||
|
<ProjectGallery
|
||||||
|
v-if="project.gallery?.length"
|
||||||
|
ref="gallery"
|
||||||
|
:gallery="project.gallery"
|
||||||
|
:project-title="project.title"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
const { t } = useI18n()
|
||||||
|
const { projects } = useProjects()
|
||||||
|
|
||||||
|
useSeoMeta({
|
||||||
|
title: () => t('seo.projects.title'),
|
||||||
|
description: () => t('seo.projects.description'),
|
||||||
|
ogTitle: () => t('seo.projects.title'),
|
||||||
|
ogDescription: () => t('seo.projects.description'),
|
||||||
|
ogImage: 'https://killiandalcin.fr/og-image.png',
|
||||||
|
ogImageWidth: 1200,
|
||||||
|
ogImageHeight: 630,
|
||||||
|
ogType: 'website',
|
||||||
|
})
|
||||||
|
|
||||||
|
const searchQuery = ref('')
|
||||||
|
const selectedCategory = ref('all')
|
||||||
|
|
||||||
|
const categories = computed(() => [
|
||||||
|
'all',
|
||||||
|
...new Set(projects.value.map((p) => p.category).filter(Boolean)),
|
||||||
|
])
|
||||||
|
|
||||||
|
const filteredProjects = computed(() => {
|
||||||
|
let result = projects.value
|
||||||
|
|
||||||
|
if (searchQuery.value) {
|
||||||
|
const query = searchQuery.value.toLowerCase()
|
||||||
|
result = result.filter(
|
||||||
|
(project) =>
|
||||||
|
project.title.toLowerCase().includes(query) ||
|
||||||
|
project.description.toLowerCase().includes(query) ||
|
||||||
|
project.technologies.some((tech) => tech.toLowerCase().includes(query)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedCategory.value !== 'all') {
|
||||||
|
result = result.filter((project) => project.category === selectedCategory.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
})
|
||||||
|
|
||||||
|
const totalProjects = computed(() => projects.value.length)
|
||||||
|
const featuredCount = computed(() => projects.value.filter((p) => p.featured).length)
|
||||||
|
|
||||||
|
function resetFilters() {
|
||||||
|
searchQuery.value = ''
|
||||||
|
selectedCategory.value = 'all'
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<!-- Hero -->
|
||||||
|
<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">// portfolio</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('projects.title') }}</h1>
|
||||||
|
<p class="text-lg sm:text-xl text-gray-500 dark:text-gray-400 max-w-2xl mx-auto leading-relaxed">{{ t('projects.subtitle') }}</p>
|
||||||
|
|
||||||
|
<!-- Stats -->
|
||||||
|
<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">{{ totalProjects }}</p>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400 mt-2 font-medium">{{ t('nav.projects') }}</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">{{ featuredCount }}</p>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400 mt-2 font-medium">{{ t('home.featuredProjects.title') }}</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">{{ categories.length - 1 }}</p>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400 mt-2 font-medium">{{ t('projects.categories.all') }}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- Filters & Grid -->
|
||||||
|
<section class="py-16 md:py-20 px-4 sm:px-6 lg:px-8">
|
||||||
|
<div class="max-w-7xl mx-auto">
|
||||||
|
<!-- Filter bar -->
|
||||||
|
<div class="flex flex-col sm:flex-row gap-4 items-start sm:items-center mb-12 p-4 sm:p-5 rounded-2xl border border-gray-200/80 dark:border-gray-800/50 bg-white/60 dark:bg-gray-900/40 backdrop-blur-sm">
|
||||||
|
<UInput
|
||||||
|
v-model="searchQuery"
|
||||||
|
icon="i-lucide-search"
|
||||||
|
:placeholder="t('common.search') + '...'"
|
||||||
|
class="w-full sm:w-72"
|
||||||
|
size="md"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
<UButton
|
||||||
|
v-for="category in categories"
|
||||||
|
:key="category"
|
||||||
|
:variant="selectedCategory === category ? 'solid' : 'soft'"
|
||||||
|
:color="selectedCategory === category ? 'primary' : 'neutral'"
|
||||||
|
size="sm"
|
||||||
|
class="font-medium"
|
||||||
|
@click="selectedCategory = category"
|
||||||
|
>
|
||||||
|
{{ category === 'all' ? t('projects.categories.all') : t(`projects.categories.${category.replace(/\s+/g, '').toLowerCase()}`) || category }}
|
||||||
|
</UButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Projects Grid -->
|
||||||
|
<div v-if="filteredProjects.length > 0" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-5 lg:gap-6">
|
||||||
|
<ProjectCard v-for="project in filteredProjects" :key="project.id" :project="project" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Empty State -->
|
||||||
|
<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-search-x" class="text-2xl text-gray-400" />
|
||||||
|
</div>
|
||||||
|
<h3 class="text-xl font-bold text-gray-900 dark:text-white mb-3">{{ t('projects.noResults.title') }}</h3>
|
||||||
|
<p class="text-gray-500 dark:text-gray-400 mb-8 max-w-md mx-auto leading-relaxed">{{ t('projects.noResults.description') }}</p>
|
||||||
|
<UButton variant="soft" size="md" icon="i-lucide-rotate-ccw" @click="resetFilters">
|
||||||
|
{{ t('common.reset') }}
|
||||||
|
</UButton>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
const { data: page } = await useAsyncData('test', () =>
|
||||||
|
queryCollection('blog_fr').path('/fr/blog/test-kotlin-syntax').first()
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="min-h-screen bg-white dark:bg-neutral-950">
|
||||||
|
<div class="mx-auto max-w-6xl px-8 py-16">
|
||||||
|
<header class="mb-10">
|
||||||
|
<div class="mb-3 text-xs font-semibold uppercase tracking-widest text-neutral-400">
|
||||||
|
Renderer Test
|
||||||
|
</div>
|
||||||
|
<h1 class="text-3xl font-bold text-neutral-900 dark:text-white">
|
||||||
|
{{ page?.title }}
|
||||||
|
</h1>
|
||||||
|
<p class="mt-2 text-neutral-500 dark:text-neutral-400">
|
||||||
|
{{ page?.description }}
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<article
|
||||||
|
class="prose prose-neutral dark:prose-invert max-w-none
|
||||||
|
prose-headings:font-semibold
|
||||||
|
prose-code:before:content-none prose-code:after:content-none
|
||||||
|
prose-pre:p-0 prose-pre:bg-transparent">
|
||||||
|
<ContentRenderer v-if="page" :value="page" />
|
||||||
|
</article>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
import { defineContentConfig, defineCollection, z } from '@nuxt/content'
|
||||||
|
|
||||||
|
const blogSchema = z.object({
|
||||||
|
title: z.string(),
|
||||||
|
description: z.string(),
|
||||||
|
date: z.string(),
|
||||||
|
tags: z.array(z.string()).optional(),
|
||||||
|
image: z.string().optional(),
|
||||||
|
})
|
||||||
|
|
||||||
|
export default defineContentConfig({
|
||||||
|
collections: {
|
||||||
|
blog_fr: defineCollection({
|
||||||
|
type: 'page',
|
||||||
|
source: { include: 'fr/blog/**/*.md', prefix: '/fr/blog' },
|
||||||
|
schema: blogSchema,
|
||||||
|
}),
|
||||||
|
blog_en: defineCollection({
|
||||||
|
type: 'page',
|
||||||
|
source: { include: 'en/blog/**/*.md', prefix: '/en/blog' },
|
||||||
|
schema: blogSchema,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
})
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user