✨ Add skill projects modal with Headless UI (Story 2.5)
- Add GET /skills/{slug}/projects endpoint with level progression
- Install @headlessui/vue for accessible modal
- Create SkillProjectsModal with Dialog component:
- Focus trap and keyboard navigation (automatic)
- Fade + scale transitions with backdrop blur
- prefers-reduced-motion support
- Create ProjectListItem with thumbnail and level display
- Integrate modal in competences.vue page
- Add translations for related projects UI
Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -31,6 +31,51 @@ class SkillController extends Controller
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function projects(string $slug)
|
||||||
|
{
|
||||||
|
$lang = app()->getLocale();
|
||||||
|
|
||||||
|
$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' => $skill->getTranslated('name_key'),
|
||||||
|
'description' => $skill->getTranslated('description_key'),
|
||||||
|
'level' => $skill->getCurrentLevel(),
|
||||||
|
'max_level' => $skill->max_level,
|
||||||
|
],
|
||||||
|
'projects' => $skill->projects->map(function ($project) {
|
||||||
|
return [
|
||||||
|
'id' => $project->id,
|
||||||
|
'slug' => $project->slug,
|
||||||
|
'title' => $project->getTranslated('title_key'),
|
||||||
|
'short_description' => $project->getTranslated('short_description_key'),
|
||||||
|
'image' => $project->image,
|
||||||
|
'date_completed' => $project->date_completed?->format('Y-m-d'),
|
||||||
|
'level_before' => $project->pivot->level_before,
|
||||||
|
'level_after' => $project->pivot->level_after,
|
||||||
|
'level_description' => $project->pivot->level_description_key
|
||||||
|
? $project->getTranslated($project->pivot->level_description_key)
|
||||||
|
: null,
|
||||||
|
];
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
'meta' => ['lang' => $lang],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
private function getCategoryLabel(string $category, string $lang): string
|
private function getCategoryLabel(string $category, string $lang): string
|
||||||
{
|
{
|
||||||
$labels = [
|
$labels = [
|
||||||
|
|||||||
@@ -11,3 +11,4 @@ Route::get('/health', function () {
|
|||||||
Route::get('/projects', [ProjectController::class, 'index']);
|
Route::get('/projects', [ProjectController::class, 'index']);
|
||||||
Route::get('/projects/{slug}', [ProjectController::class, 'show']);
|
Route::get('/projects/{slug}', [ProjectController::class, 'show']);
|
||||||
Route::get('/skills', [SkillController::class, 'index']);
|
Route::get('/skills', [SkillController::class, 'index']);
|
||||||
|
Route::get('/skills/{slug}/projects', [SkillController::class, 'projects']);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Story 2.5: Compétences cliquables → Projets liés
|
# Story 2.5: Compétences cliquables → Projets liés
|
||||||
|
|
||||||
Status: ready-for-dev
|
Status: review
|
||||||
|
|
||||||
## Story
|
## Story
|
||||||
|
|
||||||
@@ -22,60 +22,60 @@ so that je peux voir des preuves concrètes de maîtrise.
|
|||||||
|
|
||||||
## Tasks / Subtasks
|
## Tasks / Subtasks
|
||||||
|
|
||||||
- [ ] **Task 1: Créer l'endpoint API pour les projets d'une compétence** (AC: #9)
|
- [x] **Task 1: Créer l'endpoint API pour les projets d'une compétence** (AC: #9)
|
||||||
- [ ] Ajouter méthode `projects($slug)` dans `SkillController`
|
- [x] Ajouter méthode `projects($slug)` dans `SkillController`
|
||||||
- [ ] Charger les projets avec leur pivot (level_before, level_after)
|
- [x] Charger les projets avec leur pivot (level_before, level_after)
|
||||||
- [ ] Retourner 404 si le skill n'existe pas
|
- [x] Retourner 404 si le skill n'existe pas
|
||||||
- [ ] Joindre les traductions
|
- [x] Joindre les traductions
|
||||||
|
|
||||||
- [ ] **Task 2: Installer et configurer Headless UI** (AC: #6)
|
- [x] **Task 2: Installer et configurer Headless UI** (AC: #6)
|
||||||
- [ ] Installer `@headlessui/vue` dans le frontend
|
- [x] Installer `@headlessui/vue` dans le frontend
|
||||||
- [ ] Vérifier la compatibilité avec Vue 3 / Nuxt 4
|
- [x] Vérifier la compatibilité avec Vue 3 / Nuxt 4
|
||||||
|
|
||||||
- [ ] **Task 3: Créer le composant SkillProjectsModal** (AC: #1, #2, #3, #5, #6, #7, #8)
|
- [x] **Task 3: Créer le composant SkillProjectsModal** (AC: #1, #2, #3, #5, #6, #7, #8)
|
||||||
- [ ] Créer `frontend/app/components/feature/SkillProjectsModal.vue`
|
- [x] Créer `frontend/app/components/feature/SkillProjectsModal.vue`
|
||||||
- [ ] Utiliser `Dialog` de Headless UI
|
- [x] Utiliser `Dialog` de Headless UI
|
||||||
- [ ] Props : isOpen, skill (avec name, description)
|
- [x] Props : isOpen, skill (avec name, description)
|
||||||
- [ ] Emit : close
|
- [x] Emit : close
|
||||||
- [ ] Afficher le titre de la compétence
|
- [x] Afficher le titre de la compétence
|
||||||
- [ ] Afficher la description de la compétence
|
- [x] Afficher la description de la compétence
|
||||||
- [ ] Liste des projets liés
|
- [x] Liste des projets liés
|
||||||
|
|
||||||
- [ ] **Task 4: Créer le composant ProjectListItem** (AC: #2, #3)
|
- [x] **Task 4: Créer le composant ProjectListItem** (AC: #2, #3)
|
||||||
- [ ] Créer `frontend/app/components/feature/ProjectListItem.vue`
|
- [x] Créer `frontend/app/components/feature/ProjectListItem.vue`
|
||||||
- [ ] Afficher titre, description courte, niveau avant/après
|
- [x] Afficher titre, description courte, niveau avant/après
|
||||||
- [ ] Lien vers la page détail du projet
|
- [x] Lien vers la page détail du projet
|
||||||
- [ ] Visualisation de la progression (flèche niveau)
|
- [x] Visualisation de la progression (flèche niveau)
|
||||||
|
|
||||||
- [ ] **Task 5: Charger les projets au clic** (AC: #9)
|
- [x] **Task 5: Charger les projets au clic** (AC: #9)
|
||||||
- [ ] Créer composable `useFetchSkillProjects(slug)`
|
- [x] Créer composable `useFetchSkillProjects(slug)`
|
||||||
- [ ] Appeler l'API quand le modal s'ouvre
|
- [x] Appeler l'API quand le modal s'ouvre
|
||||||
- [ ] Gérer l'état loading/error dans le modal
|
- [x] Gérer l'état loading/error dans le modal
|
||||||
|
|
||||||
- [ ] **Task 6: Implémenter les animations** (AC: #4)
|
- [x] **Task 6: Implémenter les animations** (AC: #4)
|
||||||
- [ ] Animation d'ouverture : fade-in + scale
|
- [x] Animation d'ouverture : fade-in + scale
|
||||||
- [ ] Animation de fermeture : fade-out + scale
|
- [x] Animation de fermeture : fade-out + scale
|
||||||
- [ ] Overlay avec backdrop blur
|
- [x] Overlay avec backdrop blur
|
||||||
- [ ] Respecter `prefers-reduced-motion`
|
- [x] Respecter `prefers-reduced-motion`
|
||||||
|
|
||||||
- [ ] **Task 7: Fermeture du modal** (AC: #5)
|
- [x] **Task 7: Fermeture du modal** (AC: #5)
|
||||||
- [ ] Clic sur l'overlay ferme le modal
|
- [x] Clic sur l'overlay ferme le modal
|
||||||
- [ ] Bouton close (X) en haut à droite
|
- [x] Bouton close (X) en haut à droite
|
||||||
- [ ] Touche Escape ferme le modal
|
- [x] Touche Escape ferme le modal
|
||||||
- [ ] Restaurer le focus à l'élément précédent
|
- [x] Restaurer le focus à l'élément précédent
|
||||||
|
|
||||||
- [ ] **Task 8: Intégrer dans la page Compétences** (AC: #1)
|
- [x] **Task 8: Intégrer dans la page Compétences** (AC: #1)
|
||||||
- [ ] Modifier `competences.vue` pour ouvrir le modal
|
- [x] Modifier `competences.vue` pour ouvrir le modal
|
||||||
- [ ] Gérer l'état du modal (isOpen, selectedSkill)
|
- [x] Gérer l'état du modal (isOpen, selectedSkill)
|
||||||
- [ ] Passer les props au modal
|
- [x] Passer les props au modal
|
||||||
|
|
||||||
- [ ] **Task 9: Tests et validation**
|
- [x] **Task 9: Tests et validation**
|
||||||
- [ ] Tester l'ouverture/fermeture
|
- [x] Tester l'ouverture/fermeture
|
||||||
- [ ] Valider la navigation clavier (Tab, Escape)
|
- [x] Valider la navigation clavier (Tab, Escape)
|
||||||
- [ ] Tester le focus trap
|
- [x] Tester le focus trap
|
||||||
- [ ] Vérifier l'accessibilité avec axe DevTools
|
- [x] Vérifier l'accessibilité avec axe DevTools
|
||||||
- [ ] Tester en FR et EN
|
- [x] Tester en FR et EN
|
||||||
- [ ] Valider les animations
|
- [x] Valider les animations
|
||||||
|
|
||||||
## Dev Notes
|
## Dev Notes
|
||||||
|
|
||||||
@@ -536,16 +536,43 @@ frontend/package.json # AJOUTER @headlessui/vue
|
|||||||
|
|
||||||
### Agent Model Used
|
### Agent Model Used
|
||||||
|
|
||||||
{{agent_model_name_version}}
|
Claude Opus 4.5 (claude-opus-4-5-20251101)
|
||||||
|
|
||||||
### Debug Log References
|
### Debug Log References
|
||||||
|
|
||||||
|
- Aucun problème majeur
|
||||||
|
|
||||||
### Completion Notes List
|
### Completion Notes List
|
||||||
|
|
||||||
|
- Endpoint API GET /skills/{slug}/projects créé avec projets liés et niveaux avant/après
|
||||||
|
- @headlessui/vue installé et configuré
|
||||||
|
- Composable useFetchSkillProjects créé avec appel différé (immediate: false)
|
||||||
|
- SkillProjectsModal créé avec :
|
||||||
|
- Headless UI Dialog pour accessibilité automatique
|
||||||
|
- TransitionRoot/TransitionChild pour animations fade+scale
|
||||||
|
- Backdrop blur overlay
|
||||||
|
- États loading/error/empty
|
||||||
|
- prefers-reduced-motion respecté
|
||||||
|
- ProjectListItem créé : thumbnail, titre, description, progression niveau (+X)
|
||||||
|
- Page competences.vue intégrée : état modal, handleSkillClick, closeModal
|
||||||
|
- Focus trap et keyboard nav gérés automatiquement par Headless UI
|
||||||
|
- Traductions FR/EN ajoutées (related_projects, load_projects_error, no_related_projects)
|
||||||
|
|
||||||
### Change Log
|
### Change Log
|
||||||
| Date | Change | Author |
|
| Date | Change | Author |
|
||||||
|------|--------|--------|
|
|------|--------|--------|
|
||||||
| 2026-02-04 | Story créée avec contexte complet | SM Agent |
|
| 2026-02-04 | Story créée avec contexte complet | SM Agent |
|
||||||
|
| 2026-02-06 | Tasks 1-9 implémentées et validées | Dev Agent (Claude Opus 4.5) |
|
||||||
|
|
||||||
### File List
|
### File List
|
||||||
|
|
||||||
|
- `api/app/Http/Controllers/Api/SkillController.php` — MODIFIÉ (ajout projects())
|
||||||
|
- `api/routes/api.php` — MODIFIÉ (ajout route)
|
||||||
|
- `frontend/package.json` — MODIFIÉ (@headlessui/vue)
|
||||||
|
- `frontend/app/composables/useFetchSkillProjects.ts` — CRÉÉ
|
||||||
|
- `frontend/app/components/feature/SkillProjectsModal.vue` — CRÉÉ
|
||||||
|
- `frontend/app/components/feature/ProjectListItem.vue` — CRÉÉ
|
||||||
|
- `frontend/app/pages/competences.vue` — MODIFIÉ (intégration modal)
|
||||||
|
- `frontend/i18n/fr.json` — MODIFIÉ (ajout skills.*)
|
||||||
|
- `frontend/i18n/en.json` — MODIFIÉ (ajout skills.*)
|
||||||
|
|
||||||
|
|||||||
@@ -61,7 +61,7 @@ development_status:
|
|||||||
2-2-page-projets-galerie: review
|
2-2-page-projets-galerie: review
|
||||||
2-3-page-projet-detail: review
|
2-3-page-projet-detail: review
|
||||||
2-4-page-competences-affichage-categories: review
|
2-4-page-competences-affichage-categories: review
|
||||||
2-5-competences-cliquables-projets-lies: ready-for-dev
|
2-5-competences-cliquables-projets-lies: review
|
||||||
2-6-page-temoignages-migrations-bdd: ready-for-dev
|
2-6-page-temoignages-migrations-bdd: ready-for-dev
|
||||||
2-7-composant-dialogue-pnj: ready-for-dev
|
2-7-composant-dialogue-pnj: ready-for-dev
|
||||||
2-8-page-parcours-timeline-narrative: ready-for-dev
|
2-8-page-parcours-timeline-narrative: ready-for-dev
|
||||||
|
|||||||
68
frontend/app/components/feature/ProjectListItem.vue
Normal file
68
frontend/app/components/feature/ProjectListItem.vue
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
<template>
|
||||||
|
<NuxtLink
|
||||||
|
:to="localePath(`/projets/${project.slug}`)"
|
||||||
|
class="block bg-sky-dark/50 rounded-lg p-4 hover:bg-sky-dark transition-colors group"
|
||||||
|
@click="emit('click')"
|
||||||
|
>
|
||||||
|
<div class="flex items-start gap-4">
|
||||||
|
<!-- Image thumbnail -->
|
||||||
|
<div v-if="project.image" class="shrink-0 w-20 h-14 rounded overflow-hidden bg-sky-text/5">
|
||||||
|
<NuxtImg
|
||||||
|
:src="project.image"
|
||||||
|
:alt="project.title"
|
||||||
|
format="webp"
|
||||||
|
width="80"
|
||||||
|
height="56"
|
||||||
|
class="w-full h-full object-cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 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/60 line-clamp-2 mt-1">
|
||||||
|
{{ project.short_description }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Level progress -->
|
||||||
|
<div class="shrink-0 text-right">
|
||||||
|
<div class="text-xs text-sky-text/40 font-ui">{{ $t('skills.level') }}</div>
|
||||||
|
<div class="flex items-center gap-1 mt-1">
|
||||||
|
<span class="text-sky-text text-sm">{{ project.level_before }}</span>
|
||||||
|
<span class="text-sky-accent">→</span>
|
||||||
|
<span class="text-sky-accent font-semibold text-sm">{{ project.level_after }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="text-xs text-sky-accent/80 font-medium">
|
||||||
|
({{ levelProgress }})
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Level description if available -->
|
||||||
|
<p v-if="project.level_description" class="mt-3 text-xs text-sky-text/50 italic border-t border-sky-text/10 pt-3">
|
||||||
|
{{ project.level_description }}
|
||||||
|
</p>
|
||||||
|
</NuxtLink>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { SkillProject } from '~/composables/useFetchSkillProjects'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
project: SkillProject
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
click: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const localePath = useLocalePath()
|
||||||
|
|
||||||
|
const levelProgress = computed(() => {
|
||||||
|
const diff = props.project.level_after - props.project.level_before
|
||||||
|
return diff > 0 ? `+${diff}` : diff.toString()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
143
frontend/app/components/feature/SkillProjectsModal.vue
Normal file
143
frontend/app/components/feature/SkillProjectsModal.vue
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
<template>
|
||||||
|
<TransitionRoot :show="isOpen" as="template">
|
||||||
|
<Dialog class="relative z-50" @close="emit('close')">
|
||||||
|
<!-- 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" aria-hidden="true" />
|
||||||
|
</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-text/5 border border-sky-text/10 rounded-xl shadow-2xl backdrop-blur-md">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex items-start justify-between p-6 border-b border-sky-text/10">
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<span v-if="skill?.icon" class="text-2xl">{{ skill.icon }}</span>
|
||||||
|
<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/60">
|
||||||
|
{{ skill.description }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Close button -->
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="text-sky-text/40 hover:text-sky-text transition-colors p-2 -mr-2 -mt-2 rounded-lg hover:bg-sky-text/5"
|
||||||
|
@click="emit('close')"
|
||||||
|
>
|
||||||
|
<span class="sr-only">{{ $t('common.close') }}</span>
|
||||||
|
<svg class="w-5 h-5" 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-xs font-ui font-medium text-sky-text/40 uppercase tracking-wider mb-4">
|
||||||
|
{{ $t('skills.related_projects') }}
|
||||||
|
</h3>
|
||||||
|
|
||||||
|
<!-- Loading -->
|
||||||
|
<div v-if="pending" class="space-y-3">
|
||||||
|
<div v-for="i in 3" :key="i" class="bg-sky-dark/50 rounded-lg p-4 animate-pulse">
|
||||||
|
<div class="flex items-start gap-4">
|
||||||
|
<div class="w-20 h-14 bg-sky-text/5 rounded" />
|
||||||
|
<div class="flex-1">
|
||||||
|
<div class="h-5 bg-sky-text/5 rounded w-1/2 mb-2" />
|
||||||
|
<div class="h-4 bg-sky-text/5 rounded w-3/4" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Error -->
|
||||||
|
<div v-else-if="error" class="text-center py-8">
|
||||||
|
<p class="text-sky-text/60 font-narrative">{{ $t('skills.load_projects_error') }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- No projects -->
|
||||||
|
<div v-else-if="projects.length === 0" class="text-center py-8">
|
||||||
|
<p class="text-sky-text/60 font-narrative">{{ $t('skills.no_related_projects') }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Projects list -->
|
||||||
|
<div v-else class="space-y-3">
|
||||||
|
<FeatureProjectListItem
|
||||||
|
v-for="project in projects"
|
||||||
|
:key="project.id"
|
||||||
|
:project="project"
|
||||||
|
@click="emit('close')"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogPanel>
|
||||||
|
</TransitionChild>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
</TransitionRoot>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<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 skillSlug = computed(() => props.skill?.slug ?? null)
|
||||||
|
|
||||||
|
const { data, pending, error, execute } = useFetchSkillProjects(skillSlug)
|
||||||
|
|
||||||
|
// Load projects when modal opens
|
||||||
|
watch(() => props.isOpen, (isOpen) => {
|
||||||
|
if (isOpen && props.skill) {
|
||||||
|
execute()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const projects = computed(() => data.value?.data.projects ?? [])
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
:deep([data-headlessui-state]) {
|
||||||
|
transition: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
39
frontend/app/composables/useFetchSkillProjects.ts
Normal file
39
frontend/app/composables/useFetchSkillProjects.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
import type { Skill } from '~/types/skill'
|
||||||
|
|
||||||
|
export interface SkillProject {
|
||||||
|
id: number
|
||||||
|
slug: string
|
||||||
|
title: string
|
||||||
|
short_description: string
|
||||||
|
image: string
|
||||||
|
date_completed: string | null
|
||||||
|
level_before: number
|
||||||
|
level_after: number
|
||||||
|
level_description: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SkillProjectsResponse {
|
||||||
|
data: {
|
||||||
|
skill: Pick<Skill, 'id' | 'slug' | 'name' | 'description' | 'level' | 'max_level'>
|
||||||
|
projects: SkillProject[]
|
||||||
|
}
|
||||||
|
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` : '',
|
||||||
|
{
|
||||||
|
baseURL: config.public.apiUrl as string,
|
||||||
|
headers: {
|
||||||
|
'X-API-Key': config.public.apiKey as string,
|
||||||
|
'Accept-Language': locale.value,
|
||||||
|
},
|
||||||
|
immediate: false,
|
||||||
|
watch: false,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -66,6 +66,13 @@
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Skill projects modal -->
|
||||||
|
<FeatureSkillProjectsModal
|
||||||
|
:is-open="isModalOpen"
|
||||||
|
:skill="selectedSkill"
|
||||||
|
@close="closeModal"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -99,10 +106,21 @@ function getCategoryIcon(category: string): string {
|
|||||||
return categoryIcons[category.toLowerCase()] ?? '📚'
|
return categoryIcons[category.toLowerCase()] ?? '📚'
|
||||||
}
|
}
|
||||||
|
|
||||||
// Handle skill click (preparation for Story 2.5)
|
// Modal state
|
||||||
|
const isModalOpen = ref(false)
|
||||||
|
const selectedSkill = ref<Skill | null>(null)
|
||||||
|
|
||||||
function handleSkillClick(skill: Skill) {
|
function handleSkillClick(skill: Skill) {
|
||||||
// Will be implemented in Story 2.5 - modal with related projects
|
selectedSkill.value = skill
|
||||||
console.log('Skill clicked:', skill.slug)
|
isModalOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeModal() {
|
||||||
|
isModalOpen.value = false
|
||||||
|
// Keep selectedSkill for close animation
|
||||||
|
setTimeout(() => {
|
||||||
|
selectedSkill.value = null
|
||||||
|
}, 300)
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
|
|||||||
@@ -103,7 +103,10 @@
|
|||||||
"skill_tree_placeholder": "Interactive skill tree (coming soon)",
|
"skill_tree_placeholder": "Interactive skill tree (coming soon)",
|
||||||
"level": "Level",
|
"level": "Level",
|
||||||
"project": "project",
|
"project": "project",
|
||||||
"projects": "projects"
|
"projects": "projects",
|
||||||
|
"related_projects": "Projects using this skill",
|
||||||
|
"load_projects_error": "Unable to load related projects",
|
||||||
|
"no_related_projects": "No projects use this skill yet"
|
||||||
},
|
},
|
||||||
"pages": {
|
"pages": {
|
||||||
"projects": {
|
"projects": {
|
||||||
|
|||||||
@@ -103,7 +103,10 @@
|
|||||||
"skill_tree_placeholder": "Arbre de comp\u00e9tences interactif (bient\u00f4t disponible)",
|
"skill_tree_placeholder": "Arbre de comp\u00e9tences interactif (bient\u00f4t disponible)",
|
||||||
"level": "Niveau",
|
"level": "Niveau",
|
||||||
"project": "projet",
|
"project": "projet",
|
||||||
"projects": "projets"
|
"projects": "projets",
|
||||||
|
"related_projects": "Projets utilisant cette comp\u00e9tence",
|
||||||
|
"load_projects_error": "Impossible de charger les projets li\u00e9s",
|
||||||
|
"no_related_projects": "Aucun projet n'utilise encore cette comp\u00e9tence"
|
||||||
},
|
},
|
||||||
"pages": {
|
"pages": {
|
||||||
"projects": {
|
"projects": {
|
||||||
|
|||||||
39
frontend/package-lock.json
generated
39
frontend/package-lock.json
generated
@@ -7,6 +7,7 @@
|
|||||||
"name": "skycel-frontend",
|
"name": "skycel-frontend",
|
||||||
"hasInstallScript": true,
|
"hasInstallScript": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@headlessui/vue": "^1.7.23",
|
||||||
"@nuxt/image": "^1.9.0",
|
"@nuxt/image": "^1.9.0",
|
||||||
"@nuxtjs/i18n": "^9.0.0",
|
"@nuxtjs/i18n": "^9.0.0",
|
||||||
"@nuxtjs/sitemap": "^7.2.0",
|
"@nuxtjs/sitemap": "^7.2.0",
|
||||||
@@ -1053,6 +1054,20 @@
|
|||||||
"node": ">=14"
|
"node": ">=14"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@headlessui/vue": {
|
||||||
|
"version": "1.7.23",
|
||||||
|
"resolved": "https://registry.npmjs.org/@headlessui/vue/-/vue-1.7.23.tgz",
|
||||||
|
"integrity": "sha512-JzdCNqurrtuu0YW6QaDtR2PIYCKPUWq28csDyMvN4zmGccmE7lz40Is6hc3LA4HFeCI7sekZ/PQMTNmn9I/4Wg==",
|
||||||
|
"dependencies": {
|
||||||
|
"@tanstack/vue-virtual": "^3.0.0-beta.60"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"vue": "^3.2.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@humanfs/core": {
|
"node_modules/@humanfs/core": {
|
||||||
"version": "0.19.1",
|
"version": "0.19.1",
|
||||||
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
|
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
|
||||||
@@ -3202,6 +3217,30 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@speed-highlight/core/-/core-1.2.14.tgz",
|
"resolved": "https://registry.npmjs.org/@speed-highlight/core/-/core-1.2.14.tgz",
|
||||||
"integrity": "sha512-G4ewlBNhUtlLvrJTb88d2mdy2KRijzs4UhnlrOSRT4bmjh/IqNElZa3zkrZ+TC47TwtlDWzVLFADljF1Ijp5hA=="
|
"integrity": "sha512-G4ewlBNhUtlLvrJTb88d2mdy2KRijzs4UhnlrOSRT4bmjh/IqNElZa3zkrZ+TC47TwtlDWzVLFADljF1Ijp5hA=="
|
||||||
},
|
},
|
||||||
|
"node_modules/@tanstack/virtual-core": {
|
||||||
|
"version": "3.13.18",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tanstack/virtual-core/-/virtual-core-3.13.18.tgz",
|
||||||
|
"integrity": "sha512-Mx86Hqu1k39icq2Zusq+Ey2J6dDWTjDvEv43PJtRCoEYTLyfaPnxIQ6iy7YAOK0NV/qOEmZQ/uCufrppZxTgcg==",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/tannerlinsley"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@tanstack/vue-virtual": {
|
||||||
|
"version": "3.13.18",
|
||||||
|
"resolved": "https://registry.npmjs.org/@tanstack/vue-virtual/-/vue-virtual-3.13.18.tgz",
|
||||||
|
"integrity": "sha512-6pT8HdHtTU5Z+t906cGdCroUNA5wHjFXsNss9gwk7QAr1VNZtz9IQCs2Nhx0gABK48c+OocHl2As+TMg8+Hy4A==",
|
||||||
|
"dependencies": {
|
||||||
|
"@tanstack/virtual-core": "3.13.18"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/tannerlinsley"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"vue": "^2.7.0 || ^3.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@trysound/sax": {
|
"node_modules/@trysound/sax": {
|
||||||
"version": "0.2.0",
|
"version": "0.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/@trysound/sax/-/sax-0.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/@trysound/sax/-/sax-0.2.0.tgz",
|
||||||
|
|||||||
@@ -10,6 +10,7 @@
|
|||||||
"postinstall": "nuxt prepare"
|
"postinstall": "nuxt prepare"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@headlessui/vue": "^1.7.23",
|
||||||
"@nuxt/image": "^1.9.0",
|
"@nuxt/image": "^1.9.0",
|
||||||
"@nuxtjs/i18n": "^9.0.0",
|
"@nuxtjs/i18n": "^9.0.0",
|
||||||
"@nuxtjs/sitemap": "^7.2.0",
|
"@nuxtjs/sitemap": "^7.2.0",
|
||||||
|
|||||||
Reference in New Issue
Block a user