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>
552 lines
17 KiB
Markdown
552 lines
17 KiB
Markdown
# Story 2.5: Compétences cliquables → Projets liés
|
|
|
|
Status: ready-for-dev
|
|
|
|
## Story
|
|
|
|
As a visiteur,
|
|
I want cliquer sur une compétence pour voir les projets qui l'utilisent,
|
|
so that je peux voir des preuves concrètes de maîtrise.
|
|
|
|
## Acceptance Criteria
|
|
|
|
1. **Given** le visiteur est sur la page Compétences **When** il clique sur une compétence **Then** un panneau/modal s'ouvre avec la liste des projets liés à cette compétence
|
|
2. **And** pour chaque projet lié : titre, description courte, lien vers le détail
|
|
3. **And** l'indication du niveau avant/après chaque projet est visible (progression)
|
|
4. **And** une animation d'ouverture/fermeture fluide est présente (respectant `prefers-reduced-motion`)
|
|
5. **And** la fermeture est possible par clic extérieur, bouton close, ou touche Escape
|
|
6. **And** le panneau/modal utilise Headless UI pour l'accessibilité
|
|
7. **And** la navigation au clavier est fonctionnelle (Tab, Escape, Enter)
|
|
8. **And** le focus est piégé dans le modal quand ouvert (`focus trap`)
|
|
9. **And** les données viennent de la relation `skill_project` via l'API
|
|
|
|
## 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
|
|
|
|
- [ ] **Task 2: Installer et configurer Headless UI** (AC: #6)
|
|
- [ ] Installer `@headlessui/vue` dans le frontend
|
|
- [ ] 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
|
|
|
|
- [ ] **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)
|
|
|
|
- [ ] **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
|
|
|
|
- [ ] **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`
|
|
|
|
- [ ] **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
|
|
|
|
- [ ] **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
|
|
|
|
- [ ] **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
|
|
|
|
## Dev Notes
|
|
|
|
### Endpoint API Laravel
|
|
|
|
```php
|
|
<?php
|
|
// api/app/Http/Controllers/Api/SkillController.php
|
|
|
|
public function projects(Request $request, string $slug)
|
|
{
|
|
$lang = $request->header('Accept-Language', 'fr');
|
|
|
|
$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' => Translation::getTranslation($skill->name_key, $lang),
|
|
'description' => Translation::getTranslation($skill->description_key, $lang),
|
|
'level' => $skill->getCurrentLevel(),
|
|
'maxLevel' => $skill->max_level,
|
|
],
|
|
'projects' => $skill->projects->map(function ($project) use ($lang) {
|
|
return [
|
|
'id' => $project->id,
|
|
'slug' => $project->slug,
|
|
'title' => Translation::getTranslation($project->title_key, $lang),
|
|
'shortDescription' => Translation::getTranslation($project->short_description_key, $lang),
|
|
'image' => $project->image,
|
|
'dateCompleted' => $project->date_completed?->format('Y-m-d'),
|
|
'levelBefore' => $project->pivot->level_before,
|
|
'levelAfter' => $project->pivot->level_after,
|
|
'levelDescription' => $project->pivot->level_description_key
|
|
? Translation::getTranslation($project->pivot->level_description_key, $lang)
|
|
: null,
|
|
];
|
|
}),
|
|
],
|
|
'meta' => ['lang' => $lang],
|
|
]);
|
|
}
|
|
```
|
|
|
|
```php
|
|
// api/routes/api.php
|
|
Route::get('/skills/{slug}/projects', [SkillController::class, 'projects']);
|
|
```
|
|
|
|
### Installation Headless UI
|
|
|
|
```bash
|
|
cd frontend
|
|
npm install @headlessui/vue
|
|
```
|
|
|
|
### Composable useFetchSkillProjects
|
|
|
|
```typescript
|
|
// frontend/app/composables/useFetchSkillProjects.ts
|
|
import type { Skill } from '~/types/skill'
|
|
import type { Project } from '~/types/project'
|
|
|
|
interface SkillProjectsResponse {
|
|
data: {
|
|
skill: Skill
|
|
projects: (Project & { levelBefore: number; levelAfter: number; levelDescription?: string })[]
|
|
}
|
|
meta: { lang: string }
|
|
}
|
|
|
|
export function useFetchSkillProjects(slug: Ref<string | null>) {
|
|
const config = useRuntimeConfig()
|
|
const { locale } = useI18n()
|
|
|
|
return useFetch<SkillProjectsResponse>(
|
|
() => slug.value ? `/skills/${slug.value}/projects` : null,
|
|
{
|
|
baseURL: config.public.apiUrl,
|
|
headers: {
|
|
'X-API-Key': config.public.apiKey,
|
|
'Accept-Language': locale.value,
|
|
},
|
|
immediate: false,
|
|
watch: false,
|
|
}
|
|
)
|
|
}
|
|
```
|
|
|
|
### Composant SkillProjectsModal
|
|
|
|
```vue
|
|
<!-- frontend/app/components/feature/SkillProjectsModal.vue -->
|
|
<script setup lang="ts">
|
|
import {
|
|
Dialog,
|
|
DialogPanel,
|
|
DialogTitle,
|
|
TransitionRoot,
|
|
TransitionChild,
|
|
} from '@headlessui/vue'
|
|
import type { Skill } from '~/types/skill'
|
|
|
|
const props = defineProps<{
|
|
isOpen: boolean
|
|
skill: Skill | null
|
|
}>()
|
|
|
|
const emit = defineEmits<{
|
|
close: []
|
|
}>()
|
|
|
|
const { t } = useI18n()
|
|
const skillSlug = computed(() => props.skill?.slug ?? null)
|
|
|
|
const { data, pending, error, execute } = useFetchSkillProjects(skillSlug)
|
|
|
|
// Charger les projets quand le modal s'ouvre
|
|
watch(() => props.isOpen, (isOpen) => {
|
|
if (isOpen && props.skill) {
|
|
execute()
|
|
}
|
|
})
|
|
|
|
const projects = computed(() => data.value?.data.projects ?? [])
|
|
</script>
|
|
|
|
<template>
|
|
<TransitionRoot :show="isOpen" as="template">
|
|
<Dialog @close="emit('close')" class="relative z-50">
|
|
<!-- Backdrop -->
|
|
<TransitionChild
|
|
as="template"
|
|
enter="duration-300 ease-out"
|
|
enter-from="opacity-0"
|
|
enter-to="opacity-100"
|
|
leave="duration-200 ease-in"
|
|
leave-from="opacity-100"
|
|
leave-to="opacity-0"
|
|
>
|
|
<div class="fixed inset-0 bg-sky-dark/80 backdrop-blur-sm" />
|
|
</TransitionChild>
|
|
|
|
<!-- Modal container -->
|
|
<div class="fixed inset-0 overflow-y-auto">
|
|
<div class="flex min-h-full items-center justify-center p-4">
|
|
<TransitionChild
|
|
as="template"
|
|
enter="duration-300 ease-out"
|
|
enter-from="opacity-0 scale-95"
|
|
enter-to="opacity-100 scale-100"
|
|
leave="duration-200 ease-in"
|
|
leave-from="opacity-100 scale-100"
|
|
leave-to="opacity-0 scale-95"
|
|
>
|
|
<DialogPanel class="w-full max-w-2xl bg-sky-dark-50 rounded-xl shadow-xl">
|
|
<!-- Header -->
|
|
<div class="flex items-start justify-between p-6 border-b border-sky-dark-100">
|
|
<div>
|
|
<DialogTitle class="text-xl font-ui font-bold text-sky-text">
|
|
{{ skill?.name }}
|
|
</DialogTitle>
|
|
<p v-if="skill?.description" class="mt-1 text-sm text-sky-text-muted">
|
|
{{ skill.description }}
|
|
</p>
|
|
</div>
|
|
|
|
<!-- Close button -->
|
|
<button
|
|
type="button"
|
|
class="text-sky-text-muted hover:text-sky-text transition-colors p-2 -mr-2 -mt-2"
|
|
@click="emit('close')"
|
|
>
|
|
<span class="sr-only">{{ t('common.close') }}</span>
|
|
<svg class="w-6 h-6" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
|
</svg>
|
|
</button>
|
|
</div>
|
|
|
|
<!-- Content -->
|
|
<div class="p-6">
|
|
<h3 class="text-sm font-ui font-medium text-sky-text-muted uppercase tracking-wide mb-4">
|
|
{{ t('skills.relatedProjects') }}
|
|
</h3>
|
|
|
|
<!-- Loading -->
|
|
<div v-if="pending" class="space-y-4">
|
|
<div v-for="i in 3" :key="i" class="bg-sky-dark rounded-lg p-4 animate-pulse">
|
|
<div class="h-5 bg-sky-dark-100 rounded w-1/2 mb-2"></div>
|
|
<div class="h-4 bg-sky-dark-100 rounded w-3/4"></div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Error -->
|
|
<div v-else-if="error" class="text-center py-8">
|
|
<p class="text-sky-text-muted">{{ t('skills.loadProjectsError') }}</p>
|
|
</div>
|
|
|
|
<!-- No projects -->
|
|
<div v-else-if="projects.length === 0" class="text-center py-8">
|
|
<p class="text-sky-text-muted">{{ t('skills.noProjects') }}</p>
|
|
</div>
|
|
|
|
<!-- Projects list -->
|
|
<div v-else class="space-y-4">
|
|
<ProjectListItem
|
|
v-for="project in projects"
|
|
:key="project.id"
|
|
:project="project"
|
|
@click="emit('close')"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</DialogPanel>
|
|
</TransitionChild>
|
|
</div>
|
|
</div>
|
|
</Dialog>
|
|
</TransitionRoot>
|
|
</template>
|
|
|
|
<style scoped>
|
|
@media (prefers-reduced-motion: reduce) {
|
|
:deep([data-headlessui-state]) {
|
|
transition: none !important;
|
|
}
|
|
}
|
|
</style>
|
|
```
|
|
|
|
### Composant ProjectListItem
|
|
|
|
```vue
|
|
<!-- frontend/app/components/feature/ProjectListItem.vue -->
|
|
<script setup lang="ts">
|
|
import type { Project } from '~/types/project'
|
|
|
|
interface ProjectWithLevel extends Project {
|
|
levelBefore: number
|
|
levelAfter: number
|
|
levelDescription?: string
|
|
}
|
|
|
|
const props = defineProps<{
|
|
project: ProjectWithLevel
|
|
}>()
|
|
|
|
const emit = defineEmits<{
|
|
click: []
|
|
}>()
|
|
|
|
const { t } = useI18n()
|
|
const localePath = useLocalePath()
|
|
|
|
const levelProgress = computed(() => {
|
|
const diff = props.project.levelAfter - props.project.levelBefore
|
|
return diff > 0 ? `+${diff}` : diff.toString()
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<NuxtLink
|
|
:to="localePath(`/projets/${project.slug}`)"
|
|
class="block bg-sky-dark rounded-lg p-4 hover:bg-sky-dark-100 transition-colors group"
|
|
@click="emit('click')"
|
|
>
|
|
<div class="flex items-start gap-4">
|
|
<!-- Image thumbnail -->
|
|
<NuxtImg
|
|
v-if="project.image"
|
|
:src="project.image"
|
|
:alt="project.title"
|
|
format="webp"
|
|
width="80"
|
|
height="60"
|
|
class="w-20 h-15 object-cover rounded"
|
|
/>
|
|
|
|
<!-- Content -->
|
|
<div class="flex-1 min-w-0">
|
|
<h4 class="font-ui font-medium text-sky-text group-hover:text-sky-accent transition-colors truncate">
|
|
{{ project.title }}
|
|
</h4>
|
|
<p class="text-sm text-sky-text-muted line-clamp-2 mt-1">
|
|
{{ project.shortDescription }}
|
|
</p>
|
|
</div>
|
|
|
|
<!-- Level progress -->
|
|
<div class="flex-shrink-0 text-right">
|
|
<div class="text-xs text-sky-text-muted">{{ t('skills.level') }}</div>
|
|
<div class="flex items-center gap-1 mt-1">
|
|
<span class="text-sky-text">{{ project.levelBefore }}</span>
|
|
<span class="text-sky-accent">→</span>
|
|
<span class="text-sky-accent font-semibold">{{ project.levelAfter }}</span>
|
|
</div>
|
|
<div class="text-xs text-sky-accent font-medium">
|
|
({{ levelProgress }})
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Level description if available -->
|
|
<p v-if="project.levelDescription" class="mt-2 text-xs text-sky-text-muted italic">
|
|
{{ project.levelDescription }}
|
|
</p>
|
|
</NuxtLink>
|
|
</template>
|
|
```
|
|
|
|
### Modification de competences.vue
|
|
|
|
```vue
|
|
<!-- frontend/app/pages/competences.vue - Modifications -->
|
|
<script setup lang="ts">
|
|
import type { Skill } from '~/types/skill'
|
|
|
|
// ... code existant ...
|
|
|
|
// État du modal
|
|
const isModalOpen = ref(false)
|
|
const selectedSkill = ref<Skill | null>(null)
|
|
|
|
function handleSkillClick(skill: Skill) {
|
|
selectedSkill.value = skill
|
|
isModalOpen.value = true
|
|
}
|
|
|
|
function closeModal() {
|
|
isModalOpen.value = false
|
|
// Garder selectedSkill pour l'animation de fermeture
|
|
setTimeout(() => {
|
|
selectedSkill.value = null
|
|
}, 300)
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<div class="container mx-auto px-4 py-8">
|
|
<!-- ... code existant ... -->
|
|
|
|
<!-- Modal des projets liés -->
|
|
<SkillProjectsModal
|
|
:is-open="isModalOpen"
|
|
:skill="selectedSkill"
|
|
@close="closeModal"
|
|
/>
|
|
</div>
|
|
</template>
|
|
```
|
|
|
|
### Clés i18n nécessaires
|
|
|
|
**fr.json :**
|
|
```json
|
|
{
|
|
"skills": {
|
|
"relatedProjects": "Projets utilisant cette compétence",
|
|
"loadProjectsError": "Impossible de charger les projets liés",
|
|
"noProjects": "Aucun projet n'utilise encore cette compétence",
|
|
"level": "Niveau"
|
|
},
|
|
"common": {
|
|
"close": "Fermer"
|
|
}
|
|
}
|
|
```
|
|
|
|
**en.json :**
|
|
```json
|
|
{
|
|
"skills": {
|
|
"relatedProjects": "Projects using this skill",
|
|
"loadProjectsError": "Unable to load related projects",
|
|
"noProjects": "No projects use this skill yet",
|
|
"level": "Level"
|
|
},
|
|
"common": {
|
|
"close": "Close"
|
|
}
|
|
}
|
|
```
|
|
|
|
### Accessibilité
|
|
|
|
| Requirement | Implementation |
|
|
|-------------|----------------|
|
|
| Focus trap | Géré automatiquement par Headless UI Dialog |
|
|
| Keyboard navigation | Tab entre les éléments, Escape pour fermer |
|
|
| Screen reader | DialogTitle annoncé, aria-modal="true" |
|
|
| Fermeture externe | Clic overlay, bouton X, Escape |
|
|
| Focus restoration | Automatique par Headless UI |
|
|
|
|
### Dépendances
|
|
|
|
**Cette story nécessite :**
|
|
- Story 1.2 : Table skill_project avec relations
|
|
- Story 2.4 : Page Compétences avec SkillCard cliquable
|
|
|
|
**Cette story prépare pour :**
|
|
- Aucune dépendance directe
|
|
|
|
### Project Structure Notes
|
|
|
|
**Fichiers à créer :**
|
|
```
|
|
frontend/app/components/feature/
|
|
├── SkillProjectsModal.vue # CRÉER
|
|
└── ProjectListItem.vue # CRÉER
|
|
|
|
frontend/app/composables/
|
|
└── useFetchSkillProjects.ts # CRÉER
|
|
```
|
|
|
|
**Fichiers à modifier :**
|
|
```
|
|
api/app/Http/Controllers/Api/SkillController.php # AJOUTER projects()
|
|
api/routes/api.php # AJOUTER route
|
|
frontend/app/pages/competences.vue # AJOUTER modal
|
|
frontend/i18n/fr.json # AJOUTER clés
|
|
frontend/i18n/en.json # AJOUTER clés
|
|
frontend/package.json # AJOUTER @headlessui/vue
|
|
```
|
|
|
|
### References
|
|
|
|
- [Source: docs/planning-artifacts/epics.md#Story-2.5]
|
|
- [Source: docs/planning-artifacts/architecture.md#Design-System-Components]
|
|
- [Source: docs/planning-artifacts/ux-design-specification.md#Design-System-Components-Headless]
|
|
- [Source: docs/planning-artifacts/ux-design-specification.md#Accessibility-Strategy]
|
|
|
|
### Technical Requirements
|
|
|
|
| Requirement | Value | Source |
|
|
|-------------|-------|--------|
|
|
| UI Library | Headless UI Dialog | Architecture |
|
|
| Focus trap | Required | WCAG AA |
|
|
| Keyboard nav | Tab, Escape, Enter | WCAG AA |
|
|
| Animation | Respect prefers-reduced-motion | NFR6 |
|
|
| API endpoint | GET /api/skills/{slug}/projects | Architecture |
|
|
|
|
## 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
|
|
|