- 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>
206 lines
7.2 KiB
Vue
206 lines
7.2 KiB
Vue
<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>
|