Files
Portfolio-Game/frontend/app/pages/projets/[slug].vue
skycel 2269ecdb62 Add project detail page with prev/next navigation (Story 2.3)
- Enhance ProjectController show() with prev/next navigation data
- Create useFetchProject composable with ProjectNavigation type
- Implement [slug].vue with full project details:
  - Hero image, title with featured badge, formatted date
  - Description, external links (site/GitHub)
  - Skills grid with level progression (before → after)
  - Prev/next navigation with project titles
  - 404 state with spider narrator
- Add dynamic SEO meta tags with og:image from project
- Responsive design: stacked mobile, grid desktop

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-06 02:20:27 +01:00

206 lines
7.2 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="max-w-5xl mx-auto px-4 py-8 md:py-12">
<!-- Loading state -->
<div v-if="pending" class="animate-pulse">
<div class="bg-sky-text/5 rounded-xl h-64 md:h-96 mb-8" />
<div class="bg-sky-text/5 h-10 rounded w-2/3 mb-4" />
<div class="bg-sky-text/5 h-5 rounded w-1/4 mb-8" />
<div class="space-y-3">
<div class="bg-sky-text/5 h-4 rounded w-full" />
<div class="bg-sky-text/5 h-4 rounded w-full" />
<div class="bg-sky-text/5 h-4 rounded w-3/4" />
</div>
</div>
<!-- Error 404 -->
<div v-else-if="error" class="text-center py-16">
<div class="text-6xl mb-6">🕷</div>
<h1 class="text-2xl font-ui font-bold text-sky-text mb-4">
{{ $t('projects.not_found') }}
</h1>
<p class="text-sky-text/60 font-narrative mb-8 max-w-md mx-auto">
{{ $t('projects.not_found_description') }}
</p>
<NuxtLink
:to="localePath('/projets')"
class="inline-flex items-center px-6 py-3 bg-sky-accent text-sky-dark font-ui font-semibold rounded-lg hover:opacity-90 transition-opacity"
>
{{ $t('projects.back_to_gallery') }}
</NuxtLink>
</div>
<!-- Project content -->
<article v-else-if="project">
<!-- Back link -->
<NuxtLink
:to="localePath('/projets')"
class="inline-flex items-center gap-2 text-sky-text/60 hover:text-sky-accent font-ui text-sm mb-6 transition-colors"
>
<span></span>
{{ $t('projects.back_to_gallery') }}
</NuxtLink>
<!-- Main image -->
<div class="mb-8 rounded-xl overflow-hidden">
<NuxtImg
:src="project.image"
:alt="project.title"
format="webp"
class="w-full h-auto max-h-[500px] object-cover"
loading="eager"
/>
</div>
<!-- Header: title, date, featured badge -->
<header class="mb-8">
<div class="flex items-start gap-3 mb-3">
<h1 class="text-3xl md:text-4xl font-ui font-bold text-sky-text">
{{ project.title }}
</h1>
<span
v-if="project.is_featured"
class="shrink-0 mt-1 px-3 py-1 bg-sky-accent/20 text-sky-accent text-xs font-ui font-semibold rounded-full"
>
Featured
</span>
</div>
<p v-if="project.date_completed" class="text-sky-text/60 font-ui">
{{ $t('projects.completed_on') }}
{{ formatDate(project.date_completed) }}
</p>
</header>
<!-- Description -->
<div class="mb-10">
<p class="text-sky-text font-narrative text-lg leading-relaxed whitespace-pre-line">
{{ project.description }}
</p>
</div>
<!-- External links -->
<div v-if="project.url || project.github_url" class="flex flex-wrap gap-4 mb-10">
<a
v-if="project.url"
:href="project.url"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-2 px-6 py-3 bg-sky-accent text-sky-dark font-ui font-semibold rounded-lg hover:opacity-90 transition-opacity"
>
🌐 {{ $t('projects.visit_site') }}
</a>
<a
v-if="project.github_url"
:href="project.github_url"
target="_blank"
rel="noopener noreferrer"
class="inline-flex items-center gap-2 px-6 py-3 bg-sky-text/10 text-sky-text font-ui font-semibold rounded-lg hover:bg-sky-text/20 transition-colors border border-sky-text/10"
>
💻 {{ $t('projects.view_code') }}
</a>
</div>
<!-- Skills used -->
<section v-if="project.skills && project.skills.length > 0" class="mb-12">
<h2 class="text-xl font-ui font-semibold text-sky-text mb-6">
{{ $t('projects.skills_used') }}
</h2>
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
<div
v-for="skill in project.skills"
:key="skill.id"
class="bg-sky-text/5 rounded-lg p-4 border border-sky-text/10"
>
<div class="flex items-center gap-2 mb-2">
<span v-if="skill.icon" class="text-xl">{{ skill.icon }}</span>
<span class="font-ui font-medium text-sky-text">{{ skill.name }}</span>
</div>
<div v-if="skill.pivot" class="text-sm text-sky-text/60 font-ui">
{{ $t('projects.skill_level') }}:
<span class="text-sky-accent">{{ skill.pivot.level_before }}</span>
<span class="text-sky-accent font-semibold">{{ skill.pivot.level_after }}</span>
</div>
</div>
</div>
</section>
<!-- Prev/Next navigation -->
<nav class="flex justify-between items-stretch gap-4 border-t border-sky-text/10 pt-8 mt-8">
<NuxtLink
v-if="navigation?.prev"
:to="localePath(`/projets/${navigation.prev.slug}`)"
class="flex-1 max-w-[45%] group p-4 rounded-lg hover:bg-sky-text/5 transition-colors text-left"
>
<span class="block text-sm text-sky-text/40 font-ui mb-1">
{{ $t('projects.previous') }}
</span>
<span class="block text-sky-text font-ui font-medium group-hover:text-sky-accent transition-colors truncate">
{{ navigation.prev.title }}
</span>
</NuxtLink>
<div v-else class="flex-1" />
<NuxtLink
v-if="navigation?.next"
:to="localePath(`/projets/${navigation.next.slug}`)"
class="flex-1 max-w-[45%] group p-4 rounded-lg hover:bg-sky-text/5 transition-colors text-right"
>
<span class="block text-sm text-sky-text/40 font-ui mb-1">
{{ $t('projects.next') }}
</span>
<span class="block text-sky-text font-ui font-medium group-hover:text-sky-accent transition-colors truncate">
{{ navigation.next.title }}
</span>
</NuxtLink>
<div v-else class="flex-1" />
</nav>
</article>
</div>
</template>
<script setup lang="ts">
import { useProgressionStore } from '~/stores/progression'
const route = useRoute()
const { t, locale } = useI18n()
const localePath = useLocalePath()
const { setPageMeta } = useSeo()
const store = useProgressionStore()
const slug = computed(() => route.params.slug as string)
const { data, pending, error } = await useFetchProject(slug.value)
const project = computed(() => data.value?.data)
const navigation = computed(() => data.value?.navigation)
// Date formatting
const formatDate = (dateStr: string) => {
const date = new Date(dateStr)
return new Intl.DateTimeFormat(locale.value === 'fr' ? 'fr-FR' : 'en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
}).format(date)
}
// SEO - reactive to project data
watchEffect(() => {
if (project.value) {
setPageMeta({
title: `${project.value.title} | Skycel`,
description: project.value.short_description || project.value.description?.slice(0, 160),
image: project.value.image,
})
} else if (error.value) {
setPageMeta({
title: t('projects.not_found'),
description: t('projects.not_found_description'),
})
}
})
onMounted(() => {
store.visitSection('projets')
})
</script>