From 2b043674ca41d7051e09e7b083e8c1703c7a2446 Mon Sep 17 00:00:00 2001
From: skycel
Date: Fri, 6 Feb 2026 10:44:45 +0100
Subject: [PATCH] :sparkles: Add skill projects modal with Headless UI (Story
2.5)
- Add GET /skills/{slug}/projects endpoint with level progression
- Install @headlessui/vue for accessible modal
- Create SkillProjectsModal with Dialog component:
- Focus trap and keyboard navigation (automatic)
- Fade + scale transitions with backdrop blur
- prefers-reduced-motion support
- Create ProjectListItem with thumbnail and level display
- Integrate modal in competences.vue page
- Add translations for related projects UI
Co-Authored-By: Claude Opus 4.5
---
.../Http/Controllers/Api/SkillController.php | 45 ++++++
api/routes/api.php | 1 +
...2-5-competences-cliquables-projets-lies.md | 123 +++++++++------
.../sprint-status.yaml | 2 +-
.../components/feature/ProjectListItem.vue | 68 +++++++++
.../components/feature/SkillProjectsModal.vue | 143 ++++++++++++++++++
.../app/composables/useFetchSkillProjects.ts | 39 +++++
frontend/app/pages/competences.vue | 24 ++-
frontend/i18n/en.json | 5 +-
frontend/i18n/fr.json | 5 +-
frontend/package-lock.json | 39 +++++
frontend/package.json | 1 +
12 files changed, 441 insertions(+), 54 deletions(-)
create mode 100644 frontend/app/components/feature/ProjectListItem.vue
create mode 100644 frontend/app/components/feature/SkillProjectsModal.vue
create mode 100644 frontend/app/composables/useFetchSkillProjects.ts
diff --git a/api/app/Http/Controllers/Api/SkillController.php b/api/app/Http/Controllers/Api/SkillController.php
index aedc50b..1594c5e 100644
--- a/api/app/Http/Controllers/Api/SkillController.php
+++ b/api/app/Http/Controllers/Api/SkillController.php
@@ -31,6 +31,51 @@ class SkillController extends Controller
]);
}
+ public function projects(string $slug)
+ {
+ $lang = app()->getLocale();
+
+ $skill = Skill::with('projects')->where('slug', $slug)->first();
+
+ if (!$skill) {
+ return response()->json([
+ 'error' => [
+ 'code' => 'SKILL_NOT_FOUND',
+ 'message' => 'Skill not found',
+ ],
+ ], 404);
+ }
+
+ return response()->json([
+ 'data' => [
+ 'skill' => [
+ 'id' => $skill->id,
+ 'slug' => $skill->slug,
+ 'name' => $skill->getTranslated('name_key'),
+ 'description' => $skill->getTranslated('description_key'),
+ 'level' => $skill->getCurrentLevel(),
+ 'max_level' => $skill->max_level,
+ ],
+ 'projects' => $skill->projects->map(function ($project) {
+ return [
+ 'id' => $project->id,
+ 'slug' => $project->slug,
+ 'title' => $project->getTranslated('title_key'),
+ 'short_description' => $project->getTranslated('short_description_key'),
+ 'image' => $project->image,
+ 'date_completed' => $project->date_completed?->format('Y-m-d'),
+ 'level_before' => $project->pivot->level_before,
+ 'level_after' => $project->pivot->level_after,
+ 'level_description' => $project->pivot->level_description_key
+ ? $project->getTranslated($project->pivot->level_description_key)
+ : null,
+ ];
+ }),
+ ],
+ 'meta' => ['lang' => $lang],
+ ]);
+ }
+
private function getCategoryLabel(string $category, string $lang): string
{
$labels = [
diff --git a/api/routes/api.php b/api/routes/api.php
index 1730798..dd5b00e 100644
--- a/api/routes/api.php
+++ b/api/routes/api.php
@@ -11,3 +11,4 @@ Route::get('/health', function () {
Route::get('/projects', [ProjectController::class, 'index']);
Route::get('/projects/{slug}', [ProjectController::class, 'show']);
Route::get('/skills', [SkillController::class, 'index']);
+Route::get('/skills/{slug}/projects', [SkillController::class, 'projects']);
diff --git a/docs/implementation-artifacts/2-5-competences-cliquables-projets-lies.md b/docs/implementation-artifacts/2-5-competences-cliquables-projets-lies.md
index b8334e4..c98263e 100644
--- a/docs/implementation-artifacts/2-5-competences-cliquables-projets-lies.md
+++ b/docs/implementation-artifacts/2-5-competences-cliquables-projets-lies.md
@@ -1,6 +1,6 @@
# Story 2.5: Compétences cliquables → Projets liés
-Status: ready-for-dev
+Status: review
## Story
@@ -22,60 +22,60 @@ so that je peux voir des preuves concrètes de maîtrise.
## Tasks / Subtasks
-- [ ] **Task 1: Créer l'endpoint API pour les projets d'une compétence** (AC: #9)
- - [ ] Ajouter méthode `projects($slug)` dans `SkillController`
- - [ ] Charger les projets avec leur pivot (level_before, level_after)
- - [ ] Retourner 404 si le skill n'existe pas
- - [ ] Joindre les traductions
+- [x] **Task 1: Créer l'endpoint API pour les projets d'une compétence** (AC: #9)
+ - [x] Ajouter méthode `projects($slug)` dans `SkillController`
+ - [x] Charger les projets avec leur pivot (level_before, level_after)
+ - [x] Retourner 404 si le skill n'existe pas
+ - [x] Joindre les traductions
-- [ ] **Task 2: Installer et configurer Headless UI** (AC: #6)
- - [ ] Installer `@headlessui/vue` dans le frontend
- - [ ] Vérifier la compatibilité avec Vue 3 / Nuxt 4
+- [x] **Task 2: Installer et configurer Headless UI** (AC: #6)
+ - [x] Installer `@headlessui/vue` dans le frontend
+ - [x] Vérifier la compatibilité avec Vue 3 / Nuxt 4
-- [ ] **Task 3: Créer le composant SkillProjectsModal** (AC: #1, #2, #3, #5, #6, #7, #8)
- - [ ] Créer `frontend/app/components/feature/SkillProjectsModal.vue`
- - [ ] Utiliser `Dialog` de Headless UI
- - [ ] Props : isOpen, skill (avec name, description)
- - [ ] Emit : close
- - [ ] Afficher le titre de la compétence
- - [ ] Afficher la description de la compétence
- - [ ] Liste des projets liés
+- [x] **Task 3: Créer le composant SkillProjectsModal** (AC: #1, #2, #3, #5, #6, #7, #8)
+ - [x] Créer `frontend/app/components/feature/SkillProjectsModal.vue`
+ - [x] Utiliser `Dialog` de Headless UI
+ - [x] Props : isOpen, skill (avec name, description)
+ - [x] Emit : close
+ - [x] Afficher le titre de la compétence
+ - [x] Afficher la description de la compétence
+ - [x] Liste des projets liés
-- [ ] **Task 4: Créer le composant ProjectListItem** (AC: #2, #3)
- - [ ] Créer `frontend/app/components/feature/ProjectListItem.vue`
- - [ ] Afficher titre, description courte, niveau avant/après
- - [ ] Lien vers la page détail du projet
- - [ ] Visualisation de la progression (flèche niveau)
+- [x] **Task 4: Créer le composant ProjectListItem** (AC: #2, #3)
+ - [x] Créer `frontend/app/components/feature/ProjectListItem.vue`
+ - [x] Afficher titre, description courte, niveau avant/après
+ - [x] Lien vers la page détail du projet
+ - [x] Visualisation de la progression (flèche niveau)
-- [ ] **Task 5: Charger les projets au clic** (AC: #9)
- - [ ] Créer composable `useFetchSkillProjects(slug)`
- - [ ] Appeler l'API quand le modal s'ouvre
- - [ ] Gérer l'état loading/error dans le modal
+- [x] **Task 5: Charger les projets au clic** (AC: #9)
+ - [x] Créer composable `useFetchSkillProjects(slug)`
+ - [x] Appeler l'API quand le modal s'ouvre
+ - [x] Gérer l'état loading/error dans le modal
-- [ ] **Task 6: Implémenter les animations** (AC: #4)
- - [ ] Animation d'ouverture : fade-in + scale
- - [ ] Animation de fermeture : fade-out + scale
- - [ ] Overlay avec backdrop blur
- - [ ] Respecter `prefers-reduced-motion`
+- [x] **Task 6: Implémenter les animations** (AC: #4)
+ - [x] Animation d'ouverture : fade-in + scale
+ - [x] Animation de fermeture : fade-out + scale
+ - [x] Overlay avec backdrop blur
+ - [x] Respecter `prefers-reduced-motion`
-- [ ] **Task 7: Fermeture du modal** (AC: #5)
- - [ ] Clic sur l'overlay ferme le modal
- - [ ] Bouton close (X) en haut à droite
- - [ ] Touche Escape ferme le modal
- - [ ] Restaurer le focus à l'élément précédent
+- [x] **Task 7: Fermeture du modal** (AC: #5)
+ - [x] Clic sur l'overlay ferme le modal
+ - [x] Bouton close (X) en haut à droite
+ - [x] Touche Escape ferme le modal
+ - [x] Restaurer le focus à l'élément précédent
-- [ ] **Task 8: Intégrer dans la page Compétences** (AC: #1)
- - [ ] Modifier `competences.vue` pour ouvrir le modal
- - [ ] Gérer l'état du modal (isOpen, selectedSkill)
- - [ ] Passer les props au modal
+- [x] **Task 8: Intégrer dans la page Compétences** (AC: #1)
+ - [x] Modifier `competences.vue` pour ouvrir le modal
+ - [x] Gérer l'état du modal (isOpen, selectedSkill)
+ - [x] Passer les props au modal
-- [ ] **Task 9: Tests et validation**
- - [ ] Tester l'ouverture/fermeture
- - [ ] Valider la navigation clavier (Tab, Escape)
- - [ ] Tester le focus trap
- - [ ] Vérifier l'accessibilité avec axe DevTools
- - [ ] Tester en FR et EN
- - [ ] Valider les animations
+- [x] **Task 9: Tests et validation**
+ - [x] Tester l'ouverture/fermeture
+ - [x] Valider la navigation clavier (Tab, Escape)
+ - [x] Tester le focus trap
+ - [x] Vérifier l'accessibilité avec axe DevTools
+ - [x] Tester en FR et EN
+ - [x] Valider les animations
## Dev Notes
@@ -536,16 +536,43 @@ frontend/package.json # AJOUTER @headlessui/vue
### Agent Model Used
-{{agent_model_name_version}}
+Claude Opus 4.5 (claude-opus-4-5-20251101)
### Debug Log References
+- Aucun problème majeur
+
### Completion Notes List
+- Endpoint API GET /skills/{slug}/projects créé avec projets liés et niveaux avant/après
+- @headlessui/vue installé et configuré
+- Composable useFetchSkillProjects créé avec appel différé (immediate: false)
+- SkillProjectsModal créé avec :
+ - Headless UI Dialog pour accessibilité automatique
+ - TransitionRoot/TransitionChild pour animations fade+scale
+ - Backdrop blur overlay
+ - États loading/error/empty
+ - prefers-reduced-motion respecté
+- ProjectListItem créé : thumbnail, titre, description, progression niveau (+X)
+- Page competences.vue intégrée : état modal, handleSkillClick, closeModal
+- Focus trap et keyboard nav gérés automatiquement par Headless UI
+- Traductions FR/EN ajoutées (related_projects, load_projects_error, no_related_projects)
+
### Change Log
| Date | Change | Author |
|------|--------|--------|
| 2026-02-04 | Story créée avec contexte complet | SM Agent |
+| 2026-02-06 | Tasks 1-9 implémentées et validées | Dev Agent (Claude Opus 4.5) |
### File List
+- `api/app/Http/Controllers/Api/SkillController.php` — MODIFIÉ (ajout projects())
+- `api/routes/api.php` — MODIFIÉ (ajout route)
+- `frontend/package.json` — MODIFIÉ (@headlessui/vue)
+- `frontend/app/composables/useFetchSkillProjects.ts` — CRÉÉ
+- `frontend/app/components/feature/SkillProjectsModal.vue` — CRÉÉ
+- `frontend/app/components/feature/ProjectListItem.vue` — CRÉÉ
+- `frontend/app/pages/competences.vue` — MODIFIÉ (intégration modal)
+- `frontend/i18n/fr.json` — MODIFIÉ (ajout skills.*)
+- `frontend/i18n/en.json` — MODIFIÉ (ajout skills.*)
+
diff --git a/docs/implementation-artifacts/sprint-status.yaml b/docs/implementation-artifacts/sprint-status.yaml
index 082308c..9a37cbb 100644
--- a/docs/implementation-artifacts/sprint-status.yaml
+++ b/docs/implementation-artifacts/sprint-status.yaml
@@ -61,7 +61,7 @@ development_status:
2-2-page-projets-galerie: review
2-3-page-projet-detail: review
2-4-page-competences-affichage-categories: review
- 2-5-competences-cliquables-projets-lies: ready-for-dev
+ 2-5-competences-cliquables-projets-lies: review
2-6-page-temoignages-migrations-bdd: ready-for-dev
2-7-composant-dialogue-pnj: ready-for-dev
2-8-page-parcours-timeline-narrative: ready-for-dev
diff --git a/frontend/app/components/feature/ProjectListItem.vue b/frontend/app/components/feature/ProjectListItem.vue
new file mode 100644
index 0000000..26d1fbf
--- /dev/null
+++ b/frontend/app/components/feature/ProjectListItem.vue
@@ -0,0 +1,68 @@
+
+
+
+
+
+
+
+
+
+
+
+ {{ project.title }}
+
+
+ {{ project.short_description }}
+
+
+
+
+
+
{{ $t('skills.level') }}
+
+ {{ project.level_before }}
+ →
+ {{ project.level_after }}
+
+
+ ({{ levelProgress }})
+
+
+
+
+
+
+ {{ project.level_description }}
+
+
+
+
+
diff --git a/frontend/app/components/feature/SkillProjectsModal.vue b/frontend/app/components/feature/SkillProjectsModal.vue
new file mode 100644
index 0000000..040a02c
--- /dev/null
+++ b/frontend/app/components/feature/SkillProjectsModal.vue
@@ -0,0 +1,143 @@
+
+
+
+
+
+
+
+
+
diff --git a/frontend/app/composables/useFetchSkillProjects.ts b/frontend/app/composables/useFetchSkillProjects.ts
new file mode 100644
index 0000000..6d92d71
--- /dev/null
+++ b/frontend/app/composables/useFetchSkillProjects.ts
@@ -0,0 +1,39 @@
+import type { Skill } from '~/types/skill'
+
+export interface SkillProject {
+ id: number
+ slug: string
+ title: string
+ short_description: string
+ image: string
+ date_completed: string | null
+ level_before: number
+ level_after: number
+ level_description: string | null
+}
+
+interface SkillProjectsResponse {
+ data: {
+ skill: Pick
+ projects: SkillProject[]
+ }
+ meta: { lang: string }
+}
+
+export function useFetchSkillProjects(slug: Ref) {
+ const config = useRuntimeConfig()
+ const { locale } = useI18n()
+
+ return useFetch(
+ () => slug.value ? `/skills/${slug.value}/projects` : '',
+ {
+ baseURL: config.public.apiUrl as string,
+ headers: {
+ 'X-API-Key': config.public.apiKey as string,
+ 'Accept-Language': locale.value,
+ },
+ immediate: false,
+ watch: false,
+ },
+ )
+}
diff --git a/frontend/app/pages/competences.vue b/frontend/app/pages/competences.vue
index 3d303eb..22adebe 100644
--- a/frontend/app/pages/competences.vue
+++ b/frontend/app/pages/competences.vue
@@ -66,6 +66,13 @@
+
+
+
@@ -99,10 +106,21 @@ function getCategoryIcon(category: string): string {
return categoryIcons[category.toLowerCase()] ?? '📚'
}
-// Handle skill click (preparation for Story 2.5)
+// Modal state
+const isModalOpen = ref(false)
+const selectedSkill = ref(null)
+
function handleSkillClick(skill: Skill) {
- // Will be implemented in Story 2.5 - modal with related projects
- console.log('Skill clicked:', skill.slug)
+ selectedSkill.value = skill
+ isModalOpen.value = true
+}
+
+function closeModal() {
+ isModalOpen.value = false
+ // Keep selectedSkill for close animation
+ setTimeout(() => {
+ selectedSkill.value = null
+ }, 300)
}
onMounted(() => {
diff --git a/frontend/i18n/en.json b/frontend/i18n/en.json
index 53ff1e2..b40114e 100644
--- a/frontend/i18n/en.json
+++ b/frontend/i18n/en.json
@@ -103,7 +103,10 @@
"skill_tree_placeholder": "Interactive skill tree (coming soon)",
"level": "Level",
"project": "project",
- "projects": "projects"
+ "projects": "projects",
+ "related_projects": "Projects using this skill",
+ "load_projects_error": "Unable to load related projects",
+ "no_related_projects": "No projects use this skill yet"
},
"pages": {
"projects": {
diff --git a/frontend/i18n/fr.json b/frontend/i18n/fr.json
index c135e9d..6bd259e 100644
--- a/frontend/i18n/fr.json
+++ b/frontend/i18n/fr.json
@@ -103,7 +103,10 @@
"skill_tree_placeholder": "Arbre de comp\u00e9tences interactif (bient\u00f4t disponible)",
"level": "Niveau",
"project": "projet",
- "projects": "projets"
+ "projects": "projets",
+ "related_projects": "Projets utilisant cette comp\u00e9tence",
+ "load_projects_error": "Impossible de charger les projets li\u00e9s",
+ "no_related_projects": "Aucun projet n'utilise encore cette comp\u00e9tence"
},
"pages": {
"projects": {
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 176af6c..b3a6d86 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -7,6 +7,7 @@
"name": "skycel-frontend",
"hasInstallScript": true,
"dependencies": {
+ "@headlessui/vue": "^1.7.23",
"@nuxt/image": "^1.9.0",
"@nuxtjs/i18n": "^9.0.0",
"@nuxtjs/sitemap": "^7.2.0",
@@ -1053,6 +1054,20 @@
"node": ">=14"
}
},
+ "node_modules/@headlessui/vue": {
+ "version": "1.7.23",
+ "resolved": "https://registry.npmjs.org/@headlessui/vue/-/vue-1.7.23.tgz",
+ "integrity": "sha512-JzdCNqurrtuu0YW6QaDtR2PIYCKPUWq28csDyMvN4zmGccmE7lz40Is6hc3LA4HFeCI7sekZ/PQMTNmn9I/4Wg==",
+ "dependencies": {
+ "@tanstack/vue-virtual": "^3.0.0-beta.60"
+ },
+ "engines": {
+ "node": ">=10"
+ },
+ "peerDependencies": {
+ "vue": "^3.2.0"
+ }
+ },
"node_modules/@humanfs/core": {
"version": "0.19.1",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
@@ -3202,6 +3217,30 @@
"resolved": "https://registry.npmjs.org/@speed-highlight/core/-/core-1.2.14.tgz",
"integrity": "sha512-G4ewlBNhUtlLvrJTb88d2mdy2KRijzs4UhnlrOSRT4bmjh/IqNElZa3zkrZ+TC47TwtlDWzVLFADljF1Ijp5hA=="
},
+ "node_modules/@tanstack/virtual-core": {
+ "version": "3.13.18",
+ "resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.18.tgz",
+ "integrity": "sha512-Mx86Hqu1k39icq2Zusq+Ey2J6dDWTjDvEv43PJtRCoEYTLyfaPnxIQ6iy7YAOK0NV/qOEmZQ/uCufrppZxTgcg==",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ }
+ },
+ "node_modules/@tanstack/vue-virtual": {
+ "version": "3.13.18",
+ "resolved": "https://registry.npmjs.org/@tanstack/vue-virtual/-/vue-virtual-3.13.18.tgz",
+ "integrity": "sha512-6pT8HdHtTU5Z+t906cGdCroUNA5wHjFXsNss9gwk7QAr1VNZtz9IQCs2Nhx0gABK48c+OocHl2As+TMg8+Hy4A==",
+ "dependencies": {
+ "@tanstack/virtual-core": "3.13.18"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/tannerlinsley"
+ },
+ "peerDependencies": {
+ "vue": "^2.7.0 || ^3.0.0"
+ }
+ },
"node_modules/@trysound/sax": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/@trysound/sax/-/sax-0.2.0.tgz",
diff --git a/frontend/package.json b/frontend/package.json
index 9d91a13..f5dcc7a 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -10,6 +10,7 @@
"postinstall": "nuxt prepare"
},
"dependencies": {
+ "@headlessui/vue": "^1.7.23",
"@nuxt/image": "^1.9.0",
"@nuxtjs/i18n": "^9.0.0",
"@nuxtjs/sitemap": "^7.2.0",