Files
Portfolio-Game/frontend/app/components/feature/SkillProjectsModal.vue
skycel 2b043674ca 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>
2026-02-06 10:44:45 +01:00

144 lines
4.9 KiB
Vue

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