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:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user