feat(06-04): enrich blog article page with breadcrumb, TOC, prev/next
- isFr converti en computed (fix Phase 5 non-reactive isFr)
- { watch: [locale] } sur les 2 useAsyncData (article + surround)
- queryCollectionItemSurroundings avec littéraux 'blog_fr'/'blog_en', fields explicites
- Article query WITHOUT draft filter (direct URL access, D-14)
- Surround query WITH .where('draft','=',false).order('date','DESC')
- Mapping prev=surround[1], next=surround[0] (Pitfall 4 DESC order)
- Header: UBreadcrumb + H1 + meta row (date Intl + reading time) + tags + cover NuxtImg eager
- Layout grid desktop [1fr_16rem] avec max-w-3xl colonne article
- ContentRenderer prose wrapper Phase 5 préservé
- BlogToc aside + BlogPrevNext en bas
- ogType: 'article' (préparation Phase 7)
Requirements: BLOG-03, BLOG-06
This commit is contained in:
+137
-13
@@ -1,33 +1,157 @@
|
||||
<script setup lang="ts">
|
||||
const { locale } = useI18n()
|
||||
const { t, locale } = useI18n()
|
||||
const localePath = useLocalePath()
|
||||
const route = useRoute()
|
||||
|
||||
const isFr = computed(() => locale.value === 'fr')
|
||||
const slug = route.params.slug as string
|
||||
const isFr = locale.value === 'fr'
|
||||
const path = isFr ? `/fr/blog/${slug}` : `/en/blog/${slug}`
|
||||
const path = computed(() => (isFr.value ? `/fr/blog/${slug}` : `/en/blog/${slug}`))
|
||||
|
||||
const { data: page } = await useAsyncData(`blog-${locale.value}-${slug}`, () =>
|
||||
isFr
|
||||
? queryCollection('blog_fr').path(path).first()
|
||||
: queryCollection('blog_en').path(path).first()
|
||||
// 1) Main article (NO draft filter — direct URL access allowed for drafts, D-14)
|
||||
const { data: page } = await useAsyncData(
|
||||
`blog-${locale.value}-${slug}`,
|
||||
() =>
|
||||
isFr.value
|
||||
? queryCollection('blog_fr').path(path.value).first()
|
||||
: queryCollection('blog_en').path(path.value).first(),
|
||||
{ watch: [locale] },
|
||||
)
|
||||
|
||||
if (!page.value) {
|
||||
throw createError({ statusCode: 404, statusMessage: 'Article introuvable' })
|
||||
}
|
||||
|
||||
// 2) Surroundings (prev/next) WITH draft filter + order DESC
|
||||
const { data: surround } = await useAsyncData(
|
||||
`blog-surround-${locale.value}-${slug}`,
|
||||
() =>
|
||||
isFr.value
|
||||
? queryCollectionItemSurroundings('blog_fr', path.value, {
|
||||
fields: ['title', 'description', 'date', 'image', 'path', 'minutes'],
|
||||
})
|
||||
.where('draft', '=', false)
|
||||
.order('date', 'DESC')
|
||||
: queryCollectionItemSurroundings('blog_en', path.value, {
|
||||
fields: ['title', 'description', 'date', 'image', 'path', 'minutes'],
|
||||
})
|
||||
.where('draft', '=', false)
|
||||
.order('date', 'DESC'),
|
||||
{ watch: [locale] },
|
||||
)
|
||||
|
||||
interface SurroundArticle {
|
||||
path: string
|
||||
title: string
|
||||
description?: string
|
||||
date: string
|
||||
tags?: string[]
|
||||
image?: string
|
||||
minutes?: number
|
||||
}
|
||||
|
||||
// D-12 + Pitfall 4: order DESC → surround[0] = newer (next UI), surround[1] = older (prev UI)
|
||||
// @nuxt/content v3 surround return type is ContentNavigationItem (minimal) but with fields[] option
|
||||
// the runtime object carries the requested fields. Cast to SurroundArticle for BlogPrevNext props.
|
||||
const nextArticle = computed(() => (surround.value?.[0] as SurroundArticle | undefined) ?? null)
|
||||
const prevArticle = computed(() => (surround.value?.[1] as SurroundArticle | undefined) ?? null)
|
||||
|
||||
const breadcrumbItems = computed(() => [
|
||||
{ label: t('blog.breadcrumb.home'), to: localePath('/'), icon: 'i-lucide-home' },
|
||||
{ label: t('blog.breadcrumb.blog'), to: localePath('/blog') },
|
||||
{ label: page.value?.title ?? '' },
|
||||
])
|
||||
|
||||
const formattedDate = computed(() => {
|
||||
if (!page.value?.date) return ''
|
||||
try {
|
||||
return new Intl.DateTimeFormat(isFr.value ? 'fr-FR' : 'en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
}).format(new Date(page.value.date))
|
||||
} catch {
|
||||
return page.value.date
|
||||
}
|
||||
})
|
||||
|
||||
const readingMinutes = computed(() => {
|
||||
if (typeof page.value?.minutes === 'number') return page.value.minutes
|
||||
return useReadingTime(page.value?.description ?? '')
|
||||
})
|
||||
|
||||
interface TocLink {
|
||||
id: string
|
||||
depth: number
|
||||
text: string
|
||||
children?: TocLink[]
|
||||
}
|
||||
|
||||
const tocLinks = computed<TocLink[]>(() => {
|
||||
const body = page.value?.body as { toc?: { links?: TocLink[] } } | undefined
|
||||
return body?.toc?.links ?? []
|
||||
})
|
||||
|
||||
useSeoMeta({
|
||||
title: page.value.title,
|
||||
description: page.value.description,
|
||||
ogTitle: page.value.title,
|
||||
ogDescription: page.value.description,
|
||||
title: () => page.value?.title,
|
||||
description: () => page.value?.description,
|
||||
ogTitle: () => page.value?.title,
|
||||
ogDescription: () => page.value?.description,
|
||||
ogType: 'article',
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="mx-auto max-w-3xl px-4 py-12">
|
||||
<div class="max-w-7xl mx-auto px-4 py-12">
|
||||
<UBreadcrumb :items="breadcrumbItems" class="mb-6 text-sm" />
|
||||
|
||||
<div class="lg:grid lg:grid-cols-[1fr_16rem] lg:gap-12">
|
||||
<!-- Main column -->
|
||||
<div class="max-w-3xl mx-auto lg:mx-0 w-full">
|
||||
<header class="mb-8">
|
||||
<h1 class="text-3xl sm:text-4xl font-bold text-gray-900 dark:text-white mb-4">
|
||||
{{ page?.title }}
|
||||
</h1>
|
||||
|
||||
<div class="flex flex-wrap items-center gap-2 text-sm text-gray-500 dark:text-gray-400 mb-4">
|
||||
<time :datetime="page?.date" class="font-mono inline-flex items-center gap-1.5">
|
||||
<UIcon name="i-lucide-calendar" class="w-3.5 h-3.5" />
|
||||
{{ formattedDate }}
|
||||
</time>
|
||||
<span aria-hidden="true">·</span>
|
||||
<span class="inline-flex items-center gap-1.5">
|
||||
<UIcon name="i-lucide-clock" class="w-3.5 h-3.5" />
|
||||
{{ t('blog.readingTime', { minutes: readingMinutes }) }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div v-if="page?.tags?.length" class="flex flex-wrap gap-2 mb-6">
|
||||
<UBadge
|
||||
v-for="tag in page.tags"
|
||||
:key="tag"
|
||||
color="primary"
|
||||
variant="subtle"
|
||||
>
|
||||
{{ tag }}
|
||||
</UBadge>
|
||||
</div>
|
||||
|
||||
<NuxtImg
|
||||
v-if="page?.image"
|
||||
:src="page.image"
|
||||
:alt="page.title"
|
||||
loading="eager"
|
||||
format="webp"
|
||||
class="w-full aspect-[21/9] object-cover rounded-2xl mt-2 mb-4"
|
||||
/>
|
||||
</header>
|
||||
|
||||
<article class="prose dark:prose-invert max-w-none">
|
||||
<ContentRenderer v-if="page" :value="page" />
|
||||
</article>
|
||||
|
||||
<BlogPrevNext :prev="prevArticle" :next="nextArticle" />
|
||||
</div>
|
||||
|
||||
<BlogToc v-if="tocLinks.length > 0" :links="tocLinks" />
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
Reference in New Issue
Block a user