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>
14 KiB
Story 2.3: Page Projet - Détail
Status: ready-for-dev
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
- 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 - And la date de réalisation est visible
- And la liste des compétences utilisées s'affiche avec leurs niveaux (avant/après le projet)
- And les liens externes sont présents : URL du projet live (si existe), repository GitHub (si existe)
- And une navigation "Projet précédent / Projet suivant" permet de parcourir les projets
- And un bouton retour vers la galerie est visible
- And les meta tags SEO sont dynamiques (titre, description, image Open Graph)
- And si le slug n'existe pas, une page 404 appropriée s'affiche
- 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)dansProjectController - 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
- Ajouter la méthode
-
Task 2: Créer l'endpoint API pour la navigation prev/next (AC: #5)
- Ajouter une méthode
navigation($slug)ou inclure dansshow() - Retourner le projet précédent et suivant (basé sur l'ordre de tri)
- Si premier projet : prev = null, si dernier : next = null
- Ajouter une méthode
-
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
- Créer
-
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"
- Créer
-
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
- Utiliser
-
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/projectset 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
{{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 |