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:
Antigravity
2026-07-24 17:51:43 +00:00
parent f385d43d5d
commit 69c99e4f4f
67 changed files with 12005 additions and 33 deletions

View File

@@ -0,0 +1,131 @@
'use client'
import { Node, mergeAttributes } from '@tiptap/core'
import {
ReactNodeViewRenderer,
NodeViewWrapper,
type NodeViewProps,
} from '@tiptap/react'
import type { Editor } from '@tiptap/core'
import { InteractiveDemoPlayer } from '@/components/interactive-demo/interactive-demo-player'
import {
validateInteractiveDemo,
type InteractiveDemoV1,
} from '@/lib/interactive-demo'
import attnresFixture from '@/lib/interactive-demo/fixtures/attnres.demo.json'
import { AlertCircle } from 'lucide-react'
import { useLanguage } from '@/lib/i18n'
import { useMemo } from 'react'
function InteractiveDemoView(props: NodeViewProps) {
const { t } = useLanguage()
const raw = props.node.attrs.demoJson as string
const parsed = useMemo(() => {
try {
const data = JSON.parse(raw || '{}')
return validateInteractiveDemo(data)
} catch {
return {
ok: false as const,
issues: [{ code: 'invalid_json', path: '', message: 'Invalid JSON' }],
}
}
}, [raw])
if (!parsed.ok) {
return (
<NodeViewWrapper
className="interactive-demo-block my-4"
data-drag-handle
contentEditable={false}
>
<div className="rounded-xl border border-dashed border-destructive/40 bg-destructive/5 p-4 flex gap-3 text-sm">
<AlertCircle className="h-5 w-5 text-destructive shrink-0" />
<div>
<p className="font-medium">
{t('interactiveDemo.invalid') || 'Interactive demo invalide'}
</p>
<ul className="mt-1 text-muted-foreground list-disc list-inside">
{parsed.issues.slice(0, 5).map((iss, i) => (
<li key={i}>
{iss.path ? `${iss.path}: ` : ''}
{iss.message}
</li>
))}
</ul>
</div>
</div>
</NodeViewWrapper>
)
}
return (
<NodeViewWrapper
className="interactive-demo-block my-4"
data-drag-handle
contentEditable={false}
>
<InteractiveDemoPlayer demo={parsed.demo} mode="interactive" />
</NodeViewWrapper>
)
}
export const InteractiveDemoExtension = Node.create({
name: 'interactiveDemo',
group: 'block',
atom: true,
draggable: true,
selectable: true,
addAttributes() {
return {
demoJson: {
default: '{}',
parseHTML: (el) => el.getAttribute('data-demo-json') || '{}',
renderHTML: (attrs) => ({
'data-demo-json': attrs.demoJson || '{}',
}),
},
}
},
parseHTML() {
return [{ tag: 'div[data-interactive-demo]' }]
},
renderHTML({ HTMLAttributes }) {
return [
'div',
mergeAttributes(HTMLAttributes, { 'data-interactive-demo': 'true' }),
]
},
addNodeView() {
return ReactNodeViewRenderer(InteractiveDemoView)
},
})
export function insertInteractiveDemoAtSelection(
editor: Editor,
demo?: InteractiveDemoV1
): boolean {
const type = editor.schema.nodes.interactiveDemo
if (!type) return false
const payload = demo ?? (attnresFixture as InteractiveDemoV1)
const check = validateInteractiveDemo(payload)
// Prefer validated shape; if already-server-validated payload fails client Zod
// (HMR drift), still insert so the block appears — NodeView shows issues if needed.
const attrs = {
demoJson: JSON.stringify(check.ok ? check.demo : payload),
}
const { empty, $from } = editor.state.selection
const pos = empty ? $from.pos : editor.state.selection.from
return editor
.chain()
.focus()
.insertContentAt(pos, { type: 'interactiveDemo', attrs })
.run()
}