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>
453 lines
14 KiB
Markdown
453 lines
14 KiB
Markdown
# 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
|
|
|