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>
232 lines
7.2 KiB
TypeScript
232 lines
7.2 KiB
TypeScript
'use client'
|
||
|
||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||
import { Pause, Play, RotateCcw, StepForward } from 'lucide-react'
|
||
import { PageMd } from '@/components/interactive-page/page-md'
|
||
import type { AnimBeat, SimI18n } from '@/lib/simulators'
|
||
import { cn } from '@/lib/utils'
|
||
|
||
const WPM = 220
|
||
const STEP_FLOOR_MS = 1500
|
||
const STEP_CEIL_MS = 6000
|
||
const SPEEDS = [0.5, 1, 2, 4]
|
||
|
||
function stepDurationMs(speak: string, speed: number): number {
|
||
const words = speak.trim().split(/\s+/).filter(Boolean).length
|
||
const raw = words * (60_000 / WPM)
|
||
return Math.min(STEP_CEIL_MS, Math.max(STEP_FLOOR_MS, raw)) / speed
|
||
}
|
||
|
||
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
|
||
}
|
||
|
||
export type AnimPlayerShellProps = {
|
||
beats: AnimBeat[]
|
||
lang: string
|
||
disclaimer?: SimI18n
|
||
/** Scene renderer — receives the current beat index (0-based). */
|
||
children: (stepIndex: number) => React.ReactNode
|
||
}
|
||
|
||
/**
|
||
* Shared chrome for curated animation plugins: Play/Pause/Step/Reset/Speed
|
||
* + narration panel, same pacing rules as InteractiveDemoPlayer.
|
||
*/
|
||
export function AnimPlayerShell({
|
||
beats,
|
||
lang,
|
||
disclaimer,
|
||
children,
|
||
}: AnimPlayerShellProps) {
|
||
const fr = lang.startsWith('fr')
|
||
const reducedMotion = usePrefersReducedMotion()
|
||
const [stepIndex, setStepIndex] = useState(0)
|
||
const [playing, setPlaying] = useState(false)
|
||
const [speed, setSpeed] = useState(1)
|
||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||
/** Keyboard shortcuts only fire for the hovered/focused player. */
|
||
const activeRef = useRef(false)
|
||
|
||
const clearTimer = useCallback(() => {
|
||
if (timerRef.current) {
|
||
clearTimeout(timerRef.current)
|
||
timerRef.current = null
|
||
}
|
||
}, [])
|
||
|
||
const atLast = stepIndex >= beats.length - 1
|
||
|
||
useEffect(() => {
|
||
clearTimer()
|
||
if (!playing) return
|
||
const beat = beats[stepIndex]
|
||
if (!beat) return
|
||
const speak = fr ? beat.speak.fr : beat.speak.en
|
||
timerRef.current = setTimeout(
|
||
() => {
|
||
if (stepIndex >= beats.length - 1) {
|
||
setPlaying(false)
|
||
return
|
||
}
|
||
setStepIndex((s) => s + 1)
|
||
},
|
||
reducedMotion ? 250 : stepDurationMs(speak, speed)
|
||
)
|
||
return clearTimer
|
||
}, [playing, stepIndex, beats, speed, fr, reducedMotion, clearTimer])
|
||
|
||
useEffect(() => {
|
||
const onKey = (e: KeyboardEvent) => {
|
||
const tag = (e.target as HTMLElement)?.tagName
|
||
if (tag === 'INPUT' || tag === 'TEXTAREA') 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.preventDefault()
|
||
setPlaying(false)
|
||
setStepIndex((s) => Math.min(beats.length - 1, s + 1))
|
||
} else if (e.code === 'ArrowLeft') {
|
||
e.preventDefault()
|
||
setPlaying(false)
|
||
setStepIndex((s) => Math.max(0, s - 1))
|
||
} else if (e.code === 'KeyR') {
|
||
e.preventDefault()
|
||
setPlaying(false)
|
||
setStepIndex(0)
|
||
}
|
||
}
|
||
window.addEventListener('keydown', onKey)
|
||
return () => window.removeEventListener('keydown', onKey)
|
||
}, [beats.length])
|
||
|
||
const beat = beats[stepIndex]
|
||
const speak = beat ? (fr ? beat.speak.fr : beat.speak.en) : ''
|
||
|
||
return (
|
||
<div
|
||
onPointerEnter={() => {
|
||
activeRef.current = true
|
||
}}
|
||
onPointerLeave={() => {
|
||
activeRef.current = false
|
||
}}
|
||
onFocusCapture={() => {
|
||
activeRef.current = true
|
||
}}
|
||
onBlurCapture={() => {
|
||
activeRef.current = false
|
||
}}
|
||
>
|
||
<div className="overflow-hidden rounded-xl border" style={{ borderColor: 'var(--pp-line)' }}>
|
||
{children(stepIndex)}
|
||
</div>
|
||
|
||
<div
|
||
className="mt-2 flex flex-wrap items-center gap-1 rounded-lg border px-2 py-1.5"
|
||
style={{ borderColor: 'var(--pp-line)', background: 'var(--pp-card)' }}
|
||
>
|
||
<button
|
||
type="button"
|
||
onClick={() => setPlaying((p) => !p)}
|
||
aria-label={playing ? 'Pause' : 'Play'}
|
||
className="inline-flex h-8 w-8 items-center justify-center rounded-md hover:bg-black/5 dark:hover:bg-white/10"
|
||
style={{ color: 'var(--pp-plum)' }}
|
||
>
|
||
{playing ? <Pause className="h-4 w-4" /> : <Play className="h-4 w-4" />}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
setPlaying(false)
|
||
setStepIndex((s) => Math.min(beats.length - 1, s + 1))
|
||
}}
|
||
aria-label={fr ? 'Étape suivante' : 'Next step'}
|
||
className="inline-flex h-8 w-8 items-center justify-center rounded-md hover:bg-black/5 dark:hover:bg-white/10"
|
||
style={{ color: 'var(--pp-ink)' }}
|
||
>
|
||
<StepForward className="h-4 w-4" />
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => {
|
||
setPlaying(false)
|
||
setStepIndex(0)
|
||
}}
|
||
aria-label="Reset"
|
||
title="R"
|
||
className="inline-flex h-8 w-8 items-center justify-center rounded-md hover:bg-black/5 dark:hover:bg-white/10"
|
||
style={{ color: 'var(--pp-ink)' }}
|
||
>
|
||
<RotateCcw className="h-4 w-4" />
|
||
</button>
|
||
|
||
<span
|
||
className="ml-2 text-xs tabular-nums"
|
||
style={{ color: 'var(--pp-muted)', fontFamily: 'var(--pp-mono)' }}
|
||
>
|
||
{fr ? 'étape' : 'step'} {stepIndex + 1} / {beats.length}
|
||
</span>
|
||
|
||
<div
|
||
className="ml-auto flex items-center gap-0.5 rounded-md border p-0.5"
|
||
style={{ borderColor: 'var(--pp-line)' }}
|
||
role="group"
|
||
aria-label={fr ? 'Vitesse' : 'Speed'}
|
||
>
|
||
{SPEEDS.map((s) => (
|
||
<button
|
||
key={s}
|
||
type="button"
|
||
onClick={() => setSpeed(s)}
|
||
className={cn(
|
||
'rounded px-1.5 py-0.5 text-[11px] tabular-nums transition-colors',
|
||
speed === s ? 'font-semibold' : 'opacity-60 hover:opacity-100'
|
||
)}
|
||
style={
|
||
speed === s
|
||
? { background: 'var(--pp-ink)', color: 'var(--pp-paper)' }
|
||
: { color: 'var(--pp-muted)' }
|
||
}
|
||
>
|
||
{s}×
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
<div
|
||
className="mt-2 rounded-lg border px-4 py-3"
|
||
style={{ borderColor: 'var(--pp-line)', background: 'var(--pp-card)' }}
|
||
>
|
||
<PageMd md={speak} className="text-[15px] leading-relaxed [&_p]:my-0" />
|
||
</div>
|
||
|
||
{disclaimer ? (
|
||
<p className="mt-2 text-xs italic" style={{ color: 'var(--pp-muted)' }}>
|
||
{fr ? disclaimer.fr : disclaimer.en}
|
||
</p>
|
||
) : null}
|
||
|
||
<noscript>
|
||
<ol className="mt-3 list-inside list-decimal space-y-1 text-sm">
|
||
{beats.map((b) => (
|
||
<li key={b.id}>{fr ? b.speak.fr : b.speak.en}</li>
|
||
))}
|
||
</ol>
|
||
</noscript>
|
||
</div>
|
||
)
|
||
}
|