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

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