Files
Portfolio-Game/docs/implementation-artifacts/2-3-page-projet-detail.md
skycel 2269ecdb62 Add project detail page with prev/next navigation (Story 2.3)
- Enhance ProjectController show() with prev/next navigation data
- Create useFetchProject composable with ProjectNavigation type
- Implement [slug].vue with full project details:
  - Hero image, title with featured badge, formatted date
  - Description, external links (site/GitHub)
  - Skills grid with level progression (before → after)
  - Prev/next navigation with project titles
  - 404 state with spider narrator
- Add dynamic SEO meta tags with og:image from project
- Responsive design: stacked mobile, grid desktop

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-06 02:20:27 +01:00

15 KiB

Story 2.3: Page Projet - Détail

Status: review

Story

As a visiteur, I want voir les détails d'un projet spécifique, so that je comprends le travail réalisé et les technologies utilisées.

Acceptance Criteria

  1. Given le visiteur accède à /projets/{slug} (FR) ou /en/projects/{slug} (EN) When la page se charge Then le titre, la description complète et l'image principale du projet s'affichent
  2. And la date de réalisation est visible
  3. And la liste des compétences utilisées s'affiche avec leurs niveaux (avant/après le projet)
  4. And les liens externes sont présents : URL du projet live (si existe), repository GitHub (si existe)
  5. And une navigation "Projet précédent / Projet suivant" permet de parcourir les projets
  6. And un bouton retour vers la galerie est visible
  7. And les meta tags SEO sont dynamiques (titre, description, image Open Graph)
  8. And si le slug n'existe pas, une page 404 appropriée s'affiche
  9. And le design est responsive (adaptation mobile/desktop)

Tasks / Subtasks

  • Task 1: Créer l'endpoint API pour le détail du projet (AC: #1, #2, #3, #4, #8)

    • Ajouter la méthode show($slug) dans ProjectController
    • Charger le projet avec ses compétences (eager loading)
    • Retourner 404 si le slug n'existe pas
    • Inclure les données de traduction selon Accept-Language
  • Task 2: Créer l'endpoint API pour la navigation prev/next (AC: #5)

    • Ajouter une méthode navigation($slug) ou inclure dans show()
    • Retourner le projet précédent et suivant (basé sur l'ordre de tri)
    • Si premier projet : prev = null, si dernier : next = null
  • Task 3: Créer le composable useFetchProject (AC: #1)

    • Créer frontend/app/composables/useFetchProject.ts
    • Accepter le slug en paramètre
    • Gérer les états loading, error, data
    • Gérer l'erreur 404
  • Task 4: Créer la page [slug].vue (AC: #1, #2, #3, #4, #6, #9)

    • Créer frontend/app/pages/projets/[slug].vue
    • Afficher l'image principale en grand format
    • Afficher le titre et la description complète
    • Afficher la date de réalisation formatée
    • Afficher la liste des compétences avec progression (avant → après)
    • Afficher les liens externes (site live, GitHub) si présents
    • Ajouter un bouton "Retour à la galerie"
  • Task 5: Implémenter la navigation prev/next (AC: #5)

    • Ajouter les boutons "Projet précédent" et "Projet suivant"
    • Utiliser NuxtLink pour la navigation
    • Afficher le titre du projet dans le bouton
    • Désactiver/masquer si pas de prev ou next
  • Task 6: Meta tags SEO dynamiques (AC: #7)

    • Utiliser useHead() avec le titre du projet
    • Utiliser useSeoMeta() pour description, og:title, og:description, og:image
    • L'image OG doit être l'image du projet
  • Task 7: Gestion de l'erreur 404 (AC: #8)

    • Détecter si le projet n'existe pas
    • Afficher un message approprié avec le narrateur
    • Proposer de retourner à la galerie
  • Task 8: Design responsive (AC: #9)

    • Mobile : layout vertical, image pleine largeur
    • Desktop : layout 2 colonnes (image + contenu) ou grande image + contenu dessous
    • Liste des compétences responsive (flex wrap)
  • Task 9: Tests et validation

    • Tester avec différents slugs de projets
    • Tester la navigation prev/next
    • Tester le 404 avec un slug inexistant
    • Valider les meta tags SEO
    • Tester le responsive

Dev Notes

Endpoint API Laravel

<?php
// api/app/Http/Controllers/Api/ProjectController.php

public function show(Request $request, string $slug)
{
    $lang = $request->header('Accept-Language', 'fr');

    $project = Project::with('skills')
        ->where('slug', $slug)
        ->first();

    if (!$project) {
        return response()->json([
            'error' => [
                'code' => 'PROJECT_NOT_FOUND',
                'message' => 'Project not found',
            ]
        ], 404);
    }

    // Navigation prev/next
    $allProjects = Project::orderByDesc('is_featured')
        ->orderByDesc('date_completed')
        ->get(['id', 'slug', 'title_key']);

    $currentIndex = $allProjects->search(fn ($p) => $p->slug === $slug);

    $prev = $currentIndex > 0 ? $allProjects[$currentIndex - 1] : null;
    $next = $currentIndex < $allProjects->count() - 1 ? $allProjects[$currentIndex + 1] : null;

    return (new ProjectResource($project))->additional([
        'meta' => [
            'lang' => $lang,
        ],
        'navigation' => [
            'prev' => $prev ? [
                'slug' => $prev->slug,
                'title' => Translation::getTranslation($prev->title_key, $lang),
            ] : null,
            'next' => $next ? [
                'slug' => $next->slug,
                'title' => Translation::getTranslation($next->title_key, $lang),
            ] : null,
        ],
    ]);
}
// api/routes/api.php
Route::get('/projects/{slug}', [ProjectController::class, 'show']);

Composable useFetchProject

// frontend/app/composables/useFetchProject.ts
import type { Project } from '~/types/project'

interface ProjectNavigation {
  prev: { slug: string; title: string } | null
  next: { slug: string; title: string } | null
}

interface ProjectResponse {
  data: Project
  meta: { lang: string }
  navigation: ProjectNavigation
}

export function useFetchProject(slug: string | Ref<string>) {
  const config = useRuntimeConfig()
  const { locale } = useI18n()
  const slugValue = toValue(slug)

  return useFetch<ProjectResponse>(`/projects/${slugValue}`, {
    baseURL: config.public.apiUrl,
    headers: {
      'X-API-Key': config.public.apiKey,
      'Accept-Language': locale.value,
    },
  })
}

Page [slug].vue

<!-- frontend/app/pages/projets/[slug].vue -->
<script setup lang="ts">
const route = useRoute()
const { t, d } = useI18n()
const localePath = useLocalePath()

const slug = computed(() => route.params.slug as string)
const { data, pending, error } = useFetchProject(slug)

const project = computed(() => data.value?.data)
const navigation = computed(() => data.value?.navigation)

// SEO dynamique
useHead({
  title: () => project.value?.title ? `${project.value.title} | Skycel` : t('projects.loading'),
})

useSeoMeta({
  title: () => project.value?.title,
  description: () => project.value?.shortDescription,
  ogTitle: () => project.value?.title,
  ogDescription: () => project.value?.shortDescription,
  ogImage: () => project.value?.image,
})
</script>

<template>
  <div class="container mx-auto px-4 py-8">
    <!-- Loading -->
    <div v-if="pending" class="animate-pulse">
      <div class="bg-sky-dark-50 rounded-lg h-64 mb-6"></div>
      <div class="bg-sky-dark-50 h-10 rounded w-1/2 mb-4"></div>
      <div class="bg-sky-dark-50 h-4 rounded w-full mb-2"></div>
      <div class="bg-sky-dark-50 h-4 rounded w-3/4"></div>
    </div>

    <!-- Error 404 -->
    <div v-else-if="error" class="text-center py-16">
      <h1 class="text-2xl font-ui font-bold text-sky-text mb-4">
        {{ t('projects.notFound') }}
      </h1>
      <p class="text-sky-text-muted mb-6">
        {{ t('projects.notFoundDescription') }}
      </p>
      <NuxtLink
        :to="localePath('/projets')"
        class="bg-sky-accent text-white px-6 py-3 rounded-lg hover:bg-sky-accent-hover inline-block"
      >
        {{ t('projects.backToGallery') }}
      </NuxtLink>
    </div>

    <!-- Project content -->
    <article v-else-if="project">
      <!-- Retour galerie -->
      <NuxtLink
        :to="localePath('/projets')"
        class="inline-flex items-center text-sky-text-muted hover:text-sky-accent mb-6 transition-colors"
      >
        <span class="mr-2"></span>
        {{ t('projects.backToGallery') }}
      </NuxtLink>

      <!-- Image principale -->
      <div class="mb-8">
        <NuxtImg
          :src="project.image"
          :alt="project.title"
          format="webp"
          class="w-full h-auto max-h-96 object-cover rounded-lg"
        />
      </div>

      <!-- Titre et date -->
      <header class="mb-6">
        <h1 class="text-3xl md:text-4xl font-ui font-bold text-sky-text mb-2">
          {{ project.title }}
        </h1>
        <p class="text-sky-text-muted">
          {{ t('projects.completedOn') }} {{ d(new Date(project.dateCompleted), 'long') }}
        </p>
      </header>

      <!-- Description -->
      <div class="prose prose-invert max-w-none mb-8">
        <p class="text-sky-text text-lg leading-relaxed">
          {{ project.description }}
        </p>
      </div>

      <!-- Liens externes -->
      <div v-if="project.url || project.githubUrl" class="flex flex-wrap gap-4 mb-8">
        <a
          v-if="project.url"
          :href="project.url"
          target="_blank"
          rel="noopener noreferrer"
          class="inline-flex items-center bg-sky-accent text-white px-6 py-3 rounded-lg hover:bg-sky-accent-hover transition-colors"
        >
          🌐 {{ t('projects.visitSite') }}
        </a>
        <a
          v-if="project.githubUrl"
          :href="project.githubUrl"
          target="_blank"
          rel="noopener noreferrer"
          class="inline-flex items-center bg-sky-dark-50 text-sky-text px-6 py-3 rounded-lg hover:bg-sky-dark-100 transition-colors border border-sky-dark-100"
        >
          💻 {{ t('projects.viewCode') }}
        </a>
      </div>

      <!-- Compétences utilisées -->
      <section v-if="project.skills?.length" class="mb-12">
        <h2 class="text-xl font-ui font-semibold text-sky-text mb-4">
          {{ t('projects.skillsUsed') }}
        </h2>
        <div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
          <div
            v-for="skill in project.skills"
            :key="skill.id"
            class="bg-sky-dark-50 rounded-lg p-4"
          >
            <div class="font-ui font-medium text-sky-text">{{ skill.name }}</div>
            <div class="text-sm text-sky-text-muted mt-1">
              {{ t('projects.skillLevel') }}:
              <span class="text-sky-accent">{{ skill.levelBefore }}</span>
              
              <span class="text-sky-accent font-semibold">{{ skill.levelAfter }}</span>
            </div>
          </div>
        </div>
      </section>

      <!-- Navigation prev/next -->
      <nav class="flex justify-between items-center border-t border-sky-dark-100 pt-8 mt-8">
        <NuxtLink
          v-if="navigation?.prev"
          :to="localePath(`/projets/${navigation.prev.slug}`)"
          class="flex flex-col text-left hover:text-sky-accent transition-colors"
        >
          <span class="text-sm text-sky-text-muted">{{ t('projects.previous') }}</span>
          <span class="text-sky-text"> {{ navigation.prev.title }}</span>
        </NuxtLink>
        <div v-else></div>

        <NuxtLink
          v-if="navigation?.next"
          :to="localePath(`/projets/${navigation.next.slug}`)"
          class="flex flex-col text-right hover:text-sky-accent transition-colors"
        >
          <span class="text-sm text-sky-text-muted">{{ t('projects.next') }}</span>
          <span class="text-sky-text">{{ navigation.next.title }} </span>
        </NuxtLink>
        <div v-else></div>
      </nav>
    </article>
  </div>
</template>

Clés i18n nécessaires

fr.json :

{
  "projects": {
    "loading": "Chargement...",
    "notFound": "Projet introuvable",
    "notFoundDescription": "Ce projet n'existe pas ou a été supprimé.",
    "backToGallery": "Retour à la galerie",
    "completedOn": "Réalisé le",
    "visitSite": "Voir le site",
    "viewCode": "Voir le code",
    "skillsUsed": "Compétences utilisées",
    "skillLevel": "Niveau",
    "previous": "Projet précédent",
    "next": "Projet suivant"
  }
}

en.json :

{
  "projects": {
    "loading": "Loading...",
    "notFound": "Project not found",
    "notFoundDescription": "This project doesn't exist or has been removed.",
    "backToGallery": "Back to gallery",
    "completedOn": "Completed on",
    "visitSite": "Visit site",
    "viewCode": "View code",
    "skillsUsed": "Skills used",
    "skillLevel": "Level",
    "previous": "Previous project",
    "next": "Next project"
  }
}

Configuration i18n pour les dates

Ajouter dans nuxt.config.ts la configuration des formats de date :

i18n: {
  datetimeFormats: {
    fr: {
      long: { year: 'numeric', month: 'long', day: 'numeric' }
    },
    en: {
      long: { year: 'numeric', month: 'long', day: 'numeric' }
    }
  }
}

Dépendances

Cette story nécessite :

  • Story 2.1 : Composant ProjectCard
  • Story 2.2 : Endpoint API /api/projects et page galerie

Cette story prépare pour :

  • Story 2.5 : Compétences cliquables (liens vers projets)

Project Structure Notes

Fichiers à créer :

frontend/app/
├── pages/
│   └── projets/
│       └── [slug].vue           # CRÉER
└── composables/
    └── useFetchProject.ts       # CRÉER

Fichiers à modifier :

api/app/Http/Controllers/Api/ProjectController.php  # AJOUTER show()
api/routes/api.php                                   # AJOUTER route
frontend/i18n/fr.json                                # AJOUTER clés
frontend/i18n/en.json                                # AJOUTER clés
frontend/nuxt.config.ts                              # AJOUTER datetimeFormats

References

  • [Source: docs/planning-artifacts/epics.md#Story-2.3]
  • [Source: docs/planning-artifacts/architecture.md#API-&-Communication-Patterns]
  • [Source: docs/planning-artifacts/ux-design-specification.md#Responsive-Strategy]

Technical Requirements

Requirement Value Source
Route dynamique /projets/[slug] Nuxt routing
API endpoint GET /api/projects/{slug} Architecture
Navigation prev/next avec titres Epics
SEO Meta dynamiques + OG image NFR5
404 Message approprié Epics

Dev Agent Record

Agent Model Used

Claude Opus 4.5 (claude-opus-4-5-20251101)

Debug Log References

  • Aucun problème. Méthode show() existait déjà mais sans navigation prev/next.

Completion Notes List

  • ProjectController show() amélioré avec navigation prev/next et erreur 404 structurée
  • Composable useFetchProject créé avec typage ProjectNavigation
  • Page [slug].vue complète avec :
    • Image principale pleine largeur
    • Titre, date formatée selon locale, badge featured
    • Description complète
    • Liens externes (site et GitHub)
    • Grille responsive des compétences utilisées avec niveaux avant/après
    • Navigation prev/next avec titres traduits
    • État loading (skeleton), état 404 avec narrateur spider
  • SEO dynamique via useSeo avec og:image du projet
  • Formatage date via Intl.DateTimeFormat selon locale
  • Traductions FR/EN ajoutées (10 nouvelles clés projects.*)
  • Store progression visitSection('projets') sur mount

Change Log

Date Change Author
2026-02-04 Story créée avec contexte complet SM Agent
2026-02-06 Tasks 1-9 implémentées et validées Dev Agent (Claude Opus 4.5)

File List

  • api/app/Http/Controllers/Api/ProjectController.php — MODIFIÉ (show avec navigation)
  • frontend/app/composables/useFetchProject.ts — CRÉÉ
  • frontend/app/pages/projets/[slug].vue — RÉÉCRIT
  • frontend/i18n/fr.json — MODIFIÉ (ajout projects.*)
  • frontend/i18n/en.json — MODIFIÉ (ajout projects.*)