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:
2026-02-06 10:44:45 +01:00
parent 4db96a0ded
commit 2b043674ca
12 changed files with 441 additions and 54 deletions

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

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

View 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,
},
)
}

View File

@@ -66,6 +66,13 @@
</p>
</div>
</div>
<!-- Skill projects modal -->
<FeatureSkillProjectsModal
:is-open="isModalOpen"
:skill="selectedSkill"
@close="closeModal"
/>
</div>
</template>
@@ -99,10 +106,21 @@ function getCategoryIcon(category: string): string {
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) {
// Will be implemented in Story 2.5 - modal with related projects
console.log('Skill clicked:', skill.slug)
selectedSkill.value = skill
isModalOpen.value = true
}
function closeModal() {
isModalOpen.value = false
// Keep selectedSkill for close animation
setTimeout(() => {
selectedSkill.value = null
}, 300)
}
onMounted(() => {

View File

@@ -103,7 +103,10 @@
"skill_tree_placeholder": "Interactive skill tree (coming soon)",
"level": "Level",
"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": {
"projects": {

View File

@@ -103,7 +103,10 @@
"skill_tree_placeholder": "Arbre de comp\u00e9tences interactif (bient\u00f4t disponible)",
"level": "Niveau",
"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": {
"projects": {

View File

@@ -7,6 +7,7 @@
"name": "skycel-frontend",
"hasInstallScript": true,
"dependencies": {
"@headlessui/vue": "^1.7.23",
"@nuxt/image": "^1.9.0",
"@nuxtjs/i18n": "^9.0.0",
"@nuxtjs/sitemap": "^7.2.0",
@@ -1053,6 +1054,20 @@
"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": {
"version": "0.19.1",
"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",
"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": {
"version": "0.2.0",
"resolved": "https://registry.npmjs.org/@trysound/sax/-/sax-0.2.0.tgz",

View File

@@ -10,6 +10,7 @@
"postinstall": "nuxt prepare"
},
"dependencies": {
"@headlessui/vue": "^1.7.23",
"@nuxt/image": "^1.9.0",
"@nuxtjs/i18n": "^9.0.0",
"@nuxtjs/sitemap": "^7.2.0",