- Create useIntersectionObserver composable for scroll-triggered animations - Add TimelineItem component with alternating layout (desktop) - Implement journey page with i18n-based milestones data - Add 7 career milestones (2018-2025) in FR and EN - Gradient timeline line with animated connection points - Glassmorphism card design with hover effects - Respect prefers-reduced-motion for all animations Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
539 lines
16 KiB
Markdown
539 lines
16 KiB
Markdown
# Story 2.8: Page Parcours - Timeline narrative
|
|
|
|
Status: review
|
|
|
|
## 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
|
|
|
|
- [x] **Task 1: Décider de la source de données** (AC: #7)
|
|
- [x] Option A : Fichiers i18n (données statiques) - CHOISI
|
|
- [x] Le parcours change rarement, pas besoin de CRUD
|
|
|
|
- [x] **Task 2: Créer les données du parcours dans i18n** (AC: #2, #7)
|
|
- [x] Ajouter les clés `journey.milestones` dans fr.json et en.json
|
|
- [x] Structure : date, title, description, icon
|
|
- [x] 7 étapes du parcours professionnel (2018-2025)
|
|
|
|
- [x] **Task 3: Créer le composant TimelineItem** (AC: #2, #6, #9)
|
|
- [x] Créer `frontend/app/components/feature/TimelineItem.vue`
|
|
- [x] Props : milestone (date, title, description, icon)
|
|
- [x] Afficher l'icône, la date, le titre et la description
|
|
- [x] Utiliser font-narrative pour la description
|
|
|
|
- [x] **Task 4: Créer la page parcours.vue** (AC: #1, #3, #4)
|
|
- [x] Créer `frontend/app/pages/parcours.vue`
|
|
- [x] Charger les milestones depuis i18n avec tm()
|
|
- [x] Layout timeline vertical avec ligne centrale gradient
|
|
- [x] Desktop : alternance gauche/droite
|
|
- [x] Mobile : toutes les étapes à gauche de la ligne
|
|
|
|
- [x] **Task 5: Implémenter l'animation au scroll** (AC: #5)
|
|
- [x] Créer composable `useIntersectionObserver()`
|
|
- [x] Animation fade-in + slide-up pour chaque étape
|
|
- [x] Respecter prefers-reduced-motion
|
|
- [x] Points de la timeline s'illuminent au passage
|
|
|
|
- [x] **Task 6: Design de la timeline** (AC: #3, #4)
|
|
- [x] Ligne centrale verticale gradient sky-500
|
|
- [x] Points de connexion sur la ligne (circles sky-400)
|
|
- [x] Cards glassmorphism avec bordure subtile
|
|
- [x] Responsive : adaptation mobile
|
|
|
|
- [x] **Task 7: Meta tags SEO** (AC: #8)
|
|
- [x] Titre : "Parcours | Skycel"
|
|
- [x] Description du parcours
|
|
|
|
- [x] **Task 8: Tests et validation**
|
|
- [x] Build validé
|
|
- [x] Traductions FR et EN complètes
|
|
- [x] Animation au scroll fonctionnelle
|
|
|
|
## 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 |
|
|
| 2026-02-06 | Implémentation complète: useIntersectionObserver, TimelineItem, page parcours, traductions | Claude Opus 4.5 |
|
|
|
|
### File List
|
|
|