feat: page interactive, démos Play/Step et simulateur Carnot
Ajoute le pipeline PageSpec (validation, rendu, publication /p/{slug}),
les démos TipTap /demo, et le simulateur Carnot (modes frigo/PAC/moteur,
énergie kJ vs puissance W, unités K/°C/°F) avec correctifs d’équations KaTeX.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
151
memento-note/components/interactive-page/page-sticky-nav.tsx
Normal file
151
memento-note/components/interactive-page/page-sticky-nav.tsx
Normal file
@@ -0,0 +1,151 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import type { PageSpecV1 } from '@/lib/interactive-page'
|
||||
import { intentColor } from '@/lib/interactive-demo/intent-colors'
|
||||
import type { IntentId } from '@/lib/interactive-demo/types'
|
||||
import { useDarkMode } from '@/components/interactive-demo/demo-speak'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
function collectIntents(page: PageSpecV1): IntentId[] {
|
||||
const set = new Set<IntentId>()
|
||||
for (const card of page.overview?.cards ?? []) {
|
||||
if (card.intent) set.add(card.intent)
|
||||
}
|
||||
for (const section of page.sections) {
|
||||
for (const block of section.blocks) {
|
||||
if (block.type === 'stats') {
|
||||
for (const item of block.items) {
|
||||
if (item.intent) set.add(item.intent)
|
||||
}
|
||||
}
|
||||
if (block.type === 'chart') {
|
||||
for (const s of block.payload.series) {
|
||||
if (s.intent) set.add(s.intent)
|
||||
}
|
||||
}
|
||||
if (block.type === 'demo') {
|
||||
for (const panel of block.demo.scene.panels) {
|
||||
if (panel.type === 'svg-scene') {
|
||||
for (const n of panel.payload.nodes) if (n.intent) set.add(n.intent)
|
||||
for (const e of panel.payload.edges ?? []) if (e.intent) set.add(e.intent)
|
||||
}
|
||||
if (panel.type === 'chart') {
|
||||
for (const s of panel.payload.series) if (s.intent) set.add(s.intent)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return [...set]
|
||||
}
|
||||
|
||||
const INTENT_LABELS_FR: Record<IntentId, string> = {
|
||||
highlight: 'Focus',
|
||||
flow: 'Flux',
|
||||
cache: 'Mémoire',
|
||||
compute: 'Calcul',
|
||||
output: 'Résultat',
|
||||
warning: 'Attention',
|
||||
}
|
||||
|
||||
const INTENT_LABELS_EN: Record<IntentId, string> = {
|
||||
highlight: 'Focus',
|
||||
flow: 'Flow',
|
||||
cache: 'Memory',
|
||||
compute: 'Compute',
|
||||
output: 'Result',
|
||||
warning: 'Warning',
|
||||
}
|
||||
|
||||
export function PageStickyNav({ page }: { page: PageSpecV1 }) {
|
||||
const [active, setActive] = useState(page.sections[0]?.id ?? '')
|
||||
const dark = useDarkMode()
|
||||
const intents = useMemo(() => collectIntents(page), [page])
|
||||
|
||||
useEffect(() => {
|
||||
const nodes = page.sections
|
||||
.map((s) => document.getElementById(s.id))
|
||||
.filter(Boolean) as HTMLElement[]
|
||||
if (!nodes.length) return
|
||||
|
||||
const obs = new IntersectionObserver(
|
||||
(entries) => {
|
||||
const visible = entries
|
||||
.filter((e) => e.isIntersecting)
|
||||
.sort((a, b) => b.intersectionRatio - a.intersectionRatio)
|
||||
const top = visible[0]?.target?.id
|
||||
if (top) setActive(top)
|
||||
},
|
||||
{ rootMargin: '-20% 0px -55% 0px', threshold: [0.1, 0.25, 0.5] }
|
||||
)
|
||||
nodes.forEach((n) => obs.observe(n))
|
||||
return () => obs.disconnect()
|
||||
}, [page.sections])
|
||||
|
||||
return (
|
||||
<div
|
||||
className="sticky top-0 z-30 backdrop-blur-sm"
|
||||
style={{
|
||||
background: 'color-mix(in oklab, var(--pp-paper) 96%, transparent)',
|
||||
borderTop: '1px solid var(--pp-line)',
|
||||
borderBottom: '1px solid var(--pp-line)',
|
||||
}}
|
||||
>
|
||||
<nav
|
||||
className="mx-auto flex max-w-[1100px] gap-1 overflow-x-auto px-5 py-2.5 scrollbar-thin"
|
||||
aria-label="Sections"
|
||||
style={{ fontFamily: 'var(--pp-mono)' }}
|
||||
>
|
||||
{page.sections.map((s, i) => (
|
||||
<a
|
||||
key={s.id}
|
||||
href={`#${s.id}`}
|
||||
className={cn(
|
||||
'shrink-0 rounded-lg px-2.5 py-1.5 text-xs transition-colors',
|
||||
active === s.id ? 'font-semibold' : 'hover:opacity-80'
|
||||
)}
|
||||
style={
|
||||
active === s.id
|
||||
? { background: 'var(--pp-ink)', color: 'var(--pp-paper)' }
|
||||
: { color: 'var(--pp-muted)' }
|
||||
}
|
||||
>
|
||||
{i + 1} · {s.title}
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
{intents.length > 0 ? (
|
||||
<div
|
||||
className="mx-auto flex max-w-[1100px] flex-wrap gap-3 px-5 py-2 text-[11px]"
|
||||
style={{
|
||||
borderTop: '1px solid var(--pp-line)',
|
||||
fontFamily: 'var(--pp-mono)',
|
||||
color: 'var(--pp-muted)',
|
||||
}}
|
||||
>
|
||||
<span className="font-semibold uppercase tracking-[0.14em] text-[10px]">
|
||||
{page.lang.startsWith('fr') ? 'Légende' : 'Legend'}
|
||||
</span>
|
||||
{intents.map((id) => {
|
||||
const color = intentColor(id, dark)
|
||||
return (
|
||||
<span
|
||||
key={id}
|
||||
className="inline-flex items-center gap-1.5"
|
||||
>
|
||||
<span
|
||||
className="inline-block h-2.5 w-2.5 rounded-full"
|
||||
style={{ backgroundColor: color }}
|
||||
/>
|
||||
{page.lang.startsWith('fr')
|
||||
? INTENT_LABELS_FR[id]
|
||||
: INTENT_LABELS_EN[id]}
|
||||
</span>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user