'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 (
{value}% {label}
) } 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 (
{val}
{lbl && {lbl}}
) })}
) } // ── SUMMARY ────────────────────────────────────────────────────────── case 'summary': return (

{slide.title || 'En résumé'}

{content.slice(0, 6).map((item, i) => (
{item}
))}
) // ── 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 ? {slide.title} :
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 (
{date && {date}} {desc}
) })}
) } // ── 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) => ( ))} )} {rows.map((row, ri) => ( {row.map((cell, ci) => ( ))} ))}
{h}
{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}
) }