Corrige les 2 erreurs ESLint @next/next/no-html-link-for-pages qui bloquaient la CI. Co-authored-by: Cursor <cursoragent@cursor.com>
405 lines
17 KiB
TypeScript
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>
|
|
</>
|
|
)
|
|
}
|