✨ 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:
@@ -5,6 +5,7 @@ namespace App\Http\Controllers\Api;
|
|||||||
use App\Http\Controllers\Controller;
|
use App\Http\Controllers\Controller;
|
||||||
use App\Http\Resources\ProjectResource;
|
use App\Http\Resources\ProjectResource;
|
||||||
use App\Models\Project;
|
use App\Models\Project;
|
||||||
|
use App\Models\Translation;
|
||||||
|
|
||||||
class ProjectController extends Controller
|
class ProjectController extends Controller
|
||||||
{
|
{
|
||||||
@@ -18,9 +19,39 @@ class ProjectController extends Controller
|
|||||||
|
|
||||||
public function show(string $slug)
|
public function show(string $slug)
|
||||||
{
|
{
|
||||||
$project = Project::with('skills')->where('slug', $slug)->firstOrFail();
|
$project = Project::with('skills')->where('slug', $slug)->first();
|
||||||
|
|
||||||
|
if (!$project) {
|
||||||
|
return response()->json([
|
||||||
|
'error' => [
|
||||||
|
'code' => 'PROJECT_NOT_FOUND',
|
||||||
|
'message' => 'Project not found',
|
||||||
|
],
|
||||||
|
], 404);
|
||||||
|
}
|
||||||
|
|
||||||
|
$lang = app()->getLocale();
|
||||||
|
|
||||||
|
// Get all projects in order for navigation
|
||||||
|
$allProjects = Project::ordered()->get(['id', 'slug', 'title_key']);
|
||||||
|
$currentIndex = $allProjects->search(fn ($p) => $p->slug === $slug);
|
||||||
|
|
||||||
|
$prev = $currentIndex > 0 ? $allProjects[$currentIndex - 1] : null;
|
||||||
|
$next = $currentIndex < $allProjects->count() - 1 ? $allProjects[$currentIndex + 1] : null;
|
||||||
|
|
||||||
return (new ProjectResource($project))
|
return (new ProjectResource($project))
|
||||||
->additional(['meta' => ['lang' => app()->getLocale()]]);
|
->additional([
|
||||||
|
'meta' => ['lang' => $lang],
|
||||||
|
'navigation' => [
|
||||||
|
'prev' => $prev ? [
|
||||||
|
'slug' => $prev->slug,
|
||||||
|
'title' => Translation::getTranslation($prev->title_key, $lang),
|
||||||
|
] : null,
|
||||||
|
'next' => $next ? [
|
||||||
|
'slug' => $next->slug,
|
||||||
|
'title' => Translation::getTranslation($next->title_key, $lang),
|
||||||
|
] : null,
|
||||||
|
],
|
||||||
|
]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Story 2.3: Page Projet - Détail
|
# Story 2.3: Page Projet - Détail
|
||||||
|
|
||||||
Status: ready-for-dev
|
Status: review
|
||||||
|
|
||||||
## Story
|
## Story
|
||||||
|
|
||||||
@@ -22,59 +22,59 @@ so that je comprends le travail réalisé et les technologies utilisées.
|
|||||||
|
|
||||||
## Tasks / Subtasks
|
## Tasks / Subtasks
|
||||||
|
|
||||||
- [ ] **Task 1: Créer l'endpoint API pour le détail du projet** (AC: #1, #2, #3, #4, #8)
|
- [x] **Task 1: Créer l'endpoint API pour le détail du projet** (AC: #1, #2, #3, #4, #8)
|
||||||
- [ ] Ajouter la méthode `show($slug)` dans `ProjectController`
|
- [x] Ajouter la méthode `show($slug)` dans `ProjectController`
|
||||||
- [ ] Charger le projet avec ses compétences (eager loading)
|
- [x] Charger le projet avec ses compétences (eager loading)
|
||||||
- [ ] Retourner 404 si le slug n'existe pas
|
- [x] Retourner 404 si le slug n'existe pas
|
||||||
- [ ] Inclure les données de traduction selon `Accept-Language`
|
- [x] Inclure les données de traduction selon `Accept-Language`
|
||||||
|
|
||||||
- [ ] **Task 2: Créer l'endpoint API pour la navigation prev/next** (AC: #5)
|
- [x] **Task 2: Créer l'endpoint API pour la navigation prev/next** (AC: #5)
|
||||||
- [ ] Ajouter une méthode `navigation($slug)` ou inclure dans `show()`
|
- [x] Ajouter une méthode `navigation($slug)` ou inclure dans `show()`
|
||||||
- [ ] Retourner le projet précédent et suivant (basé sur l'ordre de tri)
|
- [x] Retourner le projet précédent et suivant (basé sur l'ordre de tri)
|
||||||
- [ ] Si premier projet : prev = null, si dernier : next = null
|
- [x] Si premier projet : prev = null, si dernier : next = null
|
||||||
|
|
||||||
- [ ] **Task 3: Créer le composable useFetchProject** (AC: #1)
|
- [x] **Task 3: Créer le composable useFetchProject** (AC: #1)
|
||||||
- [ ] Créer `frontend/app/composables/useFetchProject.ts`
|
- [x] Créer `frontend/app/composables/useFetchProject.ts`
|
||||||
- [ ] Accepter le slug en paramètre
|
- [x] Accepter le slug en paramètre
|
||||||
- [ ] Gérer les états loading, error, data
|
- [x] Gérer les états loading, error, data
|
||||||
- [ ] Gérer l'erreur 404
|
- [x] Gérer l'erreur 404
|
||||||
|
|
||||||
- [ ] **Task 4: Créer la page [slug].vue** (AC: #1, #2, #3, #4, #6, #9)
|
- [x] **Task 4: Créer la page [slug].vue** (AC: #1, #2, #3, #4, #6, #9)
|
||||||
- [ ] Créer `frontend/app/pages/projets/[slug].vue`
|
- [x] Créer `frontend/app/pages/projets/[slug].vue`
|
||||||
- [ ] Afficher l'image principale en grand format
|
- [x] Afficher l'image principale en grand format
|
||||||
- [ ] Afficher le titre et la description complète
|
- [x] Afficher le titre et la description complète
|
||||||
- [ ] Afficher la date de réalisation formatée
|
- [x] Afficher la date de réalisation formatée
|
||||||
- [ ] Afficher la liste des compétences avec progression (avant → après)
|
- [x] Afficher la liste des compétences avec progression (avant → après)
|
||||||
- [ ] Afficher les liens externes (site live, GitHub) si présents
|
- [x] Afficher les liens externes (site live, GitHub) si présents
|
||||||
- [ ] Ajouter un bouton "Retour à la galerie"
|
- [x] Ajouter un bouton "Retour à la galerie"
|
||||||
|
|
||||||
- [ ] **Task 5: Implémenter la navigation prev/next** (AC: #5)
|
- [x] **Task 5: Implémenter la navigation prev/next** (AC: #5)
|
||||||
- [ ] Ajouter les boutons "Projet précédent" et "Projet suivant"
|
- [x] Ajouter les boutons "Projet précédent" et "Projet suivant"
|
||||||
- [ ] Utiliser NuxtLink pour la navigation
|
- [x] Utiliser NuxtLink pour la navigation
|
||||||
- [ ] Afficher le titre du projet dans le bouton
|
- [x] Afficher le titre du projet dans le bouton
|
||||||
- [ ] Désactiver/masquer si pas de prev ou next
|
- [x] Désactiver/masquer si pas de prev ou next
|
||||||
|
|
||||||
- [ ] **Task 6: Meta tags SEO dynamiques** (AC: #7)
|
- [x] **Task 6: Meta tags SEO dynamiques** (AC: #7)
|
||||||
- [ ] Utiliser `useHead()` avec le titre du projet
|
- [x] Utiliser `useHead()` avec le titre du projet
|
||||||
- [ ] Utiliser `useSeoMeta()` pour description, og:title, og:description, og:image
|
- [x] Utiliser `useSeoMeta()` pour description, og:title, og:description, og:image
|
||||||
- [ ] L'image OG doit être l'image du projet
|
- [x] L'image OG doit être l'image du projet
|
||||||
|
|
||||||
- [ ] **Task 7: Gestion de l'erreur 404** (AC: #8)
|
- [x] **Task 7: Gestion de l'erreur 404** (AC: #8)
|
||||||
- [ ] Détecter si le projet n'existe pas
|
- [x] Détecter si le projet n'existe pas
|
||||||
- [ ] Afficher un message approprié avec le narrateur
|
- [x] Afficher un message approprié avec le narrateur
|
||||||
- [ ] Proposer de retourner à la galerie
|
- [x] Proposer de retourner à la galerie
|
||||||
|
|
||||||
- [ ] **Task 8: Design responsive** (AC: #9)
|
- [x] **Task 8: Design responsive** (AC: #9)
|
||||||
- [ ] Mobile : layout vertical, image pleine largeur
|
- [x] Mobile : layout vertical, image pleine largeur
|
||||||
- [ ] Desktop : layout 2 colonnes (image + contenu) ou grande image + contenu dessous
|
- [x] Desktop : layout 2 colonnes (image + contenu) ou grande image + contenu dessous
|
||||||
- [ ] Liste des compétences responsive (flex wrap)
|
- [x] Liste des compétences responsive (flex wrap)
|
||||||
|
|
||||||
- [ ] **Task 9: Tests et validation**
|
- [x] **Task 9: Tests et validation**
|
||||||
- [ ] Tester avec différents slugs de projets
|
- [x] Tester avec différents slugs de projets
|
||||||
- [ ] Tester la navigation prev/next
|
- [x] Tester la navigation prev/next
|
||||||
- [ ] Tester le 404 avec un slug inexistant
|
- [x] Tester le 404 avec un slug inexistant
|
||||||
- [ ] Valider les meta tags SEO
|
- [x] Valider les meta tags SEO
|
||||||
- [ ] Tester le responsive
|
- [x] Tester le responsive
|
||||||
|
|
||||||
## Dev Notes
|
## Dev Notes
|
||||||
|
|
||||||
@@ -437,16 +437,40 @@ frontend/nuxt.config.ts # AJOUTER datetimeFormats
|
|||||||
|
|
||||||
### Agent Model Used
|
### Agent Model Used
|
||||||
|
|
||||||
{{agent_model_name_version}}
|
Claude Opus 4.5 (claude-opus-4-5-20251101)
|
||||||
|
|
||||||
### Debug Log References
|
### Debug Log References
|
||||||
|
|
||||||
|
- Aucun problème. Méthode show() existait déjà mais sans navigation prev/next.
|
||||||
|
|
||||||
### Completion Notes List
|
### Completion Notes List
|
||||||
|
|
||||||
|
- ProjectController show() amélioré avec navigation prev/next et erreur 404 structurée
|
||||||
|
- Composable useFetchProject créé avec typage ProjectNavigation
|
||||||
|
- Page [slug].vue complète avec :
|
||||||
|
- Image principale pleine largeur
|
||||||
|
- Titre, date formatée selon locale, badge featured
|
||||||
|
- Description complète
|
||||||
|
- Liens externes (site et GitHub)
|
||||||
|
- Grille responsive des compétences utilisées avec niveaux avant/après
|
||||||
|
- Navigation prev/next avec titres traduits
|
||||||
|
- État loading (skeleton), état 404 avec narrateur spider
|
||||||
|
- SEO dynamique via useSeo avec og:image du projet
|
||||||
|
- Formatage date via Intl.DateTimeFormat selon locale
|
||||||
|
- Traductions FR/EN ajoutées (10 nouvelles clés projects.*)
|
||||||
|
- Store progression visitSection('projets') sur mount
|
||||||
|
|
||||||
### Change Log
|
### Change Log
|
||||||
| Date | Change | Author |
|
| Date | Change | Author |
|
||||||
|------|--------|--------|
|
|------|--------|--------|
|
||||||
| 2026-02-04 | Story créée avec contexte complet | SM Agent |
|
| 2026-02-04 | Story créée avec contexte complet | SM Agent |
|
||||||
|
| 2026-02-06 | Tasks 1-9 implémentées et validées | Dev Agent (Claude Opus 4.5) |
|
||||||
|
|
||||||
### File List
|
### File List
|
||||||
|
|
||||||
|
- `api/app/Http/Controllers/Api/ProjectController.php` — MODIFIÉ (show avec navigation)
|
||||||
|
- `frontend/app/composables/useFetchProject.ts` — CRÉÉ
|
||||||
|
- `frontend/app/pages/projets/[slug].vue` — RÉÉCRIT
|
||||||
|
- `frontend/i18n/fr.json` — MODIFIÉ (ajout projects.*)
|
||||||
|
- `frontend/i18n/en.json` — MODIFIÉ (ajout projects.*)
|
||||||
|
|
||||||
|
|||||||
@@ -58,8 +58,8 @@ development_status:
|
|||||||
# ═══════════════════════════════════════════════════════════════════════════
|
# ═══════════════════════════════════════════════════════════════════════════
|
||||||
epic-2: in-progress
|
epic-2: in-progress
|
||||||
2-1-composant-projectcard: review
|
2-1-composant-projectcard: review
|
||||||
2-2-page-projets-galerie: ready-for-dev
|
2-2-page-projets-galerie: review
|
||||||
2-3-page-projet-detail: ready-for-dev
|
2-3-page-projet-detail: review
|
||||||
2-4-page-competences-affichage-categories: ready-for-dev
|
2-4-page-competences-affichage-categories: ready-for-dev
|
||||||
2-5-competences-cliquables-projets-lies: ready-for-dev
|
2-5-competences-cliquables-projets-lies: ready-for-dev
|
||||||
2-6-page-temoignages-migrations-bdd: ready-for-dev
|
2-6-page-temoignages-migrations-bdd: ready-for-dev
|
||||||
|
|||||||
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>
|
<template>
|
||||||
<div class="min-h-screen p-8">
|
<div class="max-w-5xl mx-auto px-4 py-8 md:py-12">
|
||||||
<h1 class="text-3xl font-narrative text-sky-text">{{ slug }}</h1>
|
<!-- Loading state -->
|
||||||
<p class="mt-4 text-sky-text/70">{{ $t('pages.projects.description') }}</p>
|
<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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { useProgressionStore } from '~/stores/progression'
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const slug = computed(() => route.params.slug as string)
|
const { t, locale } = useI18n()
|
||||||
|
const localePath = useLocalePath()
|
||||||
const { setPageMeta } = useSeo()
|
const { setPageMeta } = useSeo()
|
||||||
const { t } = useI18n()
|
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({
|
setPageMeta({
|
||||||
title: slug.value,
|
title: `${project.value.title} | Skycel`,
|
||||||
description: t('pages.projects.description'),
|
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>
|
</script>
|
||||||
|
|||||||
@@ -82,7 +82,17 @@
|
|||||||
"discover": "Discover",
|
"discover": "Discover",
|
||||||
"no_projects": "No projects yet",
|
"no_projects": "No projects yet",
|
||||||
"load_error": "Unable to load projects...",
|
"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": {
|
"pages": {
|
||||||
"projects": {
|
"projects": {
|
||||||
|
|||||||
@@ -82,7 +82,17 @@
|
|||||||
"discover": "D\u00e9couvrir",
|
"discover": "D\u00e9couvrir",
|
||||||
"no_projects": "Aucun projet pour le moment",
|
"no_projects": "Aucun projet pour le moment",
|
||||||
"load_error": "Impossible de charger les projets...",
|
"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": {
|
"pages": {
|
||||||
"projects": {
|
"projects": {
|
||||||
|
|||||||
Reference in New Issue
Block a user