✨ 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:
@@ -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');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
21
frontend/app/composables/useFetchProjects.ts
Normal file
21
frontend/app/composables/useFetchProjects.ts
Normal 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: () => [],
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
Reference in New Issue
Block a user