'use client'
import { useState, useEffect, useCallback, useRef, useMemo } from 'react'
import type { CSSProperties } from 'react'
import dynamic from 'next/dynamic'
import type { PresentationSpec, SlideSpec, Palette } from '@/lib/types/presentation'
import { resolvePalette, resolveRadius } from '@/lib/ai/tools/slides-palettes'
import {
BarChart, Bar, LineChart, Line, AreaChart, Area,
PieChart, Pie, Cell, RadarChart, Radar, PolarGrid, PolarAngleAxis,
XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer,
FunnelChart, Funnel, LabelList,
} from 'recharts'
const MermaidDiagram = dynamic(
() => import('./mermaid-diagram').then(m => ({ default: m.MermaidDiagram })),
{ ssr: false, loading: () =>
Chargement…
}
)
// ── Constants ──────────────────────────────────────────────────────────────────
const CHART_COLORS = ['#6366f1', '#22d3ee', '#f59e0b', '#ef4444', '#10b981', '#a78bfa', '#fb923c', '#14b8a6']
function chartTheme(isDark: boolean) {
return {
ttStyle: {
background: isDark ? '#1C1C1C' : '#ffffff',
border: `1px solid ${isDark ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.1)'}`,
borderRadius: 8,
fontSize: 12,
color: isDark ? '#fff' : '#111',
} as CSSProperties,
tick: { fill: isDark ? 'rgba(255,255,255,0.55)' : 'rgba(0,0,0,0.55)', fontSize: 11 },
grid: isDark ? 'rgba(255,255,255,0.07)' : 'rgba(0,0,0,0.08)',
cursor: isDark ? 'rgba(255,255,255,0.04)' : 'rgba(0,0,0,0.04)',
legColor: isDark ? 'rgba(255,255,255,0.6)' : 'rgba(0,0,0,0.6)',
}
}
// ── Chart ──────────────────────────────────────────────────────────────────────
function SlideChart({ slide, isDark }: { slide: SlideSpec; isDark: boolean }) {
const chart = slide.chart
if (!chart?.data?.length) return null
const { ttStyle, tick, grid, cursor, legColor } = chartTheme(isDark)
const colors = (chart as any).colors ?? CHART_COLORS
const xKey = chart.xKey ?? 'name'
const yKeys = chart.yKeys ?? Object.keys(chart.data[0]).filter(k => k !== xKey)
const legend = chart.showLegend !== false && yKeys.length > 1
const legSt = { fontSize: 12, color: legColor }
switch (chart.type) {
case 'bar':
return (
{chart.showGrid !== false && }
{legend && }
{yKeys.map((k, i) => )}
)
case 'line':
return (
{chart.showGrid !== false && }
{legend && }
{yKeys.map((k, i) => )}
)
case 'area':
return (
{chart.showGrid !== false && }
{legend && }
{yKeys.map((k, i) => )}
)
case 'pie':
return (
{chart.data.map((_, i) => | )}
{chart.showLegend !== false && }
)
case 'radar':
return (
{yKeys.map((k, i) => )}
{legend && }
)
case 'stacked-bar':
return (
{chart.showGrid !== false && }
{yKeys.map((k, i) => )}
)
case 'combo': {
const lineKeys = chart.lineKeys ?? (yKeys.length > 1 ? [yKeys[yKeys.length - 1]] : [])
const barKeys = yKeys.filter(k => !lineKeys.includes(k))
return (
{chart.showGrid !== false && }
{barKeys.map((k, i) => )}
{lineKeys.map((k, i) => )}
)
}
case 'funnel':
return (
({ ...d, fill: colors[i % colors.length] }))} isAnimationActive>
)
case 'gauge': {
const value = chart.gaugeValue ?? (chart.data[0]?.[yKeys[0]] as number) ?? 0
const label = chart.gaugeLabel ?? yKeys[0] ?? ''
const max = 100
const pct = Math.min(Math.max(value / max, 0), 1)
const color = pct > 0.7 ? '#10b981' : pct > 0.4 ? '#f59e0b' : '#ef4444'
return (
)
}
case 'waterfall': {
// Render waterfall as bar chart with cumulative values + special coloring
let cumulative = 0
const waterfallData = chart.data.map((d, i) => {
const val = (d[yKeys[0]] as number) ?? 0
const start = cumulative
cumulative += val
return { ...d, __start: start, __end: cumulative, __delta: val, __isPositive: val >= 0, __isTotal: i === chart.data.length - 1 }
})
return (
{chart.showGrid !== false && }
{waterfallData.map((d, i) => | )}
)
}
case 'treemap': {
// Render treemap as proportional boxes
const total = chart.data.reduce((s, d) => s + ((d[yKeys[0]] as number) ?? 0), 0)
return (
{chart.data.map((d, i) => {
const val = (d[yKeys[0]] as number) ?? 0
const pct = total > 0 ? (val / total) * 100 : 10
return (
{d[xKey] as string}
{val}
)
})}
)
}
default: return null
}
}
// ── Slide content renderer (per layout) ───────────────────────────────────────
function SlideContent({ slide, index, palette, radius }: { slide: SlideSpec; index: number; palette: Palette; radius: string }) {
const layout = slide.layout ?? (index === 0 ? 'title' : 'content')
const isDark = palette.isDark
const text = isDark ? '#f1f5f9' : '#1a1a1a'
const muted = isDark ? 'rgba(241,245,249,0.55)' : 'rgba(0,0,0,0.55)'
const cardBg = isDark ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.04)'
const cardBorder = isDark ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.08)'
const accentBar: CSSProperties = { width: 48, height: 4, background: palette.accent, borderRadius: 2, marginBottom: 24, flexShrink: 0 }
const content = slide.content ?? []
switch (layout) {
// ── TITLE ────────────────────────────────────────────────────────────
case 'title':
return (
Présentation
{slide.title}
{slide.subtitle &&
{slide.subtitle}
}
)
// ── SECTION DIVIDER ─────────────────────────────────────────────────
case 'section':
return (
{content[0] ?? String(index).padStart(2, '0')}
Section {content[0] ?? String(index).padStart(2, '0')}
{slide.title}
{slide.subtitle &&
{slide.subtitle}
}
)
// ── QUOTE ─────────────────────────────────────────────────────────────
case 'quote':
return (
"
"
{slide.title}
{slide.subtitle &&
— {slide.subtitle}}
)
// ── TOC ──────────────────────────────────────────────────────────────
case 'toc':
return (
{slide.title || 'Sommaire'}
{content.map((item, i) => (
{String(i + 1).padStart(2, '0')}
{item}
))}
)
// ── CONTENT (bullets) ────────────────────────────────────────────────
case 'content':
return (
{slide.title}
{content.map((item, i) => (
{item}
))}
)
// ── TWO COLUMN ──────────────────────────────────────────────────────
case 'two-column': {
const mid = Math.ceil(content.length / 2)
const left = content.slice(0, mid)
const right = content.slice(mid)
const heads = (slide.subtitle ?? '/').split('/')
return (
{slide.title}
{[{ items: left, head: heads[0]?.trim() || '' }, { items: right, head: heads[1]?.trim() || '' }].map(({ items, head }, col) => (
{head &&
{head}}
{items.map((item, i) => (
{item}
))}
))}
)
}
// ── CARDS ────────────────────────────────────────────────────────────
case 'cards': {
const items = content.slice(0, 6)
const cols = items.length <= 2 ? 2 : items.length <= 3 ? 3 : items.length === 4 ? 2 : 3
return (
{slide.title}
{items.map((item, i) => {
const sep = item.search(/[:\u2014\u2013\-]/)
const head = sep > 0 ? item.slice(0, sep).trim() : ''
const body = sep > 0 ? item.slice(sep + 1).trim() : item
return (
{String(i + 1).padStart(2, '0')}
{head &&
{head}
}
{body}
)
})}
)
}
// ── STATS ────────────────────────────────────────────────────────────
case 'stats': {
const items = content.slice(0, 4)
return (
{slide.title}
{items.map((item, i) => {
const parts = item.split(/[-–—:]/)
const val = parts[0]?.trim() ?? item
const lbl = parts.slice(1).join(' ').trim()
return (
)
})}
)
}
// ── SUMMARY ──────────────────────────────────────────────────────────
case 'summary':
return (
{slide.title || 'En résumé'}
{content.slice(0, 6).map((item, i) => (
))}
)
// ── CHART ────────────────────────────────────────────────────────────
case 'chart':
return (
{slide.title}
{slide.subtitle &&
{slide.subtitle}
}
)
// ── DIAGRAM ──────────────────────────────────────────────────────────
case 'diagram':
return (
{slide.title}
{slide.subtitle &&
{slide.subtitle}
}
{slide.mermaid ?
:
No diagram
}
)
// ── IMAGE ────────────────────────────────────────────────────────────
case 'image':
return (
{slide.title}
{slide.imageUrl
?

:
No image
}
{content[0] &&
{content[0]}
}
)
// ── TIMELINE ────────────────────────────────────────────────────────
case 'timeline': {
const items = content.slice(0, 8)
return (
{slide.title}
{items.map((item, i) => {
const sep = item.search(/[:\u2014\u2013]/)
const date = sep > 0 ? item.slice(0, sep).trim() : ''
const desc = sep > 0 ? item.slice(sep + 1).trim() : item
return (
)
})}
)
}
// ── KPI DASHBOARD ───────────────────────────────────────────────────
case 'kpi-dashboard': {
const items = content.slice(0, 6)
return (
{slide.title}
{items.map((item, i) => {
// Format: "value | label | trend" or "value — label (trend)"
const parts = item.split(/[|]/).map(s => s.trim())
const val = parts[0] ?? item
const lbl = parts[1] ?? ''
const trend = parts[2] ?? ''
const isUp = trend.includes('↑') || trend.includes('+')
const isDown = trend.includes('↓') || trend.includes('-')
return (
{val}
{lbl && {lbl}}
{trend && {trend}}
)
})}
)
}
// ── DATA TABLE ──────────────────────────────────────────────────────
case 'data-table': {
const headers = slide.tableHeaders ?? (content[0]?.split('|').map(s => s.trim()) ?? [])
const rows = slide.tableRows ?? content.slice(headers === slide.tableHeaders ? 0 : 1).map(row => row.split('|').map(s => s.trim()))
return (
{slide.title}
{headers.length > 0 && (
{headers.map((h, i) => (
| {h} |
))}
)}
{rows.map((row, ri) => (
{row.map((cell, ci) => (
| {cell} |
))}
))}
)
}
// ── DEFAULT ──────────────────────────────────────────────────────────
default:
return (
{slide.title}
{content.map((item, i) => (
{item}
))}
)
}
}
// ── Normalize slide formats (adapter) ──────────────────────────────────────────
function normalizeSlide(slide: any, index: number): SlideSpec {
if (!slide) return { title: '', content: [], layout: 'content' }
if (Array.isArray(slide.content) && slide.layout !== undefined) {
return slide as SlideSpec
}
const type = slide.type ?? (index === 0 ? 'title' : 'content')
switch (type) {
case 'title':
return {
title: slide.title ?? '',
subtitle: slide.subtitle,
content: [],
layout: 'title',
notes: slide.notes
}
case 'bullets':
return {
title: slide.title ?? '',
content: slide.items ?? [],
layout: 'content',
notes: slide.notes
}
case 'chart':
return {
title: slide.title ?? '',
subtitle: slide.subtitle,
content: [],
layout: 'chart',
chart: {
type: slide.chartType ?? 'bar',
// Normalize {label,value} → {name,value} so recharts xKey='name' works
data: (slide.data ?? []).map((d: any) => ({
name: d.label ?? d.name ?? '',
value: typeof d.value === 'number' ? d.value : Number(d.value) || 0,
})),
xKey: 'name',
yKeys: ['value'],
},
notes: slide.notes
}
case 'stats':
return {
title: slide.title ?? '',
content: (slide.stats ?? []).map((s: any) => `${s.value} - ${s.label}`),
layout: 'stats',
notes: slide.notes
}
case 'table':
return {
title: slide.title ?? '',
content: [],
tableHeaders: slide.headers ?? [],
tableRows: slide.rows ?? [],
layout: 'data-table',
notes: slide.notes
}
case 'cards':
return {
title: slide.title ?? '',
content: (slide.cards ?? []).map((c: any) => `${c.title} : ${c.description}`),
layout: 'cards',
notes: slide.notes
}
case 'timeline':
return {
title: slide.title ?? '',
content: (slide.events ?? []).map((e: any) => `${e.date} : ${e.title}${e.description ? ' — ' + e.description : ''}`),
layout: 'timeline',
notes: slide.notes
}
case 'quote':
return {
title: slide.quote ?? '',
subtitle: slide.author ? `— ${slide.author}${slide.context ? ' (' + slide.context + ')' : ''}` : '',
content: [],
layout: 'quote',
notes: slide.notes
}
case 'comparison':
return {
title: slide.title ?? '',
subtitle: `${slide.left?.title ?? ''} / ${slide.right?.title ?? ''}`,
content: [...(slide.left?.points ?? []), ...(slide.right?.points ?? [])],
layout: 'two-column',
notes: slide.notes
}
case 'equation':
return {
title: slide.title ?? '',
subtitle: slide.explanation,
content: (slide.equations ?? []).map((eq: any) => `${eq.latex}${eq.label ? ' — ' + eq.label : ''}`),
layout: 'content',
notes: slide.notes
}
case 'image':
return {
title: slide.title ?? '',
imageUrl: slide.url,
content: slide.caption ? [slide.caption] : [],
layout: 'image',
notes: slide.notes
}
case 'summary':
return {
title: slide.title ?? '',
content: slide.items ?? [],
layout: 'summary',
notes: slide.notes
}
default:
return {
title: slide.title ?? '',
content: slide.content ?? [],
layout: 'content',
subtitle: slide.subtitle,
imageUrl: slide.imageUrl,
notes: slide.notes,
chart: slide.chart,
mermaid: slide.mermaid,
tableHeaders: slide.tableHeaders,
tableRows: slide.tableRows
}
}
}
// ══════════════════════════════════════════════════════════════════════════════
// MAIN RENDERER — Pure React + CSS transitions (no Reveal.js dependency)
// ══════════════════════════════════════════════════════════════════════════════
export interface SlidesRendererProps {
spec: PresentationSpec
}
export function SlidesRenderer({ spec }: SlidesRendererProps) {
const [current, setCurrent] = useState(0)
const [showNotes, setShowNotes] = useState(false)
const containerRef = useRef(null)
const normalizedSlides = useMemo(() => {
return (spec.slides ?? []).map((slide, i) => normalizeSlide(slide, i))
}, [spec.slides])
const total = normalizedSlides.length
const { palette } = resolvePalette(spec)
const radius = resolveRadius(spec.style)
const isDark = palette.isDark
const next = useCallback(() => setCurrent(c => Math.min(c + 1, total - 1)), [total])
const prev = useCallback(() => setCurrent(c => Math.max(c - 1, 0)), [])
// Keyboard + touch navigation
useEffect(() => {
const el = containerRef.current
if (!el) return
const onKey = (e: KeyboardEvent) => {
if (e.key === 'ArrowRight' || e.key === ' ' || e.key === 'Enter') { e.preventDefault(); next() }
if (e.key === 'ArrowLeft' || e.key === 'Backspace') { e.preventDefault(); prev() }
if (e.key === 'n' || e.key === 'N') { e.preventDefault(); setShowNotes(n => !n) }
}
let startX = 0
const onTouchStart = (e: TouchEvent) => { startX = e.touches[0].clientX }
const onTouchEnd = (e: TouchEvent) => {
const diff = e.changedTouches[0].clientX - startX
if (Math.abs(diff) > 50) { diff < 0 ? next() : prev() }
}
el.addEventListener('keydown', onKey)
el.addEventListener('touchstart', onTouchStart, { passive: true })
el.addEventListener('touchend', onTouchEnd, { passive: true })
el.focus()
return () => {
el.removeEventListener('keydown', onKey)
el.removeEventListener('touchstart', onTouchStart)
el.removeEventListener('touchend', onTouchEnd)
}
}, [next, prev])
// Nav button style
const navBtn = (isLeft: boolean): CSSProperties => ({
position: 'absolute',
top: '50%',
transform: 'translateY(-50%)',
...(isLeft ? { left: 16 } : { right: 16 }),
zIndex: 50,
width: 44,
height: 44,
borderRadius: '50%',
border: `1px solid ${isDark ? 'rgba(255,255,255,0.15)' : 'rgba(0,0,0,0.12)'}`,
background: isDark ? 'rgba(255,255,255,0.08)' : 'rgba(255,255,255,0.85)',
color: isDark ? 'rgba(255,255,255,0.85)' : 'rgba(0,0,0,0.6)',
fontSize: 20,
cursor: 'pointer',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
backdropFilter: 'blur(8px)',
transition: 'all 0.15s',
userSelect: 'none' as const,
padding: 0,
boxShadow: isDark ? 'none' : '0 2px 8px rgba(0,0,0,0.1)',
})
const currentSlideNotes = normalizedSlides[current]?.notes
return (
{/* Slides */}
{normalizedSlides.map((slide, i) => {
const offset = i - current
return (
)
})}
{/* Navigation buttons */}
{current > 0 && (
)}
{current < total - 1 && (
)}
{/* Progress bar */}
{/* Presenter Speaker Notes Overlay */}
{showNotes && currentSlideNotes && (
Notes de présentation
{currentSlideNotes}
)}
{/* Controls: Slide counter & Speaker notes toggle */}
{currentSlideNotes && (
)}
{current + 1} / {total}
)
}