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:
Antigravity
2026-05-23 09:19:52 +00:00
parent a122a0eade
commit 18ffd76c1e
16 changed files with 1042 additions and 134 deletions

View File

@@ -4,9 +4,21 @@ import { useState, useEffect } from 'react'
import { createPortal } from 'react-dom'
import { NoteChartFromCode } from './note-chart'
import { suggestCharts, chartSuggestionToMarkdown, type ChartSuggestion, type SuggestChartsResponse } from '@/lib/ai/services/chart-suggestion.service'
import { BarChart3, X, Search, AlertCircle } from 'lucide-react'
import { BarChart3, X, Search, AlertCircle, Upgrade } from 'lucide-react'
import { cn } from '@/lib/utils'
// Chart type to color mapping for visual variety
const CHART_TYPE_COLORS: Record<string, string> = {
bar: 'bg-blue-500/10 text-blue-600 border-blue-500/20',
'horizontal-bar': 'bg-indigo-500/10 text-indigo-600 border-indigo-500/20',
line: 'bg-emerald-500/10 text-emerald-600 border-emerald-500/20',
area: 'bg-teal-500/10 text-teal-600 border-teal-500/20',
pie: 'bg-violet-500/10 text-violet-600 border-violet-500/20',
radar: 'bg-fuchsia-500/10 text-fuchsia-600 border-fuchsia-500/20',
funnel: 'bg-amber-500/10 text-amber-600 border-amber-500/20',
gauge: 'bg-rose-500/10 text-rose-600 border-rose-500/20',
}
interface ChartSuggestionsDialogProps {
isOpen: boolean
content: string
@@ -107,7 +119,7 @@ export function ChartSuggestionsDialog({
{/* Header */}
<div className="flex items-center justify-between px-6 py-4 border-b">
<div className="flex items-center gap-3">
<BarChart3 className="w-5 h-5 text-primary" />
<BarChart3 className="w-5 h-5 text-blue-500" />
<div>
<h2 className="text-lg font-semibold">Chart Suggestions</h2>
<p className="text-sm text-muted-foreground">
@@ -142,6 +154,22 @@ export function ChartSuggestionsDialog({
<p className="text-muted-foreground">Analyzing your content for chart data...</p>
</div>
</div>
) : response?.quotaExceeded ? (
<div className="flex items-center justify-center py-12">
<div className="text-center max-w-md">
<Upgrade className="w-12 h-12 mx-auto mb-4 text-orange-500" />
<h3 className="text-lg font-semibold mb-2">AI Quota Exceeded</h3>
<p className="text-muted-foreground mb-4">
{response.error || 'You have reached your AI usage limit.'}
</p>
<button
className="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition-colors"
onClick={() => (window.location.href = '/settings/billing')}
>
Upgrade Plan
</button>
</div>
</div>
) : response?.error ? (
<div className="flex items-center justify-center py-12">
<div className="text-center max-w-md">
@@ -186,8 +214,8 @@ export function ChartSuggestionsDialog({
className={cn(
'text-left p-4 rounded-xl border-2 transition-all hover:shadow-md',
isSelected
? 'border-primary bg-primary/5'
: 'border-border hover:border-primary/50'
? (CHART_TYPE_COLORS[suggestion.type]?.replace('/10', '/20') || 'border-blue-500 bg-blue-500/5')
: 'border-border hover:border-blue-500/30'
)}
>
{/* Mini thumbnail */}
@@ -199,7 +227,10 @@ export function ChartSuggestionsDialog({
{/* Chart type badge */}
<div className="mb-2">
<span className="text-xs font-medium px-2 py-1 bg-primary/10 text-primary rounded-full">
<span className={cn(
'text-xs font-medium px-2 py-1 rounded-full border',
CHART_TYPE_COLORS[suggestion.type] || CHART_TYPE_COLORS.bar
)}>
{suggestion.type}
</span>
</div>

View File

@@ -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>
)

View File

@@ -3,15 +3,50 @@
import { useEffect, useState, useMemo, useCallback, useRef } from 'react'
import dynamic from 'next/dynamic'
import { useRouter } from 'next/navigation'
import { Loader2, Network, Filter, X, ExternalLink, Maximize2 } from 'lucide-react'
import {
Loader2,
Network,
Filter,
X,
ExternalLink,
Maximize2,
Calendar,
Clock,
Link2,
FileText,
Check,
Tag,
Sparkles,
ChevronRight,
BookOpen
} from 'lucide-react'
import DOMPurify from 'isomorphic-dompurify'
import { markdownToHtml } from '@/lib/markdown-to-html'
import { useLanguage } from '@/lib/i18n'
import { LabelBadge } from './label-badge'
import { NoteChecklist } from './note-checklist'
const ForceGraph2D = dynamic(() => import('react-force-graph-2d'), { ssr: false })
const MarkdownContent = dynamic(() => import('./markdown-content').then(m => ({ default: m.MarkdownContent })), {
ssr: false,
loading: () => <div className="h-20 w-full animate-pulse bg-concrete/5 rounded" />
})
interface GraphNode { id: string; title: string; notebookId: string | null; createdAt: string; degree: number }
interface GraphEdge { source: string; target: string; weight: number; type: string }
interface Cluster { id: string; name: string }
interface RawData { nodes: GraphNode[]; edges: GraphEdge[]; clusters: Cluster[] }
interface NotePreview { id: string; title: string; content: string; createdAt: string }
interface NotePreview {
id: string
title: string | null
content: string
createdAt: string | Date
updatedAt?: string | Date
labels?: string[] | null
type?: 'text' | 'markdown' | 'richtext' | 'checklist'
checkItems?: { id: string; text: string; checked: boolean }[] | null
isMarkdown?: boolean
}
const PALETTE = ['#6366f1', '#10b981', '#f59e0b', '#ec4899', '#14b8a6', '#8b5cf6', '#ef4444', '#3b82f6', '#84cc16', '#A47148']
@@ -29,6 +64,39 @@ export function NoteGraphView() {
const [previewLoading, setPreviewLoading] = useState(false)
const [selectedNotebookId, setSelectedNotebookId] = useState<string | null>(null)
const { t } = useLanguage()
const plainText = useCallback((html: string | null | undefined) =>
(html ?? '')
.replace(/<[^>]+>/g, ' ')
.replace(/#{1,6}\s/g, '')
.replace(/\*{1,3}([^*]+)\*{1,3}/g, '$1')
.replace(/_{1,2}([^_]+)_{1,2}/g, '$1')
.replace(/`[^`]+`/g, '')
.replace(/!?\[[^\]]*\]\([^)]*\)/g, '')
.replace(/\s+/g, ' ').trim().slice(0, 400), [])
const htmlContent = useMemo(() => {
if (!notePreview?.content) return ''
const isMarkdown = notePreview.type === 'markdown' || notePreview.isMarkdown || (!notePreview.content.includes('<') && !notePreview.content.includes('</'))
let rawHtml = notePreview.content
if (isMarkdown) {
rawHtml = markdownToHtml(notePreview.content)
}
return DOMPurify.sanitize(rawHtml)
}, [notePreview])
const wordCount = useMemo(() => {
if (!notePreview?.content) return 0
const text = plainText(notePreview.content)
return text.split(/\s+/).filter(Boolean).length
}, [notePreview, plainText])
const charCount = useMemo(() => {
if (!notePreview?.content) return 0
return plainText(notePreview.content).length
}, [notePreview, plainText])
// ─── Resize ───────────────────────────────────────────────────────────────
useEffect(() => {
const el = containerRef.current
@@ -172,15 +240,7 @@ export function NoteGraphView() {
graphRef.current?.zoomToFit(400, 50)
}, [])
const plainText = (html: string | null | undefined) =>
(html ?? '')
.replace(/<[^>]+>/g, ' ')
.replace(/#{1,6}\s/g, '')
.replace(/\*{1,3}([^*]+)\*{1,3}/g, '$1')
.replace(/_{1,2}([^_]+)_{1,2}/g, '$1')
.replace(/`[^`]+`/g, '')
.replace(/!?\[[^\]]*\]\([^)]*\)/g, '')
.replace(/\s+/g, ' ').trim().slice(0, 400)
// ─── Cluster painting (stable ref, no deps) ──────────────────────────────
const dataRef = useRef<{ nodes: any[]; colorMap: Map<string|null,string>; clusters: Cluster[] }>({ nodes: [], colorMap: new Map(), clusters: [] })
@@ -240,10 +300,10 @@ export function NoteGraphView() {
{/* Header */}
<div className="px-5 py-3 flex items-center gap-4 shrink-0 border-b border-border/40 bg-white">
<Network size={16} className="text-indigo-500" />
<h1 className="text-sm font-semibold text-ink">Vue en graphe</h1>
<h1 className="text-sm font-semibold text-ink">{t('graphView.title')}</h1>
{rawData && (
<span className="text-[10px] text-concrete/50 font-medium">
{rawData.nodes.length} notes · {rawData.edges.length} liens
{t('graphView.notesCount', { count: rawData.nodes.length })} · {t('graphView.connectionsCount', { count: rawData.edges.length })}
</span>
)}
<div className="flex-1" />
@@ -251,7 +311,7 @@ export function NoteGraphView() {
<Filter size={12} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-concrete/40" />
<input
type="text"
placeholder="Filtrer…"
placeholder={t('graphView.searchPlaceholder')}
value={searchFilter}
onChange={e => setSearchFilter(e.target.value)}
className="pl-7 pr-7 py-1.5 bg-white border border-border/60 rounded-md text-xs text-ink outline-none focus:border-indigo-400 w-44 placeholder:text-concrete/40"
@@ -324,7 +384,7 @@ export function NoteGraphView() {
{!loading && !error && graphData.nodes.length === 0 && (
<div className="absolute inset-0 flex flex-col items-center justify-center gap-3 text-concrete/40">
<Network size={32} />
<p className="text-xs">Aucune note trouvée</p>
<p className="text-xs">{t('graphView.noNotesFound')}</p>
</div>
)}
@@ -332,24 +392,24 @@ export function NoteGraphView() {
{!loading && graphData.nodes.length > 0 && (
<button
onClick={handleZoomToFit}
className="absolute top-4 right-4 z-10 flex items-center gap-1.5 px-3 py-1.5 bg-white border border-border/50 rounded-md text-[11px] text-ink font-medium shadow-sm hover:bg-gray-50 transition-colors"
className="absolute top-4 right-4 z-10 flex items-center gap-1.5 px-3 py-1.5 bg-white border border-border/50 rounded-md text-[11px] text-ink font-medium shadow-sm hover:bg-gray-50 transition-colors animate-fade-in"
>
<Maximize2 size={12} />
Vue globale
{t('graphView.globalView')}
</button>
)}
{/* Cluster legend (Interactive Notebook Filter) */}
{rawData && rawData.clusters && rawData.clusters.length > 0 && (
<div className="absolute top-4 left-4 z-10 flex flex-col gap-2 max-h-[50vh] overflow-y-auto pr-1">
<span className="text-[9px] font-bold text-slate-800 uppercase tracking-wider pl-1 select-none">Carnets</span>
<span className="text-[9px] font-bold text-slate-800 uppercase tracking-wider pl-1 select-none">{t('graphView.notebooks')}</span>
{selectedNotebookId && (
<button
onClick={() => setSelectedNotebookId(null)}
className="flex items-center gap-1.5 px-2.5 py-1 bg-white border border-rose-200 text-rose-600 rounded-full shadow-sm hover:bg-rose-50 transition-all text-[9px] font-semibold w-fit"
>
<X size={10} />
Réinitialiser
{t('graphView.resetFilter')}
</button>
)}
<div className="flex flex-col gap-1.5">
@@ -381,8 +441,8 @@ export function NoteGraphView() {
{/* Legend of relationship types */}
{!loading && !error && graphData.nodes.length > 0 && (
<div className="absolute bottom-4 left-4 z-10 flex flex-col gap-2 p-3 bg-white/95 border border-border/40 rounded-lg shadow-sm max-w-xs">
<h3 className="text-[9px] font-bold text-slate-800 uppercase tracking-wider mb-1">Types de liaisons</h3>
<div className="absolute bottom-4 left-4 z-10 flex flex-col gap-2 p-3 bg-white/95 border border-border/40 rounded-lg shadow-sm max-w-xs select-none">
<h3 className="text-[9px] font-bold text-slate-800 uppercase tracking-wider mb-1">{t('graphView.relationshipTypes')}</h3>
<div className="flex flex-col gap-1.5">
<div className="flex items-center gap-2">
<span className="w-5 h-0.5 rounded shrink-0 bg-[#10b981]" />
@@ -410,44 +470,149 @@ export function NoteGraphView() {
{/* Note detail panel */}
{selectedNode && (
<div className="absolute inset-y-0 right-0 w-80 bg-white border-l border-border/50 flex flex-col shadow-xl z-20">
<div className="flex items-start justify-between p-4 border-b border-border/30">
<div className="flex-1 min-w-0 pr-2">
<h2 className="text-sm font-semibold text-ink leading-tight">{selectedNode.title}</h2>
<p className="text-[10px] text-concrete/50 mt-1">
{new Date(selectedNode.createdAt).toLocaleDateString('fr-FR', { year: 'numeric', month: 'long', day: 'numeric' })}
{' · '}{selectedNode.degree} connexion{selectedNode.degree !== 1 ? 's' : ''}
</p>
<div className="absolute inset-y-0 right-0 w-80 backdrop-blur-md bg-white/95 dark:bg-stone-900/95 border-l border-border/40 flex flex-col shadow-[0_8px_30px_rgb(0,0,0,0.06)] z-20 transition-all duration-300">
{/* Header */}
<div className="flex items-start justify-between p-5 border-b border-border/40">
<div className="flex-1 min-w-0 pr-3">
{selectedNotebookName && (
<button
onClick={() => setSelectedNotebookId(selectedNode.notebookId === selectedNotebookId ? null : selectedNode.notebookId)}
className="mt-2 inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-[9px] font-semibold bg-slate-100 hover:bg-slate-200 text-concrete transition-colors border border-border/30 hover:scale-105 transition-all"
className="mb-2 inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-[9px] font-bold uppercase tracking-wider bg-slate-100 hover:bg-slate-200/80 text-concrete transition-all border border-border/20 hover:scale-105"
>
<span className="w-1.5 h-1.5 rounded-full shrink-0" style={{ backgroundColor: colorMap.get(selectedNode.notebookId) ?? '#94a3b8' }} />
{selectedNotebookName}
</button>
)}
<h2 className="text-sm font-semibold text-slate-800 dark:text-slate-100 leading-snug tracking-tight select-all">
{selectedNode.title || <span className="italic text-concrete/40">{t('notes.untitled')}</span>}
</h2>
</div>
<button onClick={() => setSelectedNode(null)} className="p-1 rounded text-concrete/40 hover:text-ink hover:bg-black/5">
<button
onClick={() => setSelectedNode(null)}
className="p-1.5 rounded-full text-concrete/40 hover:text-slate-800 dark:hover:text-slate-100 hover:bg-slate-100 dark:hover:bg-stone-800 transition-all"
>
<X size={14} />
</button>
</div>
<div className="flex-1 overflow-y-auto p-4">
{previewLoading ? (
<Loader2 size={16} className="animate-spin text-concrete/30 mx-auto mt-8" />
) : (
<p className="text-xs text-concrete/70 leading-relaxed">
{plainText(notePreview?.content) || <span className="italic text-concrete/30">Note vide</span>}
</p>
{/* Quick Metadata Info */}
<div className="px-5 py-3.5 bg-slate-50/50 dark:bg-stone-950/20 border-b border-border/30 grid grid-cols-2 gap-y-2 gap-x-4 text-[10px] text-concrete/60 select-none">
<div className="flex items-center gap-1.5">
<Calendar size={11} className="text-concrete/40 shrink-0" />
<span className="truncate" title={new Date(selectedNode.createdAt).toLocaleString()}>
{new Date(selectedNode.createdAt).toLocaleDateString('fr-FR', { day: '2-digit', month: 'short', year: 'numeric' })}
</span>
</div>
<div className="flex items-center gap-1.5">
<Link2 size={11} className="text-concrete/40 shrink-0" />
<span>
{t(selectedNode.degree === 1 ? 'graphView.connections' : 'graphView.connectionsPlural', { count: selectedNode.degree })}
</span>
</div>
{!previewLoading && notePreview && (
<>
<div className="flex items-center gap-1.5">
<FileText size={11} className="text-concrete/40 shrink-0" />
<span>{t('graphView.preview.words', { count: wordCount })}</span>
</div>
{notePreview.updatedAt && (
<div className="flex items-center gap-1.5 col-span-2 border-t border-border/20 pt-1.5 mt-0.5">
<Clock size={11} className="text-concrete/40 shrink-0" />
<span className="truncate">
{t('graphView.preview.updated')}{' '}
{new Date(notePreview.updatedAt).toLocaleDateString('fr-FR', { day: '2-digit', month: 'short', hour: '2-digit', minute: '2-digit' })}
</span>
</div>
)}
</>
)}
</div>
<div className="p-3 border-t border-border/30">
{/* Scrollable Content */}
<div className="flex-1 overflow-y-auto p-5 space-y-4">
{previewLoading ? (
/* Sleek Skeleton Loader */
<div className="space-y-4 animate-pulse">
<div className="h-4 bg-stone-200/60 dark:bg-stone-800/60 rounded w-3/4" />
<div className="space-y-2">
<div className="h-3 bg-stone-200/50 dark:bg-stone-800/50 rounded w-full" />
<div className="h-3 bg-stone-200/50 dark:bg-stone-800/50 rounded w-11/12" />
<div className="h-3 bg-stone-200/50 dark:bg-stone-800/50 rounded w-4/5" />
</div>
<div className="space-y-2 pt-2">
<div className="h-3 bg-stone-200/50 dark:bg-stone-800/50 rounded w-full" />
<div className="h-3 bg-stone-200/50 dark:bg-stone-800/50 rounded w-5/6" />
</div>
</div>
) : !notePreview || (!notePreview.content && (!notePreview.checkItems || notePreview.checkItems.length === 0)) ? (
<div className="flex flex-col items-center justify-center py-16 text-concrete/30 gap-2.5">
<FileText size={32} className="stroke-[1.2] text-concrete/20" />
<p className="text-xs italic font-medium">{t('graphView.preview.emptyNote')}</p>
</div>
) : (
<>
{/* Note Content Renderer */}
{notePreview.type === 'checklist' && notePreview.checkItems && notePreview.checkItems.length > 0 ? (
<div className="space-y-2 select-none">
<NoteChecklist
items={notePreview.checkItems}
onToggleItem={() => {}}
/>
</div>
) : notePreview.type === 'markdown' || notePreview.isMarkdown ? (
<div className="text-xs text-slate-600 dark:text-stone-300">
<MarkdownContent
content={notePreview.content}
className="prose-h1:text-sm prose-h1:font-bold prose-h1:text-slate-800 dark:prose-h1:text-stone-100 prose-h1:mt-3 prose-h1:mb-1 prose-h2:text-xs prose-h2:font-bold prose-h2:text-slate-700 dark:prose-h2:text-stone-200 prose-h2:mt-2 prose-h2:mb-1 prose-p:text-xs prose-p:leading-relaxed prose-p:mb-2 prose-ul:list-disc prose-ul:pl-4 prose-ol:list-decimal prose-ol:pl-4"
/>
</div>
) : (
<div
className="text-xs text-slate-600 dark:text-stone-300 space-y-2 leading-relaxed break-words
[&_h1]:text-sm [&_h1]:font-bold [&_h1]:text-slate-800 dark:[&_h1]:text-stone-100 [&_h1]:mt-4 [&_h1]:mb-1.5 [&_h1]:first:mt-0
[&_h2]:text-xs [&_h2]:font-bold [&_h2]:text-slate-700 dark:[&_h2]:text-stone-200 [&_h2]:mt-3 [&_h2]:mb-1 [&_h2]:first:mt-0
[&_h3]:text-xs [&_h3]:font-semibold [&_h3]:text-slate-600 dark:[&_h3]:text-stone-300 [&_h3]:mt-2 [&_h3]:mb-1 [&_h3]:first:mt-0
[&_p]:mb-2 [&_p]:last:mb-0
[&_ul]:list-disc [&_ul]:pl-4 [&_ul]:mb-2
[&_ol]:list-decimal [&_ol]:pl-4 [&_ol]:mb-2
[&_li]:mb-0.5
[&_strong]:font-semibold [&_strong]:text-slate-800 dark:[&_strong]:text-stone-100
[&_em]:italic
[&_code]:px-1 [&_code]:py-0.5 [&_code]:bg-slate-100 dark:[&_code]:bg-stone-850 [&_code]:rounded [&_code]:font-mono [&_code]:text-[10px]
[&_pre]:p-2.5 [&_pre]:bg-slate-900 [&_pre]:text-slate-100 [&_pre]:rounded-lg [&_pre]:overflow-x-auto [&_pre]:font-mono [&_pre]:text-[10px] [&_pre]:my-2
[&_blockquote]:border-l-2 [&_blockquote]:border-slate-300 dark:[&_blockquote]:border-stone-700 [&_blockquote]:pl-3 [&_blockquote]:italic [&_blockquote]:text-slate-500 [&_blockquote]:my-2
[&_a]:text-indigo-600 dark:[&_a]:text-indigo-400 [&_a]:underline [&_a]:hover:text-indigo-500"
dangerouslySetInnerHTML={{ __html: htmlContent }}
/>
)}
{/* Refined Tags list */}
{Array.isArray(notePreview.labels) && notePreview.labels.length > 0 && (
<div className="border-t border-border/20 pt-4 mt-4 select-none">
<div className="flex items-center gap-1 text-[10px] font-bold text-slate-800 dark:text-stone-300 uppercase tracking-wider mb-2">
<Tag size={10} className="text-concrete/40 shrink-0" />
<span>{t('graphView.preview.tags')}</span>
</div>
<div className="flex flex-wrap gap-1.5">
{notePreview.labels.map((label: string) => (
<LabelBadge key={label} label={label} variant="default" />
))}
</div>
</div>
)}
</>
)}
</div>
{/* Premium Action Footer */}
<div className="p-4 border-t border-border/40 bg-slate-50/50 dark:bg-stone-950/20">
<button
onClick={() => router.push(`/notes/${selectedNode.id}`)}
className="w-full flex items-center justify-center gap-2 py-2 bg-indigo-600 hover:bg-indigo-500 text-white text-xs font-medium rounded-md transition-colors"
className="group w-full flex items-center justify-center gap-2 py-2.5 px-4 bg-brand-accent hover:bg-brand-accent/90 text-white active:scale-[0.98] text-xs font-semibold rounded-lg shadow-sm transition-all duration-200"
>
<ExternalLink size={12} />
Ouvrir la note
<BookOpen size={12} className="group-hover:scale-110 transition-transform" />
<span>{t('graphView.preview.openNote')}</span>
<ChevronRight size={12} className="group-hover:translate-x-0.5 transition-transform ml-0.5" />
</button>
</div>
</div>