🎉 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>
This commit is contained in:
452
docs/implementation-artifacts/2-3-page-projet-detail.md
Normal file
452
docs/implementation-artifacts/2-3-page-projet-detail.md
Normal file
@@ -0,0 +1,452 @@
|
||||
# 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
|
||||
|
||||
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
|
||||
<?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,
|
||||
],
|
||||
]);
|
||||
}
|
||||
```
|
||||
|
||||
```php
|
||||
// api/routes/api.php
|
||||
Route::get('/projects/{slug}', [ProjectController::class, 'show']);
|
||||
```
|
||||
|
||||
### Composable useFetchProject
|
||||
|
||||
```typescript
|
||||
// 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
|
||||
|
||||
```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 :**
|
||||
```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 :**
|
||||
```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 :
|
||||
|
||||
```typescript
|
||||
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
|
||||
|
||||
{{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
|
||||
|
||||
Reference in New Issue
Block a user