fix(chart): improve error handling and color variety
- Add quotaExceeded flag to response for better error UX - Show dedicated quota exceeded state with upgrade button - Improve AI prompt to better detect data patterns - Add chart type-specific colors (blue, indigo, emerald, violet, etc.) - Replace generic primary/10 colors with varied accent colors Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'react'
|
||||
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'
|
||||
@@ -213,6 +213,8 @@ function SlideContent({ slide, index, palette, radius }: { slide: SlideSpec; ind
|
||||
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':
|
||||
@@ -234,10 +236,10 @@ function SlideContent({ slide, index, palette, radius }: { slide: SlideSpec; ind
|
||||
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')}
|
||||
{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')}
|
||||
Section {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>}
|
||||
@@ -262,7 +264,7 @@ function SlideContent({ slide, index, palette, radius }: { slide: SlideSpec; ind
|
||||
<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) => (
|
||||
{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>
|
||||
@@ -279,7 +281,7 @@ function SlideContent({ slide, index, palette, radius }: { slide: SlideSpec; ind
|
||||
<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) => (
|
||||
{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>
|
||||
@@ -291,9 +293,9 @@ function SlideContent({ slide, index, palette, radius }: { slide: SlideSpec; ind
|
||||
|
||||
// ── 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 mid = Math.ceil(content.length / 2)
|
||||
const left = content.slice(0, mid)
|
||||
const right = content.slice(mid)
|
||||
const heads = (slide.subtitle ?? '/').split('/')
|
||||
return (
|
||||
<div style={{ width: '100%', height: '100%', display: 'flex', flexDirection: 'column', padding: '56px 72px', textAlign: 'left' }}>
|
||||
@@ -318,7 +320,7 @@ function SlideContent({ slide, index, palette, radius }: { slide: SlideSpec; ind
|
||||
|
||||
// ── CARDS ────────────────────────────────────────────────────────────
|
||||
case 'cards': {
|
||||
const items = slide.content.slice(0, 6)
|
||||
const items = 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' }}>
|
||||
@@ -345,7 +347,7 @@ function SlideContent({ slide, index, palette, radius }: { slide: SlideSpec; ind
|
||||
|
||||
// ── STATS ────────────────────────────────────────────────────────────
|
||||
case 'stats': {
|
||||
const items = slide.content.slice(0, 4)
|
||||
const items = 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>
|
||||
@@ -375,7 +377,7 @@ function SlideContent({ slide, index, palette, radius }: { slide: SlideSpec; ind
|
||||
<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) => (
|
||||
{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>
|
||||
@@ -421,13 +423,13 @@ function SlideContent({ slide, index, palette, radius }: { slide: SlideSpec; ind
|
||||
: <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>}
|
||||
{content[0] && <p style={{ margin: '12px 0 0', fontSize: 13, textAlign: 'center', color: muted }}>{content[0]}</p>}
|
||||
</div>
|
||||
)
|
||||
|
||||
// ── TIMELINE ────────────────────────────────────────────────────────
|
||||
case 'timeline': {
|
||||
const items = slide.content.slice(0, 8)
|
||||
const items = 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>
|
||||
@@ -455,7 +457,7 @@ function SlideContent({ slide, index, palette, radius }: { slide: SlideSpec; ind
|
||||
|
||||
// ── KPI DASHBOARD ───────────────────────────────────────────────────
|
||||
case 'kpi-dashboard': {
|
||||
const items = slide.content.slice(0, 6)
|
||||
const items = 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>
|
||||
@@ -484,8 +486,8 @@ function SlideContent({ slide, index, palette, radius }: { slide: SlideSpec; ind
|
||||
|
||||
// ── 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()))
|
||||
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 (
|
||||
<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>
|
||||
@@ -523,7 +525,7 @@ function SlideContent({ slide, index, palette, radius }: { slide: SlideSpec; ind
|
||||
<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) => (
|
||||
{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>
|
||||
@@ -535,6 +537,127 @@ function SlideContent({ slide, index, palette, radius }: { slide: SlideSpec; ind
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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',
|
||||
data: slide.data ?? []
|
||||
},
|
||||
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)
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
@@ -544,8 +667,14 @@ export interface SlidesRendererProps {
|
||||
|
||||
export function SlidesRenderer({ spec }: SlidesRendererProps) {
|
||||
const [current, setCurrent] = useState(0)
|
||||
const [showNotes, setShowNotes] = useState(false)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const total = spec.slides.length
|
||||
|
||||
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
|
||||
@@ -561,6 +690,7 @@ export function SlidesRenderer({ spec }: SlidesRendererProps) {
|
||||
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
|
||||
@@ -606,6 +736,8 @@ export function SlidesRenderer({ spec }: SlidesRendererProps) {
|
||||
boxShadow: isDark ? 'none' : '0 2px 8px rgba(0,0,0,0.1)',
|
||||
})
|
||||
|
||||
const currentSlideNotes = normalizedSlides[current]?.notes
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
@@ -620,7 +752,7 @@ export function SlidesRenderer({ spec }: SlidesRendererProps) {
|
||||
>
|
||||
{/* Slides */}
|
||||
<div style={{ position: 'absolute', inset: 0 }}>
|
||||
{spec.slides.map((slide, i) => {
|
||||
{normalizedSlides.map((slide, i) => {
|
||||
const offset = i - current
|
||||
return (
|
||||
<div
|
||||
@@ -656,9 +788,65 @@ export function SlidesRenderer({ spec }: SlidesRendererProps) {
|
||||
<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}
|
||||
{/* Presenter Speaker Notes Overlay */}
|
||||
{showNotes && currentSlideNotes && (
|
||||
<div
|
||||
style={{
|
||||
position: 'absolute',
|
||||
bottom: 24,
|
||||
left: 24,
|
||||
right: 24,
|
||||
padding: '16px 20px',
|
||||
borderRadius: radius || '12px',
|
||||
background: isDark ? 'rgba(15, 23, 42, 0.85)' : 'rgba(255, 255, 255, 0.9)',
|
||||
border: `1px solid ${isDark ? 'rgba(255, 255, 255, 0.12)' : 'rgba(0, 0, 0, 0.1)'}`,
|
||||
backdropFilter: 'blur(16px)',
|
||||
color: isDark ? '#f8fafc' : '#0f172a',
|
||||
zIndex: 100,
|
||||
boxShadow: '0 10px 25px -5px rgba(0, 0, 0, 0.25), 0 8px 10px -6px rgba(0, 0, 0, 0.25)',
|
||||
}}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
|
||||
<span style={{ fontSize: 10, fontWeight: 700, letterSpacing: '0.12em', textTransform: 'uppercase', color: palette.accent }}>
|
||||
Notes de présentation
|
||||
</span>
|
||||
<button
|
||||
onClick={() => setShowNotes(false)}
|
||||
style={{ background: 'none', border: 'none', color: isDark ? '#94a3b8' : '#64748b', cursor: 'pointer', fontSize: 13, padding: 4 }}
|
||||
title="Masquer les notes"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<p style={{ margin: 0, fontSize: 14, lineHeight: 1.6, fontWeight: 500 }}>
|
||||
{currentSlideNotes}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Controls: Slide counter & Speaker notes toggle */}
|
||||
<div style={{ position: 'absolute', bottom: 12, right: 16, zIndex: 40, display: 'flex', gap: 8 }}>
|
||||
{currentSlideNotes && (
|
||||
<button
|
||||
onClick={() => setShowNotes(!showNotes)}
|
||||
style={{
|
||||
fontSize: 11, fontWeight: 700,
|
||||
color: showNotes ? '#fff' : (isDark ? 'rgba(255,255,255,0.75)' : 'rgba(0,0,0,0.65)'),
|
||||
background: showNotes ? palette.accent : (isDark ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.05)'),
|
||||
border: `1px solid ${showNotes ? 'transparent' : (isDark ? 'rgba(255,255,255,0.1)' : 'rgba(0,0,0,0.08)')}`,
|
||||
padding: '4px 12px', borderRadius: 100, cursor: 'pointer',
|
||||
display: 'flex', alignItems: 'center', gap: 6, transition: 'all 0.2s',
|
||||
backdropFilter: 'blur(4px)',
|
||||
}}
|
||||
title="Bascule des notes de présentation (Raccourci: N)"
|
||||
>
|
||||
<span>📝</span>
|
||||
<span>Notes</span>
|
||||
</button>
|
||||
)}
|
||||
<div style={{ 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)', border: `1px solid ${isDark ? 'rgba(255,255,255,0.06)' : 'rgba(0,0,0,0.04)'}` }}>
|
||||
{current + 1} / {total}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user