feat: SEO page sujet — canonical URL, JSON-LD Article, Twitter card, image OG dynamique avec avatars des personnalités

This commit is contained in:
Jalil Arfaoui 2026-03-18 11:08:20 +01:00
parent 068fd3ea0e
commit 47c2f8ba10
2 changed files with 196 additions and 1 deletions

View file

@ -0,0 +1,175 @@
import { ImageResponse } from 'next/og'
import { readFile } from 'node:fs/promises'
import { join } from 'node:path'
import { Effect } from 'effect'
import { createSSRSupabaseClient } from '../../../infra/supabase/ssr'
import { createSubjectRepository } from '../../../infra/database/subject-repository-supabase'
import { createStatementRepository } from '../../../infra/database/statement-repository-supabase'
export const alt = 'Sujet de débat'
export const size = { width: 1200, height: 630 }
export const contentType = 'image/png'
export default async function OGImage({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params
const supabase = await createSSRSupabaseClient()
const subjectRepo = createSubjectRepository(supabase)
const statementRepo = createStatementRepository(supabase)
const subject = await Effect.runPromise(subjectRepo.findBySlug(slug))
if (!subject) {
return new ImageResponse(<div>Sujet introuvable</div>, { ...size })
}
const statements = await Effect.runPromise(statementRepo.findBySubjectWithFigures(subject.id))
// Unique figures
const figuresMap = new Map<string, { name: string; slug: string }>()
for (const s of statements) {
if (!figuresMap.has(s.publicFigure.id)) {
figuresMap.set(s.publicFigure.id, { name: s.publicFigure.name, slug: s.publicFigure.slug })
}
}
const figures = Array.from(figuresMap.values())
// Unique positions
const positionIds = new Set(statements.map((s) => s.position.id))
const [gothamBold, gothamBook, logoBuffer] = await Promise.all([
readFile(join(process.cwd(), 'public/fonts/Gotham-Bold.woff')),
readFile(join(process.cwd(), 'public/fonts/Gotham-Book.woff')),
readFile(join(process.cwd(), 'public/images/logo.png')),
])
const logoSrc = `data:image/png;base64,${logoBuffer.toString('base64')}`
// Load avatars for first 8 figures
const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL || 'http://127.0.0.1:64321'
const avatarEntries = await Promise.all(
figures.slice(0, 8).map(async (f) => {
try {
const res = await fetch(`${supabaseUrl}/storage/v1/object/public/avatars/${f.slug}.jpg`)
if (!res.ok) return null
const buffer = Buffer.from(await res.arrayBuffer())
return { name: f.name, src: `data:image/jpeg;base64,${buffer.toString('base64')}` }
} catch {
return null
}
}),
)
const avatars = avatarEntries.filter((a): a is { name: string; src: string } => a !== null)
const presentation = subject.presentation ?? ''
const truncated = presentation.length > 140 ? presentation.slice(0, 137) + '\u2026' : presentation
return new ImageResponse(
<div
style={{
width: '100%',
height: '100%',
display: 'flex',
backgroundColor: '#fff',
}}
>
<div style={{ width: '8px', backgroundColor: '#f21e40', flexShrink: 0 }} />
<div
style={{
display: 'flex',
flexDirection: 'column',
justifyContent: 'space-between',
padding: '35px 40px 30px 40px',
flex: 1,
}}
>
{/* Title + presentation */}
<div style={{ display: 'flex', flexDirection: 'column' }}>
<span
style={{
fontFamily: 'Gotham Bold',
fontSize: '56px',
color: '#f21e40',
textTransform: 'uppercase',
lineHeight: 1.1,
}}
>
{subject.title}
</span>
{truncated && (
<span
style={{
fontFamily: 'Gotham Book',
fontSize: '28px',
color: '#666',
marginTop: '12px',
lineHeight: 1.4,
}}
>
{truncated}
</span>
)}
</div>
{/* Stats + avatars */}
<div style={{ display: 'flex', flexDirection: 'column', marginTop: '25px' }}>
<span
style={{
fontFamily: 'Gotham Bold',
fontSize: '22px',
color: '#999',
textTransform: 'uppercase',
letterSpacing: '1px',
marginBottom: '15px',
}}
>
{`${positionIds.size} position${positionIds.size > 1 ? 's' : ''} \u00B7 ${figures.length} personnalit\u00E9${figures.length > 1 ? 's' : ''}`}
</span>
{avatars.length > 0 && (
<div style={{ display: 'flex', gap: '8px', alignItems: 'center' }}>
{avatars.map((a) => (
<div
key={a.name}
style={{
width: '65px',
height: '65px',
borderRadius: '50%',
overflow: 'hidden',
flexShrink: 0,
display: 'flex',
}}
>
<img src={a.src} width={65} height={65} style={{ objectFit: 'cover' }} />
</div>
))}
{figures.length > 8 && (
<span
style={{
fontFamily: 'Gotham Book',
fontSize: '24px',
color: '#999',
marginLeft: '8px',
}}
>
{`+${figures.length - 8}`}
</span>
)}
</div>
)}
</div>
{/* Logo */}
<div style={{ display: 'flex', justifyContent: 'flex-end', marginTop: '15px' }}>
<img src={logoSrc} height={70} />
</div>
</div>
</div>,
{
...size,
fonts: [
{ name: 'Gotham Bold', data: gothamBold, style: 'normal', weight: 700 },
{ name: 'Gotham Book', data: gothamBook, style: 'normal', weight: 400 },
],
},
)
}

View file

@ -27,14 +27,21 @@ export async function generateMetadata({ params }: PageProps): Promise<Metadata>
const subjectRepo = createSubjectRepository(supabase)
const subject = await Effect.runPromise(subjectRepo.findBySlug(slug))
if (!subject) return { title: 'Sujet introuvable' }
const url = `/s/${slug}`
return {
title: subject.title,
description: subject.presentation,
alternates: { canonical: url },
openGraph: {
title: subject.title,
description: subject.presentation,
type: 'article',
url: `/s/${slug}`,
url,
},
twitter: {
card: 'summary_large_image',
title: subject.title,
description: subject.presentation,
},
}
} catch {
@ -96,8 +103,21 @@ export default async function SubjectDetailPage({ params }: PageProps) {
!!contributor &&
canPerform(contributor.reputation, major ? 'delete_major_subject' : 'delete_minor_subject')
const jsonLd = {
'@context': 'https://schema.org',
'@type': 'Article',
headline: subject.title,
description: subject.presentation,
url: `https://debats.co/s/${subject.slug}`,
author: { '@type': 'Organization', name: 'Débats.co', url: 'https://debats.co' },
}
return (
<ContentWithSidebar topMargin>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(jsonLd) }}
/>
<header className={styles.header}>
<h1 className={styles.title}>{subject.title}</h1>
<p className={styles.presentation}>{subject.presentation}</p>