# 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

