feat: brainstorm sessions, PDF document Q&A, embedding fixes, and UI improvements
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 7s
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 7s
- Add brainstorm feature with collaborative canvas, AI idea generation, live cursors, playback, and export - Add PDF upload/extraction/ingestion pipeline with pgvector document search (RAG) - Add document Q&A overlay with streaming chat and PDF preview - Add note attachments UI with status polling, grid layout, and auto-scroll - Add task extraction AI tool and agent executor improvements - Fix NoteEmbedding missing updatedAt column, re-index 66 notes with 1536-dim embeddings - Fix brainstorm 'Create Note' button: add success toast and redirect to created note - Fix memory echo notification infinite polling - Fix chat route to always include document_search tool - Add brainstorm i18n keys across all 14 locales - Add socket server for real-time brainstorm collaboration - Add hierarchical notebook selector and organize notebook dialog improvements - Add sidebar brainstorm section with session management - Update prisma schema with brainstorm tables, attachments, and document chunks
This commit is contained in:
585
memento-note/components/brainstorm/wave-canvas.tsx
Normal file
585
memento-note/components/brainstorm/wave-canvas.tsx
Normal file
@@ -0,0 +1,585 @@
|
||||
'use client'
|
||||
|
||||
import React, { useEffect, useRef, useState, useCallback } from 'react'
|
||||
import * as d3 from 'd3'
|
||||
import { BrainstormSession } from '@/types/brainstorm'
|
||||
|
||||
interface WaveCanvasProps {
|
||||
session: BrainstormSession
|
||||
onNodeSelect: (id: string) => void
|
||||
onPositionUpdate?: (id: string, pos: { x: number; y: number }) => void
|
||||
selectedNodeId: string | null
|
||||
onCreateIdea?: (data: { title: string; parentIdeaId?: string; x: number; y: number }) => void
|
||||
remoteMove?: { ideaId: string; x: number; y: number; _seq: number } | null
|
||||
manualEditTrigger?: number
|
||||
playbackIdeas?: any[] | null
|
||||
}
|
||||
|
||||
const WAVE_COLORS: Record<number, string> = {
|
||||
1: '#fb923c',
|
||||
2: '#60a5fa',
|
||||
3: '#a78bfa',
|
||||
}
|
||||
|
||||
export const WaveCanvas: React.FC<WaveCanvasProps> = ({
|
||||
session,
|
||||
onNodeSelect,
|
||||
onPositionUpdate,
|
||||
selectedNodeId,
|
||||
onCreateIdea,
|
||||
remoteMove,
|
||||
manualEditTrigger,
|
||||
playbackIdeas,
|
||||
}) => {
|
||||
const svgRef = useRef<SVGSVGElement>(null)
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const nodeRef = useRef<d3.Selection<SVGGElement, any, SVGGElement, unknown> | null>(null)
|
||||
const linkRef = useRef<d3.Selection<SVGLineElement, any, SVGGElement, unknown> | null>(null)
|
||||
const simulationRef = useRef<d3.Simulation<any, any> | null>(null)
|
||||
const transformRef = useRef<d3.ZoomTransform>(d3.zoomIdentity)
|
||||
|
||||
const onNodeSelectRef = useRef(onNodeSelect)
|
||||
onNodeSelectRef.current = onNodeSelect
|
||||
const onPositionUpdateRef = useRef(onPositionUpdate)
|
||||
onPositionUpdateRef.current = onPositionUpdate
|
||||
const onCreateIdeaRef = useRef(onCreateIdea)
|
||||
onCreateIdeaRef.current = onCreateIdea
|
||||
|
||||
const ideasKey = session?.ideas?.map(i => `${i.id}:${i.status}:${i.positionX}:${i.positionY}`).join('|') || ''
|
||||
const sessionId = session?.id || ''
|
||||
|
||||
const [isDark, setIsDark] = useState(false)
|
||||
useEffect(() => {
|
||||
const el = document.documentElement
|
||||
setIsDark(el.classList.contains('dark'))
|
||||
const observer = new MutationObserver(() => {
|
||||
setIsDark(el.classList.contains('dark'))
|
||||
})
|
||||
observer.observe(el, { attributes: true, attributeFilter: ['class'] })
|
||||
return () => observer.disconnect()
|
||||
}, [])
|
||||
|
||||
const [editingNode, setEditingNode] = useState<{
|
||||
x: number
|
||||
y: number
|
||||
parentId: string | null
|
||||
} | null>(null)
|
||||
const [editText, setEditText] = useState('')
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (editingNode && inputRef.current) {
|
||||
inputRef.current.focus()
|
||||
}
|
||||
}, [editingNode])
|
||||
|
||||
useEffect(() => {
|
||||
if (!manualEditTrigger || manualEditTrigger === 0) return
|
||||
const container = containerRef.current
|
||||
if (!container) return
|
||||
const rect = container.getBoundingClientRect()
|
||||
setEditingNode({
|
||||
x: rect.width / 2,
|
||||
y: rect.height / 2,
|
||||
parentId: null,
|
||||
})
|
||||
setEditText('')
|
||||
}, [manualEditTrigger])
|
||||
|
||||
const toSvgCoords = useCallback((clientX: number, clientY: number) => {
|
||||
const svg = svgRef.current
|
||||
if (!svg) return { x: 0, y: 0 }
|
||||
const pt = svg.createSVGPoint()
|
||||
pt.x = clientX
|
||||
pt.y = clientY
|
||||
const ctm = svg.getScreenCTM()
|
||||
if (!ctm) return { x: 0, y: 0 }
|
||||
const svgPt = pt.matrixTransform(ctm.inverse())
|
||||
const t = transformRef.current
|
||||
return {
|
||||
x: (svgPt.x - t.x) / t.k,
|
||||
y: (svgPt.y - t.y) / t.k,
|
||||
}
|
||||
}, [])
|
||||
|
||||
const toScreenCoords = useCallback((svgX: number, svgY: number) => {
|
||||
const svg = svgRef.current
|
||||
if (!svg) return { x: 0, y: 0 }
|
||||
const t = transformRef.current
|
||||
const screenX = svgX * t.k + t.x
|
||||
const screenY = svgY * t.k + t.y
|
||||
const ctm = svg.getScreenCTM()
|
||||
if (!ctm) return { x: 0, y: 0 }
|
||||
const pt = svg.createSVGPoint()
|
||||
pt.x = screenX
|
||||
pt.y = screenY
|
||||
const screenPt = pt.matrixTransform(ctm)
|
||||
return { x: screenPt.x, y: screenPt.y }
|
||||
}, [])
|
||||
|
||||
const handleSubmitIdea = useCallback(() => {
|
||||
if (!editText.trim() || !editingNode || !onCreateIdeaRef.current) return
|
||||
onCreateIdeaRef.current({
|
||||
title: editText.trim(),
|
||||
parentIdeaId: editingNode.parentId || undefined,
|
||||
x: editingNode.x,
|
||||
y: editingNode.y,
|
||||
})
|
||||
setEditingNode(null)
|
||||
setEditText('')
|
||||
}, [editText, editingNode])
|
||||
|
||||
useEffect(() => {
|
||||
if (!svgRef.current || !containerRef.current) return
|
||||
if (!session?.id || !session?.ideas) return
|
||||
|
||||
const activeIdeas = playbackIdeas
|
||||
? playbackIdeas.map((pi: any) => ({
|
||||
...pi,
|
||||
description: '',
|
||||
connectionToSeed: null,
|
||||
convertedToNoteId: null,
|
||||
relatedNoteIds: null,
|
||||
createdBy: null,
|
||||
noteRefs: [],
|
||||
creator: null,
|
||||
createdAt: new Date(),
|
||||
sessionId: session.id,
|
||||
}))
|
||||
: session.ideas
|
||||
|
||||
const width = containerRef.current.clientWidth
|
||||
const height = containerRef.current.clientHeight
|
||||
const centerX = width / 2
|
||||
const centerY = height / 2
|
||||
|
||||
const svg = d3.select(svgRef.current)
|
||||
svg.selectAll('*').remove()
|
||||
|
||||
const g = svg.append('g')
|
||||
|
||||
const zoom = d3.zoom<SVGSVGElement, unknown>()
|
||||
.scaleExtent([0.1, 5])
|
||||
.on('zoom', (event) => {
|
||||
g.attr('transform', event.transform)
|
||||
transformRef.current = event.transform
|
||||
})
|
||||
|
||||
svg.call(zoom)
|
||||
svg.call(zoom.transform, d3.zoomIdentity.translate(centerX, centerY).scale(0.8))
|
||||
|
||||
interface D3Node extends d3.SimulationNodeDatum {
|
||||
id: string
|
||||
type: 'root' | 'idea'
|
||||
wave?: number
|
||||
title: string
|
||||
color: string
|
||||
radius: number
|
||||
status?: string
|
||||
createdByType?: string | null
|
||||
creatorInitial?: string
|
||||
}
|
||||
|
||||
interface D3Link extends d3.SimulationLinkDatum<D3Node> {
|
||||
source: string | D3Node
|
||||
target: string | D3Node
|
||||
type: 'wave' | 'parent'
|
||||
}
|
||||
|
||||
const nodes: D3Node[] = []
|
||||
const links: D3Link[] = []
|
||||
|
||||
nodes.push({
|
||||
id: 'root',
|
||||
type: 'root',
|
||||
title: session.seedIdea,
|
||||
color: '#141414',
|
||||
radius: 40,
|
||||
fx: 0,
|
||||
fy: 0,
|
||||
})
|
||||
|
||||
activeIdeas.forEach((idea) => {
|
||||
const creator = (idea as any).creator as { name: string | null } | undefined | null
|
||||
nodes.push({
|
||||
id: idea.id,
|
||||
type: 'idea',
|
||||
wave: idea.waveNumber,
|
||||
title: idea.title,
|
||||
color: WAVE_COLORS[idea.waveNumber as 1 | 2 | 3] || '#94a3b8',
|
||||
radius: idea.status === 'dismissed' ? 18 : 28,
|
||||
status: idea.status,
|
||||
createdByType: idea.createdByType || 'ai',
|
||||
creatorInitial: creator?.name ? creator.name.charAt(0).toUpperCase() : undefined,
|
||||
x: idea.positionX ?? undefined,
|
||||
y: idea.positionY ?? undefined,
|
||||
})
|
||||
|
||||
if (idea.parentIdeaId) {
|
||||
links.push({ source: idea.parentIdeaId, target: idea.id, type: 'parent' })
|
||||
} else {
|
||||
links.push({ source: 'root', target: idea.id, type: 'wave' })
|
||||
}
|
||||
})
|
||||
|
||||
const simulation = d3
|
||||
.forceSimulation<D3Node>(nodes)
|
||||
.force(
|
||||
'link',
|
||||
d3
|
||||
.forceLink<D3Node, D3Link>(links)
|
||||
.id((d) => d.id)
|
||||
.distance((d) => {
|
||||
if (d.type === 'wave') {
|
||||
const targetNode = nodes.find(
|
||||
(n) =>
|
||||
n.id ===
|
||||
(typeof d.target === 'string' ? d.target : (d.target as any).id)
|
||||
)
|
||||
return (targetNode?.wave || 1) * 200
|
||||
}
|
||||
if (d.type === 'parent') return 180
|
||||
return 100
|
||||
})
|
||||
)
|
||||
.force('charge', d3.forceManyBody().strength(-800))
|
||||
.force(
|
||||
'radial',
|
||||
d3
|
||||
.forceRadial<D3Node>(
|
||||
(d) => {
|
||||
if (d.type === 'root') return 0
|
||||
return (d.wave || 1) * 200
|
||||
},
|
||||
0,
|
||||
0
|
||||
)
|
||||
.strength(0.8)
|
||||
)
|
||||
.force('collision', d3.forceCollide<D3Node>().radius((d) => d.radius + 30))
|
||||
|
||||
simulationRef.current = simulation
|
||||
|
||||
const ringRadii = [200, 400, 600]
|
||||
g.selectAll('.ring')
|
||||
.data(ringRadii)
|
||||
.enter()
|
||||
.append('circle')
|
||||
.attr('class', 'ring')
|
||||
.attr('r', (d) => d)
|
||||
.attr('fill', 'none')
|
||||
.attr('stroke', isDark ? '#ffffff10' : '#e2e8f0')
|
||||
.attr('stroke-width', 1)
|
||||
.attr('stroke-dasharray', '4,4')
|
||||
.style('opacity', 0.5)
|
||||
|
||||
const link = g
|
||||
.append('g')
|
||||
.selectAll('line')
|
||||
.data(links)
|
||||
.enter()
|
||||
.append('line')
|
||||
.attr('stroke', (d) =>
|
||||
d.type === 'wave' ? (isDark ? '#334155' : '#cbd5e1') : (isDark ? '#854d0e' : '#fde047')
|
||||
)
|
||||
.attr('stroke-width', (d) => (d.type === 'wave' ? 1.5 : 2))
|
||||
.attr('stroke-dasharray', (d) => (d.type === 'parent' ? 'none' : '4,4'))
|
||||
|
||||
linkRef.current = link
|
||||
|
||||
const node = g
|
||||
.append('g')
|
||||
.selectAll('.node')
|
||||
.data(nodes)
|
||||
.enter()
|
||||
.append('g')
|
||||
.attr('class', (d) => `node${d.status === 'dismissed' ? ' dismissed' : ''}`)
|
||||
.style('cursor', 'pointer')
|
||||
.style('opacity', (d) => d.status === 'dismissed' ? 0.3 : 1)
|
||||
.attr('data-id', (d) => d.id)
|
||||
.on('click', (_event, d) => {
|
||||
if (d.type === 'idea') onNodeSelectRef.current?.(d.id)
|
||||
})
|
||||
.on('dblclick', (event, d) => {
|
||||
if (!onCreateIdeaRef.current) return
|
||||
event.stopPropagation()
|
||||
const parentId = d.type === 'root' ? null : d.id
|
||||
const angle = Math.random() * Math.PI * 2
|
||||
const dist = 180
|
||||
const nx = (d.x || 0) + Math.cos(angle) * dist
|
||||
const ny = (d.y || 0) + Math.sin(angle) * dist
|
||||
const screen = toScreenCoords(nx, ny)
|
||||
const container = containerRef.current
|
||||
if (!container) return
|
||||
const rect = container.getBoundingClientRect()
|
||||
setEditingNode({ x: screen.x - rect.left, y: screen.y - rect.top, parentId })
|
||||
setEditText('')
|
||||
})
|
||||
.call(
|
||||
d3
|
||||
.drag<SVGGElement, D3Node>()
|
||||
.on('start', (event, d) => {
|
||||
if (!event.active) simulation.alphaTarget(0.3).restart()
|
||||
d.fx = d.x
|
||||
d.fy = d.y
|
||||
})
|
||||
.on('drag', (event, d) => {
|
||||
d.fx = event.x
|
||||
d.fy = event.y
|
||||
})
|
||||
.on('end', (event, d) => {
|
||||
if (!event.active) simulation.alphaTarget(0)
|
||||
d.fx = null
|
||||
d.fy = null
|
||||
if (d.type === 'idea' && onPositionUpdateRef.current) {
|
||||
onPositionUpdateRef.current(d.id, { x: event.x, y: event.y })
|
||||
}
|
||||
}) as any
|
||||
)
|
||||
|
||||
nodeRef.current = node
|
||||
|
||||
node
|
||||
.append('circle')
|
||||
.attr('r', (d) => d.radius)
|
||||
.attr('fill', (d) =>
|
||||
d.status === 'converted'
|
||||
? (isDark ? '#064e3b' : '#ecfdf5')
|
||||
: d.type === 'root'
|
||||
? (isDark ? '#f97316' : '#141414')
|
||||
: (isDark ? '#1e1e1e' : '#fff')
|
||||
)
|
||||
.attr('stroke', (d) =>
|
||||
d.status === 'converted' ? '#10b981' : d.color
|
||||
)
|
||||
.attr('stroke-width', 2)
|
||||
|
||||
node
|
||||
.append('text')
|
||||
.attr('dy', (d) =>
|
||||
d.type === 'root' ? '.35em' : d.radius + 20
|
||||
)
|
||||
.attr('text-anchor', 'middle')
|
||||
.attr('fill', (d) =>
|
||||
d.type === 'root'
|
||||
? '#fff'
|
||||
: d.status === 'dismissed'
|
||||
? (isDark ? '#475569' : '#94a3b8')
|
||||
: (isDark ? '#e2e8f0' : '#141414')
|
||||
)
|
||||
.attr(
|
||||
'class',
|
||||
(d) =>
|
||||
d.type === 'root'
|
||||
? 'text-[10px] font-bold pointer-events-none tracking-widest'
|
||||
: 'text-[11px] font-bold uppercase tracking-tight pointer-events-none'
|
||||
)
|
||||
.text((d) =>
|
||||
d.type === 'root'
|
||||
? 'SEED'
|
||||
: d.title.length > 18
|
||||
? d.title.substring(0, 18) + '...'
|
||||
: d.title
|
||||
)
|
||||
|
||||
node
|
||||
.filter((d) => d.status === 'converted')
|
||||
.append('text')
|
||||
.attr('x', (d) => d.radius * 0.55)
|
||||
.attr('y', (d) => -d.radius * 0.55)
|
||||
.attr('text-anchor', 'middle')
|
||||
.attr('fill', isDark ? '#34d399' : '#10b981')
|
||||
.attr('font-size', '14px')
|
||||
.attr('class', 'pointer-events-none')
|
||||
.text('✓')
|
||||
|
||||
node
|
||||
.filter((d) => {
|
||||
if (!d.wave || d.type === 'root') return false
|
||||
try {
|
||||
const ids: string[] = JSON.parse(
|
||||
(session.ideas.find((i) => i.id === d.id)?.relatedNoteIds) || '[]'
|
||||
)
|
||||
return ids.length > 0
|
||||
} catch { return false }
|
||||
})
|
||||
.append('text')
|
||||
.attr('x', (d) => -d.radius * 0.55)
|
||||
.attr('y', (d) => -d.radius * 0.55)
|
||||
.attr('text-anchor', 'middle')
|
||||
.attr('fill', isDark ? '#94a3b8' : '#64748b')
|
||||
.attr('font-size', '12px')
|
||||
.attr('class', 'pointer-events-none')
|
||||
.text('📎')
|
||||
|
||||
node
|
||||
.filter((d) => d.type === 'idea' && d.createdByType === 'human')
|
||||
.append('circle')
|
||||
.attr('cx', (d) => d.radius * 0.6)
|
||||
.attr('cy', (d) => d.radius * 0.6)
|
||||
.attr('r', 7)
|
||||
.attr('fill', '#3b82f6')
|
||||
.attr('stroke', isDark ? '#1e1e1e' : '#fff')
|
||||
.attr('stroke-width', 1.5)
|
||||
.attr('class', 'pointer-events-none')
|
||||
|
||||
node
|
||||
.filter((d) => d.type === 'idea' && d.createdByType === 'human')
|
||||
.append('text')
|
||||
.attr('x', (d) => d.radius * 0.6)
|
||||
.attr('y', (d) => d.radius * 0.6 + 3.5)
|
||||
.attr('text-anchor', 'middle')
|
||||
.attr('fill', '#fff')
|
||||
.attr('font-size', '8px')
|
||||
.attr('font-weight', 'bold')
|
||||
.attr('class', 'pointer-events-none')
|
||||
.text((d) => d.creatorInitial || 'U')
|
||||
|
||||
node
|
||||
.filter((d) => d.type === 'idea' && d.createdByType === 'ai')
|
||||
.append('text')
|
||||
.attr('x', (d) => d.radius * 0.55)
|
||||
.attr('y', (d) => -d.radius * 0.55 + 4)
|
||||
.attr('text-anchor', 'middle')
|
||||
.attr('fill', '#a78bfa')
|
||||
.attr('font-size', '10px')
|
||||
.attr('class', 'pointer-events-none')
|
||||
.text('✦')
|
||||
|
||||
g.append('text')
|
||||
.attr('text-anchor', 'middle')
|
||||
.attr('dy', 80)
|
||||
.attr('class', 'text-lg font-serif italic pointer-events-none')
|
||||
.attr('fill', isDark ? '#94a3b8' : '#333')
|
||||
.text(session.seedIdea.length > 60 ? session.seedIdea.substring(0, 60) + '...' : session.seedIdea)
|
||||
|
||||
svg.on('dblclick', (event) => {
|
||||
if (!onCreateIdeaRef.current) return
|
||||
if ((event.target as Element).closest('.node')) return
|
||||
const container = containerRef.current
|
||||
if (!container) return
|
||||
const rect = container.getBoundingClientRect()
|
||||
setEditingNode({ x: event.clientX - rect.left, y: event.clientY - rect.top, parentId: null })
|
||||
setEditText('')
|
||||
})
|
||||
|
||||
simulation.on('tick', () => {
|
||||
link
|
||||
.attr('x1', (d) => (d.source as any).x)
|
||||
.attr('y1', (d) => (d.source as any).y)
|
||||
.attr('x2', (d) => (d.target as any).x)
|
||||
.attr('y2', (d) => (d.target as any).y)
|
||||
|
||||
node.attr('transform', (d) => `translate(${d.x},${d.y})`)
|
||||
})
|
||||
|
||||
return () => {
|
||||
simulation.stop()
|
||||
}
|
||||
}, [sessionId, ideasKey, isDark, toSvgCoords, toScreenCoords, playbackIdeas])
|
||||
|
||||
useEffect(() => {
|
||||
if (!nodeRef.current) return
|
||||
nodeRef.current.selectAll('circle')
|
||||
.attr('stroke-width', (d: any) => (d.id === selectedNodeId ? 4 : 2))
|
||||
.style('filter', (d: any) =>
|
||||
d.id === selectedNodeId
|
||||
? `drop-shadow(0 0 12px ${d.color}cc)`
|
||||
: 'none'
|
||||
)
|
||||
}, [selectedNodeId])
|
||||
|
||||
useEffect(() => {
|
||||
if (!remoteMove || !nodeRef.current || !linkRef.current || !simulationRef.current) return
|
||||
const sim = simulationRef.current
|
||||
const node = nodeRef.current
|
||||
const link = linkRef.current
|
||||
|
||||
node.each(function(d: any) {
|
||||
if (d.id === remoteMove.ideaId) {
|
||||
d.fx = remoteMove.x
|
||||
d.fy = remoteMove.y
|
||||
}
|
||||
})
|
||||
|
||||
sim.alpha(0.3).restart()
|
||||
|
||||
for (let i = 0; i < 30; i++) sim.tick()
|
||||
sim.stop()
|
||||
|
||||
link
|
||||
.attr('x1', (d: any) => d.source.x)
|
||||
.attr('y1', (d: any) => d.source.y)
|
||||
.attr('x2', (d: any) => d.target.x)
|
||||
.attr('y2', (d: any) => d.target.y)
|
||||
node.attr('transform', (d: any) => `translate(${d.x},${d.y})`)
|
||||
}, [remoteMove])
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="w-full h-full relative cursor-grab active:cursor-grabbing bg-[#F8F7F2] dark:bg-[#0A0A0A] bg-[radial-gradient(#e5e7eb_1px,transparent_1px)] dark:bg-[radial-gradient(#ffffff10_1px,transparent_1px)] [background-size:20px_20px]"
|
||||
>
|
||||
<svg ref={svgRef} className="w-full h-full" />
|
||||
|
||||
{editingNode && (
|
||||
<div
|
||||
className="absolute z-50 pointer-events-auto"
|
||||
style={{
|
||||
left: editingNode.x,
|
||||
top: editingNode.y,
|
||||
transform: 'translate(-50%, 12px)',
|
||||
}}
|
||||
>
|
||||
<div className="w-[260px] bg-white dark:bg-[#1A1A1A] rounded-2xl shadow-2xl border border-black/10 dark:border-white/10 overflow-hidden">
|
||||
<div className="px-3.5 pt-3 pb-1 flex items-center gap-2">
|
||||
<div className="w-5 h-5 rounded-md bg-memento-blue/10 flex items-center justify-center">
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="#3b82f6" strokeWidth="2.5" strokeLinecap="round">
|
||||
<path d="M12 5v14M5 12h14" />
|
||||
</svg>
|
||||
</div>
|
||||
<span className="text-[10px] font-bold uppercase tracking-[0.15em] text-foreground/50">
|
||||
{editingNode.parentId ? 'Réponse' : 'Nouvelle idée'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="px-3.5 pb-3">
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={editText}
|
||||
onChange={(e) => setEditText(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && editText.trim()) handleSubmitIdea()
|
||||
if (e.key === 'Escape') { setEditingNode(null); setEditText('') }
|
||||
}}
|
||||
placeholder={editingNode.parentId ? 'Votre réponse…' : 'Votre idée…'}
|
||||
className="w-full px-3 py-2.5 text-sm font-serif bg-black/[0.03] dark:bg-white/[0.06] border border-black/[0.06] dark:border-white/[0.12] rounded-xl outline-none focus:border-memento-blue/50 focus:bg-white dark:focus:bg-white/[0.08] transition-all placeholder:text-foreground/25 text-foreground"
|
||||
/>
|
||||
<div className="flex items-center justify-between mt-1.5 px-0.5">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<kbd className="text-[9px] text-foreground/30 font-mono bg-black/[0.04] px-1.5 py-0.5 rounded">↵</kbd>
|
||||
<span className="text-[9px] text-foreground/25">enregistrer</span>
|
||||
<kbd className="text-[9px] text-foreground/30 font-mono bg-black/[0.04] px-1.5 py-0.5 rounded">esc</kbd>
|
||||
<span className="text-[9px] text-foreground/25">annuler</span>
|
||||
</div>
|
||||
{editingNode.parentId && (
|
||||
<span className="text-[9px] font-medium text-memento-blue/60 flex items-center gap-0.5">
|
||||
→ enfant
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-center -mt-[1px]">
|
||||
<div className="w-3 h-3 bg-white dark:bg-[#1A1A1A] border-r border-b border-black/10 dark:border-white/10 rotate-45 -translate-y-1.5" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="absolute bottom-6 left-6 pointer-events-none">
|
||||
<p className="text-[10px] font-bold tracking-[0.3em] uppercase text-gray-400 dark:text-gray-600 opacity-60">
|
||||
Double-clic pour ajouter une idée
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user