Files
Momento/memento-note/app/(public)/p/[slug]/page.tsx
Antigravity 556a0b2f3f feat(credits): solde IA unique (option M), packs Stripe et polish billing
Un pot de crédits global (BASIC 100 / PRO 1 000 / BUSINESS 4 000) remplace
les seaux par fonction côté débit. Packs one-shot S/M/L, admin sans seaux
legacy, usage multi-features, hints de coût, anti-flash thème et hydratation
admin. Inclut aussi le pipeline slides renforcé et la page publique polishée.
2026-07-16 20:43:18 +00:00

1096 lines
46 KiB
TypeScript

import { notFound } from 'next/navigation'
import { getPublishedNote } from '@/app/actions/notes-publishing'
import { Flag, Sparkles } from 'lucide-react'
import { format } from 'date-fns'
import { fr } from 'date-fns/locale'
import { computePublishedSourceHash } from '@/lib/publish/template-render'
import { processNoteHtmlForPublish } from '@/lib/publish/process-note-html'
import { REWRITE_SHARED_CSS, KATEX_PUBLISH_CSS } from '@/lib/publish/shared-css'
import { ReadingProgress } from '@/components/publish/reading-progress'
import { CopyLinkButton } from '@/components/publish/copy-link-button'
export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params
const note = await getPublishedNote(slug)
if (!note) return { title: 'Note introuvable — Memento' }
return {
title: `${note.title || 'Note publiée'} — Memento`,
description: note.content?.replace(/<[^>]+>/g, '').slice(0, 160),
}
}
export const revalidate = 60
function estimateReadingTime(html: string): number {
const words = html.replace(/<[^>]+>/g, ' ').trim().split(/\s+/).filter(Boolean).length
return Math.max(1, Math.ceil(words / 200))
}
function formatDate(d: Date | string | null | undefined, pattern = 'd MMMM yyyy') {
if (!d) return null
return format(new Date(d), pattern, { locale: fr })
}
/* ─── Shared base CSS (selection, print, typography polish) ─────────────── */
const BASE_RESET = `
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html { -webkit-font-smoothing: antialiased; -moz-osx-font-smoothing: grayscale; text-rendering: optimizeLegibility; }
img { max-width: 100%; height: auto; }
::selection { background: color-mix(in srgb, var(--pub-accent, #A47148) 28%, transparent); }
@media print {
.pub-nav, .pub-progress, .pub-copy, .pub-report { display: none !important; }
.pub-root { min-height: auto !important; }
}
`
const ARTICLE_TYPO = `
.pub-article img { max-width: 100%; height: auto; }
.pub-article h2 { scroll-margin-top: 5rem; }
.pub-article h3 { scroll-margin-top: 5rem; }
.pub-article p + p { margin-top: 0; }
.pub-article .pub-body-source > p:first-of-type::first-letter,
.pub-article .pub-rewrite-body > p:first-of-type::first-letter {
font-weight: 700;
}
.pub-article mark {
background: color-mix(in srgb, var(--pub-accent, #A47148) 22%, transparent);
padding: 0.05em 0.2em;
border-radius: 2px;
}
.pub-article hr {
border: none;
border-top: 1px solid color-mix(in srgb, var(--pub-accent, #A47148) 25%, transparent);
margin: 2.5em auto;
width: 40%;
}
.pub-article .pub-gallery {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 12px;
margin-top: 2.5em;
}
.pub-article .pub-gallery-img {
width: 100%;
height: 200px;
object-fit: cover;
border-radius: 8px;
display: block;
}
.pub-article .pub-figure { margin: 2.25em 0; text-align: center; }
.pub-article .pub-figure-img {
max-width: 100%;
border-radius: 10px;
box-shadow: 0 16px 48px rgba(0,0,0,0.1);
}
.pub-article .pub-figure-caption {
margin-top: 0.7em;
font-size: 0.82em;
opacity: 0.65;
font-style: italic;
}
`
/* ─── MAGAZINE — editorial prestige ─────────────────────────────────────── */
function MagazinePage({ note, bodyHtml, readingTime, slug, isStale }: PageProps) {
const dateLabel = formatDate(note.publishedAt)
return (
<>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css" />
<link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,500;9..144,700;9..144,900&family=Source+Serif+4:ital,opsz,wght@0,8..60,400;0,8..60,600;1,8..60,400&family=Inter:wght@400;500;600&display=swap" rel="stylesheet" />
<ReadingProgress accent="#C9A227" />
<div className="mag-root pub-root" data-pub-root>
<nav className="mag-nav pub-nav">
<span className="mag-nav-brand">Memento</span>
<div className="mag-nav-right">
<CopyLinkButton
className="mag-copy pub-copy"
label="Copier le lien"
copiedLabel="Copié"
/>
<span className="mag-badge"><Sparkles size={10} /> Magazine</span>
<a href={`/p/${slug}/report`} className="mag-report pub-report"><Flag size={12} /> Signaler</a>
</div>
</nav>
<header className="mag-header">
<div className="mag-header-glow" aria-hidden />
<div className="mag-header-inner">
<div className="mag-kicker">
<span>Article</span>
{dateLabel && <><span className="mag-dot" />{dateLabel}</>}
<span className="mag-dot" />
<span>{readingTime} min de lecture</span>
</div>
<h1 className="mag-title">{note.title || 'Sans titre'}</h1>
{note.user?.name && (
<div className="mag-author">
{note.user.image && <img src={note.user.image} alt="" className="mag-avatar" />}
<div>
<span className="mag-author-name">{note.user.name}</span>
<span className="mag-author-role">Publié sur Memento</span>
</div>
</div>
)}
</div>
</header>
<div className="mag-content">
<div dir="auto" className="mag-body pub-article" dangerouslySetInnerHTML={{ __html: bodyHtml }} />
</div>
<footer className="mag-footer">
<div className="mag-footer-inner">
<div className="mag-footer-brand">Memento</div>
<p className="mag-footer-tag">Votre mémoire augmentée par l&rsquo;IA</p>
{isStale && (
<p className="mag-stale">Le contenu source a évolué depuis la dernière publication.</p>
)}
</div>
</footer>
</div>
<style>{`
${BASE_RESET}
${ARTICLE_TYPO}
.mag-root {
--pub-accent: #C9A227;
min-height: 100vh;
background: #faf9f6;
color: #141414;
font-family: 'Inter', system-ui, sans-serif;
}
.mag-nav {
position: sticky; top: 0; z-index: 30;
background: rgba(12,12,12,0.92);
backdrop-filter: blur(16px) saturate(1.2);
color: #fff;
display: flex; align-items: center; justify-content: space-between;
padding: 0 28px; height: 54px;
border-bottom: 1px solid rgba(255,255,255,0.06);
}
.mag-nav-brand {
font-family: 'Fraunces', Georgia, serif;
font-size: 18px; font-weight: 900; letter-spacing: 0.02em;
}
.mag-nav-right { display: flex; align-items: center; gap: 10px; }
.mag-badge {
display: none;
align-items: center; gap: 5px;
font-size: 10px; font-weight: 600; letter-spacing: 0.14em;
text-transform: uppercase; color: #e8c96a;
}
@media (min-width: 640px) { .mag-badge { display: flex; } }
.mag-copy {
display: inline-flex; align-items: center; gap: 6px;
font-size: 11px; font-weight: 500; color: rgba(255,255,255,0.65);
background: rgba(255,255,255,0.06); border: 1px solid rgba(255,255,255,0.1);
border-radius: 999px; padding: 5px 12px; cursor: pointer;
font-family: inherit; transition: color .15s, background .15s;
}
.mag-copy:hover { color: #fff; background: rgba(255,255,255,0.12); }
.mag-report {
display: flex; align-items: center; gap: 5px;
font-size: 11px; color: rgba(255,255,255,0.4); text-decoration: none;
transition: color .15s;
}
.mag-report:hover { color: #fff; }
.mag-header {
position: relative;
background: #0c0c0c;
color: #fff;
padding: 72px 28px 88px;
text-align: center;
overflow: hidden;
}
.mag-header-glow {
position: absolute; inset: 0; pointer-events: none;
background:
radial-gradient(ellipse 80% 60% at 50% 0%, rgba(201,162,39,0.18), transparent 60%),
radial-gradient(ellipse 50% 40% at 80% 100%, rgba(164,113,72,0.12), transparent 50%);
}
.mag-header-inner { position: relative; max-width: 820px; margin: 0 auto; }
.mag-kicker {
display: flex; flex-wrap: wrap; align-items: center; justify-content: center; gap: 8px;
font-size: 11px; font-weight: 600; letter-spacing: 0.18em;
text-transform: uppercase; color: rgba(255,255,255,0.5); margin-bottom: 28px;
}
.mag-kicker span:first-child { color: #e8c96a; }
.mag-dot { width: 3px; height: 3px; border-radius: 50%; background: rgba(255,255,255,0.25); }
.mag-title {
font-family: 'Fraunces', Georgia, serif;
font-size: clamp(2.1rem, 6.5vw, 4.25rem);
font-weight: 900; line-height: 1.05; letter-spacing: -0.03em;
margin-bottom: 36px;
text-wrap: balance;
}
.mag-author {
display: inline-flex; align-items: center; gap: 12px;
text-align: left;
}
.mag-avatar {
width: 44px; height: 44px; border-radius: 50%;
border: 2px solid rgba(232,201,106,0.45);
object-fit: cover;
}
.mag-author-name { display: block; font-size: 14px; font-weight: 600; color: #fff; }
.mag-author-role { display: block; font-size: 12px; color: rgba(255,255,255,0.45); margin-top: 2px; }
.mag-content {
max-width: 720px; margin: 0 auto;
padding: 56px 24px 96px;
}
@media (min-width: 900px) {
.mag-content { padding-left: 32px; padding-right: 32px; }
}
.mag-body {
font-family: 'Source Serif 4', Georgia, serif;
font-size: 19px; line-height: 1.85; color: #1a1a1a;
font-optical-sizing: auto;
}
.mag-body .pub-tpl { all: unset; display: block; }
.mag-body .pub-magazine-dek {
font-size: 1.28em; line-height: 1.55; color: #333;
margin-bottom: 2.25em; padding-bottom: 1.75em;
border-bottom: 2px solid #0c0c0c;
font-style: italic;
text-wrap: pretty;
}
.mag-body .pub-pull-quote {
font-family: 'Fraunces', serif;
font-size: clamp(1.45em, 3.5vw, 1.9em);
font-style: italic; font-weight: 700;
color: #0c0c0c; line-height: 1.3;
margin: 2.75em 0; padding: 1.4em 0;
border-top: 3px solid #C9A227;
border-bottom: 3px solid #C9A227;
text-align: center; text-wrap: balance;
}
.mag-body .pub-pull-quote p { margin: 0; }
.mag-body .pub-hero {
margin: -8px -8px 2.75rem;
border-radius: 4px; overflow: hidden;
max-height: 520px; aspect-ratio: 16/9; background: #1a1a1a;
box-shadow: 0 24px 64px rgba(0,0,0,0.12);
}
@media (min-width: 900px) {
.mag-body .pub-hero { margin-left: -48px; margin-right: -48px; border-radius: 6px; }
}
.mag-body .pub-hero-img { width: 100%; height: 100%; object-fit: cover; display: block; }
.mag-body h2 {
font-family: 'Fraunces', serif; font-size: 1.55em; font-weight: 700;
margin-top: 2.6em; margin-bottom: 0.55em; letter-spacing: -0.02em; color: #0c0c0c;
}
.mag-body h3 { font-size: 1.18em; font-weight: 600; margin-top: 2em; margin-bottom: 0.45em; }
.mag-body p { margin: 1.05em 0; text-wrap: pretty; }
.mag-body .pub-body-source > p:first-of-type::first-letter {
font-family: 'Fraunces', serif;
float: left; font-size: 3.4em; line-height: 0.85;
padding-right: 0.1em; padding-top: 0.08em;
font-weight: 900; color: #0c0c0c;
}
.mag-body blockquote {
border-left: 3px solid #C9A227; padding-left: 1.25em;
margin: 1.75em 0; color: #444; font-style: italic;
}
.mag-body ul, .mag-body ol { padding-left: 1.4em; margin: 1.1em 0; }
.mag-body li { margin: 0.4em 0; }
.mag-body pre {
background: #121212; color: #e8e3d5; padding: 22px;
border-radius: 10px; overflow-x: auto; font-size: 13.5px; margin: 1.75em 0;
}
.mag-body code {
font-family: 'SF Mono', Menlo, monospace; font-size: 0.88em;
background: #f0ebe3; padding: 1px 6px; border-radius: 4px;
}
.mag-body pre code { background: none; padding: 0; color: inherit; }
.mag-body table { border-collapse: collapse; width: 100%; margin: 1.75em 0; }
.mag-body th {
background: #0c0c0c; color: #fff; padding: 11px 14px;
text-align: left; font-size: 12px; letter-spacing: 0.06em; text-transform: uppercase;
}
.mag-body td { border-bottom: 1px solid #e8e4dc; padding: 11px 14px; }
.mag-body a {
color: #9a7212; text-decoration: underline;
text-underline-offset: 3px; text-decoration-thickness: 1px;
}
.mag-body a:hover { color: #6b4f0c; }
.mag-body .pub-callout { border-radius: 8px; padding: 14px 18px; margin: 1.5em 0; }
${KATEX_PUBLISH_CSS}
.mag-footer {
background: #0c0c0c; color: rgba(255,255,255,0.4);
padding: 48px 28px 56px; text-align: center;
}
.mag-footer-inner { max-width: 480px; margin: 0 auto; }
.mag-footer-brand {
font-family: 'Fraunces', serif; font-size: 20px; font-weight: 900;
color: #e8c96a; margin-bottom: 8px;
}
.mag-footer-tag { font-size: 13px; line-height: 1.5; }
.mag-stale { margin-top: 14px; font-size: 11px; color: rgba(255,255,255,0.28); }
.mag-body {
--pub-accent: #C9A227;
--pub-summary-color: #333;
--pub-exercise-border: #e0d4c3;
--pub-exercise-header-bg: #faf6ee;
--pub-solution-bg: #faf6ee;
--pub-definition-border: #e0d4c3;
--pub-definition-bg: #fafaf8;
--pub-toggle-border: #e5e5e5;
--pub-toggle-header-bg: #f7f7f5;
--pub-highlight-bg: #faf6ee;
--pub-checklist-bg: rgba(0,0,0,0.03);
}
.mag-body .pub-rewrite-body .pub-code { background: #0d0d0d; }
.mag-body .pub-rewrite-body .pub-code code { background: #0d0d0d; color: #e8e3d5; }
.mag-body .pub-rewrite-body .pub-definition-term { background: #e8c96a; color: #1a1a1a; }
.mag-body .pub-rewrite-body .pub-step-num { background: #9a7212; color: #fff; }
.mag-body .pub-rewrite-body .pub-exercise-num { color: #9a7212; }
.mag-body .pub-rewrite-body .pub-solution > summary { color: #9a7212; }
.mag-body .pub-rewrite-body .pub-checklist li::before { color: #9a7212; }
${REWRITE_SHARED_CSS}
`}</style>
</>
)
}
/* ─── BRIEF — crisp knowledge card ──────────────────────────────────────── */
function BriefPage({ note, bodyHtml, readingTime, slug, isStale }: PageProps) {
const dateLabel = formatDate(note.publishedAt, 'd MMM yyyy')
return (
<>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css" />
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,400;0,9..40,500;0,9..40,600;0,9..40,700;1,9..40,400&family=DM+Mono:wght@400;500&display=swap" rel="stylesheet" />
<ReadingProgress accent="#3B82F6" />
<div className="br-root pub-root" data-pub-root>
<nav className="br-nav pub-nav">
<span className="br-brand">Memento</span>
<div className="br-nav-right">
<CopyLinkButton className="br-copy pub-copy" label="Copier le lien" copiedLabel="Copié" />
<span className="br-badge"><Sparkles size={9} /> Fiche expert</span>
<a href={`/p/${slug}/report`} className="br-report pub-report" aria-label="Signaler"><Flag size={12} /></a>
</div>
</nav>
<header className="br-header">
<div className="br-header-mesh" aria-hidden />
<div className="br-header-inner">
<div className="br-meta">
<span className="br-tag">Fiche expert</span>
<span className="br-meta-item">{readingTime} min</span>
{dateLabel && <span className="br-meta-item">{dateLabel}</span>}
</div>
<h1 className="br-title">{note.title || 'Sans titre'}</h1>
{note.user?.name && (
<div className="br-author">
{note.user.image && <img src={note.user.image} alt="" className="br-avatar" />}
<span>{note.user.name}</span>
</div>
)}
</div>
</header>
<div className="br-layout">
<div dir="auto" className="br-body pub-article" dangerouslySetInnerHTML={{ __html: bodyHtml }} />
</div>
<footer className="br-footer">
<strong>Memento</strong>
<span> Fiche structurée pour aller à l&rsquo;essentiel</span>
{isStale && <p className="br-stale">Le contenu source a évolué depuis la publication.</p>}
</footer>
</div>
<style>{`
${BASE_RESET}
${ARTICLE_TYPO}
.br-root {
--pub-accent: #3B82F6;
min-height: 100vh;
background: linear-gradient(180deg, #eef2f8 0%, #f6f8fb 40%, #f6f8fb 100%);
color: #0f172a;
font-family: 'DM Sans', system-ui, sans-serif;
}
.br-nav {
position: sticky; top: 0; z-index: 30;
background: rgba(255,255,255,0.85);
backdrop-filter: blur(16px) saturate(1.2);
border-bottom: 1px solid rgba(15,23,42,0.06);
display: flex; align-items: center; justify-content: space-between;
padding: 0 24px; height: 52px;
}
.br-brand { font-weight: 700; font-size: 15px; letter-spacing: -0.02em; color: #0f172a; }
.br-nav-right { display: flex; align-items: center; gap: 10px; }
.br-badge {
display: none; align-items: center; gap: 4px;
font-size: 11px; font-weight: 600; color: #2563eb;
background: #eff6ff; padding: 4px 10px; border-radius: 999px;
}
@media (min-width: 640px) { .br-badge { display: flex; } }
.br-copy {
display: inline-flex; align-items: center; gap: 6px;
font-size: 11px; font-weight: 500; color: #475569;
background: #f1f5f9; border: 1px solid #e2e8f0;
border-radius: 999px; padding: 5px 12px; cursor: pointer;
font-family: inherit; transition: background .15s;
}
.br-copy:hover { background: #e2e8f0; }
.br-report { color: #94a3b8; display: flex; padding: 6px; border-radius: 8px; transition: color .15s, background .15s; }
.br-report:hover { color: #475569; background: #f1f5f9; }
.br-header {
position: relative;
background: linear-gradient(145deg, #0b1f3a 0%, #123056 45%, #1e3a5f 100%);
color: #fff; padding: 52px 24px 64px; overflow: hidden;
}
.br-header-mesh {
position: absolute; inset: 0; pointer-events: none; opacity: 0.45;
background-image:
radial-gradient(circle at 20% 20%, rgba(59,130,246,0.35), transparent 40%),
radial-gradient(circle at 85% 70%, rgba(14,165,233,0.2), transparent 35%),
linear-gradient(rgba(255,255,255,0.03) 1px, transparent 1px),
linear-gradient(90deg, rgba(255,255,255,0.03) 1px, transparent 1px);
background-size: auto, auto, 32px 32px, 32px 32px;
}
.br-header-inner { position: relative; max-width: 760px; margin: 0 auto; }
.br-meta { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; margin-bottom: 18px; }
.br-tag {
background: #3B82F6; color: #fff;
font-size: 10px; font-weight: 700; letter-spacing: 0.12em;
text-transform: uppercase; padding: 4px 10px; border-radius: 6px;
}
.br-meta-item {
font-size: 12px; font-weight: 500;
color: rgba(255,255,255,0.55); letter-spacing: 0.04em;
}
.br-title {
font-size: clamp(1.6rem, 4.2vw, 2.65rem);
font-weight: 700; line-height: 1.15; letter-spacing: -0.03em;
margin-bottom: 22px; text-wrap: balance;
}
.br-author {
display: flex; align-items: center; gap: 10px;
font-size: 13px; color: rgba(255,255,255,0.55);
}
.br-avatar {
width: 30px; height: 30px; border-radius: 50%;
border: 2px solid rgba(255,255,255,0.2); object-fit: cover;
}
.br-layout {
max-width: 760px; margin: -28px auto 0;
padding: 0 20px 80px; position: relative; z-index: 1;
}
.br-body { font-size: 16.5px; line-height: 1.75; color: #1e293b; }
.br-body .pub-tpl { all: unset; display: block; }
.br-body .pub-brief-lead {
background: #fff; border-radius: 16px;
border: 1px solid rgba(15,23,42,0.06);
border-left: 4px solid #3B82F6;
padding: 22px 26px; margin-bottom: 1.25em;
box-shadow: 0 12px 40px rgba(15,23,42,0.06);
}
.br-body .pub-brief-lead p {
margin: 0; font-size: 1.06em; line-height: 1.7;
color: #0f172a; font-weight: 500;
}
.br-body .pub-key-points {
background: #fff; border-radius: 16px; padding: 22px 26px;
margin-bottom: 1.25em;
border: 1px solid rgba(15,23,42,0.06);
box-shadow: 0 12px 40px rgba(15,23,42,0.05);
}
.br-body .pub-key-points-label {
font-size: 10px; text-transform: uppercase; letter-spacing: 0.18em;
font-weight: 700; color: #2563eb; margin-bottom: 14px; display: block;
}
.br-body .pub-key-points ul { list-style: none; padding: 0; margin: 0; counter-reset: kp; }
.br-body .pub-key-points li {
counter-increment: kp;
padding: 12px 14px 12px 48px; border-radius: 12px;
background: #f8fafc; margin-bottom: 8px; position: relative;
font-size: 0.95em; font-weight: 500; border: 1px solid #eef2f7;
}
.br-body .pub-key-points li::before {
content: counter(kp);
position: absolute; left: 12px; top: 50%; transform: translateY(-50%);
width: 26px; height: 26px; border-radius: 8px;
background: #eff6ff; color: #2563eb;
font-weight: 700; font-size: 12px;
display: flex; align-items: center; justify-content: center;
}
.br-body .pub-body-source,
.br-body .pub-rewrite-body {
background: #fff; border-radius: 16px; padding: 32px 28px;
border: 1px solid rgba(15,23,42,0.06);
box-shadow: 0 12px 40px rgba(15,23,42,0.05);
}
@media (min-width: 640px) {
.br-body .pub-body-source,
.br-body .pub-rewrite-body { padding: 36px 40px; }
}
.br-body .pub-hero {
margin: 0 0 1.25rem; border-radius: 16px; overflow: hidden;
max-height: 380px; aspect-ratio: 16/9; background: #dbe3ef;
box-shadow: 0 16px 48px rgba(15,23,42,0.1);
}
.br-body .pub-hero-img { width: 100%; height: 100%; object-fit: cover; display: block; }
.br-body h2 {
font-size: 1.22em; font-weight: 700; margin-top: 1.9em; margin-bottom: 0.55em;
color: #0f172a; letter-spacing: -0.02em;
padding-bottom: 0.4em; border-bottom: 1px solid #e2e8f0;
}
.br-body h3 { font-size: 1.06em; font-weight: 600; margin-top: 1.4em; margin-bottom: 0.4em; color: #1e3a5f; }
.br-body p { margin: 0.85em 0; text-wrap: pretty; }
.br-body blockquote {
border-left: 3px solid #3B82F6; padding: 4px 0 4px 1em;
margin: 1.2em 0; color: #334155; font-style: italic;
}
.br-body ul, .br-body ol { padding-left: 1.3em; margin: 1em 0; }
.br-body li { margin: 0.35em 0; }
.br-body pre {
background: #0f172a; color: #e2e8f0; padding: 20px;
border-radius: 12px; overflow-x: auto; font-size: 13px;
margin: 1.4em 0; font-family: 'DM Mono', monospace;
}
.br-body code {
font-family: 'DM Mono', monospace; font-size: 0.87em;
background: #eef2f7; padding: 1px 6px; border-radius: 4px; color: #1e3a5f;
}
.br-body pre code { background: none; color: inherit; padding: 0; }
.br-body table { border-collapse: collapse; width: 100%; margin: 1.4em 0; border-radius: 10px; overflow: hidden; }
.br-body th { background: #0f172a; color: #fff; padding: 10px 14px; text-align: left; font-size: 12px; }
.br-body td { border-bottom: 1px solid #e2e8f0; padding: 10px 14px; background: #fff; }
.br-body a { color: #2563eb; text-decoration: underline; text-underline-offset: 3px; }
.br-body .pub-callout { border-radius: 10px; padding: 12px 16px; margin: 1.2em 0; }
${KATEX_PUBLISH_CSS}
.br-footer {
background: #0f172a; color: rgba(255,255,255,0.4);
padding: 36px 24px; text-align: center; font-size: 13px;
}
.br-footer strong { color: #fff; font-weight: 600; }
.br-stale { font-size: 11px; color: rgba(255,255,255,0.28); margin-top: 8px; }
.br-body {
--pub-accent: #2563eb;
--pub-summary-color: #334155;
--pub-exercise-border: #bfdbfe;
--pub-exercise-header-bg: #eff6ff;
--pub-solution-bg: #f0f7ff;
--pub-definition-border: #bfdbfe;
--pub-definition-bg: #f8faff;
--pub-toggle-border: #e2e8f0;
--pub-toggle-header-bg: #f8fafc;
--pub-highlight-bg: #eff6ff;
--pub-checklist-bg: #f4f6f9;
}
.br-body .pub-rewrite-body .pub-code { background: #0f172a; }
.br-body .pub-rewrite-body .pub-code code { background: #0f172a; color: #e2e8f0; }
${REWRITE_SHARED_CSS}
`}</style>
</>
)
}
/* ─── ESSAY — literary calm ─────────────────────────────────────────────── */
function EssayPage({ note, bodyHtml, readingTime, slug, isStale }: PageProps) {
const dateLabel = formatDate(note.publishedAt)
return (
<>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css" />
<link href="https://fonts.googleapis.com/css2?family=Literata:ital,opsz,wght@0,7..72,400;0,7..72,600;0,7..72,700;1,7..72,400;1,7..72,600&family=Inter:wght@400;500;600&display=swap" rel="stylesheet" />
<ReadingProgress accent="#7A5C2E" />
<div className="es-root pub-root" data-pub-root>
<nav className="es-nav pub-nav">
<span className="es-brand">Memento</span>
<div className="es-nav-right">
<CopyLinkButton className="es-copy pub-copy" label="Copier le lien" copiedLabel="Copié" />
<span className="es-badge"><Sparkles size={10} /> Essai</span>
<a href={`/p/${slug}/report`} className="es-report pub-report" aria-label="Signaler"><Flag size={12} /></a>
</div>
</nav>
<main className="es-main">
<div className="es-ornament" aria-hidden>
<span className="es-ornament-line" />
<span className="es-ornament-diamond" />
<span className="es-ornament-line" />
</div>
<header className="es-header">
{dateLabel && <time className="es-date">{dateLabel}</time>}
<h1 className="es-title">{note.title || 'Sans titre'}</h1>
<div className="es-meta-row">
{note.user?.name && (
<div className="es-author">
{note.user.image && <img src={note.user.image} alt="" className="es-avatar" />}
<span className="es-author-name">{note.user.name}</span>
</div>
)}
<span className="es-reading-time">{readingTime} min de lecture</span>
</div>
</header>
<div dir="auto" className="es-body pub-article" dangerouslySetInnerHTML={{ __html: bodyHtml }} />
<footer className="es-footer">
<div className="es-ornament" aria-hidden>
<span className="es-ornament-line" />
<span className="es-ornament-diamond" />
<span className="es-ornament-line" />
</div>
<p>Publié sur <strong>Memento</strong></p>
{isStale && <p className="es-stale">Le contenu source a évolué depuis la publication.</p>}
</footer>
</main>
</div>
<style>{`
${BASE_RESET}
${ARTICLE_TYPO}
.es-root {
--pub-accent: #7A5C2E;
min-height: 100vh;
background:
radial-gradient(ellipse 100% 60% at 50% 0%, #f7f1e6 0%, transparent 55%),
#fbf8f2;
color: #2c2416;
font-family: 'Inter', system-ui, sans-serif;
}
.es-nav {
position: sticky; top: 0; z-index: 30;
background: rgba(251,248,242,0.88);
backdrop-filter: blur(14px);
border-bottom: 1px solid rgba(92,74,42,0.1);
display: flex; align-items: center; justify-content: space-between;
padding: 0 28px; height: 52px;
}
.es-brand {
font-family: 'Literata', Georgia, serif;
font-size: 16px; font-style: italic; color: #5c4a2a; font-weight: 600;
}
.es-nav-right { display: flex; align-items: center; gap: 12px; }
.es-badge {
display: none; align-items: center; gap: 4px;
font-size: 11px; font-weight: 500; color: #8b6c3a;
}
@media (min-width: 640px) { .es-badge { display: flex; } }
.es-copy {
display: inline-flex; align-items: center; gap: 6px;
font-size: 11px; font-weight: 500; color: #6b5337;
background: transparent; border: 1px solid #d8cebc;
border-radius: 999px; padding: 5px 12px; cursor: pointer;
font-family: inherit; transition: background .15s;
}
.es-copy:hover { background: rgba(122,92,46,0.06); }
.es-report { color: #b5a48a; display: flex; transition: color .15s; }
.es-report:hover { color: #5c4a2a; }
.es-main { max-width: 680px; margin: 0 auto; padding: 40px 28px 100px; }
.es-ornament {
display: flex; align-items: center; justify-content: center; gap: 12px;
margin: 28px 0 8px;
}
.es-ornament-line {
width: 48px; height: 1px;
background: linear-gradient(90deg, transparent, #c8b99a, transparent);
}
.es-ornament-diamond {
width: 7px; height: 7px; background: #7A5C2E;
transform: rotate(45deg); opacity: 0.7;
}
.es-header { text-align: center; padding: 28px 0 8px; }
.es-date {
display: block; font-size: 11px; font-weight: 600;
letter-spacing: 0.2em; text-transform: uppercase;
color: #a08b5c; margin-bottom: 22px;
}
.es-title {
font-family: 'Literata', Georgia, serif;
font-size: clamp(1.85rem, 5.2vw, 3.1rem);
font-weight: 600; font-style: italic; line-height: 1.18;
letter-spacing: -0.02em; color: #1a140c;
margin-bottom: 28px; text-wrap: balance;
}
.es-meta-row {
display: flex; flex-wrap: wrap; align-items: center;
justify-content: center; gap: 16px;
padding-top: 22px; border-top: 1px solid #e2d9ca;
}
.es-author { display: flex; align-items: center; gap: 10px; }
.es-avatar {
width: 36px; height: 36px; border-radius: 50%;
border: 1.5px solid #d8cebc; object-fit: cover;
}
.es-author-name { font-size: 13px; font-weight: 600; color: #3d2f14; }
.es-reading-time { font-size: 12px; color: #a08b5c; }
.es-body {
margin-top: 48px;
font-family: 'Literata', Georgia, serif;
font-size: 19.5px; line-height: 1.9; color: #2c2416;
font-optical-sizing: auto;
}
.es-body .pub-tpl { all: unset; display: block; }
.es-body .pub-epigraph {
position: relative; text-align: center;
font-size: 1.12em; font-style: italic; color: #6b5337;
margin: 0 0 2.75em; padding: 1.6em 1.5em 1.2em;
border-top: 1px solid #d8cebc; border-bottom: 1px solid #d8cebc;
}
.es-body .pub-epigraph::before {
content: '“';
position: absolute; top: -0.25em; left: 50%; transform: translateX(-50%);
font-size: 3.5em; color: #d8cebc; line-height: 1;
font-family: 'Literata', Georgia, serif; pointer-events: none;
}
.es-body .pub-essay-summary {
font-size: 1.12em; line-height: 1.75; color: #4a3726;
margin-bottom: 2.25em; font-style: italic;
padding-left: 1.25em; border-left: 2px solid #c8b99a;
}
.es-body .pub-body-source > p:first-of-type::first-letter {
font-family: 'Literata', Georgia, serif;
float: left; font-size: 3.6em; line-height: 0.82;
padding-right: 0.08em; padding-top: 0.06em;
font-weight: 700; color: #1a140c;
}
.es-body .pub-body-source p { text-indent: 1.5em; margin: 0 0 0.15em; }
.es-body .pub-body-source p:first-of-type { text-indent: 0; }
.es-body .pub-rewrite-body p { margin: 0.9em 0; }
.es-body .pub-hero {
margin: 0 -12px 2.75em; overflow: hidden;
max-height: 440px; aspect-ratio: 16/9; background: #e8e0d0;
border-radius: 2px;
}
@media (min-width: 720px) {
.es-body .pub-hero { margin-left: -40px; margin-right: -40px; }
}
.es-body .pub-hero-img {
width: 100%; height: 100%; object-fit: cover; display: block;
filter: sepia(8%) contrast(1.02);
}
.es-body h2 {
font-family: 'Literata', serif; font-size: 1.4em; font-style: italic;
font-weight: 600; margin-top: 2.4em; margin-bottom: 0.65em; color: #1a140c;
}
.es-body h3 {
font-family: 'Literata', serif; font-size: 1.15em;
font-weight: 600; margin-top: 1.9em; margin-bottom: 0.45em;
}
.es-body p { text-wrap: pretty; }
.es-body blockquote {
border: none; margin: 2.25em 0; padding: 0 1.5em;
font-size: 1.12em; color: #5c4a2a; font-style: italic;
text-align: center;
}
.es-body ul, .es-body ol { padding-left: 1.35em; margin: 1em 0; }
.es-body li { margin: 0.45em 0; }
.es-body pre {
background: #f2ede4; padding: 20px; border-radius: 6px;
overflow-x: auto; font-family: 'SF Mono', Menlo, monospace;
font-size: 13px; margin: 1.5em 0;
}
.es-body code {
font-family: 'SF Mono', Menlo, monospace; font-size: 0.85em;
background: #ede7da; padding: 1px 5px; border-radius: 3px;
}
.es-body pre code { background: none; padding: 0; }
.es-body table { border-collapse: collapse; width: 100%; margin: 1.5em 0; }
.es-body th {
border-bottom: 2px solid #5c4a2a; padding: 8px 12px;
text-align: left; font-size: 13px; color: #5c4a2a;
}
.es-body td { border-bottom: 1px solid #e8e0d0; padding: 8px 12px; }
.es-body a {
color: #7a5c2e; text-decoration: underline;
text-decoration-style: dotted; text-underline-offset: 3px;
}
.es-body .pub-figure-img { filter: sepia(5%); border-radius: 2px; }
.es-body .pub-callout { border-radius: 4px; padding: 12px 16px; margin: 1.5em 0; }
${KATEX_PUBLISH_CSS}
.es-footer {
text-align: center; padding: 48px 0 0;
font-size: 13px; color: #a08b5c;
}
.es-footer strong { color: #5c4a2a; }
.es-stale { font-size: 11px; color: #c4b49a; margin-top: 8px; }
.es-body {
--pub-accent: #7a5c2e;
--pub-summary-color: #5c4a2a;
--pub-exercise-border: #d8cebc;
--pub-exercise-header-bg: #f5f0e8;
--pub-solution-bg: rgba(122,92,46,0.05);
--pub-definition-border: #d8cebc;
--pub-definition-bg: #fdfbf7;
--pub-toggle-border: #e2d9ca;
--pub-toggle-header-bg: #f7f3ec;
--pub-highlight-bg: #fdf6eb;
--pub-checklist-bg: rgba(0,0,0,0.025);
}
.es-body .pub-rewrite-body .pub-code { background: #2c2416; }
.es-body .pub-rewrite-body .pub-code code { background: #2c2416; color: #f0e8da; }
${REWRITE_SHARED_CSS}
`}</style>
</>
)
}
/* ─── SIMPLE — clean Substack-grade default ─────────────────────────────── */
function SimplePage({ note, bodyHtml, readingTime, slug, isStale }: PageProps) {
const dateLabel = formatDate(note.publishedAt, 'd MMM yyyy')
return (
<>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/katex.min.css" />
<link href="https://fonts.googleapis.com/css2?family=Source+Serif+4:ital,opsz,wght@0,8..60,400;0,8..60,600;0,8..60,700;1,8..60,400&family=Inter:wght@400;500;600&display=swap" rel="stylesheet" />
<ReadingProgress accent="#A47148" />
<div className="sp-root pub-root" data-pub-root>
<nav className="sp-nav pub-nav">
<span className="sp-brand">Memento</span>
<div className="sp-nav-right">
<CopyLinkButton className="sp-copy pub-copy" label="Copier le lien" copiedLabel="Copié" />
<a href={`/p/${slug}/report`} className="sp-report pub-report">
<Flag size={12} /> Signaler
</a>
</div>
</nav>
<article className="sp-article">
<header className="sp-header">
<div className="sp-kicker">
{dateLabel && <span>{dateLabel}</span>}
{dateLabel && <span className="sp-dot" />}
<span>{readingTime} min de lecture</span>
</div>
<h1 className="sp-title">{note.title || 'Sans titre'}</h1>
{note.user?.name && (
<div className="sp-author">
{note.user.image && <img src={note.user.image} alt="" className="sp-avatar" />}
<span>par <strong>{note.user.name}</strong></span>
</div>
)}
</header>
<div className="sp-rule" />
<div dir="auto" className="sp-body pub-article" dangerouslySetInnerHTML={{ __html: bodyHtml }} />
</article>
<footer className="sp-footer">
<span className="sp-footer-brand">Memento</span>
<span> Votre mémoire augmentée par l&rsquo;IA</span>
{isStale && (
<p className="sp-stale">Le contenu source a évolué depuis la publication.</p>
)}
</footer>
</div>
<style>{`
${BASE_RESET}
${ARTICLE_TYPO}
.sp-root {
--pub-accent: #A47148;
min-height: 100vh;
background: #faf9f5;
color: #1a1a1a;
font-family: 'Inter', system-ui, sans-serif;
}
.sp-nav {
position: sticky; top: 0; z-index: 30;
background: rgba(250,249,245,0.9);
backdrop-filter: blur(14px);
border-bottom: 1px solid rgba(0,0,0,0.06);
display: flex; align-items: center; justify-content: space-between;
padding: 0 24px; height: 52px;
}
.sp-brand {
font-family: 'Source Serif 4', Georgia, serif;
font-weight: 700; font-size: 16px; color: #1a1a1a;
}
.sp-nav-right { display: flex; align-items: center; gap: 10px; }
.sp-copy {
display: inline-flex; align-items: center; gap: 6px;
font-size: 11px; font-weight: 500; color: #555;
background: #fff; border: 1px solid rgba(0,0,0,0.08);
border-radius: 999px; padding: 5px 12px; cursor: pointer;
font-family: inherit; box-shadow: 0 1px 2px rgba(0,0,0,0.04);
transition: border-color .15s, color .15s;
}
.sp-copy:hover { border-color: #A47148; color: #A47148; }
.sp-report {
display: flex; align-items: center; gap: 5px;
font-size: 12px; color: #888; text-decoration: none;
padding: 5px 10px; border-radius: 8px;
border: 1px solid rgba(0,0,0,0.08); transition: color .15s, border-color .15s;
}
.sp-report:hover { color: #333; border-color: rgba(0,0,0,0.15); }
.sp-article {
max-width: 680px; margin: 0 auto;
padding: 48px 24px 72px;
}
.sp-header { margin-bottom: 8px; }
.sp-kicker {
display: flex; flex-wrap: wrap; align-items: center; gap: 8px;
font-size: 12px; font-weight: 500; color: #888; margin-bottom: 16px;
letter-spacing: 0.02em;
}
.sp-dot { width: 3px; height: 3px; border-radius: 50%; background: #ccc; }
.sp-title {
font-family: 'Source Serif 4', Georgia, serif;
font-size: clamp(1.75rem, 4.5vw, 2.5rem);
font-weight: 700; line-height: 1.18; letter-spacing: -0.025em;
margin: 0 0 20px; text-wrap: balance; color: #111;
}
.sp-author {
display: flex; align-items: center; gap: 10px;
font-size: 14px; color: #666;
}
.sp-author strong { color: #333; font-weight: 600; }
.sp-avatar {
width: 32px; height: 32px; border-radius: 50%;
object-fit: cover; border: 1px solid rgba(0,0,0,0.08);
}
.sp-rule {
height: 1px; background: linear-gradient(90deg, transparent, rgba(0,0,0,0.1), transparent);
margin: 32px 0 36px;
}
.sp-body {
font-family: 'Source Serif 4', Georgia, serif;
font-size: 18px; line-height: 1.85; color: #1f1f1f;
font-optical-sizing: auto;
}
.sp-body h2 {
font-size: 1.4em; font-weight: 700; margin-top: 2em; margin-bottom: 0.55em;
letter-spacing: -0.02em; color: #111;
}
.sp-body h3 { font-size: 1.15em; font-weight: 600; margin-top: 1.7em; margin-bottom: 0.45em; }
.sp-body p { margin: 1em 0; text-wrap: pretty; }
.sp-body .pub-body-source > p:first-of-type::first-letter {
font-weight: 700; color: #A47148;
}
.sp-body ul, .sp-body ol { padding-left: 1.4em; margin: 1em 0; }
.sp-body li { margin: 0.4em 0; }
.sp-body blockquote {
border-left: 3px solid #A47148; padding: 2px 0 2px 1.1em;
margin: 1.4em 0; color: #555; font-style: italic;
}
.sp-body pre {
background: #f0ede6; padding: 18px; border-radius: 10px;
overflow-x: auto; font-size: 13.5px; margin: 1.4em 0;
}
.sp-body code {
font-family: 'SF Mono', Menlo, monospace; font-size: 0.87em;
background: #f0ede6; padding: 1px 5px; border-radius: 4px;
}
.sp-body pre code { background: none; padding: 0; }
.sp-body table { border-collapse: collapse; width: 100%; margin: 1.4em 0; }
.sp-body th, .sp-body td { border: 1px solid #e0dcd3; padding: 9px 12px; text-align: left; }
.sp-body th { background: #f4f1ea; font-weight: 600; font-size: 13px; }
.sp-body a {
color: #A47148; text-decoration: underline; text-underline-offset: 3px;
}
.sp-body .pub-callout { border-radius: 10px; padding: 12px 16px; margin: 1.1em 0; }
.sp-body .pub-hero {
margin: 0 0 2rem; border-radius: 12px; overflow: hidden;
max-height: 420px; aspect-ratio: 16/9; background: #e8e4dc;
}
.sp-body .pub-hero-img { width: 100%; height: 100%; object-fit: cover; display: block; }
${KATEX_PUBLISH_CSS}
${REWRITE_SHARED_CSS}
.sp-footer {
border-top: 1px solid rgba(0,0,0,0.07);
padding: 36px 24px 48px; text-align: center;
font-size: 13px; color: #999;
}
.sp-footer-brand {
font-family: 'Source Serif 4', serif;
font-weight: 700; color: #A47148;
}
.sp-stale { margin-top: 10px; font-size: 11px; color: #bbb; }
.sp-body {
--pub-accent: #A47148;
--pub-summary-color: #555;
--pub-exercise-border: #e0d4c3;
--pub-exercise-header-bg: #f6f1ea;
--pub-solution-bg: rgba(164,113,72,0.05);
--pub-definition-border: #e0d4c3;
--pub-definition-bg: #faf8f4;
--pub-toggle-border: #e8e4dc;
--pub-toggle-header-bg: #f5f2eb;
--pub-highlight-bg: #faf6ee;
--pub-checklist-bg: rgba(0,0,0,0.03);
}
`}</style>
</>
)
}
/* ─── Types ─────────────────────────────────────────────────────────────── */
type NoteData = NonNullable<Awaited<ReturnType<typeof getPublishedNote>>>
interface PageProps {
note: NoteData
bodyHtml: string
readingTime: number
slug: string
isStale: boolean
}
/* ─── Route ──────────────────────────────────────────────────────────────── */
export default async function PublishedNotePage({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params
const note = await getPublishedNote(slug)
if (!note) notFound()
const usesAiLayout = Boolean(note.publishedContent)
const rawHtml = usesAiLayout ? note.publishedContent! : (note.content || '')
const bodyHtml = processNoteHtmlForPublish(rawHtml)
const readingSource = usesAiLayout ? note.publishedContent! : (note.content || '')
const readingTime = estimateReadingTime(readingSource)
const isStale = Boolean(
note.publishedSourceHash
&& computePublishedSourceHash(note.content || '') !== note.publishedSourceHash
)
const props: PageProps = { note, bodyHtml, readingTime, slug, isStale }
if (!usesAiLayout) return <SimplePage {...props} />
if (note.publishedTemplate === 'brief') return <BriefPage {...props} />
if (note.publishedTemplate === 'essay') return <EssayPage {...props} />
return <MagazinePage {...props} />
}