feat: add slides generation tool with multiple slide types
- Add slides.tool.ts with support for title, bullets, chart, stats, table, cards, timeline, quote, comparison, equation, image, summary slide types - Chart types: bar, horizontal-bar, line, donut, radar - Integrate with agent executor and canvas system - Add multilingual support (en/fr) - Various UI improvements and bug fixes Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -4,17 +4,23 @@ import dynamic from 'next/dynamic'
|
||||
import { useState, useEffect, useRef, useMemo } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { toast } from 'sonner'
|
||||
import { Download, Presentation } from 'lucide-react'
|
||||
import { Download, Presentation, ExternalLink, Maximize2, ChevronLeft, ChevronRight } from 'lucide-react'
|
||||
import type { ExcalidrawElement } from '@excalidraw/excalidraw/element/types'
|
||||
import type { AppState, BinaryFiles } from '@excalidraw/excalidraw/types'
|
||||
import type { ExcalidrawImperativeAPI } from '@excalidraw/excalidraw/types'
|
||||
import '@excalidraw/excalidraw/index.css'
|
||||
import type { PresentationSpec } from '@/lib/types/presentation'
|
||||
|
||||
const Excalidraw = dynamic(
|
||||
async () => (await import('@excalidraw/excalidraw')).Excalidraw,
|
||||
{ ssr: false }
|
||||
)
|
||||
|
||||
const SlidesRenderer = dynamic(
|
||||
() => import('./slides-renderer').then(m => ({ default: m.SlidesRenderer })),
|
||||
{ ssr: false, loading: () => <div className="absolute inset-0 flex items-center justify-center bg-zinc-950 text-white/40 text-sm">Chargement des slides…</div> }
|
||||
)
|
||||
|
||||
interface CanvasBoardProps {
|
||||
initialData?: string
|
||||
canvasId?: string
|
||||
@@ -22,32 +28,37 @@ interface CanvasBoardProps {
|
||||
}
|
||||
|
||||
type PptxPayload = { type: string; filename: string; base64: string; slideCount?: number; theme?: string }
|
||||
type SlidesPayload = { type: 'slides'; html: string; title: string; theme?: string; style?: string; slideCount?: number; spec?: PresentationSpec }
|
||||
|
||||
function parseCanvasScene(initialData?: string): {
|
||||
slides: SlidesPayload | null
|
||||
pptx: PptxPayload | null
|
||||
elements: readonly ExcalidrawElement[]
|
||||
files: BinaryFiles
|
||||
} {
|
||||
if (!initialData) {
|
||||
return { pptx: null, elements: [], files: {} }
|
||||
return { slides: null, pptx: null, elements: [], files: {} }
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(initialData)
|
||||
if (parsed && parsed.type === 'slides' && parsed.html) {
|
||||
return { slides: parsed as SlidesPayload, pptx: null, elements: [], files: {} }
|
||||
}
|
||||
if (parsed && parsed.type === 'pptx' && parsed.base64) {
|
||||
return { pptx: parsed as PptxPayload, elements: [], files: {} }
|
||||
return { slides: null, pptx: parsed as PptxPayload, elements: [], files: {} }
|
||||
}
|
||||
if (parsed && Array.isArray(parsed)) {
|
||||
return { pptx: null, elements: parsed as ExcalidrawElement[], files: {} }
|
||||
return { slides: null, pptx: null, elements: parsed as ExcalidrawElement[], files: {} }
|
||||
}
|
||||
if (parsed && parsed.elements) {
|
||||
const files: BinaryFiles =
|
||||
parsed.files && typeof parsed.files === 'object' ? parsed.files : {}
|
||||
return { pptx: null, elements: parsed.elements as readonly ExcalidrawElement[], files }
|
||||
return { slides: null, pptx: null, elements: parsed.elements as readonly ExcalidrawElement[], files }
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('[CanvasBoard] Failed to parse canvas data:', e)
|
||||
}
|
||||
return { pptx: null, elements: [], files: {} }
|
||||
return { slides: null, pptx: null, elements: [], files: {} }
|
||||
}
|
||||
|
||||
function PptxViewer({ data, name }: { data: PptxPayload; name: string }) {
|
||||
@@ -105,6 +116,153 @@ function PptxViewer({ data, name }: { data: PptxPayload; name: string }) {
|
||||
)
|
||||
}
|
||||
|
||||
function SlidesViewer({ data, name, canvasId }: { data: SlidesPayload; name: string; canvasId?: string | null }) {
|
||||
const iframeRef = useRef<HTMLIFrameElement>(null)
|
||||
const [currentSlide, setCurrentSlide] = useState(1)
|
||||
const [isLoaded, setIsLoaded] = useState(!!data.spec) // spec → React renderer, no iframe loading
|
||||
const totalSlides = data.slideCount || 1
|
||||
|
||||
// Hide the global AI floating button while slides are displayed
|
||||
useEffect(() => {
|
||||
window.dispatchEvent(new CustomEvent('contextual-ai-visibility', { detail: true }))
|
||||
return () => {
|
||||
window.dispatchEvent(new CustomEvent('contextual-ai-visibility', { detail: false }))
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Listen for slide-change events from the iframe via postMessage
|
||||
useEffect(() => {
|
||||
const handler = (e: MessageEvent) => {
|
||||
if (e.data?.type === 'slideChange' && typeof e.data.current === 'number') {
|
||||
setCurrentSlide(e.data.current)
|
||||
}
|
||||
}
|
||||
window.addEventListener('message', handler)
|
||||
return () => window.removeEventListener('message', handler)
|
||||
}, [])
|
||||
|
||||
const navigate = (dir: number) => {
|
||||
const iframe = iframeRef.current
|
||||
if (!iframe) return
|
||||
// Direct contentWindow access (works with allow-same-origin)
|
||||
try {
|
||||
const win = iframe.contentWindow as any
|
||||
if (typeof win?.changeSlide === 'function') {
|
||||
win.changeSlide(dir)
|
||||
return
|
||||
}
|
||||
} catch (_) {}
|
||||
// Fallback: postMessage
|
||||
iframe.contentWindow?.postMessage({ type: 'navigate', dir }, '*')
|
||||
}
|
||||
|
||||
const openFullscreen = () => {
|
||||
const blob = new Blob([data.html], { type: 'text/html' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
window.open(url, '_blank')
|
||||
setTimeout(() => URL.revokeObjectURL(url), 10000)
|
||||
}
|
||||
|
||||
const downloadHtml = () => {
|
||||
const blob = new Blob([data.html], { type: 'text/html' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `${name.replace(/[^a-z0-9]/gi, '-').toLowerCase() || 'presentation'}.html`
|
||||
document.body.appendChild(a)
|
||||
a.click()
|
||||
document.body.removeChild(a)
|
||||
setTimeout(() => URL.revokeObjectURL(url), 5000)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="absolute inset-0 flex flex-col bg-zinc-950">
|
||||
{/* Toolbar */}
|
||||
<div className="shrink-0 h-11 flex items-center justify-between px-4 bg-zinc-900/80 border-b border-white/10 backdrop-blur-sm z-10">
|
||||
<div className="flex items-center gap-2.5 text-white/70 min-w-0">
|
||||
<Presentation className="w-4 h-4 shrink-0 text-white/50" />
|
||||
<span className="text-sm font-medium truncate">{name}</span>
|
||||
{totalSlides > 1 && (
|
||||
<span className="text-xs bg-white/10 px-2 py-0.5 rounded-full shrink-0 tabular-nums">
|
||||
{currentSlide} / {totalSlides}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{canvasId && (
|
||||
<a
|
||||
href={`/api/canvas/slides/pptx?id=${canvasId}`}
|
||||
download
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 bg-white/10 hover:bg-white/20 rounded-lg text-white/70 hover:text-white text-xs font-medium transition-all"
|
||||
title="Exporter en PowerPoint"
|
||||
>
|
||||
<Download className="w-3.5 h-3.5" />
|
||||
Export PPTX
|
||||
</a>
|
||||
)}
|
||||
<button
|
||||
onClick={downloadHtml}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 bg-white/10 hover:bg-white/20 rounded-lg text-white/70 hover:text-white text-xs font-medium transition-all"
|
||||
title="Télécharger le HTML"
|
||||
>
|
||||
<Download className="w-3.5 h-3.5" />
|
||||
Export HTML
|
||||
</button>
|
||||
<button
|
||||
onClick={openFullscreen}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 bg-white/10 hover:bg-white/20 rounded-lg text-white/70 hover:text-white text-xs font-medium transition-all"
|
||||
title="Ouvrir en plein écran"
|
||||
>
|
||||
<Maximize2 className="w-3.5 h-3.5" />
|
||||
Plein écran
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/* Slides: React renderer (legacy spec) or iframe (new HTML) */}
|
||||
<div className="flex-1 relative overflow-hidden group">
|
||||
{data.spec ? (
|
||||
<SlidesRenderer spec={data.spec} />
|
||||
) : (
|
||||
<>
|
||||
{/* Loading overlay — visible until iframe fires onLoad */}
|
||||
{!isLoaded && (
|
||||
<div className="absolute inset-0 z-20 flex flex-col items-center justify-center bg-zinc-950 gap-3">
|
||||
<div className="w-8 h-8 rounded-full border-2 border-white/10 border-t-white/60 animate-spin" />
|
||||
<span className="text-xs text-white/30">Chargement de la présentation…</span>
|
||||
</div>
|
||||
)}
|
||||
<iframe
|
||||
ref={iframeRef}
|
||||
srcDoc={data.html}
|
||||
className="absolute inset-0 w-full h-full border-0"
|
||||
sandbox="allow-scripts allow-same-origin allow-popups allow-forms"
|
||||
title={name}
|
||||
allow="fullscreen"
|
||||
onLoad={() => setIsLoaded(true)}
|
||||
/>
|
||||
{/* Prev button */}
|
||||
<button
|
||||
onClick={() => navigate(-1)}
|
||||
className="absolute left-2 top-1/2 -translate-y-1/2 z-10 w-9 h-9 flex items-center justify-center rounded-full bg-black/40 hover:bg-black/70 backdrop-blur-sm border border-white/10 text-white/40 hover:text-white transition-all opacity-0 group-hover:opacity-100"
|
||||
aria-label="Slide précédent"
|
||||
>
|
||||
<ChevronLeft className="w-5 h-5" />
|
||||
</button>
|
||||
{/* Next button */}
|
||||
<button
|
||||
onClick={() => navigate(1)}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 z-10 w-9 h-9 flex items-center justify-center rounded-full bg-black/40 hover:bg-black/70 backdrop-blur-sm border border-white/10 text-white/40 hover:text-white transition-all opacity-0 group-hover:opacity-100"
|
||||
aria-label="Slide suivant"
|
||||
>
|
||||
<ChevronRight className="w-5 h-5" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function CanvasBoard({ initialData, canvasId, name }: CanvasBoardProps) {
|
||||
const [isDarkMode, setIsDarkMode] = useState(false)
|
||||
const [saveStatus, setSaveStatus] = useState<'saved' | 'saving' | 'error'>('saved')
|
||||
@@ -173,6 +331,10 @@ export function CanvasBoard({ initialData, canvasId, name }: CanvasBoardProps) {
|
||||
}, 2000)
|
||||
}
|
||||
|
||||
if (scene.slides) {
|
||||
return <SlidesViewer data={scene.slides} name={name} canvasId={localId} />
|
||||
}
|
||||
|
||||
if (scene.pptx) {
|
||||
return <PptxViewer data={scene.pptx} name={name} />
|
||||
}
|
||||
|
||||
39
memento-note/components/lab/mermaid-diagram.tsx
Normal file
39
memento-note/components/lab/mermaid-diagram.tsx
Normal file
@@ -0,0 +1,39 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useRef } from 'react'
|
||||
import mermaid from 'mermaid'
|
||||
|
||||
let counter = 0
|
||||
|
||||
export function MermaidDiagram({ chart, isDark }: { chart: string; isDark?: boolean }) {
|
||||
const ref = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!ref.current || !chart.trim()) return
|
||||
const id = `mermaid-slide-${++counter}`
|
||||
mermaid.initialize({
|
||||
startOnLoad: false,
|
||||
theme: isDark ? 'dark' : 'default',
|
||||
themeVariables: isDark
|
||||
? { primaryColor: '#A47148', primaryTextColor: '#F9F8F6', lineColor: '#D4A373', secondaryColor: '#2A2A2A', tertiaryColor: '#1C1C1C' }
|
||||
: { primaryColor: '#A47148', primaryTextColor: '#1C1C1C', lineColor: '#D4A373' },
|
||||
securityLevel: 'loose',
|
||||
})
|
||||
mermaid.render(id, chart)
|
||||
.then(({ svg }) => {
|
||||
if (ref.current) ref.current.innerHTML = svg
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error('[MermaidDiagram] render error:', err)
|
||||
if (ref.current) ref.current.innerHTML = `<pre style="color:rgba(255,255,255,0.4);font-size:12px">${chart}</pre>`
|
||||
})
|
||||
}, [chart, isDark])
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className="flex justify-center items-center w-full h-full overflow-hidden"
|
||||
style={{ minHeight: 200 }}
|
||||
/>
|
||||
)
|
||||
}
|
||||
665
memento-note/components/lab/slides-renderer.tsx
Normal file
665
memento-note/components/lab/slides-renderer.tsx
Normal file
@@ -0,0 +1,665 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } 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: () => <div style={{ color: 'rgba(255,255,255,0.3)', padding: 24 }}>Chargement…</div> }
|
||||
)
|
||||
|
||||
// ── Constants ──────────────────────────────────────────────────────────────────
|
||||
const CHART_COLORS = ['#6366f1', '#22d3ee', '#f59e0b', '#ef4444', '#10b981', '#a78bfa', '#fb923c', '#14b8a6']
|
||||
const TT_STYLE: CSSProperties = { background: '#1C1C1C', border: '1px solid rgba(255,255,255,0.08)', borderRadius: 8, fontSize: 12 }
|
||||
const TICK = { fill: 'rgba(255,255,255,0.55)', fontSize: 11 }
|
||||
const GRID_STROKE = 'rgba(255,255,255,0.07)'
|
||||
|
||||
// ── Chart ──────────────────────────────────────────────────────────────────────
|
||||
function SlideChart({ slide }: { slide: SlideSpec }) {
|
||||
const { chart } = slide
|
||||
if (!chart?.data?.length) return null
|
||||
|
||||
const colors = chart.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: 'rgba(255,255,255,0.6)' }
|
||||
|
||||
switch (chart.type) {
|
||||
case 'bar':
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={chart.data} margin={{ top: 8, right: 24, left: 0, bottom: 4 }}>
|
||||
{chart.showGrid !== false && <CartesianGrid strokeDasharray="3 3" stroke={GRID_STROKE} />}
|
||||
<XAxis dataKey={xKey} tick={TICK} axisLine={false} tickLine={false} />
|
||||
<YAxis tick={TICK} axisLine={false} tickLine={false} width={40} />
|
||||
<Tooltip contentStyle={TT_STYLE} cursor={{ fill: 'rgba(255,255,255,0.04)' }} />
|
||||
{legend && <Legend wrapperStyle={legSt} />}
|
||||
{yKeys.map((k, i) => <Bar key={k} dataKey={k} fill={colors[i % colors.length]} radius={[4, 4, 0, 0]} maxBarSize={56} />)}
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
)
|
||||
case 'line':
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<LineChart data={chart.data} margin={{ top: 8, right: 24, left: 0, bottom: 4 }}>
|
||||
{chart.showGrid !== false && <CartesianGrid strokeDasharray="3 3" stroke={GRID_STROKE} />}
|
||||
<XAxis dataKey={xKey} tick={TICK} axisLine={false} tickLine={false} />
|
||||
<YAxis tick={TICK} axisLine={false} tickLine={false} width={40} />
|
||||
<Tooltip contentStyle={TT_STYLE} />
|
||||
{legend && <Legend wrapperStyle={legSt} />}
|
||||
{yKeys.map((k, i) => <Line key={k} type="monotone" dataKey={k} stroke={colors[i % colors.length]} strokeWidth={2.5} dot={{ r: 4, strokeWidth: 0 }} activeDot={{ r: 6 }} />)}
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
)
|
||||
case 'area':
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<AreaChart data={chart.data} margin={{ top: 8, right: 24, left: 0, bottom: 4 }}>
|
||||
{chart.showGrid !== false && <CartesianGrid strokeDasharray="3 3" stroke={GRID_STROKE} />}
|
||||
<XAxis dataKey={xKey} tick={TICK} axisLine={false} tickLine={false} />
|
||||
<YAxis tick={TICK} axisLine={false} tickLine={false} width={40} />
|
||||
<Tooltip contentStyle={TT_STYLE} />
|
||||
{legend && <Legend wrapperStyle={legSt} />}
|
||||
{yKeys.map((k, i) => <Area key={k} type="monotone" dataKey={k} stroke={colors[i % colors.length]} fill={`${colors[i % colors.length]}28`} strokeWidth={2.5} />)}
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
)
|
||||
case 'pie':
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<PieChart>
|
||||
<Pie data={chart.data} dataKey={yKeys[0] ?? 'value'} nameKey={xKey} cx="50%" cy="50%" outerRadius="70%" innerRadius="35%" paddingAngle={2}>
|
||||
{chart.data.map((_, i) => <Cell key={i} fill={colors[i % colors.length]} stroke="transparent" />)}
|
||||
</Pie>
|
||||
<Tooltip contentStyle={TT_STYLE} />
|
||||
{chart.showLegend !== false && <Legend wrapperStyle={legSt} />}
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
)
|
||||
case 'radar':
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<RadarChart data={chart.data} cx="50%" cy="50%">
|
||||
<PolarGrid stroke="rgba(255,255,255,0.1)" />
|
||||
<PolarAngleAxis dataKey={xKey} tick={{ fill: 'rgba(255,255,255,0.65)', fontSize: 11 }} />
|
||||
{yKeys.map((k, i) => <Radar key={k} name={k} dataKey={k} stroke={colors[i % colors.length]} fill={colors[i % colors.length]} fillOpacity={0.15} />)}
|
||||
<Tooltip contentStyle={TT_STYLE} />
|
||||
{legend && <Legend wrapperStyle={legSt} />}
|
||||
</RadarChart>
|
||||
</ResponsiveContainer>
|
||||
)
|
||||
case 'stacked-bar':
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={chart.data} margin={{ top: 8, right: 24, left: 0, bottom: 4 }}>
|
||||
{chart.showGrid !== false && <CartesianGrid strokeDasharray="3 3" stroke={GRID_STROKE} />}
|
||||
<XAxis dataKey={xKey} tick={TICK} axisLine={false} tickLine={false} />
|
||||
<YAxis tick={TICK} axisLine={false} tickLine={false} width={40} />
|
||||
<Tooltip contentStyle={TT_STYLE} cursor={{ fill: 'rgba(255,255,255,0.04)' }} />
|
||||
<Legend wrapperStyle={legSt} />
|
||||
{yKeys.map((k, i) => <Bar key={k} dataKey={k} stackId="a" fill={colors[i % colors.length]} radius={i === yKeys.length - 1 ? [4, 4, 0, 0] : [0, 0, 0, 0]} />)}
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
)
|
||||
case 'combo': {
|
||||
const lineKeys = chart.lineKeys ?? (yKeys.length > 1 ? [yKeys[yKeys.length - 1]] : [])
|
||||
const barKeys = yKeys.filter(k => !lineKeys.includes(k))
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={chart.data} margin={{ top: 8, right: 24, left: 0, bottom: 4 }}>
|
||||
{chart.showGrid !== false && <CartesianGrid strokeDasharray="3 3" stroke={GRID_STROKE} />}
|
||||
<XAxis dataKey={xKey} tick={TICK} axisLine={false} tickLine={false} />
|
||||
<YAxis tick={TICK} axisLine={false} tickLine={false} width={40} />
|
||||
<Tooltip contentStyle={TT_STYLE} cursor={{ fill: 'rgba(255,255,255,0.04)' }} />
|
||||
<Legend wrapperStyle={legSt} />
|
||||
{barKeys.map((k, i) => <Bar key={k} dataKey={k} fill={colors[i % colors.length]} radius={[4, 4, 0, 0]} maxBarSize={56} />)}
|
||||
{lineKeys.map((k, i) => <Line key={k} type="monotone" dataKey={k} stroke={colors[(barKeys.length + i) % colors.length]} strokeWidth={2.5} dot={{ r: 4, strokeWidth: 0 }} />)}
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
)
|
||||
}
|
||||
case 'funnel':
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<FunnelChart>
|
||||
<Tooltip contentStyle={TT_STYLE} />
|
||||
<Funnel dataKey={yKeys[0] ?? 'value'} data={chart.data.map((d, i) => ({ ...d, fill: colors[i % colors.length] }))} isAnimationActive>
|
||||
<LabelList position="center" fill="#fff" fontSize={12} fontWeight={700} dataKey={xKey} />
|
||||
</Funnel>
|
||||
</FunnelChart>
|
||||
</ResponsiveContainer>
|
||||
)
|
||||
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 (
|
||||
<div style={{ width: '100%', height: '100%', display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center' }}>
|
||||
<svg viewBox="0 0 200 120" style={{ width: '60%', maxHeight: '70%' }}>
|
||||
<path d="M 20 100 A 80 80 0 0 1 180 100" fill="none" stroke="rgba(255,255,255,0.1)" strokeWidth="16" strokeLinecap="round" />
|
||||
<path d="M 20 100 A 80 80 0 0 1 180 100" fill="none" stroke={color} strokeWidth="16" strokeLinecap="round"
|
||||
strokeDasharray={`${pct * 251.2} 251.2`} />
|
||||
<text x="100" y="90" textAnchor="middle" fill="#fff" fontSize="28" fontWeight="800">{value}%</text>
|
||||
<text x="100" y="112" textAnchor="middle" fill="rgba(255,255,255,0.5)" fontSize="11">{label}</text>
|
||||
</svg>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
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 (
|
||||
<ResponsiveContainer width="100%" height="100%">
|
||||
<BarChart data={waterfallData} margin={{ top: 8, right: 24, left: 0, bottom: 4 }}>
|
||||
{chart.showGrid !== false && <CartesianGrid strokeDasharray="3 3" stroke={GRID_STROKE} />}
|
||||
<XAxis dataKey={xKey} tick={TICK} axisLine={false} tickLine={false} />
|
||||
<YAxis tick={TICK} axisLine={false} tickLine={false} width={40} />
|
||||
<Tooltip contentStyle={TT_STYLE} />
|
||||
<Bar dataKey="__start" stackId="w" fill="transparent" />
|
||||
<Bar dataKey="__delta" stackId="w" radius={[4, 4, 0, 0]} maxBarSize={56}>
|
||||
{waterfallData.map((d, i) => <Cell key={i} fill={d.__isTotal ? colors[2] : d.__isPositive ? colors[4] : colors[3]} />)}
|
||||
</Bar>
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
)
|
||||
}
|
||||
case 'treemap': {
|
||||
// Render treemap as proportional boxes
|
||||
const total = chart.data.reduce((s, d) => s + ((d[yKeys[0]] as number) ?? 0), 0)
|
||||
return (
|
||||
<div style={{ width: '100%', height: '100%', display: 'flex', flexWrap: 'wrap', gap: 4, padding: 8, alignContent: 'flex-start' }}>
|
||||
{chart.data.map((d, i) => {
|
||||
const val = (d[yKeys[0]] as number) ?? 0
|
||||
const pct = total > 0 ? (val / total) * 100 : 10
|
||||
return (
|
||||
<div key={i} style={{ width: `${Math.max(pct * 2.5, 12)}%`, height: `${Math.max(pct * 1.5, 20)}%`, minWidth: 60, minHeight: 40, background: colors[i % colors.length], borderRadius: 8, display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', padding: 8 }}>
|
||||
<span style={{ color: '#fff', fontSize: 11, fontWeight: 700, textAlign: 'center' }}>{d[xKey] as string}</span>
|
||||
<span style={{ color: 'rgba(255,255,255,0.7)', fontSize: 10 }}>{val}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
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 }
|
||||
|
||||
switch (layout) {
|
||||
// ── TITLE ────────────────────────────────────────────────────────────
|
||||
case 'title':
|
||||
return (
|
||||
<div style={{ width: '100%', height: '100%', background: `linear-gradient(140deg, ${palette.primary} 0%, ${isDark ? palette.bg : palette.secondary} 100%)`, display: 'flex', flexDirection: 'column', justifyContent: 'flex-end', padding: '72px 80px', position: 'relative', overflow: 'hidden' }}>
|
||||
<div style={{ position: 'absolute', top: -100, right: -80, width: 500, height: 500, borderRadius: '50%', background: 'rgba(255,255,255,0.05)', pointerEvents: 'none' }} />
|
||||
<div style={{ position: 'absolute', bottom: -120, right: -20, width: 320, height: 320, borderRadius: '50%', border: '2px solid rgba(255,255,255,0.06)', pointerEvents: 'none' }} />
|
||||
<div style={{ position: 'absolute', top: 56, left: 80, fontSize: 11, fontWeight: 700, letterSpacing: '0.2em', textTransform: 'uppercase' as const, color: 'rgba(255,255,255,0.4)' }}>Présentation</div>
|
||||
<div style={{ position: 'relative', zIndex: 1 }}>
|
||||
<div style={{ width: 52, height: 5, background: palette.accent, borderRadius: 3, marginBottom: 20, opacity: 0.9 }} />
|
||||
<h1 style={{ color: '#fff', fontSize: 56, fontWeight: 800, lineHeight: 1.05, letterSpacing: '-0.04em', margin: 0, maxWidth: 900 }}>{slide.title}</h1>
|
||||
{slide.subtitle && <p style={{ color: 'rgba(255,255,255,0.6)', fontSize: 20, fontWeight: 300, marginTop: 16, maxWidth: 640, lineHeight: 1.5 }}>{slide.subtitle}</p>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
// ── SECTION DIVIDER ─────────────────────────────────────────────────
|
||||
case 'section':
|
||||
return (
|
||||
<div style={{ width: '100%', height: '100%', background: isDark ? palette.primary : palette.primary, display: 'flex', flexDirection: 'column', justifyContent: 'center', padding: '52px 80px', position: 'relative', overflow: 'hidden' }}>
|
||||
<div style={{ position: 'absolute', right: 40, bottom: -30, fontSize: 200, fontWeight: 900, color: 'rgba(255,255,255,0.05)', lineHeight: 1, letterSpacing: '-0.06em', userSelect: 'none' as const, pointerEvents: 'none' as const }}>
|
||||
{slide.content[0] ?? String(index).padStart(2, '0')}
|
||||
</div>
|
||||
<span style={{ display: 'inline-block', background: 'rgba(255,255,255,0.12)', color: 'rgba(255,255,255,0.7)', fontSize: 12, fontWeight: 700, letterSpacing: '0.2em', textTransform: 'uppercase' as const, padding: '6px 16px', borderRadius: 100, marginBottom: 20, alignSelf: 'flex-start' }}>
|
||||
Section {slide.content[0] ?? String(index).padStart(2, '0')}
|
||||
</span>
|
||||
<h2 style={{ color: '#fff', fontSize: 44, fontWeight: 800, letterSpacing: '-0.04em', lineHeight: 1.05, margin: 0, maxWidth: 780 }}>{slide.title}</h2>
|
||||
{slide.subtitle && <p style={{ color: 'rgba(255,255,255,0.55)', fontSize: 18, marginTop: 12 }}>{slide.subtitle}</p>}
|
||||
</div>
|
||||
)
|
||||
|
||||
// ── QUOTE ─────────────────────────────────────────────────────────────
|
||||
case 'quote':
|
||||
return (
|
||||
<div style={{ width: '100%', height: '100%', background: isDark ? '#0d1117' : '#1a1a2e', display: 'flex', flexDirection: 'column', justifyContent: 'center', padding: '52px 80px', position: 'relative', overflow: 'hidden' }}>
|
||||
<div style={{ position: 'absolute', right: -10, top: -40, fontSize: 400, fontWeight: 900, color: 'rgba(255,255,255,0.03)', lineHeight: 0.7, fontFamily: 'Georgia, serif', userSelect: 'none' as const, pointerEvents: 'none' as const }}>"</div>
|
||||
<div style={{ fontSize: 80, color: palette.accent, lineHeight: 0.6, fontFamily: 'Georgia, serif', marginBottom: 16, opacity: 0.7 }}>"</div>
|
||||
<blockquote style={{ color: '#fff', fontSize: 28, fontWeight: 600, lineHeight: 1.4, letterSpacing: '-0.02em', margin: 0, maxWidth: 860, fontStyle: 'italic' }}>{slide.title}</blockquote>
|
||||
{slide.subtitle && <cite style={{ display: 'block', color: 'rgba(255,255,255,0.45)', fontSize: 14, fontStyle: 'normal', fontWeight: 600, letterSpacing: '0.1em', textTransform: 'uppercase' as const, marginTop: 28 }}>— {slide.subtitle}</cite>}
|
||||
</div>
|
||||
)
|
||||
|
||||
// ── TOC ──────────────────────────────────────────────────────────────
|
||||
case 'toc':
|
||||
return (
|
||||
<div style={{ width: '100%', height: '100%', display: 'flex', flexDirection: 'column', padding: '56px 72px', textAlign: 'left' }}>
|
||||
<h2 style={{ margin: 0, fontSize: 38, fontWeight: 800, letterSpacing: '-0.04em', color: text }}>{slide.title || 'Sommaire'}</h2>
|
||||
<div style={accentBar} />
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 10 }}>
|
||||
{slide.content.map((item, i) => (
|
||||
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 16, padding: '12px 18px', borderRadius: radius, background: cardBg, border: `1px solid ${cardBorder}` }}>
|
||||
<span style={{ fontWeight: 700, fontSize: 14, letterSpacing: '0.08em', color: palette.accent, minWidth: 28 }}>{String(i + 1).padStart(2, '0')}</span>
|
||||
<span style={{ fontSize: 16, fontWeight: 500, color: text }}>{item}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
// ── CONTENT (bullets) ────────────────────────────────────────────────
|
||||
case 'content':
|
||||
return (
|
||||
<div style={{ width: '100%', height: '100%', display: 'flex', flexDirection: 'column', padding: '56px 72px', textAlign: 'left' }}>
|
||||
<h2 style={{ margin: 0, fontSize: 38, fontWeight: 800, letterSpacing: '-0.04em', color: text }}>{slide.title}</h2>
|
||||
<div style={accentBar} />
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 14, flex: 1, justifyContent: 'center' }}>
|
||||
{slide.content.map((item, i) => (
|
||||
<div key={i} style={{ display: 'flex', alignItems: 'flex-start', gap: 16, padding: '4px 0' }}>
|
||||
<span style={{ width: 8, height: 8, borderRadius: '50%', background: palette.accent, marginTop: 8, flexShrink: 0 }} />
|
||||
<span style={{ fontSize: 18, lineHeight: 1.6, color: text }}>{item}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
// ── TWO COLUMN ──────────────────────────────────────────────────────
|
||||
case 'two-column': {
|
||||
const mid = Math.ceil(slide.content.length / 2)
|
||||
const left = slide.content.slice(0, mid)
|
||||
const right = slide.content.slice(mid)
|
||||
const heads = (slide.subtitle ?? '/').split('/')
|
||||
return (
|
||||
<div style={{ width: '100%', height: '100%', display: 'flex', flexDirection: 'column', padding: '56px 72px', textAlign: 'left' }}>
|
||||
<h2 style={{ margin: 0, fontSize: 38, fontWeight: 800, letterSpacing: '-0.04em', color: text }}>{slide.title}</h2>
|
||||
<div style={accentBar} />
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 28, flex: 1, alignContent: 'start' }}>
|
||||
{[{ items: left, head: heads[0]?.trim() || '' }, { items: right, head: heads[1]?.trim() || '' }].map(({ items, head }, col) => (
|
||||
<div key={col} style={{ display: 'flex', flexDirection: 'column', gap: 4, padding: '20px 24px', borderRadius: radius, background: cardBg, border: `1px solid ${cardBorder}` }}>
|
||||
{head && <span style={{ fontSize: 12, fontWeight: 700, letterSpacing: '0.15em', textTransform: 'uppercase' as const, color: palette.accent, marginBottom: 12 }}>{head}</span>}
|
||||
{items.map((item, i) => (
|
||||
<div key={i} style={{ display: 'flex', alignItems: 'flex-start', gap: 12, padding: '6px 0' }}>
|
||||
<span style={{ width: 6, height: 6, borderRadius: '50%', background: palette.accent, marginTop: 7, flexShrink: 0, opacity: 0.7 }} />
|
||||
<span style={{ fontSize: 15, lineHeight: 1.55, color: text }}>{item}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── CARDS ────────────────────────────────────────────────────────────
|
||||
case 'cards': {
|
||||
const items = slide.content.slice(0, 6)
|
||||
const cols = items.length <= 2 ? 2 : items.length <= 3 ? 3 : items.length === 4 ? 2 : 3
|
||||
return (
|
||||
<div style={{ width: '100%', height: '100%', display: 'flex', flexDirection: 'column', padding: '56px 72px', textAlign: 'left' }}>
|
||||
<h2 style={{ margin: 0, fontSize: 38, fontWeight: 800, letterSpacing: '-0.04em', color: text }}>{slide.title}</h2>
|
||||
<div style={accentBar} />
|
||||
<div style={{ display: 'grid', gridTemplateColumns: `repeat(${cols}, 1fr)`, gap: 16, flex: 1, alignContent: 'center' }}>
|
||||
{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 (
|
||||
<div key={i} style={{ display: 'flex', flexDirection: 'column', gap: 8, padding: '20px', borderRadius: radius, background: cardBg, border: `1px solid ${cardBorder}`, position: 'relative', overflow: 'hidden' }}>
|
||||
<span style={{ position: 'absolute', top: 10, right: 14, fontWeight: 900, fontSize: 28, color: isDark ? 'rgba(255,255,255,0.05)' : 'rgba(0,0,0,0.04)', lineHeight: 1 }}>{String(i + 1).padStart(2, '0')}</span>
|
||||
<div style={{ width: 24, height: 3, background: palette.accent, borderRadius: 2 }} />
|
||||
{head && <p style={{ margin: 0, fontSize: 15, fontWeight: 700, letterSpacing: '0.02em', color: text }}>{head}</p>}
|
||||
<p style={{ margin: 0, fontSize: 14, lineHeight: 1.55, color: muted }}>{body}</p>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── STATS ────────────────────────────────────────────────────────────
|
||||
case 'stats': {
|
||||
const items = slide.content.slice(0, 4)
|
||||
return (
|
||||
<div style={{ width: '100%', height: '100%', display: 'flex', flexDirection: 'column', padding: '56px 72px', textAlign: 'left' }}>
|
||||
<h2 style={{ margin: 0, fontSize: 38, fontWeight: 800, letterSpacing: '-0.04em', color: text }}>{slide.title}</h2>
|
||||
<div style={accentBar} />
|
||||
<div style={{ display: 'grid', gridTemplateColumns: `repeat(${items.length}, 1fr)`, gap: 20, flex: 1, alignContent: 'center' }}>
|
||||
{items.map((item, i) => {
|
||||
const parts = item.split(/[-–—:]/)
|
||||
const val = parts[0]?.trim() ?? item
|
||||
const lbl = parts.slice(1).join(' ').trim()
|
||||
return (
|
||||
<div key={i} style={{ display: 'flex', flexDirection: 'column', justifyContent: 'center', alignItems: 'flex-start', padding: '28px 24px', borderRadius: radius, background: cardBg, border: `1px solid ${cardBorder}` }}>
|
||||
<span style={{ fontSize: 42, fontWeight: 900, letterSpacing: '-0.04em', color: palette.accent, lineHeight: 1 }}>{val}</span>
|
||||
<div style={{ width: 32, height: 3, background: palette.accent, borderRadius: 2, margin: '12px 0 10px', opacity: 0.6 }} />
|
||||
{lbl && <span style={{ fontSize: 13, fontWeight: 600, letterSpacing: '0.1em', textTransform: 'uppercase' as const, color: muted }}>{lbl}</span>}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── SUMMARY ──────────────────────────────────────────────────────────
|
||||
case 'summary':
|
||||
return (
|
||||
<div style={{ width: '100%', height: '100%', display: 'flex', flexDirection: 'column', padding: '56px 72px', textAlign: 'left' }}>
|
||||
<h2 style={{ margin: 0, fontSize: 38, fontWeight: 800, letterSpacing: '-0.04em', color: text }}>{slide.title || 'En résumé'}</h2>
|
||||
<div style={accentBar} />
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12, flex: 1, justifyContent: 'center' }}>
|
||||
{slide.content.slice(0, 6).map((item, i) => (
|
||||
<div key={i} style={{ display: 'flex', alignItems: 'center', gap: 16, padding: '14px 20px', borderRadius: radius, background: cardBg, border: `1px solid ${cardBorder}` }}>
|
||||
<div style={{ width: 26, height: 26, minWidth: 26, background: palette.accent, borderRadius: '50%', display: 'flex', alignItems: 'center', justifyContent: 'center', fontSize: 13, color: '#fff', fontWeight: 900 }}>✓</div>
|
||||
<span style={{ fontSize: 16, lineHeight: 1.45, color: text }}>{item}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
// ── CHART ────────────────────────────────────────────────────────────
|
||||
case 'chart':
|
||||
return (
|
||||
<div style={{ width: '100%', height: '100%', display: 'flex', flexDirection: 'column', padding: '48px 60px', background: isDark ? palette.bg : '#111827', textAlign: 'left' }}>
|
||||
<h2 style={{ margin: 0, fontSize: 30, fontWeight: 800, letterSpacing: '-0.04em', color: '#fff' }}>{slide.title}</h2>
|
||||
{slide.subtitle && <p style={{ margin: '6px 0 0', fontSize: 15, color: 'rgba(255,255,255,0.5)' }}>{slide.subtitle}</p>}
|
||||
<div style={{ flex: 1, marginTop: 24 }}>
|
||||
<SlideChart slide={slide} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
// ── DIAGRAM ──────────────────────────────────────────────────────────
|
||||
case 'diagram':
|
||||
return (
|
||||
<div style={{ width: '100%', height: '100%', display: 'flex', flexDirection: 'column', padding: '48px 60px', background: isDark ? palette.bg : '#111827', textAlign: 'left' }}>
|
||||
<h2 style={{ margin: 0, fontSize: 30, fontWeight: 800, letterSpacing: '-0.04em', color: '#fff' }}>{slide.title}</h2>
|
||||
{slide.subtitle && <p style={{ margin: '6px 0 0', fontSize: 15, color: 'rgba(255,255,255,0.5)' }}>{slide.subtitle}</p>}
|
||||
<div style={{ flex: 1, marginTop: 20, overflow: 'hidden', display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
{slide.mermaid ? <MermaidDiagram chart={slide.mermaid} isDark={true} /> : <p style={{ color: 'rgba(255,255,255,0.3)' }}>No diagram</p>}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
// ── IMAGE ────────────────────────────────────────────────────────────
|
||||
case 'image':
|
||||
return (
|
||||
<div style={{ width: '100%', height: '100%', display: 'flex', flexDirection: 'column', padding: '56px 72px', textAlign: 'left' }}>
|
||||
<h2 style={{ margin: 0, fontSize: 38, fontWeight: 800, letterSpacing: '-0.04em', color: text }}>{slide.title}</h2>
|
||||
<div style={accentBar} />
|
||||
<div style={{ flex: 1, display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
|
||||
{slide.imageUrl
|
||||
? <img src={slide.imageUrl} alt={slide.title} style={{ maxHeight: '70%', maxWidth: '90%', borderRadius: radius, objectFit: 'contain' }} />
|
||||
: <div style={{ color: muted, fontSize: 14 }}>No image</div>
|
||||
}
|
||||
</div>
|
||||
{slide.content[0] && <p style={{ margin: '12px 0 0', fontSize: 13, textAlign: 'center', color: muted }}>{slide.content[0]}</p>}
|
||||
</div>
|
||||
)
|
||||
|
||||
// ── TIMELINE ────────────────────────────────────────────────────────
|
||||
case 'timeline': {
|
||||
const items = slide.content.slice(0, 8)
|
||||
return (
|
||||
<div style={{ width: '100%', height: '100%', display: 'flex', flexDirection: 'column', padding: '48px 60px', textAlign: 'left' }}>
|
||||
<h2 style={{ margin: 0, fontSize: 34, fontWeight: 800, letterSpacing: '-0.04em', color: text }}>{slide.title}</h2>
|
||||
<div style={accentBar} />
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', justifyContent: 'center', position: 'relative', paddingLeft: 28 }}>
|
||||
<div style={{ position: 'absolute', left: 11, top: 0, bottom: 0, width: 2, background: `linear-gradient(to bottom, ${palette.accent}, transparent)` }} />
|
||||
{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 (
|
||||
<div key={i} style={{ display: 'flex', alignItems: 'flex-start', gap: 16, marginBottom: 16, position: 'relative' }}>
|
||||
<div style={{ position: 'absolute', left: -22, top: 6, width: 12, height: 12, borderRadius: '50%', background: palette.accent, border: `3px solid ${isDark ? palette.bg : '#fff'}`, zIndex: 1 }} />
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 2, paddingLeft: 8 }}>
|
||||
{date && <span style={{ fontSize: 11, fontWeight: 700, letterSpacing: '0.1em', textTransform: 'uppercase' as const, color: palette.accent }}>{date}</span>}
|
||||
<span style={{ fontSize: 15, lineHeight: 1.5, color: text }}>{desc}</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── KPI DASHBOARD ───────────────────────────────────────────────────
|
||||
case 'kpi-dashboard': {
|
||||
const items = slide.content.slice(0, 6)
|
||||
return (
|
||||
<div style={{ width: '100%', height: '100%', display: 'flex', flexDirection: 'column', padding: '48px 60px', textAlign: 'left' }}>
|
||||
<h2 style={{ margin: 0, fontSize: 34, fontWeight: 800, letterSpacing: '-0.04em', color: text }}>{slide.title}</h2>
|
||||
<div style={accentBar} />
|
||||
<div style={{ display: 'grid', gridTemplateColumns: `repeat(${items.length <= 3 ? items.length : 3}, 1fr)`, gap: 14, flex: 1, alignContent: 'center' }}>
|
||||
{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 (
|
||||
<div key={i} style={{ display: 'flex', flexDirection: 'column', gap: 6, padding: '20px 18px', borderRadius: radius, background: cardBg, border: `1px solid ${cardBorder}` }}>
|
||||
<span style={{ fontSize: 32, fontWeight: 900, letterSpacing: '-0.03em', color: palette.accent, lineHeight: 1 }}>{val}</span>
|
||||
{lbl && <span style={{ fontSize: 12, fontWeight: 600, color: muted, letterSpacing: '0.05em', textTransform: 'uppercase' as const }}>{lbl}</span>}
|
||||
{trend && <span style={{ fontSize: 12, fontWeight: 700, color: isUp ? '#10b981' : isDown ? '#ef4444' : muted }}>{trend}</span>}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── DATA TABLE ──────────────────────────────────────────────────────
|
||||
case 'data-table': {
|
||||
const headers = slide.tableHeaders ?? (slide.content[0]?.split('|').map(s => s.trim()) ?? [])
|
||||
const rows = slide.tableRows ?? slide.content.slice(headers === slide.tableHeaders ? 0 : 1).map(row => row.split('|').map(s => s.trim()))
|
||||
return (
|
||||
<div style={{ width: '100%', height: '100%', display: 'flex', flexDirection: 'column', padding: '48px 60px', textAlign: 'left' }}>
|
||||
<h2 style={{ margin: 0, fontSize: 34, fontWeight: 800, letterSpacing: '-0.04em', color: text }}>{slide.title}</h2>
|
||||
<div style={accentBar} />
|
||||
<div style={{ flex: 1, overflow: 'auto' }}>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
|
||||
{headers.length > 0 && (
|
||||
<thead>
|
||||
<tr>
|
||||
{headers.map((h, i) => (
|
||||
<th key={i} style={{ padding: '10px 14px', borderBottom: `2px solid ${palette.accent}`, textAlign: 'left', fontWeight: 700, fontSize: 11, letterSpacing: '0.1em', textTransform: 'uppercase' as const, color: palette.accent }}>{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
)}
|
||||
<tbody>
|
||||
{rows.map((row, ri) => (
|
||||
<tr key={ri} style={{ background: ri % 2 === 0 ? 'transparent' : cardBg }}>
|
||||
{row.map((cell, ci) => (
|
||||
<td key={ci} style={{ padding: '10px 14px', borderBottom: `1px solid ${cardBorder}`, color: text, lineHeight: 1.4 }}>{cell}</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── DEFAULT ──────────────────────────────────────────────────────────
|
||||
default:
|
||||
return (
|
||||
<div style={{ width: '100%', height: '100%', display: 'flex', flexDirection: 'column', padding: '56px 72px', textAlign: 'left' }}>
|
||||
<h2 style={{ margin: 0, fontSize: 38, fontWeight: 800, letterSpacing: '-0.04em', color: text }}>{slide.title}</h2>
|
||||
<div style={accentBar} />
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 14, flex: 1, justifyContent: 'center' }}>
|
||||
{slide.content.map((item, i) => (
|
||||
<div key={i} style={{ display: 'flex', alignItems: 'flex-start', gap: 16, padding: '4px 0' }}>
|
||||
<span style={{ width: 8, height: 8, borderRadius: '50%', background: palette.accent, marginTop: 8, flexShrink: 0 }} />
|
||||
<span style={{ fontSize: 18, lineHeight: 1.6, color: text }}>{item}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
// 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 containerRef = useRef<HTMLDivElement>(null)
|
||||
const total = spec.slides.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() }
|
||||
}
|
||||
|
||||
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)',
|
||||
})
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
tabIndex={0}
|
||||
style={{
|
||||
position: 'absolute', inset: 0,
|
||||
background: palette.bg,
|
||||
fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif',
|
||||
overflow: 'hidden',
|
||||
outline: 'none',
|
||||
}}
|
||||
>
|
||||
{/* Slides */}
|
||||
<div style={{ position: 'absolute', inset: 0 }}>
|
||||
{spec.slides.map((slide, i) => {
|
||||
const offset = i - current
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
inset: 0,
|
||||
transform: `translateX(${offset * 100}%)`,
|
||||
transition: 'transform 0.45s cubic-bezier(0.4, 0, 0.2, 1)',
|
||||
willChange: 'transform',
|
||||
}}
|
||||
>
|
||||
<SlideContent slide={slide} index={i} palette={palette} radius={radius} />
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Navigation buttons */}
|
||||
{current > 0 && (
|
||||
<button style={navBtn(true)} onClick={prev} aria-label="Précédent">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><polyline points="15 18 9 12 15 6" /></svg>
|
||||
</button>
|
||||
)}
|
||||
{current < total - 1 && (
|
||||
<button style={navBtn(false)} onClick={next} aria-label="Suivant">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round"><polyline points="9 6 15 12 9 18" /></svg>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Progress bar */}
|
||||
<div style={{ position: 'absolute', bottom: 0, left: 0, right: 0, height: 3, background: isDark ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.06)', zIndex: 40 }}>
|
||||
<div style={{ height: '100%', width: `${((current + 1) / total) * 100}%`, background: palette.accent, transition: 'width 0.4s ease', borderRadius: '0 2px 2px 0' }} />
|
||||
</div>
|
||||
|
||||
{/* Slide counter */}
|
||||
<div style={{ position: 'absolute', bottom: 12, right: 16, zIndex: 40, fontSize: 12, fontWeight: 600, color: isDark ? 'rgba(255,255,255,0.4)' : 'rgba(0,0,0,0.35)', background: isDark ? 'rgba(0,0,0,0.4)' : 'rgba(255,255,255,0.8)', padding: '3px 10px', borderRadius: 100, backdropFilter: 'blur(4px)' }}>
|
||||
{current + 1} / {total}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user