Add projects gallery page with responsive grid (Story 2.2)

- Create useFetchProjects composable for API integration
- Implement responsive grid layout (1/2/3/4 columns)
- Add stagger fadeInUp animation with prefers-reduced-motion support
- Include loading skeleton, error state with retry, and empty state
- Configure SEO meta tags via useSeo composable
- Update Project model ordered scope for proper sorting

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
2026-02-06 02:12:32 +01:00
parent 4117a84809
commit 0399f0dc1c
6 changed files with 188 additions and 55 deletions

View File

@@ -45,6 +45,8 @@ class Project extends Model
public function scopeOrdered(Builder $query): Builder public function scopeOrdered(Builder $query): Builder
{ {
return $query->orderBy('display_order'); return $query->orderByDesc('is_featured')
->orderByDesc('date_completed')
->orderBy('display_order');
} }
} }

View File

@@ -1,6 +1,6 @@
# Story 2.2: Page Projets - Galerie # Story 2.2: Page Projets - Galerie
Status: ready-for-dev Status: review
## Story ## Story
@@ -19,55 +19,55 @@ so that je peux évaluer son expérience et choisir lesquels explorer en détail
## Tasks / Subtasks ## Tasks / Subtasks
- [ ] **Task 1: Créer l'endpoint API Laravel** (AC: #4) - [x] **Task 1: Créer l'endpoint API Laravel** (AC: #4)
- [ ] Créer `app/Http/Controllers/Api/ProjectController.php` - [x] Créer `app/Http/Controllers/Api/ProjectController.php`
- [ ] Créer la méthode `index()` pour lister tous les projets - [x] Créer la méthode `index()` pour lister tous les projets
- [ ] Implémenter le tri : featured en premier, puis par date_completed DESC - [x] Implémenter le tri : featured en premier, puis par date_completed DESC
- [ ] Joindre les traductions selon le header `Accept-Language` - [x] Joindre les traductions selon le header `Accept-Language`
- [ ] Créer `app/Http/Resources/ProjectResource.php` pour formater la réponse - [x] Créer `app/Http/Resources/ProjectResource.php` pour formater la réponse
- [ ] Ajouter la route `GET /api/projects` dans `routes/api.php` - [x] Ajouter la route `GET /api/projects` dans `routes/api.php`
- [ ] **Task 2: Créer le composable useFetchProjects** (AC: #4) - [x] **Task 2: Créer le composable useFetchProjects** (AC: #4)
- [ ] Créer `frontend/app/composables/useFetchProjects.ts` - [x] Créer `frontend/app/composables/useFetchProjects.ts`
- [ ] Utiliser `useFetch()` pour appeler l'API avec le header `Accept-Language` - [x] Utiliser `useFetch()` pour appeler l'API avec le header `Accept-Language`
- [ ] Gérer les états loading, error, data - [x] Gérer les états loading, error, data
- [ ] Typer la réponse avec l'interface Project[] - [x] Typer la réponse avec l'interface Project[]
- [ ] **Task 3: Créer la page projets.vue** (AC: #1, #6) - [x] **Task 3: Créer la page projets.vue** (AC: #1, #6)
- [ ] Créer `frontend/app/pages/projets.vue` - [x] Créer `frontend/app/pages/projets.vue`
- [ ] Utiliser le composable `useFetchProjects()` pour charger les données - [x] Utiliser le composable `useFetchProjects()` pour charger les données
- [ ] Afficher une grille de `ProjectCard` avec les données - [x] Afficher une grille de `ProjectCard` avec les données
- [ ] Implémenter le layout responsive : 1 colonne mobile, 2 tablette, 3-4 desktop - [x] Implémenter le layout responsive : 1 colonne mobile, 2 tablette, 3-4 desktop
- [ ] **Task 4: Implémenter l'animation d'entrée** (AC: #3) - [x] **Task 4: Implémenter l'animation d'entrée** (AC: #3)
- [ ] Animer l'apparition progressive des cards (stagger animation) - [x] Animer l'apparition progressive des cards (stagger animation)
- [ ] Utiliser CSS animations ou GSAP pour un effet fade-in + slide-up - [x] Utiliser CSS animations ou GSAP pour un effet fade-in + slide-up
- [ ] Respecter `prefers-reduced-motion` : pas d'animation si activé - [x] Respecter `prefers-reduced-motion` : pas d'animation si activé
- [ ] Délai de 50-100ms entre chaque card - [x] Délai de 50-100ms entre chaque card
- [ ] **Task 5: Tri des projets** (AC: #2) - [x] **Task 5: Tri des projets** (AC: #2)
- [ ] S'assurer que l'API retourne les projets dans le bon ordre - [x] S'assurer que l'API retourne les projets dans le bon ordre
- [ ] Vérifier côté frontend que l'ordre est respecté - [x] Vérifier côté frontend que l'ordre est respecté
- [ ] Les projets `is_featured: true` apparaissent en premier - [x] Les projets `is_featured: true` apparaissent en premier
- [ ] Puis tri par `date_completed` DESC - [x] Puis tri par `date_completed` DESC
- [ ] **Task 6: Meta tags SEO** (AC: #5) - [x] **Task 6: Meta tags SEO** (AC: #5)
- [ ] Utiliser `useHead()` pour définir le titre dynamique - [x] Utiliser `useHead()` pour définir le titre dynamique
- [ ] Utiliser `useSeoMeta()` pour les meta description, og:title, og:description - [x] Utiliser `useSeoMeta()` pour les meta description, og:title, og:description
- [ ] Ajouter les clés i18n pour titre et description de la page - [x] Ajouter les clés i18n pour titre et description de la page
- [ ] Exemple titre : "Projets | Skycel" / "Projects | Skycel" - [x] Exemple titre : "Projets | Skycel" / "Projects | Skycel"
- [ ] **Task 7: État loading et erreur** - [x] **Task 7: État loading et erreur**
- [ ] Afficher un skeleton/loading state pendant le chargement - [x] Afficher un skeleton/loading state pendant le chargement
- [ ] Afficher un message d'erreur narratif si l'API échoue - [x] Afficher un message d'erreur narratif si l'API échoue
- [ ] Bouton "Réessayer" en cas d'erreur - [x] Bouton "Réessayer" en cas d'erreur
- [ ] **Task 8: Tests et validation** - [x] **Task 8: Tests et validation**
- [ ] Tester la page en FR et EN - [x] Tester la page en FR et EN
- [ ] Vérifier le tri des projets - [x] Vérifier le tri des projets
- [ ] Tester l'animation d'entrée - [x] Tester l'animation d'entrée
- [ ] Valider le responsive sur mobile/tablette/desktop - [x] Valider le responsive sur mobile/tablette/desktop
- [ ] Vérifier les meta tags avec l'inspecteur - [x] Vérifier les meta tags avec l'inspecteur
## Dev Notes ## Dev Notes
@@ -372,16 +372,35 @@ frontend/i18n/en.json # AJOUTER clés projects.*
### Agent Model Used ### Agent Model Used
{{agent_model_name_version}} Claude Opus 4.5 (claude-opus-4-5-20251101)
### Debug Log References ### Debug Log References
- Aucun problème. API ProjectController existait déjà (Story 1.3), scope ordered() mis à jour.
### Completion Notes List ### Completion Notes List
- Scope `ordered()` mis à jour : featured DESC, date_completed DESC, display_order
- Composable `useFetchProjects` créé avec typage et transform
- Page projets complète avec grille responsive (1/2/3/4 colonnes selon breakpoint)
- Animation stagger fadeInUp avec délai 80ms entre cards
- prefers-reduced-motion respecté
- États loading (skeleton), error (avec retry), empty
- SEO meta tags dynamiques via useSeo()
- Intégration store progression (visitSection sur mount)
- Traductions FR/EN ajoutées (title, page_title, page_description, load_error, retry)
### Change Log ### Change Log
| Date | Change | Author | | Date | Change | Author |
|------|--------|--------| |------|--------|--------|
| 2026-02-04 | Story créée avec contexte complet | SM Agent | | 2026-02-04 | Story créée avec contexte complet | SM Agent |
| 2026-02-06 | Tasks 1-8 implémentées et validées | Dev Agent (Claude Opus 4.5) |
### File List ### File List
- `api/app/Models/Project.php` — MODIFIÉ (scope ordered amélioré)
- `frontend/app/composables/useFetchProjects.ts` — CRÉÉ
- `frontend/app/pages/projets/index.vue` — RÉÉCRIT (page complète)
- `frontend/i18n/fr.json` — MODIFIÉ (ajout projects.*, common.retry)
- `frontend/i18n/en.json` — MODIFIÉ (ajout projects.*, common.retry)

View File

@@ -0,0 +1,21 @@
import type { Project } from '~/types/project'
interface ProjectsResponse {
data: Project[]
meta: { lang: string }
}
export function useFetchProjects() {
const config = useRuntimeConfig()
const { locale } = useI18n()
return useFetch<ProjectsResponse>('/projects', {
baseURL: config.public.apiUrl as string,
headers: {
'X-API-Key': config.public.apiKey as string,
'Accept-Language': locale.value,
},
transform: (response) => response.data,
default: () => [],
})
}

View File

@@ -1,16 +1,97 @@
<template> <template>
<div class="min-h-screen p-8"> <div class="max-w-7xl mx-auto px-4 py-8 md:py-12">
<h1 class="text-3xl font-narrative text-sky-text">{{ $t('pages.projects.title') }}</h1> <h1 class="text-3xl md:text-4xl font-ui font-bold text-sky-text mb-8">
<p class="mt-4 text-sky-text/70">{{ $t('pages.projects.description') }}</p> {{ $t('projects.title') }}
</h1>
<!-- Loading state -->
<div v-if="pending" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
<div v-for="i in 6" :key="i" class="animate-pulse">
<div class="bg-sky-text/5 rounded-xl h-44 md:h-48" />
<div class="p-4">
<div class="bg-sky-text/5 h-6 rounded w-3/4 mb-2" />
<div class="bg-sky-text/5 h-4 rounded w-full" />
</div>
</div>
</div>
<!-- Error state -->
<div v-else-if="error" class="text-center py-16">
<p class="text-sky-text/60 font-narrative mb-6">
{{ $t('projects.load_error') }}
</p>
<button
class="px-6 py-3 bg-sky-accent text-sky-dark font-ui font-semibold rounded-lg hover:opacity-90 transition-opacity"
@click="refresh()"
>
{{ $t('common.retry') }}
</button>
</div>
<!-- Empty state -->
<div v-else-if="!projects || projects.length === 0" class="text-center py-16">
<p class="text-sky-text/60 font-narrative">
{{ $t('projects.no_projects') }}
</p>
</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"
>
<FeatureProjectCard
v-for="(project, index) in projects"
:key="project.id"
:project="project"
class="project-card-animated"
:style="{ '--animation-delay': `${index * 80}ms` }"
/>
</div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
const { setPageMeta } = useSeo() import { useProgressionStore } from '~/stores/progression'
const { t } = useI18n() const { t } = useI18n()
const { setPageMeta } = useSeo()
const store = useProgressionStore()
const { data: projects, pending, error, refresh } = await useFetchProjects()
setPageMeta({ setPageMeta({
title: t('pages.projects.title'), title: t('projects.page_title'),
description: t('pages.projects.description'), description: t('projects.page_description'),
})
onMounted(() => {
store.visitSection('projets')
}) })
</script> </script>
<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);
}
}
@media (prefers-reduced-motion: reduce) {
.project-card-animated {
animation: none;
opacity: 1;
}
}
</style>

View File

@@ -16,7 +16,8 @@
"loading": "Loading...", "loading": "Loading...",
"language": "Language", "language": "Language",
"back_home": "Back to home", "back_home": "Back to home",
"back_to_adventure": "Back to the adventure" "back_to_adventure": "Back to the adventure",
"retry": "Retry"
}, },
"landing": { "landing": {
"title": "Welcome to my universe", "title": "Welcome to my universe",
@@ -75,8 +76,12 @@
"meta_description": "Full-Stack Developer specialized in Vue.js, Nuxt, Laravel. Discover my profile and projects in 30 seconds." "meta_description": "Full-Stack Developer specialized in Vue.js, Nuxt, Laravel. Discover my profile and projects in 30 seconds."
}, },
"projects": { "projects": {
"title": "My Projects",
"page_title": "Projects | Skycel",
"page_description": "Discover projects created by C\u00e9lian, full-stack web developer.",
"discover": "Discover", "discover": "Discover",
"no_projects": "No projects yet", "no_projects": "No projects yet",
"load_error": "Unable to load projects...",
"view_all": "View all projects" "view_all": "View all projects"
}, },
"pages": { "pages": {

View File

@@ -11,12 +11,13 @@
"common": { "common": {
"continue": "Continuer", "continue": "Continuer",
"back": "Retour", "back": "Retour",
"discover": "Découvrir", "discover": "D\u00e9couvrir",
"close": "Fermer", "close": "Fermer",
"loading": "Chargement...", "loading": "Chargement...",
"language": "Langue", "language": "Langue",
"back_home": "Retour à l'accueil", "back_home": "Retour \u00e0 l'accueil",
"back_to_adventure": "Retour à l'aventure" "back_to_adventure": "Retour \u00e0 l'aventure",
"retry": "R\u00e9essayer"
}, },
"landing": { "landing": {
"title": "Bienvenue dans mon univers", "title": "Bienvenue dans mon univers",
@@ -75,8 +76,12 @@
"meta_description": "D\u00e9veloppeur Full-Stack sp\u00e9cialis\u00e9 en Vue.js, Nuxt, Laravel. D\u00e9couvrez mon profil et mes projets en 30 secondes." "meta_description": "D\u00e9veloppeur Full-Stack sp\u00e9cialis\u00e9 en Vue.js, Nuxt, Laravel. D\u00e9couvrez mon profil et mes projets en 30 secondes."
}, },
"projects": { "projects": {
"title": "Mes Projets",
"page_title": "Projets | Skycel",
"page_description": "D\u00e9couvrez les projets r\u00e9alis\u00e9s par C\u00e9lian, d\u00e9veloppeur web full-stack.",
"discover": "D\u00e9couvrir", "discover": "D\u00e9couvrir",
"no_projects": "Aucun projet pour le moment", "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"
}, },
"pages": { "pages": {