Files
Portfolio-Game/docs/implementation-artifacts/2-5-competences-cliquables-projets-lies.md
skycel ec1ae92799 🎉 Init monorepo Nuxt 4 + Laravel 12 (Story 1.1)
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>
2026-02-05 02:08:56 +01:00

17 KiB

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
// 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],
    ]);
}
// api/routes/api.php
Route::get('/skills/{slug}/projects', [SkillController::class, 'projects']);

Installation Headless UI

cd frontend
npm install @headlessui/vue

Composable useFetchSkillProjects

// 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

<!-- 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

<!-- 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

<!-- 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 :

{
  "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 :

{
  "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