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>
388 lines
11 KiB
Markdown
388 lines
11 KiB
Markdown
# 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
|
|
|
|
1. **Given** le visiteur accède à `/projets` (FR) ou `/en/projects` (EN) **When** la page se charge **Then** une grille responsive de `ProjectCard` s'affiche
|
|
2. **And** les projets sont triés par date avec les "featured" en tête
|
|
3. **And** une animation d'entrée progressive des cards est présente (respectant `prefers-reduced-motion`)
|
|
4. **And** les données sont chargées depuis l'API `/api/projects` avec le contenu traduit
|
|
5. **And** les meta tags SEO sont dynamiques pour cette page
|
|
6. **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.php` pour formater la réponse
|
|
- [ ] Ajouter la route `GET /api/projects` dans `routes/api.php`
|
|
|
|
- [ ] **Task 2: Créer le composable useFetchProjects** (AC: #4)
|
|
- [ ] Créer `frontend/app/composables/useFetchProjects.ts`
|
|
- [ ] Utiliser `useFetch()` pour appeler l'API avec le header `Accept-Language`
|
|
- [ ] Gérer les états loading, error, data
|
|
- [ ] Typer la réponse avec l'interface Project[]
|
|
|
|
- [ ] **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 `ProjectCard` avec les données
|
|
- [ ] Implémenter le layout responsive : 1 colonne mobile, 2 tablette, 3-4 desktop
|
|
|
|
- [ ] **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: true` apparaissent en premier
|
|
- [ ] Puis tri par `date_completed` DESC
|
|
|
|
- [ ] **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"
|
|
|
|
- [ ] **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
|
|
<?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
|
|
<?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,
|
|
]);
|
|
}),
|
|
];
|
|
}
|
|
}
|
|
```
|
|
|
|
```php
|
|
// api/routes/api.php
|
|
Route::get('/projects', [ProjectController::class, 'index']);
|
|
```
|
|
|
|
### Composable useFetchProjects
|
|
|
|
```typescript
|
|
// 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
|
|
|
|
```vue
|
|
<!-- 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 :**
|
|
```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 :**
|
|
```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()` avec `baseURL` et 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 |
|
|
|
|
### File List
|
|
|