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:
@@ -0,0 +1,387 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import {
|
||||
Pause,
|
||||
Play,
|
||||
RotateCcw,
|
||||
SkipBack,
|
||||
SkipForward,
|
||||
StepBack,
|
||||
StepForward,
|
||||
} from 'lucide-react'
|
||||
import { DemoSceneView } from '@/components/interactive-demo/demo-scene-view'
|
||||
import { DemoSpeak } from '@/components/interactive-demo/demo-speak'
|
||||
import {
|
||||
resolveInteractiveDemo,
|
||||
type InteractiveDemoV1,
|
||||
} from '@/lib/interactive-demo'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useLanguage } from '@/lib/i18n'
|
||||
|
||||
const SPEEDS = [0.5, 1, 2, 4] as const
|
||||
const WPM = 220
|
||||
const STEP_FLOOR_MS = 1500
|
||||
const STEP_CEIL_MS = 6000
|
||||
const ACT_CHANGE_PAUSE_MS = 800
|
||||
|
||||
function usePrefersReducedMotion(): boolean {
|
||||
const [reduced, setReduced] = useState(false)
|
||||
useEffect(() => {
|
||||
const mq = window.matchMedia('(prefers-reduced-motion: reduce)')
|
||||
setReduced(mq.matches)
|
||||
const onChange = () => setReduced(mq.matches)
|
||||
mq.addEventListener('change', onChange)
|
||||
return () => mq.removeEventListener('change', onChange)
|
||||
}, [])
|
||||
return reduced
|
||||
}
|
||||
|
||||
function stepDurationMs(speak: string, speed: number): number {
|
||||
const words = speak.trim().split(/\s+/).filter(Boolean).length
|
||||
const raw = words * (60_000 / WPM)
|
||||
const clamped = Math.min(STEP_CEIL_MS, Math.max(STEP_FLOOR_MS, raw))
|
||||
return clamped / speed
|
||||
}
|
||||
|
||||
export type InteractiveDemoPlayerProps = {
|
||||
demo: InteractiveDemoV1
|
||||
mode?: 'interactive' | 'static'
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function InteractiveDemoPlayer({
|
||||
demo,
|
||||
mode = 'interactive',
|
||||
className,
|
||||
}: InteractiveDemoPlayerProps) {
|
||||
const { t } = useLanguage()
|
||||
const reducedMotion = usePrefersReducedMotion()
|
||||
const resolved = useMemo(() => resolveInteractiveDemo(demo), [demo])
|
||||
|
||||
const [actIndex, setActIndex] = useState(0)
|
||||
const [stepIndex, setStepIndex] = useState(0)
|
||||
const [playing, setPlaying] = useState(false)
|
||||
const [speedIdx, setSpeedIdx] = useState(1)
|
||||
const speed = SPEEDS[speedIdx] ?? 1
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||
const actPauseRef = useRef(false)
|
||||
/** Keyboard shortcuts only fire for the hovered/focused player. */
|
||||
const activeRef = useRef(false)
|
||||
|
||||
const act = resolved.acts[actIndex] ?? resolved.acts[0]
|
||||
const scenes = useMemo(() => {
|
||||
let scene = demo.scene
|
||||
const map: (typeof demo.scene)[] = []
|
||||
for (const a of demo.acts) {
|
||||
if (a.scene) scene = a.scene
|
||||
map.push(scene)
|
||||
}
|
||||
return map
|
||||
}, [demo])
|
||||
|
||||
const scene = scenes[actIndex] ?? demo.scene
|
||||
|
||||
const state =
|
||||
mode === 'static'
|
||||
? act?.final
|
||||
: act?.steps[stepIndex] ?? act?.final
|
||||
|
||||
const clearTimer = useCallback(() => {
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current)
|
||||
timerRef.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
const goStep = useCallback(
|
||||
(next: number) => {
|
||||
if (!act) return
|
||||
if (next < 0) {
|
||||
setStepIndex(0)
|
||||
return
|
||||
}
|
||||
if (next >= act.steps.length) {
|
||||
setPlaying(false)
|
||||
setStepIndex(act.steps.length - 1)
|
||||
return
|
||||
}
|
||||
setStepIndex(next)
|
||||
},
|
||||
[act]
|
||||
)
|
||||
|
||||
const resetAct = useCallback(() => {
|
||||
clearTimer()
|
||||
actPauseRef.current = false
|
||||
setPlaying(false)
|
||||
setStepIndex(0)
|
||||
}, [clearTimer])
|
||||
|
||||
const resetDemo = useCallback(() => {
|
||||
clearTimer()
|
||||
actPauseRef.current = false
|
||||
setPlaying(false)
|
||||
setActIndex(0)
|
||||
setStepIndex(0)
|
||||
}, [clearTimer])
|
||||
|
||||
const skipAct = useCallback(() => {
|
||||
clearTimer()
|
||||
actPauseRef.current = false
|
||||
setPlaying(false)
|
||||
if (actIndex >= resolved.acts.length - 1) {
|
||||
setStepIndex((act?.steps.length ?? 1) - 1)
|
||||
return
|
||||
}
|
||||
setActIndex((i) => i + 1)
|
||||
setStepIndex(0)
|
||||
}, [actIndex, act, resolved.acts.length, clearTimer])
|
||||
|
||||
const prevAct = useCallback(() => {
|
||||
clearTimer()
|
||||
actPauseRef.current = false
|
||||
setPlaying(false)
|
||||
if (actIndex <= 0) {
|
||||
setStepIndex(0)
|
||||
return
|
||||
}
|
||||
setActIndex((i) => i - 1)
|
||||
setStepIndex(0)
|
||||
}, [actIndex, clearTimer])
|
||||
|
||||
// Auto-play: duration recalculates immediately when speed/step changes
|
||||
useEffect(() => {
|
||||
clearTimer()
|
||||
if (!playing || mode !== 'interactive' || !act || !state) return
|
||||
|
||||
const atLastStep = stepIndex >= act.steps.length - 1
|
||||
const ms = stepDurationMs(state.speak, speed)
|
||||
|
||||
timerRef.current = setTimeout(() => {
|
||||
if (atLastStep) {
|
||||
// End of act while playing
|
||||
if (actIndex < resolved.acts.length - 1) {
|
||||
actPauseRef.current = true
|
||||
timerRef.current = setTimeout(() => {
|
||||
actPauseRef.current = false
|
||||
setActIndex((i) => i + 1)
|
||||
setStepIndex(0)
|
||||
}, ACT_CHANGE_PAUSE_MS)
|
||||
} else {
|
||||
// End of demo — pause on final state
|
||||
setPlaying(false)
|
||||
}
|
||||
return
|
||||
}
|
||||
setStepIndex((s) => s + 1)
|
||||
}, ms)
|
||||
|
||||
return clearTimer
|
||||
}, [
|
||||
playing,
|
||||
stepIndex,
|
||||
speed,
|
||||
act,
|
||||
state,
|
||||
mode,
|
||||
actIndex,
|
||||
resolved.acts.length,
|
||||
clearTimer,
|
||||
])
|
||||
|
||||
useEffect(() => {
|
||||
if (mode !== 'interactive') return
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
const tag = (e.target as HTMLElement)?.tagName
|
||||
if (
|
||||
tag === 'INPUT' ||
|
||||
tag === 'TEXTAREA' ||
|
||||
(e.target as HTMLElement)?.isContentEditable
|
||||
) {
|
||||
return
|
||||
}
|
||||
// Multi-player pages: only the hovered/focused player answers the keyboard
|
||||
if (!activeRef.current) return
|
||||
if (e.code === 'Space') {
|
||||
e.preventDefault()
|
||||
setPlaying((p) => !p)
|
||||
} else if (e.code === 'ArrowRight' && e.shiftKey) {
|
||||
e.preventDefault()
|
||||
skipAct()
|
||||
} else if (e.code === 'ArrowLeft' && e.shiftKey) {
|
||||
e.preventDefault()
|
||||
prevAct()
|
||||
} else if (e.code === 'ArrowRight') {
|
||||
e.preventDefault()
|
||||
setPlaying(false)
|
||||
goStep(stepIndex + 1)
|
||||
} else if (e.code === 'ArrowLeft') {
|
||||
e.preventDefault()
|
||||
setPlaying(false)
|
||||
goStep(stepIndex - 1)
|
||||
} else if (e.code === 'KeyR' && e.shiftKey) {
|
||||
e.preventDefault()
|
||||
resetDemo()
|
||||
} else if (e.code === 'KeyR') {
|
||||
e.preventDefault()
|
||||
resetAct()
|
||||
}
|
||||
}
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
}, [mode, stepIndex, goStep, resetAct, resetDemo, skipAct, prevAct])
|
||||
|
||||
if (!act || !state || !scene) {
|
||||
return (
|
||||
<div className="rounded-lg border border-dashed border-border p-4 text-sm text-muted-foreground">
|
||||
{t('interactiveDemo.empty') || 'Interactive demo unavailable'}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const progressLabel = `${act.title} · ${t('interactiveDemo.step') || 'étape'} ${stepIndex + 1} / ${act.steps.length}`
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'interactive-demo-player my-4 overflow-hidden rounded-2xl border border-border/80 bg-card text-card-foreground shadow-sm',
|
||||
className
|
||||
)}
|
||||
data-demo-id={demo.id}
|
||||
data-mode={mode}
|
||||
onPointerEnter={() => {
|
||||
activeRef.current = true
|
||||
}}
|
||||
onPointerLeave={() => {
|
||||
activeRef.current = false
|
||||
}}
|
||||
onFocusCapture={() => {
|
||||
activeRef.current = true
|
||||
}}
|
||||
onBlurCapture={() => {
|
||||
activeRef.current = false
|
||||
}}
|
||||
>
|
||||
{demo.disclaimer && (
|
||||
<p className="border-b border-border/40 bg-muted/30 px-4 py-2.5 text-[11px] leading-relaxed text-muted-foreground italic">
|
||||
{demo.disclaimer}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<div className="p-3 sm:p-4">
|
||||
<DemoSceneView
|
||||
scene={scene}
|
||||
state={state}
|
||||
reducedMotion={reducedMotion || mode === 'static'}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-border/50 bg-gradient-to-b from-muted/40 to-muted/10 px-4 pb-3 pt-1">
|
||||
<DemoSpeak
|
||||
speak={state.speak}
|
||||
className="py-3 text-[15px] leading-relaxed text-foreground/90 prose prose-sm dark:prose-invert max-w-none [&>p]:my-0 [&>p]:font-medium"
|
||||
/>
|
||||
<div className="flex items-center justify-between gap-2 pb-2.5 text-[11px] uppercase tracking-wide text-muted-foreground">
|
||||
<span className="truncate font-semibold normal-case tracking-normal text-foreground/70">
|
||||
{progressLabel}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{mode === 'interactive' && (
|
||||
<div className="flex flex-wrap items-center gap-2 pb-2">
|
||||
<div className="flex items-center gap-1 rounded-lg border border-border/70 bg-background/80 p-0.5 shadow-sm">
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-8 w-8 items-center justify-center rounded-md bg-primary text-primary-foreground hover:opacity-90"
|
||||
onClick={() => setPlaying((p) => !p)}
|
||||
aria-label={playing ? 'Pause' : 'Play'}
|
||||
>
|
||||
{playing ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-8 w-8 items-center justify-center rounded-md text-foreground/80 hover:bg-muted"
|
||||
onClick={() => {
|
||||
setPlaying(false)
|
||||
goStep(stepIndex - 1)
|
||||
}}
|
||||
aria-label="Previous step"
|
||||
>
|
||||
<StepBack className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-8 w-8 items-center justify-center rounded-md text-foreground/80 hover:bg-muted"
|
||||
onClick={() => {
|
||||
setPlaying(false)
|
||||
goStep(stepIndex + 1)
|
||||
}}
|
||||
aria-label="Next step"
|
||||
>
|
||||
<StepForward className="h-4 w-4" />
|
||||
</button>
|
||||
<div className="mx-0.5 h-5 w-px bg-border" />
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-8 w-8 items-center justify-center rounded-md text-foreground/80 hover:bg-muted"
|
||||
onClick={prevAct}
|
||||
aria-label="Previous act"
|
||||
>
|
||||
<SkipBack className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-8 w-8 items-center justify-center rounded-md text-foreground/80 hover:bg-muted"
|
||||
onClick={skipAct}
|
||||
aria-label="Next act"
|
||||
>
|
||||
<SkipForward className="h-4 w-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex h-8 w-8 items-center justify-center rounded-md text-foreground/80 hover:bg-muted"
|
||||
onClick={resetAct}
|
||||
aria-label="Reset act"
|
||||
title="R"
|
||||
>
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="ml-auto flex items-center gap-0.5 rounded-lg border border-border/70 bg-background/80 p-0.5 shadow-sm"
|
||||
role="group"
|
||||
aria-label={t('interactiveDemo.speed') || 'Vitesse'}
|
||||
>
|
||||
{SPEEDS.map((s, i) => (
|
||||
<button
|
||||
key={s}
|
||||
type="button"
|
||||
onClick={() => setSpeedIdx(i)}
|
||||
aria-pressed={speedIdx === i}
|
||||
className={cn(
|
||||
'h-7 min-w-[2.35rem] rounded-md px-1.5 text-xs font-semibold tabular-nums transition-colors',
|
||||
speedIdx === i
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'text-muted-foreground hover:bg-muted hover:text-foreground'
|
||||
)}
|
||||
>
|
||||
{s}×
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<noscript>
|
||||
<ol className="space-y-1 list-inside list-decimal px-4 pb-4 text-sm">
|
||||
{act.steps.map((s) => (
|
||||
<li key={s.stepId}>{s.speak}</li>
|
||||
))}
|
||||
</ol>
|
||||
</noscript>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user