✨ 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:
33
frontend/app/composables/useFetchProject.ts
Normal file
33
frontend/app/composables/useFetchProject.ts
Normal 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,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -82,7 +82,17 @@
|
||||
"discover": "Discover",
|
||||
"no_projects": "No projects yet",
|
||||
"load_error": "Unable to load projects...",
|
||||
"view_all": "View all projects"
|
||||
"view_all": "View all projects",
|
||||
"not_found": "Project not found",
|
||||
"not_found_description": "This project doesn't exist or has been removed.",
|
||||
"back_to_gallery": "Back to gallery",
|
||||
"completed_on": "Completed on",
|
||||
"visit_site": "Visit site",
|
||||
"view_code": "View code",
|
||||
"skills_used": "Skills used",
|
||||
"skill_level": "Level",
|
||||
"previous": "Previous project",
|
||||
"next": "Next project"
|
||||
},
|
||||
"pages": {
|
||||
"projects": {
|
||||
|
||||
@@ -82,7 +82,17 @@
|
||||
"discover": "D\u00e9couvrir",
|
||||
"no_projects": "Aucun projet pour le moment",
|
||||
"load_error": "Impossible de charger les projets...",
|
||||
"view_all": "Voir tous les projets"
|
||||
"view_all": "Voir tous les projets",
|
||||
"not_found": "Projet introuvable",
|
||||
"not_found_description": "Ce projet n'existe pas ou a \u00e9t\u00e9 supprim\u00e9.",
|
||||
"back_to_gallery": "Retour \u00e0 la galerie",
|
||||
"completed_on": "R\u00e9alis\u00e9 le",
|
||||
"visit_site": "Voir le site",
|
||||
"view_code": "Voir le code",
|
||||
"skills_used": "Comp\u00e9tences utilis\u00e9es",
|
||||
"skill_level": "Niveau",
|
||||
"previous": "Projet pr\u00e9c\u00e9dent",
|
||||
"next": "Projet suivant"
|
||||
},
|
||||
"pages": {
|
||||
"projects": {
|
||||
|
||||
Reference in New Issue
Block a user