Files
Momento/memento-note/components/brainstorm/wave-canvas.tsx
Antigravity bd495be965
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 12s
feat: design system overhaul — sidebar, AI chats, settings, brainstorm, color cleanup
- Sidebar: dynamic brand-accent colors, brainstorm section restyled
- AI chat general: popup panel with expand/collapse, hides when contextual AI open
- AI chat contextual: tabs reordered (Actions first), X close button, height fix
- Settings: all tabs restyled, 6 new color presets (sage, terracotta, iron, etc.)
- Global color cleanup: emerald/orange hardcoded → brand-accent dynamic
- Brainstorm page: orange → brand-accent throughout
- PageEntry animation component added to key pages
- Floating AI button: bg-brand-accent instead of hardcoded black
- i18n: all 15 locales updated with new AI/billing keys
- Billing: freemium quota tracking, BYOK, stripe subscription scaffolding
- Admin: integrated into new design
- AGENTS.md + CLAUDE.md project rules added
2026-05-16 12:59:30 +00:00

588 lines
19 KiB
TypeScript

'use client'
import React, { useEffect, useRef, useState, useCallback } from 'react'
import * as d3 from 'd3'
import { BrainstormSession } from '@/types/brainstorm'
import { useLanguage } from '@/lib/i18n'
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 { t, language } = useLanguage()
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'
? t('brainstorm.seedNodeBadge')
: 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, language, t])
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-brand-accent/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 ? t('brainstorm.canvasEditTitleReply') : t('brainstorm.canvasEditTitleNewIdea')}
</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 ? t('brainstorm.canvasPlaceholderReply') : t('brainstorm.canvasPlaceholderIdea')}
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-brand-accent/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">{t('brainstorm.canvasShortcutSave')}</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">{t('brainstorm.canvasShortcutCancel')}</span>
</div>
{editingNode.parentId && (
<span className="text-[9px] font-medium text-brand-accent/60 flex items-center gap-0.5">
{t('brainstorm.canvasChildBranch')}
</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">
{t('brainstorm.canvasDoubleClickHint')}
</p>
</div>
</div>
)
}