feat: add slides generation tool with multiple slide types
- Add slides.tool.ts with support for title, bullets, chart, stats, table, cards, timeline, quote, comparison, equation, image, summary slide types - Chart types: bar, horizontal-bar, line, donut, radar - Integrate with agent executor and canvas system - Add multilingual support (en/fr) - Various UI improvements and bug fixes Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
354
memento-note/components/note-graph-view.tsx
Normal file
354
memento-note/components/note-graph-view.tsx
Normal file
@@ -0,0 +1,354 @@
|
||||
'use client'
|
||||
|
||||
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'
|
||||
|
||||
const ForceGraph2D = dynamic(() => import('react-force-graph-2d'), { ssr: false })
|
||||
|
||||
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 }
|
||||
|
||||
const PALETTE = ['#6366f1', '#10b981', '#f59e0b', '#ec4899', '#14b8a6', '#8b5cf6', '#ef4444', '#3b82f6', '#84cc16', '#A47148']
|
||||
|
||||
export function NoteGraphView() {
|
||||
const router = useRouter()
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const graphRef = useRef<any>(null)
|
||||
const [dimensions, setDimensions] = useState({ width: 800, height: 600 })
|
||||
const [rawData, setRawData] = useState<RawData | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [searchFilter, setSearchFilter] = useState('')
|
||||
const [selectedNode, setSelectedNode] = useState<GraphNode | null>(null)
|
||||
const [notePreview, setNotePreview] = useState<NotePreview | null>(null)
|
||||
const [previewLoading, setPreviewLoading] = useState(false)
|
||||
|
||||
// ─── Resize ───────────────────────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
const el = containerRef.current
|
||||
if (!el) return
|
||||
const ro = new ResizeObserver(entries => {
|
||||
const { width, height } = entries[0].contentRect
|
||||
setDimensions({ width: Math.floor(width), height: Math.floor(height) })
|
||||
})
|
||||
ro.observe(el)
|
||||
return () => ro.disconnect()
|
||||
}, [])
|
||||
|
||||
// ─── Fetch data ───────────────────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
setLoading(true)
|
||||
fetch('/api/graph')
|
||||
.then(r => { if (!r.ok) throw new Error('Erreur réseau'); return r.json() })
|
||||
.then(d => setRawData(d))
|
||||
.catch(e => setError(e.message))
|
||||
.finally(() => setLoading(false))
|
||||
}, [])
|
||||
|
||||
// ─── Configure forces once graph is mounted ───────────────────────────────
|
||||
const forcesConfigured = useRef(false)
|
||||
useEffect(() => {
|
||||
if (!rawData || forcesConfigured.current) return
|
||||
// Wait for the ForceGraph to mount
|
||||
const timer = setTimeout(() => {
|
||||
const fg = graphRef.current
|
||||
if (!fg) return
|
||||
fg.d3Force('charge')?.strength(-120)
|
||||
fg.d3Force('link')?.distance(55)
|
||||
fg.d3Force('center')?.strength(0.05)
|
||||
forcesConfigured.current = true
|
||||
}, 200)
|
||||
return () => clearTimeout(timer)
|
||||
}, [rawData])
|
||||
|
||||
// ─── Note preview ─────────────────────────────────────────────────────────
|
||||
useEffect(() => {
|
||||
if (!selectedNode) { setNotePreview(null); return }
|
||||
setPreviewLoading(true)
|
||||
fetch(`/api/notes/${selectedNode.id}`)
|
||||
.then(r => r.ok ? r.json() : null)
|
||||
.then(res => setNotePreview(res?.data ?? null))
|
||||
.catch(() => setNotePreview(null))
|
||||
.finally(() => setPreviewLoading(false))
|
||||
}, [selectedNode])
|
||||
|
||||
// ─── Color map ────────────────────────────────────────────────────────────
|
||||
const colorMap = useMemo(() => {
|
||||
if (!rawData) return new Map<string | null, string>()
|
||||
const map = new Map<string | null, string>()
|
||||
const ids = [...new Set(rawData.nodes.map(n => n.notebookId).filter(Boolean))]
|
||||
ids.forEach((id, i) => map.set(id, PALETTE[i % PALETTE.length]))
|
||||
return map
|
||||
}, [rawData])
|
||||
|
||||
// ─── Graph data ───────────────────────────────────────────────────────────
|
||||
const graphData = useMemo(() => {
|
||||
if (!rawData) return { nodes: [], links: [] }
|
||||
const filtered = searchFilter.trim()
|
||||
? rawData.nodes.filter(n => n.title.toLowerCase().includes(searchFilter.toLowerCase()))
|
||||
: rawData.nodes
|
||||
const filteredIds = new Set(filtered.map(n => n.id))
|
||||
return {
|
||||
nodes: filtered.map(n => ({
|
||||
id: n.id,
|
||||
name: n.title,
|
||||
val: 1 + Math.min(n.degree, 8) * 0.5,
|
||||
color: colorMap.get(n.notebookId) ?? '#94a3b8',
|
||||
notebookId: n.notebookId,
|
||||
degree: n.degree,
|
||||
})),
|
||||
links: rawData.edges
|
||||
.filter(e => filteredIds.has(e.source) && filteredIds.has(e.target))
|
||||
.map(e => ({
|
||||
source: e.source,
|
||||
target: e.target,
|
||||
color: e.type === 'title_mention' ? '#f59e0b' : e.type === 'shared_label' ? '#6366f1' : '#e2e8f0',
|
||||
width: e.type === 'title_mention' ? 2 : e.type === 'shared_label' ? 1.5 : 0.6,
|
||||
})),
|
||||
}
|
||||
}, [rawData, searchFilter, colorMap])
|
||||
|
||||
// ─── Handlers (double-click via timer) ──────────────────────────────────
|
||||
const lastClickRef = useRef<{ id: string; time: number } | null>(null)
|
||||
|
||||
const handleNodeClick = useCallback((node: any) => {
|
||||
if (!rawData) return
|
||||
const now = Date.now()
|
||||
const last = lastClickRef.current
|
||||
if (last && last.id === node.id && now - last.time < 350) {
|
||||
// Double-click → zoom
|
||||
lastClickRef.current = null
|
||||
graphRef.current?.centerAt(node.x, node.y, 600)
|
||||
graphRef.current?.zoom(3, 600)
|
||||
return
|
||||
}
|
||||
lastClickRef.current = { id: node.id, time: now }
|
||||
setSelectedNode(rawData.nodes.find(n => n.id === node.id) ?? null)
|
||||
}, [rawData])
|
||||
|
||||
const handleZoomToFit = useCallback(() => {
|
||||
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: [] })
|
||||
dataRef.current = { nodes: graphData.nodes, colorMap, clusters: rawData?.clusters ?? [] }
|
||||
|
||||
const paintClusters = useRef((ctx: CanvasRenderingContext2D, globalScale: number) => {
|
||||
const { nodes, colorMap: cm, clusters } = dataRef.current
|
||||
if (!nodes || nodes.length === 0) return
|
||||
|
||||
const groups = new Map<string, { x: number; y: number }[]>()
|
||||
for (const node of nodes) {
|
||||
if (!node.notebookId || node.x === undefined || node.y === undefined) continue
|
||||
if (!groups.has(node.notebookId)) groups.set(node.notebookId, [])
|
||||
groups.get(node.notebookId)!.push({ x: node.x, y: node.y })
|
||||
}
|
||||
|
||||
for (const [nbId, pts] of groups) {
|
||||
if (pts.length < 3) continue
|
||||
const color = cm.get(nbId) ?? '#94a3b8'
|
||||
const cx = pts.reduce((s, p) => s + p.x, 0) / pts.length
|
||||
const cy = pts.reduce((s, p) => s + p.y, 0) / pts.length
|
||||
let maxR = 0
|
||||
for (const p of pts) {
|
||||
const d = Math.sqrt((p.x - cx) ** 2 + (p.y - cy) ** 2)
|
||||
if (d > maxR) maxR = d
|
||||
}
|
||||
const r = maxR + 30
|
||||
|
||||
ctx.beginPath()
|
||||
ctx.arc(cx, cy, r, 0, 2 * Math.PI)
|
||||
ctx.fillStyle = color + '0A'
|
||||
ctx.fill()
|
||||
ctx.strokeStyle = color + '30'
|
||||
ctx.lineWidth = 1.5 / globalScale
|
||||
ctx.setLineDash([5 / globalScale, 5 / globalScale])
|
||||
ctx.stroke()
|
||||
ctx.setLineDash([])
|
||||
|
||||
// Cluster name
|
||||
if (globalScale > 0.4) {
|
||||
const name = clusters.find(c => c.id === nbId)?.name ?? ''
|
||||
if (name) {
|
||||
const fs = Math.min(12, 9 / globalScale)
|
||||
ctx.font = `600 ${fs}px -apple-system, sans-serif`
|
||||
ctx.fillStyle = color + 'BB'
|
||||
ctx.textAlign = 'center'
|
||||
ctx.textBaseline = 'bottom'
|
||||
ctx.fillText(name, cx, cy - r + 4)
|
||||
}
|
||||
}
|
||||
}
|
||||
}).current
|
||||
|
||||
// ─── Render ───────────────────────────────────────────────────────────────
|
||||
return (
|
||||
<div className="flex flex-col h-full bg-[#FAFAF9]">
|
||||
{/* 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>
|
||||
{rawData && (
|
||||
<span className="text-[10px] text-concrete/50 font-medium">
|
||||
{rawData.nodes.length} notes · {rawData.edges.length} liens
|
||||
</span>
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
<div className="relative">
|
||||
<Filter size={12} className="absolute left-2.5 top-1/2 -translate-y-1/2 text-concrete/40" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Filtrer…"
|
||||
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"
|
||||
/>
|
||||
{searchFilter && (
|
||||
<button onClick={() => setSearchFilter('')} className="absolute right-2 top-1/2 -translate-y-1/2 text-concrete/40 hover:text-ink">
|
||||
<X size={12} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Canvas */}
|
||||
<div ref={containerRef} className="flex-1 relative overflow-hidden">
|
||||
{loading && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-[#FAFAF9]">
|
||||
<Loader2 size={24} className="animate-spin text-concrete/40" />
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<p className="text-sm text-rose-500">{error}</p>
|
||||
</div>
|
||||
)}
|
||||
{!loading && !error && graphData.nodes.length > 0 && (
|
||||
<ForceGraph2D
|
||||
ref={graphRef}
|
||||
graphData={graphData}
|
||||
width={dimensions.width}
|
||||
height={dimensions.height}
|
||||
backgroundColor="#FAFAF9"
|
||||
nodeRelSize={5}
|
||||
nodeVal="val"
|
||||
nodeColor="color"
|
||||
nodeLabel="name"
|
||||
linkColor="color"
|
||||
linkWidth="width"
|
||||
onNodeClick={handleNodeClick}
|
||||
onNodeHover={(node: any) => {
|
||||
if (containerRef.current) containerRef.current.style.cursor = node ? 'pointer' : 'default'
|
||||
}}
|
||||
onRenderFramePre={paintClusters}
|
||||
nodeCanvasObjectMode={() => 'after'}
|
||||
nodeCanvasObject={(node: any, ctx: CanvasRenderingContext2D, globalScale: number) => {
|
||||
if (globalScale < 0.7) return
|
||||
const name: string = node.name ?? ''
|
||||
const label = name.length > 20 ? name.slice(0, 18) + '…' : name
|
||||
const fontSize = 11 / globalScale
|
||||
if (fontSize > 18) return
|
||||
ctx.font = `${fontSize}px -apple-system, sans-serif`
|
||||
ctx.textAlign = 'center'
|
||||
ctx.textBaseline = 'top'
|
||||
const r = Math.sqrt(node.val ?? 1) * 5
|
||||
// White background behind label
|
||||
const tw = ctx.measureText(label).width
|
||||
const lx = node.x - tw / 2 - 2
|
||||
const ly = node.y + r + 2
|
||||
ctx.fillStyle = 'rgba(250,250,249,0.85)'
|
||||
ctx.fillRect(lx, ly, tw + 4, fontSize + 2)
|
||||
// Label text
|
||||
ctx.fillStyle = '#334155'
|
||||
ctx.fillText(label, node.x, ly + 1)
|
||||
}}
|
||||
cooldownTicks={80}
|
||||
d3AlphaDecay={0.03}
|
||||
d3VelocityDecay={0.4}
|
||||
/>
|
||||
)}
|
||||
{!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>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Zoom to fit */}
|
||||
{!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"
|
||||
>
|
||||
<Maximize2 size={12} />
|
||||
Vue globale
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Cluster legend */}
|
||||
{rawData && rawData.clusters && rawData.clusters.length > 0 && (
|
||||
<div className="absolute top-4 left-4 z-10 flex flex-col gap-1.5 max-h-[50vh] overflow-y-auto pr-1">
|
||||
{rawData.clusters.map(c => (
|
||||
<div key={c.id} className="flex items-center gap-1.5 px-2 py-0.5 bg-white/90 border border-border/30 rounded-full shadow-sm">
|
||||
<span className="w-2 h-2 rounded-full shrink-0" style={{ backgroundColor: colorMap.get(c.id) ?? '#94a3b8' }} />
|
||||
<span className="text-[9px] font-medium text-concrete/70 whitespace-nowrap">{c.name}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 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>
|
||||
<button onClick={() => setSelectedNode(null)} className="p-1 rounded text-concrete/40 hover:text-ink hover:bg-black/5">
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-3 border-t border-border/30">
|
||||
<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"
|
||||
>
|
||||
<ExternalLink size={12} />
|
||||
Ouvrir la note
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user