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>
11 KiB
11 KiB
Story 2.2: Page Projets - Galerie
Status: ready-for-dev
Story
As a visiteur, I want voir la liste des projets réalisés par le développeur, so that je peux évaluer son expérience et choisir lesquels explorer en détail.
Acceptance Criteria
- Given le visiteur accède à
/projets(FR) ou/en/projects(EN) When la page se charge Then une grille responsive deProjectCards'affiche - And les projets sont triés par date avec les "featured" en tête
- And une animation d'entrée progressive des cards est présente (respectant
prefers-reduced-motion) - And les données sont chargées depuis l'API
/api/projectsavec le contenu traduit - And les meta tags SEO sont dynamiques pour cette page
- And le layout s'adapte : grille sur desktop, cards empilées sur mobile
Tasks / Subtasks
-
Task 1: Créer l'endpoint API Laravel (AC: #4)
- Créer
app/Http/Controllers/Api/ProjectController.php - Créer la méthode
index()pour lister tous les projets - Implémenter le tri : featured en premier, puis par date_completed DESC
- Joindre les traductions selon le header
Accept-Language - Créer
app/Http/Resources/ProjectResource.phppour formater la réponse - Ajouter la route
GET /api/projectsdansroutes/api.php
- Créer
-
Task 2: Créer le composable useFetchProjects (AC: #4)
- Créer
frontend/app/composables/useFetchProjects.ts - Utiliser
useFetch()pour appeler l'API avec le headerAccept-Language - Gérer les états loading, error, data
- Typer la réponse avec l'interface Project[]
- Créer
-
Task 3: Créer la page projets.vue (AC: #1, #6)
- Créer
frontend/app/pages/projets.vue - Utiliser le composable
useFetchProjects()pour charger les données - Afficher une grille de
ProjectCardavec les données - Implémenter le layout responsive : 1 colonne mobile, 2 tablette, 3-4 desktop
- Créer
-
Task 4: Implémenter l'animation d'entrée (AC: #3)
- Animer l'apparition progressive des cards (stagger animation)
- Utiliser CSS animations ou GSAP pour un effet fade-in + slide-up
- Respecter
prefers-reduced-motion: pas d'animation si activé - Délai de 50-100ms entre chaque card
-
Task 5: Tri des projets (AC: #2)
- S'assurer que l'API retourne les projets dans le bon ordre
- Vérifier côté frontend que l'ordre est respecté
- Les projets
is_featured: trueapparaissent en premier - Puis tri par
date_completedDESC
-
Task 6: Meta tags SEO (AC: #5)
- Utiliser
useHead()pour définir le titre dynamique - Utiliser
useSeoMeta()pour les meta description, og:title, og:description - Ajouter les clés i18n pour titre et description de la page
- Exemple titre : "Projets | Skycel" / "Projects | Skycel"
- Utiliser
-
Task 7: État loading et erreur
- Afficher un skeleton/loading state pendant le chargement
- Afficher un message d'erreur narratif si l'API échoue
- Bouton "Réessayer" en cas d'erreur
-
Task 8: Tests et validation
- Tester la page en FR et EN
- Vérifier le tri des projets
- Tester l'animation d'entrée
- Valider le responsive sur mobile/tablette/desktop
- Vérifier les meta tags avec l'inspecteur
Dev Notes
Endpoint API Laravel
<?php
// api/app/Http/Controllers/Api/ProjectController.php
namespace App\Http\Controllers\Api;
use App\Http\Controllers\Controller;
use App\Http\Resources\ProjectResource;
use App\Models\Project;
use Illuminate\Http\Request;
class ProjectController extends Controller
{
public function index(Request $request)
{
$lang = $request->header('Accept-Language', 'fr');
$projects = Project::query()
->with(['skills'])
->orderByDesc('is_featured')
->orderByDesc('date_completed')
->get();
return ProjectResource::collection($projects)
->additional(['meta' => ['lang' => $lang]]);
}
}
<?php
// api/app/Http/Resources/ProjectResource.php
namespace App\Http\Resources;
use App\Models\Translation;
use Illuminate\Http\Request;
use Illuminate\Http\Resources\Json\JsonResource;
class ProjectResource extends JsonResource
{
public function toArray(Request $request): array
{
$lang = $request->header('Accept-Language', 'fr');
return [
'id' => $this->id,
'slug' => $this->slug,
'title' => Translation::getTranslation($this->title_key, $lang),
'description' => Translation::getTranslation($this->description_key, $lang),
'shortDescription' => Translation::getTranslation($this->short_description_key, $lang),
'image' => $this->image,
'url' => $this->url,
'githubUrl' => $this->github_url,
'dateCompleted' => $this->date_completed?->format('Y-m-d'),
'isFeatured' => $this->is_featured,
'displayOrder' => $this->display_order,
'skills' => $this->whenLoaded('skills', function () use ($lang) {
return $this->skills->map(fn ($skill) => [
'id' => $skill->id,
'slug' => $skill->slug,
'name' => Translation::getTranslation($skill->name_key, $lang),
'levelBefore' => $skill->pivot->level_before,
'levelAfter' => $skill->pivot->level_after,
]);
}),
];
}
}
// api/routes/api.php
Route::get('/projects', [ProjectController::class, 'index']);
Composable useFetchProjects
// frontend/app/composables/useFetchProjects.ts
import type { Project } from '~/types/project'
export function useFetchProjects() {
const config = useRuntimeConfig()
const { locale } = useI18n()
return useFetch<{ data: Project[], meta: { lang: string } }>('/projects', {
baseURL: config.public.apiUrl,
headers: {
'X-API-Key': config.public.apiKey,
'Accept-Language': locale.value,
},
transform: (response) => response.data,
})
}
Page Projets
<!-- frontend/app/pages/projets.vue -->
<script setup lang="ts">
const { t } = useI18n()
const { data: projects, pending, error, refresh } = useFetchProjects()
// SEO
useHead({
title: () => t('projects.pageTitle'),
})
useSeoMeta({
title: () => t('projects.pageTitle'),
description: () => t('projects.pageDescription'),
ogTitle: () => t('projects.pageTitle'),
ogDescription: () => t('projects.pageDescription'),
})
</script>
<template>
<div class="container mx-auto px-4 py-8">
<h1 class="text-3xl font-ui font-bold text-sky-text mb-8">
{{ t('projects.title') }}
</h1>
<!-- Loading state -->
<div v-if="pending" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
<div v-for="i in 6" :key="i" class="animate-pulse">
<div class="bg-sky-dark-50 rounded-lg h-48"></div>
<div class="p-4">
<div class="bg-sky-dark-50 h-6 rounded w-3/4 mb-2"></div>
<div class="bg-sky-dark-50 h-4 rounded w-full"></div>
</div>
</div>
</div>
<!-- Error state -->
<div v-else-if="error" class="text-center py-12">
<p class="text-sky-text-muted mb-4">{{ t('projects.loadError') }}</p>
<button
@click="refresh()"
class="bg-sky-accent text-white px-6 py-2 rounded-lg hover:bg-sky-accent-hover"
>
{{ t('common.retry') }}
</button>
</div>
<!-- Projects grid -->
<div
v-else
class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6"
>
<ProjectCard
v-for="(project, index) in projects"
:key="project.id"
:project="project"
class="project-card-animated"
:style="{ '--animation-delay': `${index * 100}ms` }"
/>
</div>
</div>
</template>
<style scoped>
.project-card-animated {
animation: fadeInUp 0.5s ease-out forwards;
animation-delay: var(--animation-delay, 0ms);
opacity: 0;
}
@keyframes fadeInUp {
from {
opacity: 0;
transform: translateY(20px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Respect prefers-reduced-motion */
@media (prefers-reduced-motion: reduce) {
.project-card-animated {
animation: none;
opacity: 1;
}
}
</style>
Clés i18n nécessaires
fr.json :
{
"projects": {
"title": "Mes Projets",
"pageTitle": "Projets | Skycel",
"pageDescription": "Découvrez les projets réalisés par Célian, développeur web full-stack.",
"discover": "Découvrir",
"loadError": "Impossible de charger les projets..."
},
"common": {
"retry": "Réessayer"
}
}
en.json :
{
"projects": {
"title": "My Projects",
"pageTitle": "Projects | Skycel",
"pageDescription": "Discover projects created by Célian, full-stack web developer.",
"discover": "Discover",
"loadError": "Unable to load projects..."
},
"common": {
"retry": "Retry"
}
}
Layout responsive
| Breakpoint | Colonnes | Gap |
|---|---|---|
| Mobile (< 768px) | 1 | 24px |
| Tablette (768px - 1023px) | 2 | 24px |
| Desktop (1024px - 1279px) | 3 | 24px |
| Large (≥ 1280px) | 4 | 24px |
Dépendances
Cette story nécessite :
- Story 1.1 : Nuxt 4 + Laravel 12 initialisés
- Story 1.2 : Table projects, Model Project avec relations
- Story 1.3 : Système i18n configuré
- Story 1.4 : Layouts et routing
- Story 2.1 : Composant ProjectCard
Cette story prépare pour :
- Story 2.3 : Page Projet - Détail (navigation depuis la galerie)
Project Structure Notes
Fichiers à créer :
api/app/Http/
├── Controllers/Api/
│ └── ProjectController.php # CRÉER
└── Resources/
└── ProjectResource.php # CRÉER
frontend/app/
├── pages/
│ └── projets.vue # CRÉER
└── composables/
└── useFetchProjects.ts # CRÉER
Fichiers à modifier :
api/routes/api.php # AJOUTER route /projects
frontend/i18n/fr.json # AJOUTER clés projects.*
frontend/i18n/en.json # AJOUTER clés projects.*
References
- [Source: docs/planning-artifacts/epics.md#Story-2.2]
- [Source: docs/planning-artifacts/architecture.md#API-&-Communication-Patterns]
- [Source: docs/planning-artifacts/architecture.md#Frontend-Architecture]
- [Source: docs/planning-artifacts/ux-design-specification.md#Screen-Architecture-Summary]
Technical Requirements
| Requirement | Value | Source |
|---|---|---|
| API endpoint | GET /api/projects | Architecture |
| Response format | { data: [], meta: {} } | Architecture |
| Header langue | Accept-Language | Architecture |
| Animation | Stagger fade-in | Epics |
| SEO | Meta tags dynamiques | NFR5 |
Previous Story Intelligence
Patterns établis à suivre :
- Controllers API dans
app/Http/Controllers/Api/ - Resources dans
app/Http/Resources/ - Composables dans
app/composables/ - Pages dans
app/pages/ - Utiliser
useFetch()avecbaseURLet headers
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 |