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>
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
- 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
- And pour chaque projet lié : titre, description courte, lien vers le détail
- And l'indication du niveau avant/après chaque projet est visible (progression)
- And une animation d'ouverture/fermeture fluide est présente (respectant
prefers-reduced-motion) - And la fermeture est possible par clic extérieur, bouton close, ou touche Escape
- And le panneau/modal utilise Headless UI pour l'accessibilité
- And la navigation au clavier est fonctionnelle (Tab, Escape, Enter)
- And le focus est piégé dans le modal quand ouvert (
focus trap) - And les données viennent de la relation
skill_projectvia 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)dansSkillController - Charger les projets avec leur pivot (level_before, level_after)
- Retourner 404 si le skill n'existe pas
- Joindre les traductions
- Ajouter méthode
-
Task 2: Installer et configurer Headless UI (AC: #6)
- Installer
@headlessui/vuedans le frontend - Vérifier la compatibilité avec Vue 3 / Nuxt 4
- Installer
-
Task 3: Créer le composant SkillProjectsModal (AC: #1, #2, #3, #5, #6, #7, #8)
- Créer
frontend/app/components/feature/SkillProjectsModal.vue - Utiliser
Dialogde 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
- Créer
-
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)
- Créer
-
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
- Créer composable
-
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.vuepour ouvrir le modal - Gérer l'état du modal (isOpen, selectedSkill)
- Passer les props au modal
- Modifier
-
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 |