Files
Portfolio-Game/docs/implementation-artifacts/2-8-page-parcours-timeline-narrative.md
skycel ec1ae92799 🎉 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>
2026-02-05 02:08:56 +01:00

16 KiB

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 :

{
  "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 :

{
  "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

// 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

<!-- 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

<!-- 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 :

{
  "journey": {
    "endMessage": "L'aventure continue... Qui sait où le code me mènera demain ?"
  }
}

en.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