🎉 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>
This commit is contained in:
@@ -0,0 +1,551 @@
|
||||
# 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
|
||||
|
||||
Reference in New Issue
Block a user