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>
This commit is contained in:
2026-02-06 02:20:27 +01:00
parent 0399f0dc1c
commit 2269ecdb62
7 changed files with 356 additions and 62 deletions

View File

@@ -5,6 +5,7 @@ namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Resources\ProjectResource;
use App\Models\Project;
use App\Models\Translation;
class ProjectController extends Controller
{
@@ -18,9 +19,39 @@ class ProjectController extends Controller
public function show(string $slug)
{
$project = Project::with('skills')->where('slug', $slug)->firstOrFail();
$project = Project::with('skills')->where('slug', $slug)->first();
if (!$project) {
return response()->json([
'error' => [
'code' => 'PROJECT_NOT_FOUND',
'message' => 'Project not found',
],
], 404);
}
$lang = app()->getLocale();
// Get all projects in order for navigation
$allProjects = Project::ordered()->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' => app()->getLocale()]]);
->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,
],
]);
}
}

View File

@@ -1,6 +1,6 @@
# Story 2.3: Page Projet - Détail
Status: ready-for-dev
Status: review
## Story
@@ -22,59 +22,59 @@ so that je comprends le travail réalisé et les technologies utilisées.
## 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`
- [x] **Task 1: Créer l'endpoint API pour le détail du projet** (AC: #1, #2, #3, #4, #8)
- [x] Ajouter la méthode `show($slug)` dans `ProjectController`
- [x] Charger le projet avec ses compétences (eager loading)
- [x] Retourner 404 si le slug n'existe pas
- [x] 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
- [x] **Task 2: Créer l'endpoint API pour la navigation prev/next** (AC: #5)
- [x] Ajouter une méthode `navigation($slug)` ou inclure dans `show()`
- [x] Retourner le projet précédent et suivant (basé sur l'ordre de tri)
- [x] 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
- [x] **Task 3: Créer le composable useFetchProject** (AC: #1)
- [x] Créer `frontend/app/composables/useFetchProject.ts`
- [x] Accepter le slug en paramètre
- [x] Gérer les états loading, error, data
- [x] 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"
- [x] **Task 4: Créer la page [slug].vue** (AC: #1, #2, #3, #4, #6, #9)
- [x] Créer `frontend/app/pages/projets/[slug].vue`
- [x] Afficher l'image principale en grand format
- [x] Afficher le titre et la description complète
- [x] Afficher la date de réalisation formatée
- [x] Afficher la liste des compétences avec progression (avant → après)
- [x] Afficher les liens externes (site live, GitHub) si présents
- [x] 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
- [x] **Task 5: Implémenter la navigation prev/next** (AC: #5)
- [x] Ajouter les boutons "Projet précédent" et "Projet suivant"
- [x] Utiliser NuxtLink pour la navigation
- [x] Afficher le titre du projet dans le bouton
- [x] 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
- [x] **Task 6: Meta tags SEO dynamiques** (AC: #7)
- [x] Utiliser `useHead()` avec le titre du projet
- [x] Utiliser `useSeoMeta()` pour description, og:title, og:description, og:image
- [x] 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
- [x] **Task 7: Gestion de l'erreur 404** (AC: #8)
- [x] Détecter si le projet n'existe pas
- [x] Afficher un message approprié avec le narrateur
- [x] 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)
- [x] **Task 8: Design responsive** (AC: #9)
- [x] Mobile : layout vertical, image pleine largeur
- [x] Desktop : layout 2 colonnes (image + contenu) ou grande image + contenu dessous
- [x] 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
- [x] **Task 9: Tests et validation**
- [x] Tester avec différents slugs de projets
- [x] Tester la navigation prev/next
- [x] Tester le 404 avec un slug inexistant
- [x] Valider les meta tags SEO
- [x] Tester le responsive
## Dev Notes
@@ -437,16 +437,40 @@ frontend/nuxt.config.ts # AJOUTER datetimeFormats
### Agent Model Used
{{agent_model_name_version}}
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.*)

View File

@@ -58,8 +58,8 @@ development_status:
# ═══════════════════════════════════════════════════════════════════════════
epic-2: in-progress
2-1-composant-projectcard: review
2-2-page-projets-galerie: ready-for-dev
2-3-page-projet-detail: ready-for-dev
2-2-page-projets-galerie: review
2-3-page-projet-detail: review
2-4-page-competences-affichage-categories: ready-for-dev
2-5-competences-cliquables-projets-lies: ready-for-dev
2-6-page-temoignages-migrations-bdd: ready-for-dev

View File

@@ -0,0 +1,33 @@
import type { Project } from '~/types/project'
export interface ProjectNavigation {
prev: { slug: string; title: string } | null
next: { slug: string; title: string } | null
}
interface ProjectResponse {
data: Project
meta: { lang: string }
navigation: ProjectNavigation
}
interface ProjectError {
error: {
code: string
message: string
}
}
export function useFetchProject(slug: string | Ref<string>) {
const config = useRuntimeConfig()
const { locale } = useI18n()
const slugValue = toValue(slug)
return useFetch<ProjectResponse, ProjectError>(`/projects/${slugValue}`, {
baseURL: config.public.apiUrl as string,
headers: {
'X-API-Key': config.public.apiKey as string,
'Accept-Language': locale.value,
},
})
}

View File

@@ -1,19 +1,205 @@
<template>
<div class="min-h-screen p-8">
<h1 class="text-3xl font-narrative text-sky-text">{{ slug }}</h1>
<p class="mt-4 text-sky-text/70">{{ $t('pages.projects.description') }}</p>
<div class="max-w-5xl mx-auto px-4 py-8 md:py-12">
<!-- Loading state -->
<div v-if="pending" class="animate-pulse">
<div class="bg-sky-text/5 rounded-xl h-64 md:h-96 mb-8" />
<div class="bg-sky-text/5 h-10 rounded w-2/3 mb-4" />
<div class="bg-sky-text/5 h-5 rounded w-1/4 mb-8" />
<div class="space-y-3">
<div class="bg-sky-text/5 h-4 rounded w-full" />
<div class="bg-sky-text/5 h-4 rounded w-full" />
<div class="bg-sky-text/5 h-4 rounded w-3/4" />
</div>
</div>
<!-- Error 404 -->
<div v-else-if="error" class="text-center py-16">
<div class="text-6xl mb-6">🕷</div>
<h1 class="text-2xl font-ui font-bold text-sky-text mb-4">
{{ $t('projects.not_found') }}
</h1>
<p class="text-sky-text/60 font-narrative mb-8 max-w-md mx-auto">
{{ $t('projects.not_found_description') }}
</p>
<NuxtLink
:to="localePath('/projets')"
class="inline-flex items-center px-6 py-3 bg-sky-accent text-sky-dark font-ui font-semibold rounded-lg hover:opacity-90 transition-opacity"
>
{{ $t('projects.back_to_gallery') }}
</NuxtLink>
</div>
<!-- Project content -->
<article v-else-if="project">
<!-- Back link -->
<NuxtLink
:to="localePath('/projets')"
class="inline-flex items-center gap-2 text-sky-text/60 hover:text-sky-accent font-ui text-sm mb-6 transition-colors"
>
<span></span>
{{ $t('projects.back_to_gallery') }}
</NuxtLink>
<!-- Main image -->
<div class="mb-8 rounded-xl overflow-hidden">
<NuxtImg
:src="project.image"
:alt="project.title"
format="webp"
class="w-full h-auto max-h-[500px] object-cover"
loading="eager"
/>
</div>
<!-- Header: title, date, featured badge -->
<header class="mb-8">
<div class="flex items-start gap-3 mb-3">
<h1 class="text-3xl md:text-4xl font-ui font-bold text-sky-text">
{{ project.title }}
</h1>
<span
v-if="project.is_featured"
class="shrink-0 mt-1 px-3 py-1 bg-sky-accent/20 text-sky-accent text-xs font-ui font-semibold rounded-full"
>
Featured
</span>
</div>
<p v-if="project.date_completed" class="text-sky-text/60 font-ui">
{{ $t('projects.completed_on') }}
{{ formatDate(project.date_completed) }}
</p>
</header>
<!-- Description -->
<div class="mb-10">
<p class="text-sky-text font-narrative text-lg leading-relaxed whitespace-pre-line">
{{ project.description }}
</p>
</div>
<!-- External links -->
<div v-if="project.url || project.github_url" class="flex flex-wrap gap-4 mb-10">
<a
v-if="project.url"
:href="project.url"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-2 px-6 py-3 bg-sky-accent text-sky-dark font-ui font-semibold rounded-lg hover:opacity-90 transition-opacity"
>
🌐 {{ $t('projects.visit_site') }}
</a>
<a
v-if="project.github_url"
:href="project.github_url"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-2 px-6 py-3 bg-sky-text/10 text-sky-text font-ui font-semibold rounded-lg hover:bg-sky-text/20 transition-colors border border-sky-text/10"
>
💻 {{ $t('projects.view_code') }}
</a>
</div>
<!-- Skills used -->
<section v-if="project.skills && project.skills.length > 0" class="mb-12">
<h2 class="text-xl font-ui font-semibold text-sky-text mb-6">
{{ $t('projects.skills_used') }}
</h2>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
<div
v-for="skill in project.skills"
:key="skill.id"
class="bg-sky-text/5 rounded-lg p-4 border border-sky-text/10"
>
<div class="flex items-center gap-2 mb-2">
<span v-if="skill.icon" class="text-xl">{{ skill.icon }}</span>
<span class="font-ui font-medium text-sky-text">{{ skill.name }}</span>
</div>
<div v-if="skill.pivot" class="text-sm text-sky-text/60 font-ui">
{{ $t('projects.skill_level') }}:
<span class="text-sky-accent">{{ skill.pivot.level_before }}</span>
<span class="text-sky-accent font-semibold">{{ skill.pivot.level_after }}</span>
</div>
</div>
</div>
</section>
<!-- Prev/Next navigation -->
<nav class="flex justify-between items-stretch gap-4 border-t border-sky-text/10 pt-8 mt-8">
<NuxtLink
v-if="navigation?.prev"
:to="localePath(`/projets/${navigation.prev.slug}`)"
class="flex-1 max-w-[45%] group p-4 rounded-lg hover:bg-sky-text/5 transition-colors text-left"
>
<span class="block text-sm text-sky-text/40 font-ui mb-1">
{{ $t('projects.previous') }}
</span>
<span class="block text-sky-text font-ui font-medium group-hover:text-sky-accent transition-colors truncate">
{{ navigation.prev.title }}
</span>
</NuxtLink>
<div v-else class="flex-1" />
<NuxtLink
v-if="navigation?.next"
:to="localePath(`/projets/${navigation.next.slug}`)"
class="flex-1 max-w-[45%] group p-4 rounded-lg hover:bg-sky-text/5 transition-colors text-right"
>
<span class="block text-sm text-sky-text/40 font-ui mb-1">
{{ $t('projects.next') }}
</span>
<span class="block text-sky-text font-ui font-medium group-hover:text-sky-accent transition-colors truncate">
{{ navigation.next.title }}
</span>
</NuxtLink>
<div v-else class="flex-1" />
</nav>
</article>
</div>
</template>
<script setup lang="ts">
import { useProgressionStore } from '~/stores/progression'
const route = useRoute()
const slug = computed(() => route.params.slug as string)
const { t, locale } = useI18n()
const localePath = useLocalePath()
const { setPageMeta } = useSeo()
const { t } = useI18n()
const store = useProgressionStore()
setPageMeta({
title: slug.value,
description: t('pages.projects.description'),
const slug = computed(() => route.params.slug as string)
const { data, pending, error } = await useFetchProject(slug.value)
const project = computed(() => data.value?.data)
const navigation = computed(() => data.value?.navigation)
// Date formatting
const formatDate = (dateStr: string) => {
const date = new Date(dateStr)
return new Intl.DateTimeFormat(locale.value === 'fr' ? 'fr-FR' : 'en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
}).format(date)
}
// SEO - reactive to project data
watchEffect(() => {
if (project.value) {
setPageMeta({
title: `${project.value.title} | Skycel`,
description: project.value.short_description || project.value.description?.slice(0, 160),
image: project.value.image,
})
} else if (error.value) {
setPageMeta({
title: t('projects.not_found'),
description: t('projects.not_found_description'),
})
}
})
onMounted(() => {
store.visitSection('projets')
})
</script>

View File

@@ -82,7 +82,17 @@
"discover": "Discover",
"no_projects": "No projects yet",
"load_error": "Unable to load projects...",
"view_all": "View all projects"
"view_all": "View all projects",
"not_found": "Project not found",
"not_found_description": "This project doesn't exist or has been removed.",
"back_to_gallery": "Back to gallery",
"completed_on": "Completed on",
"visit_site": "Visit site",
"view_code": "View code",
"skills_used": "Skills used",
"skill_level": "Level",
"previous": "Previous project",
"next": "Next project"
},
"pages": {
"projects": {

View File

@@ -82,7 +82,17 @@
"discover": "D\u00e9couvrir",
"no_projects": "Aucun projet pour le moment",
"load_error": "Impossible de charger les projets...",
"view_all": "Voir tous les projets"
"view_all": "Voir tous les projets",
"not_found": "Projet introuvable",
"not_found_description": "Ce projet n'existe pas ou a \u00e9t\u00e9 supprim\u00e9.",
"back_to_gallery": "Retour \u00e0 la galerie",
"completed_on": "R\u00e9alis\u00e9 le",
"visit_site": "Voir le site",
"view_code": "Voir le code",
"skills_used": "Comp\u00e9tences utilis\u00e9es",
"skill_level": "Niveau",
"previous": "Projet pr\u00e9c\u00e9dent",
"next": "Projet suivant"
},
"pages": {
"projects": {