🎉 Init monorepo Nuxt 4 + Laravel 12 (Story 1.1)

Setup complet de l'infrastructure projet :
- Frontend Nuxt 4 (SSR, TypeScript, i18n, Pinia, TailwindCSS)
- Backend Laravel 12 API-only avec middleware X-API-Key et CORS
- Design tokens (sky-dark, sky-accent, sky-text) et polices (Merriweather, Inter)
- Documentation planning et implementation artifacts

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-05 02:08:56 +01:00
commit ec1ae92799
116 changed files with 55669 additions and 0 deletions

View File

@@ -0,0 +1,540 @@
# Story 2.8: Page Parcours - Timeline narrative
Status: ready-for-dev
## Story
As a visiteur,
I want découvrir le parcours professionnel du développeur sous forme de timeline,
so that je comprends son évolution et son expérience.
## Acceptance Criteria
1. **Given** le visiteur accède à `/parcours` (FR) ou `/en/journey` (EN) **When** la page se charge **Then** une timeline verticale affiche les étapes chronologiques du parcours
2. **And** chaque étape affiche : date, titre, description narrative traduite
3. **And** sur desktop : les étapes alternent gauche/droite pour un effet visuel dynamique
4. **And** sur mobile : les étapes sont linéaires (toutes du même côté)
5. **And** une animation d'apparition au scroll est présente (respectant `prefers-reduced-motion`)
6. **And** des icônes ou images illustrent les étapes clés
7. **And** le contenu est bilingue (FR/EN) et chargé depuis l'API ou fichiers i18n
8. **And** les meta tags SEO sont dynamiques pour cette page
9. **And** la police serif narrative est utilisée pour les descriptions
## Tasks / Subtasks
- [ ] **Task 1: Décider de la source de données** (AC: #7)
- [ ] Option A : Fichiers i18n (données statiques)
- [ ] Option B : Table BDD + API (données dynamiques)
- [ ] Recommandation : Fichiers i18n (le parcours change rarement, pas besoin de CRUD)
- [ ] **Task 2: Créer les données du parcours dans i18n** (AC: #2, #7)
- [ ] Ajouter les clés `journey.milestones` dans fr.json et en.json
- [ ] Structure : date, title, description, icon
- [ ] 5-8 étapes du parcours professionnel
- [ ] **Task 3: Créer le composant TimelineItem** (AC: #2, #6, #9)
- [ ] Créer `frontend/app/components/feature/TimelineItem.vue`
- [ ] Props : milestone (date, title, description, icon)
- [ ] Afficher l'icône/image, la date, le titre et la description
- [ ] Utiliser font-narrative pour la description
- [ ] **Task 4: Créer la page parcours.vue** (AC: #1, #3, #4)
- [ ] Créer `frontend/app/pages/parcours.vue`
- [ ] Charger les milestones depuis i18n
- [ ] Layout timeline vertical avec ligne centrale
- [ ] Desktop : alternance gauche/droite
- [ ] Mobile : toutes les étapes à droite
- [ ] **Task 5: Implémenter l'animation au scroll** (AC: #5)
- [ ] Utiliser IntersectionObserver pour détecter l'entrée dans le viewport
- [ ] Animation fade-in + slide-up pour chaque étape
- [ ] Respecter prefers-reduced-motion
- [ ] Créer un composable `useIntersectionObserver()`
- [ ] **Task 6: Design de la timeline** (AC: #3, #4)
- [ ] Ligne centrale verticale (sky-dark-100)
- [ ] Points de connexion sur la ligne (circles sky-accent)
- [ ] Cards avec flèche vers la ligne centrale
- [ ] Responsive : adaptation mobile
- [ ] **Task 7: Meta tags SEO** (AC: #8)
- [ ] Titre : "Mon Parcours | Skycel"
- [ ] Description du parcours
- [ ] **Task 8: Tests et validation**
- [ ] Tester en FR et EN
- [ ] Valider l'alternance desktop
- [ ] Vérifier le layout mobile
- [ ] Tester l'animation au scroll
- [ ] Valider prefers-reduced-motion
## Dev Notes
### Structure des données dans i18n
**fr.json :**
```json
{
"journey": {
"title": "Mon Parcours",
"pageTitle": "Parcours | Skycel",
"pageDescription": "Découvrez le parcours professionnel de Célian, de ses débuts à aujourd'hui.",
"milestones": [
{
"date": "2018",
"title": "Premiers pas en développement",
"description": "Découverte du code à travers des projets personnels. HTML, CSS, JavaScript deviennent mes nouveaux compagnons de route. L'étincelle est là.",
"icon": "🚀"
},
{
"date": "2019",
"title": "Formation intensive",
"description": "Plongée dans le monde du développement web professionnel. Apprentissage de frameworks modernes, bonnes pratiques, et méthodologies agiles.",
"icon": "📚"
},
{
"date": "2020",
"title": "Premiers clients",
"description": "Lancement en freelance. Premiers projets concrets, premiers défis réels. Chaque client m'apprend quelque chose de nouveau.",
"icon": "💼"
},
{
"date": "2021",
"title": "Spécialisation Vue.js & Laravel",
"description": "Le duo qui change tout. Vue.js côté front, Laravel côté back. Une stack qui me permet de créer des expériences web complètes et performantes.",
"icon": "⚡"
},
{
"date": "2022",
"title": "Création de la micro-entreprise",
"description": "Officialisation de l'aventure entrepreneuriale. L'araignée devient la mascotte, le Bug devient le guide. L'identité Skycel prend forme.",
"icon": "🕷️"
},
{
"date": "2023-2024",
"title": "Projets ambitieux",
"description": "Des applications web complexes aux sites e-commerce, chaque projet repousse les limites. TypeScript, Nuxt 4, et une obsession pour la qualité.",
"icon": "🎯"
},
{
"date": "2025",
"title": "Aujourd'hui",
"description": "Ce portfolio que vous explorez. Une aventure en soi, qui reflète ma passion pour créer des expériences web mémorables. Et ce n'est que le début...",
"icon": "✨"
}
]
}
}
```
**en.json :**
```json
{
"journey": {
"title": "My Journey",
"pageTitle": "Journey | Skycel",
"pageDescription": "Discover Célian's professional journey, from the beginning to today.",
"milestones": [
{
"date": "2018",
"title": "First steps in development",
"description": "Discovering code through personal projects. HTML, CSS, JavaScript became my new travel companions. The spark was there.",
"icon": "🚀"
},
{
"date": "2019",
"title": "Intensive training",
"description": "Deep dive into professional web development. Learning modern frameworks, best practices, and agile methodologies.",
"icon": "📚"
},
{
"date": "2020",
"title": "First clients",
"description": "Starting as a freelancer. First real projects, first real challenges. Each client teaches me something new.",
"icon": "💼"
},
{
"date": "2021",
"title": "Specialization in Vue.js & Laravel",
"description": "The game-changing duo. Vue.js on the front, Laravel on the back. A stack that allows me to create complete, performant web experiences.",
"icon": "⚡"
},
{
"date": "2022",
"title": "Creating the micro-enterprise",
"description": "Making the entrepreneurial adventure official. The spider becomes the mascot, the Bug becomes the guide. The Skycel identity takes shape.",
"icon": "🕷️"
},
{
"date": "2023-2024",
"title": "Ambitious projects",
"description": "From complex web applications to e-commerce sites, each project pushes boundaries. TypeScript, Nuxt 4, and an obsession with quality.",
"icon": "🎯"
},
{
"date": "2025",
"title": "Today",
"description": "This portfolio you're exploring. An adventure in itself, reflecting my passion for creating memorable web experiences. And this is just the beginning...",
"icon": "✨"
}
]
}
}
```
### Composable useIntersectionObserver
```typescript
// frontend/app/composables/useIntersectionObserver.ts
export interface UseIntersectionObserverOptions {
threshold?: number
rootMargin?: string
once?: boolean
}
export function useIntersectionObserver(
target: Ref<HTMLElement | null>,
options: UseIntersectionObserverOptions = {}
) {
const { threshold = 0.1, rootMargin = '0px', once = true } = options
const isVisible = ref(false)
let observer: IntersectionObserver | null = null
onMounted(() => {
if (!target.value) return
observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
isVisible.value = true
if (once && observer) {
observer.unobserve(entry.target)
}
} else if (!once) {
isVisible.value = false
}
})
},
{ threshold, rootMargin }
)
observer.observe(target.value)
})
onUnmounted(() => {
if (observer) {
observer.disconnect()
}
})
return { isVisible }
}
```
### Composant TimelineItem
```vue
<!-- frontend/app/components/feature/TimelineItem.vue -->
<script setup lang="ts">
interface Milestone {
date: string
title: string
description: string
icon: string
}
const props = defineProps<{
milestone: Milestone
index: number
isLeft: boolean
}>()
const itemRef = ref<HTMLElement | null>(null)
const reducedMotion = useReducedMotion()
const { isVisible } = useIntersectionObserver(itemRef, {
threshold: 0.2,
rootMargin: '-50px',
})
// Animation désactivée si prefers-reduced-motion
const shouldAnimate = computed(() => !reducedMotion.value)
</script>
<template>
<div
ref="itemRef"
class="timeline-item relative flex"
:class="[
isLeft ? 'md:flex-row-reverse' : 'md:flex-row',
'flex-row'
]"
>
<!-- Contenu de l'étape -->
<div
class="timeline-content w-full md:w-1/2 px-4 md:px-8"
:class="[
shouldAnimate && isVisible ? 'animate-in' : '',
shouldAnimate && !isVisible ? 'opacity-0 translate-y-4' : '',
!shouldAnimate ? '' : ''
]"
>
<div
class="relative bg-sky-dark-50 rounded-lg p-6 shadow-lg"
:class="[
isLeft ? 'md:mr-8' : 'md:ml-8',
'ml-8'
]"
>
<!-- Flèche vers la ligne -->
<div
class="absolute top-6 w-4 h-4 bg-sky-dark-50 transform rotate-45"
:class="[
isLeft ? 'md:-right-2 md:left-auto -left-2' : 'md:-left-2 -left-2',
]"
></div>
<!-- Icône -->
<div class="text-4xl mb-3">
{{ milestone.icon }}
</div>
<!-- Date -->
<span class="inline-block px-3 py-1 bg-sky-accent/20 text-sky-accent text-sm font-ui font-medium rounded-full mb-3">
{{ milestone.date }}
</span>
<!-- Titre -->
<h3 class="text-xl font-ui font-bold text-sky-text mb-2">
{{ milestone.title }}
</h3>
<!-- Description -->
<p class="font-narrative text-sky-text-muted leading-relaxed">
{{ milestone.description }}
</p>
</div>
</div>
<!-- Point sur la ligne (visible uniquement côté desktop) -->
<div class="timeline-dot absolute left-0 md:left-1/2 top-6 transform md:-translate-x-1/2 -translate-x-1/2">
<div class="w-4 h-4 bg-sky-accent rounded-full ring-4 ring-sky-dark"></div>
</div>
</div>
</template>
<style scoped>
.animate-in {
animation: fadeSlideUp 0.6s ease-out forwards;
}
@keyframes fadeSlideUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
@media (prefers-reduced-motion: reduce) {
.timeline-content {
opacity: 1 !important;
transform: none !important;
}
.animate-in {
animation: none;
}
}
</style>
```
### Page parcours.vue
```vue
<!-- frontend/app/pages/parcours.vue -->
<script setup lang="ts">
const { t, tm } = useI18n()
interface Milestone {
date: string
title: string
description: string
icon: string
}
// Charger les milestones depuis i18n
const milestones = computed(() => {
const data = tm('journey.milestones')
if (Array.isArray(data)) {
return data as Milestone[]
}
return []
})
// SEO
useHead({
title: () => t('journey.pageTitle'),
})
useSeoMeta({
title: () => t('journey.pageTitle'),
description: () => t('journey.pageDescription'),
ogTitle: () => t('journey.pageTitle'),
ogDescription: () => t('journey.pageDescription'),
})
</script>
<template>
<div class="container mx-auto px-4 py-8">
<h1 class="text-3xl font-ui font-bold text-sky-text mb-12 text-center">
{{ t('journey.title') }}
</h1>
<!-- Timeline -->
<div class="relative">
<!-- Ligne centrale (visible uniquement sur desktop) -->
<div class="absolute left-0 md:left-1/2 top-0 bottom-0 w-0.5 bg-sky-dark-100 transform md:-translate-x-1/2"></div>
<!-- Étapes -->
<div class="space-y-12">
<TimelineItem
v-for="(milestone, index) in milestones"
:key="index"
:milestone="milestone"
:index="index"
:is-left="index % 2 === 0"
/>
</div>
</div>
<!-- Message de fin -->
<div class="mt-16 text-center">
<p class="font-narrative text-xl text-sky-text-muted italic">
{{ t('journey.endMessage') }}
</p>
</div>
</div>
</template>
```
### Clés i18n supplémentaires
**fr.json :**
```json
{
"journey": {
"endMessage": "L'aventure continue... Qui sait où le code me mènera demain ?"
}
}
```
**en.json :**
```json
{
"journey": {
"endMessage": "The adventure continues... Who knows where code will take me tomorrow?"
}
}
```
### Design de la timeline
```
DESKTOP (alternance gauche/droite) :
┌─────────────────┐
│ 2018 │
│ Description │──●──
└─────────────────┘ │
──●────┼────┌─────────────────┐
│ │ 2019 │
│ │ Description │
│ └─────────────────┘
┌─────────────────┐ │
│ 2020 │──●──
│ Description │ │
└─────────────────┘ │
MOBILE (linéaire à droite) :
│ ┌─────────────────┐
●──│ 2018 │
│ │ Description │
│ └─────────────────┘
│ ┌─────────────────┐
●──│ 2019 │
│ │ Description │
│ └─────────────────┘
```
### Dépendances
**Cette story nécessite :**
- Story 1.3 : Système i18n configuré
- Story 1.4 : Layouts et routing
**Cette story prépare pour :**
- Aucune dépendance directe (dernière story de l'Epic 2)
### Project Structure Notes
**Fichiers à créer :**
```
frontend/app/
├── pages/
│ └── parcours.vue # CRÉER
├── components/feature/
│ └── TimelineItem.vue # CRÉER
└── composables/
└── useIntersectionObserver.ts # CRÉER
```
**Fichiers à modifier :**
```
frontend/i18n/fr.json # AJOUTER journey.*
frontend/i18n/en.json # AJOUTER journey.*
```
### References
- [Source: docs/planning-artifacts/epics.md#Story-2.8]
- [Source: docs/planning-artifacts/ux-design-specification.md#Screen-Architecture-Summary]
- [Source: docs/planning-artifacts/ux-design-specification.md#Typography-System]
### Technical Requirements
| Requirement | Value | Source |
|-------------|-------|--------|
| Source données | Fichiers i18n | Décision technique |
| Layout desktop | Alternance gauche/droite | Epics |
| Layout mobile | Linéaire à droite | Epics |
| Animation | IntersectionObserver + fade-in | Epics |
| Police | font-narrative pour descriptions | UX Spec |
## Dev Agent Record
### Agent Model Used
{{agent_model_name_version}}
### Debug Log References
### Completion Notes List
### Change Log
| Date | Change | Author |
|------|--------|--------|
| 2026-02-04 | Story créée avec contexte complet | SM Agent |
### File List