Files
Momento/memento-note/app/(public)/c/[slug]/page.tsx
Antigravity be500189c3
All checks were successful
CI / Lint, Unit Tests & Build (push) Successful in 5m18s
CI / Deploy production (on server) (push) Successful in 59s
fix(lint): remplacer <a href="/"> par Link sur la page site carnet
Corrige les 2 erreurs ESLint @next/next/no-html-link-for-pages qui bloquaient la CI.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-28 08:38:00 +00:00

405 lines
17 KiB
TypeScript

import { notFound } from 'next/navigation'
import Link from 'next/link'
import { Flag, Sparkles, BookOpen, Clock } from 'lucide-react'
import { format } from 'date-fns'
import { fr } from 'date-fns/locale'
import prisma from '@/lib/prisma'
import { processNoteHtmlForPublish } from '@/lib/publish/process-note-html'
import { REWRITE_SHARED_CSS, KATEX_PUBLISH_CSS } from '@/lib/publish/shared-css'
export const revalidate = 60
async function getNotebookSite(slug: string) {
const site = await prisma.notebookSite.findUnique({
where: { slug },
include: {
notebook: {
select: {
id: true,
name: true,
icon: true,
user: { select: { name: true, image: true } },
notes: {
select: { id: true, title: true, content: true, order: true, updatedAt: true },
where: { trashedAt: null, isArchived: false },
orderBy: { order: 'asc' },
},
},
},
},
})
if (!site || !site.isPublic) return null
return site
}
export async function generateMetadata({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params
const site = await getNotebookSite(slug)
if (!site) return { title: 'Site introuvable — Memento' }
return {
title: `${site.notebook.name} — Memento`,
description: site.description || `Carnet publié sur Memento`,
openGraph: { title: site.notebook.name, description: site.description || '' },
}
}
function wordCount(html: string) {
return (html || '').replace(/<[^>]+>/g, ' ').trim().split(/\s+/).filter(Boolean).length
}
function readingTime(html: string) {
return Math.max(1, Math.ceil(wordCount(html) / 200))
}
function preview(html: string, len = 140) {
return (html || '').replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim().slice(0, len)
}
export default async function NotebookSitePage({ params }: { params: Promise<{ slug: string }> }) {
const { slug } = await params
const site = await getNotebookSite(slug)
if (!site) notFound()
let selectedIds: string[] = []
try { selectedIds = JSON.parse(site.selectedNoteIds) } catch {}
const allNotes = site.notebook.notes
const orderedNotes = selectedIds.length > 0
? selectedIds.map(id => allNotes.find(n => n.id === id)).filter(Boolean) as typeof allNotes
: allNotes
const notebook = site.notebook
const totalWc = orderedNotes.reduce((s, n) => s + wordCount(n.content || ''), 0)
const totalRt = Math.max(1, Math.ceil(totalWc / 200))
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=Playfair+Display:ital,wght@0,700;0,900;1,700&family=Source+Serif+4:opsz,wght@8..60,300..600&family=Inter:wght@400;500;600&display=swap" rel="stylesheet" />
{/* ── NAV ── */}
<nav className="cs-nav">
<Link href="/" className="cs-nav-brand">Memento</Link>
<div className="cs-nav-right">
<span className="cs-nav-badge"><Sparkles size={10} /> Site publié</span>
<a href={`/c/${slug}/report`} className="cs-nav-report"><Flag size={11} /> Signaler</a>
</div>
</nav>
{/* ── HERO ── */}
<header className="cs-hero">
<div className="cs-hero-inner">
{notebook.icon && <div className="cs-hero-icon">{notebook.icon}</div>}
<div className="cs-hero-label">CARNET · {orderedNotes.length} NOTES · {totalRt} MIN</div>
<h1 className="cs-hero-title">{notebook.name}</h1>
{site.description && <p className="cs-hero-desc">{site.description}</p>}
{notebook.user?.name && (
<div className="cs-hero-author">
{notebook.user.image && <img src={notebook.user.image} alt="" className="cs-hero-avatar" />}
<span>par <strong>{notebook.user.name}</strong></span>
</div>
)}
</div>
</header>
{/* ── TABLE DES MATIÈRES ── */}
<section className="cs-toc-section">
<div className="cs-toc-inner">
<p className="cs-toc-label">TABLE DES MATIÈRES</p>
<div className="cs-toc-grid">
{orderedNotes.map((note, i) => (
<a key={note.id} href={`#note-${note.id}`} className="cs-toc-card">
<span className="cs-toc-num">{String(i + 1).padStart(2, '0')}</span>
<div>
<div className="cs-toc-card-title">{note.title || 'Sans titre'}</div>
<div className="cs-toc-card-preview">{preview(note.content || '')}</div>
<div className="cs-toc-card-rt"><Clock size={10} /> {readingTime(note.content || '')} min</div>
</div>
</a>
))}
</div>
</div>
</section>
{/* ── NOTES ── */}
<main className="cs-main">
<aside className="cs-sidebar">
<p className="cs-sidebar-label">NAVIGATION</p>
{orderedNotes.map((note, i) => (
<a key={note.id} href={`#note-${note.id}`} className="cs-sidebar-link">
<span className="cs-sidebar-num">{i + 1}</span>
<span className="cs-sidebar-title">{note.title || 'Sans titre'}</span>
</a>
))}
</aside>
<div className="cs-articles">
{orderedNotes.map((note, i) => {
const bodyHtml = processNoteHtmlForPublish(note.content || '')
const rt = readingTime(note.content || '')
return (
<article key={note.id} id={`note-${note.id}`} className="cs-article">
<div className="cs-article-meta">
<span className="cs-article-num">{String(i + 1).padStart(2, '0')}</span>
<span className="cs-article-rt"><Clock size={10} /> {rt} min de lecture</span>
</div>
<h2 className="cs-article-title">{note.title || 'Sans titre'}</h2>
<div
className="cs-article-body pub-article pub-rewrite-body"
dir="auto"
dangerouslySetInnerHTML={{ __html: bodyHtml }}
/>
</article>
)
})}
</div>
</main>
{/* ── FOOTER ── */}
<footer className="cs-footer">
<div className="cs-footer-inner">
<span>Publié avec <Link href="/" className="cs-footer-brand">Memento</Link></span>
<a href={`/c/${slug}/report`} className="cs-footer-report"><Flag size={11} /> Signaler ce contenu</a>
</div>
</footer>
<style>{`
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
html { scroll-behavior: smooth; }
body { background: #fff; color: #111; font-family: 'Inter', system-ui, sans-serif; }
/* ── NAV ── */
.cs-nav {
position: sticky; top: 0; z-index: 50;
background: #111; color: #fff;
display: flex; align-items: center; justify-content: space-between;
padding: 0 32px; height: 52px;
}
.cs-nav-brand {
font-family: 'Playfair Display', Georgia, serif;
font-size: 17px; font-weight: 900; letter-spacing: .04em;
color: #fff; text-decoration: none;
}
.cs-nav-right { display: flex; align-items: center; gap: 16px; }
.cs-nav-badge {
display: flex; align-items: center; gap: 4px;
font-size: 10px; font-weight: 700; letter-spacing: .12em;
text-transform: uppercase; color: #f5c96a;
}
.cs-nav-report {
display: flex; align-items: center; gap: 4px;
font-size: 11px; color: rgba(255,255,255,.4); text-decoration: none;
}
.cs-nav-report:hover { color: rgba(255,255,255,.7); }
/* ── HERO ── */
.cs-hero {
background: #111; color: #fff;
padding: 72px 32px 80px; text-align: center;
}
.cs-hero-inner { max-width: 780px; margin: 0 auto; }
.cs-hero-icon { font-size: 48px; margin-bottom: 20px; line-height: 1; }
.cs-hero-label {
font-size: 11px; font-weight: 700; letter-spacing: .22em;
text-transform: uppercase; color: #f5c96a; margin-bottom: 24px;
}
.cs-hero-title {
font-family: 'Playfair Display', Georgia, serif;
font-size: clamp(36px, 6vw, 66px);
font-weight: 900; line-height: 1.08; letter-spacing: -.02em;
margin-bottom: 24px;
}
.cs-hero-desc {
font-size: 18px; line-height: 1.7; color: rgba(255,255,255,.65);
max-width: 600px; margin: 0 auto 28px;
}
.cs-hero-author {
display: inline-flex; align-items: center; gap: 10px;
font-size: 13px; color: rgba(255,255,255,.5);
}
.cs-hero-avatar {
width: 28px; height: 28px; border-radius: 50%;
border: 2px solid rgba(255,255,255,.2); object-fit: cover;
}
/* ── TABLE DES MATIÈRES ── */
.cs-toc-section { background: #f8f8f6; border-bottom: 1px solid #e8e8e4; padding: 56px 32px; }
.cs-toc-inner { max-width: 1040px; margin: 0 auto; }
.cs-toc-label {
font-size: 10px; font-weight: 700; letter-spacing: .2em;
color: #aaa; text-transform: uppercase; margin-bottom: 24px;
}
.cs-toc-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 16px;
}
.cs-toc-card {
display: flex; gap: 16px; align-items: flex-start;
background: #fff; border: 1px solid #e8e8e4;
border-radius: 12px; padding: 20px 22px;
text-decoration: none; color: inherit;
transition: border-color .15s, box-shadow .15s;
}
.cs-toc-card:hover {
border-color: #111;
box-shadow: 0 4px 20px rgba(0,0,0,.07);
}
.cs-toc-num {
font-size: 11px; font-weight: 700; color: #f5c96a;
background: #111; border-radius: 6px;
padding: 4px 7px; line-height: 1; flex-shrink: 0; margin-top: 2px;
}
.cs-toc-card-title { font-size: 14px; font-weight: 700; color: #111; line-height: 1.35; margin-bottom: 6px; }
.cs-toc-card-preview { font-size: 12px; color: #777; line-height: 1.5; margin-bottom: 8px; }
.cs-toc-card-rt {
display: flex; align-items: center; gap: 4px;
font-size: 10px; color: #bbb; font-weight: 500;
}
/* ── LAYOUT PRINCIPAL ── */
.cs-main {
max-width: 1200px; margin: 0 auto;
display: grid; grid-template-columns: 260px 1fr;
gap: 0; min-height: 60vh;
}
@media (max-width: 820px) { .cs-main { grid-template-columns: 1fr; } }
/* ── SIDEBAR ── */
.cs-sidebar {
padding: 48px 24px 48px 32px;
border-right: 1px solid #f0f0ee;
position: sticky; top: 52px;
height: calc(100vh - 52px); overflow-y: auto;
}
@media (max-width: 820px) { .cs-sidebar { display: none; } }
.cs-sidebar-label {
font-size: 9px; font-weight: 700; letter-spacing: .2em;
color: #bbb; text-transform: uppercase; margin-bottom: 16px;
padding-bottom: 12px; border-bottom: 1px solid #f0f0ee;
}
.cs-sidebar-link {
display: flex; align-items: flex-start; gap: 10px;
padding: 8px 10px; border-radius: 8px; margin-bottom: 2px;
text-decoration: none; color: #555; font-size: 12.5px; line-height: 1.45;
transition: background .12s, color .12s;
}
.cs-sidebar-link:hover { background: #f5f5f3; color: #111; }
.cs-sidebar-num {
flex-shrink: 0; font-size: 10px; font-weight: 700;
color: #ccc; width: 18px; text-align: right; padding-top: 1px;
}
.cs-sidebar-title { font-weight: 500; }
/* ── ARTICLES ── */
.cs-articles { padding: 64px 60px; }
@media (max-width: 1024px) { .cs-articles { padding: 48px 32px; } }
@media (max-width: 640px) { .cs-articles { padding: 32px 20px; } }
.cs-article { padding-top: 56px; margin-top: 56px; border-top: 1px solid #eeecea; }
.cs-article:first-child { margin-top: 0; padding-top: 0; border-top: none; }
.cs-article-meta {
display: flex; align-items: center; gap: 14px;
margin-bottom: 16px;
}
.cs-article-num {
font-size: 11px; font-weight: 700; color: #f5c96a;
background: #111; border-radius: 6px; padding: 3px 8px; line-height: 1;
}
.cs-article-rt {
display: flex; align-items: center; gap: 4px;
font-size: 11px; color: #bbb; font-weight: 500;
}
.cs-article-title {
font-family: 'Playfair Display', Georgia, serif;
font-size: clamp(24px, 3.5vw, 38px);
font-weight: 900; line-height: 1.15; letter-spacing: -.01em;
color: #111; margin-bottom: 32px;
}
/* ── ARTICLE BODY ── */
.cs-article-body {
font-family: 'Source Serif 4', Georgia, serif;
font-size: 18px; line-height: 1.85; color: #1a1a1a;
max-width: 680px;
}
.cs-article-body h2 {
font-family: 'Playfair Display', serif; font-size: 1.5em;
font-weight: 700; margin-top: 2.5em; margin-bottom: .6em; color: #111;
}
.cs-article-body h3 {
font-size: 1.15em; font-weight: 700;
margin-top: 2em; margin-bottom: .5em; color: #111;
font-family: 'Inter', sans-serif;
}
.cs-article-body p { margin: 1em 0; }
.cs-article-body blockquote {
border-left: 4px solid #f5c96a; padding-left: 1.2em;
margin: 1.5em 0; color: #444; font-style: italic;
}
.cs-article-body ul, .cs-article-body ol { padding-left: 1.5em; margin: 1em 0; }
.cs-article-body li { margin: .4em 0; }
.cs-article-body pre {
background: #111; color: #e8e3d5; padding: 22px 24px;
border-radius: 10px; overflow-x: auto; font-size: 13px; margin: 1.5em 0;
font-family: 'SF Mono', Menlo, monospace;
}
.cs-article-body code {
font-family: 'SF Mono', Menlo, monospace; font-size: .87em;
background: #f3f2ef; padding: 1px 5px; border-radius: 3px; color: #1a1a1a;
}
.cs-article-body pre code { background: none; color: inherit; padding: 0; }
.cs-article-body table { border-collapse: collapse; width: 100%; margin: 1.5em 0; }
.cs-article-body th {
background: #111; color: #fff; padding: 10px 14px;
text-align: left; font-size: 13px; letter-spacing: .05em; font-family: 'Inter', sans-serif;
}
.cs-article-body td { border-bottom: 1px solid #e8e8e4; padding: 10px 14px; }
.cs-article-body a { color: #b8832a; text-decoration: underline; text-underline-offset: 3px; }
.cs-article-body .pub-figure { margin: 2.5em 0; text-align: center; }
.cs-article-body .pub-figure-img { max-width: 100%; border-radius: 8px; box-shadow: 0 12px 40px rgba(0,0,0,.1); }
.cs-article-body .pub-figure-caption { margin-top: .6em; font-size: .8em; color: #888; font-style: italic; }
.cs-article-body .pub-gallery {
display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 10px; margin: 2em 0;
}
.cs-article-body .pub-gallery-img { width: 100%; height: 180px; object-fit: cover; border-radius: 8px; display: block; }
/* Variables CSS pour blocs structurés */
.cs-article-body {
--pub-accent: #9a7212;
--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,.03);
}
.cs-article-body .pub-code { background: #0d0d0d; }
.cs-article-body .pub-code code { background: #0d0d0d; color: #e8e3d5; }
/* ── FOOTER ── */
.cs-footer { background: #111; color: rgba(255,255,255,.4); padding: 32px; }
.cs-footer-inner {
max-width: 1040px; margin: 0 auto;
display: flex; justify-content: space-between; align-items: center;
font-size: 13px; flex-wrap: wrap; gap: 12px;
}
.cs-footer-brand { color: #f5c96a; font-weight: 700; text-decoration: none; }
.cs-footer-report {
display: flex; align-items: center; gap: 5px;
font-size: 11px; color: rgba(255,255,255,.3); text-decoration: none;
}
.cs-footer-report:hover { color: rgba(255,255,255,.6); }
${KATEX_PUBLISH_CSS}
${REWRITE_SHARED_CSS}
`}</style>
</>
)
}