✨ 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:
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>
|
||||
</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(() => {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
39
frontend/package-lock.json
generated
39
frontend/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user