Files
Portfolio-Game/frontend/app/pages/projets/[slug].vue
skycel 99fa61fcaa feat(frontend): système narrateur contextuel avec arc de révélation
Story 3.3 : Textes narrateur contextuels et arc de révélation
- Composable useNarrator.ts avec queue de messages prioritaires
- Composable useIdleDetection.ts (détection inactivité 30s)
- Plugin narrator-transitions.client.ts (déclencheurs de navigation)
- Layout adventure.vue avec NarratorBubble intégré
- Store progression: narratorStage devient un getter calculé (0-20-40-60-80%)
- Pages projets, competences, temoignages, parcours utilisent layout adventure
- Messages: intro, transitions, encouragements 25/50/75%, hints, contact_unlocked

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-07 03:04:07 +01:00

210 lines
7.3 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'
definePageMeta({
layout: 'adventure',
})
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>