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>
This commit is contained in:
2026-02-06 02:20:27 +01:00
parent 0399f0dc1c
commit 2269ecdb62
7 changed files with 356 additions and 62 deletions

View File

@@ -0,0 +1,33 @@
import type { Project } from '~/types/project'
export interface ProjectNavigation {
prev: { slug: string; title: string } | null
next: { slug: string; title: string } | null
}
interface ProjectResponse {
data: Project
meta: { lang: string }
navigation: ProjectNavigation
}
interface ProjectError {
error: {
code: string
message: string
}
}
export function useFetchProject(slug: string | Ref<string>) {
const config = useRuntimeConfig()
const { locale } = useI18n()
const slugValue = toValue(slug)
return useFetch<ProjectResponse, ProjectError>(`/projects/${slugValue}`, {
baseURL: config.public.apiUrl as string,
headers: {
'X-API-Key': config.public.apiKey as string,
'Accept-Language': locale.value,
},
})
}

View File

@@ -1,19 +1,205 @@
<template>
<div class="min-h-screen p-8">
<h1 class="text-3xl font-narrative text-sky-text">{{ slug }}</h1>
<p class="mt-4 text-sky-text/70">{{ $t('pages.projects.description') }}</p>
<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 slug = computed(() => route.params.slug as string)
const { t, locale } = useI18n()
const localePath = useLocalePath()
const { setPageMeta } = useSeo()
const { t } = useI18n()
const store = useProgressionStore()
setPageMeta({
title: slug.value,
description: t('pages.projects.description'),
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>