Files
Momento/memento-note/components/brainstorm/brainstorm-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

387 lines
13 KiB
TypeScript

'use client'
import React, { useCallback, useMemo, useRef, useState } from 'react'
import dynamic from 'next/dynamic'
import { BrainstormSession, BrainstormIdea } from '@/types/brainstorm'
import {
useExpandIdea,
useDismissIdea,
useConvertIdea,
useExportBrainstorm,
brainstormQuotaMessageKey,
} from '@/hooks/use-brainstorm'
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet'
import { Button } from '@/components/ui/button'
import { Sparkles, X, FileText, Download } from 'lucide-react'
import { toast } from 'sonner'
import { useLanguage } from '@/lib/i18n'
const ForceGraph2D = dynamic(() => import('react-force-graph-2d'), {
ssr: false,
})
interface GraphNode {
id: string
name: string
val: number
color: string
borderColor: string
wave: number
status: string
idea: BrainstormIdea
x?: number
y?: number
__bckgDimensions?: [number, number]
}
interface GraphLink {
source: string | GraphNode
target: string | GraphNode
color: string
}
const WAVE_COLORS: Record<number, string> = {
0: '#ffffff',
1: '#f97316',
2: '#3b82f6',
3: '#a855f7',
}
const WAVE_BORDER: Record<number, string> = {
0: '#e5e5e5',
1: '#ea580c',
2: '#2563eb',
3: '#9333ea',
}
const STATUS_ALPHA: Record<string, number> = {
active: 1,
dismissed: 0.25,
converted: 0.9,
}
interface BrainstormCanvasProps {
session: BrainstormSession
}
export function BrainstormCanvas({ session }: BrainstormCanvasProps) {
const { t } = useLanguage()
const fgRef = useRef<any>(null)
const [selectedIdea, setSelectedIdea] = useState<BrainstormIdea | null>(null)
const [isSheetOpen, setIsSheetOpen] = useState(false)
const expandIdea = useExpandIdea(session.id)
const dismissIdea = useDismissIdea(session.id)
const convertIdea = useConvertIdea(session.id)
const exportBrainstorm = useExportBrainstorm(session.id)
const { graphData } = useMemo(() => {
const nodes: GraphNode[] = []
const links: GraphLink[] = []
nodes.push({
id: 'seed',
name: session.seedIdea.length > 30 ? session.seedIdea.slice(0, 30) + '...' : session.seedIdea,
val: 25,
color: WAVE_COLORS[0],
borderColor: WAVE_BORDER[0],
wave: 0,
status: 'active',
idea: {
id: 'seed',
sessionId: session.id,
waveNumber: 0,
title: session.seedIdea,
description: t('brainstorm.originalSeedDescription'),
connectionToSeed: null,
noveltyScore: null,
parentIdeaId: null,
convertedToNoteId: null,
relatedNoteIds: null,
status: 'active',
positionX: null,
positionY: null,
createdAt: session.createdAt,
} as BrainstormIdea,
})
for (const idea of session.ideas) {
const parentNode = idea.parentIdeaId
? idea.parentIdeaId
: 'seed'
nodes.push({
id: idea.id,
name: idea.title,
val: idea.status === 'dismissed' ? 8 : 15,
color: WAVE_COLORS[idea.waveNumber] || WAVE_COLORS[3],
borderColor: idea.status === 'converted' ? '#22c55e' : (WAVE_BORDER[idea.waveNumber] || WAVE_BORDER[3]),
wave: idea.waveNumber,
status: idea.status,
idea,
})
const linkColor = WAVE_COLORS[idea.waveNumber] || WAVE_COLORS[3]
links.push({
source: parentNode,
target: idea.id,
color: idea.status === 'dismissed' ? '#444444' : linkColor,
})
}
return { graphData: { nodes, links } }
}, [session, t])
const handleNodeClick = useCallback((node: any) => {
setSelectedIdea(node.idea)
setIsSheetOpen(true)
}, [])
const handleExpand = useCallback(async () => {
if (!selectedIdea || selectedIdea.id === 'seed') return
try {
await expandIdea.mutateAsync({ ideaId: selectedIdea.id })
toast.success(t('brainstorm.toastExpandSuccess'))
} catch (err: unknown) {
const quotaKey = brainstormQuotaMessageKey(err)
toast.error(quotaKey ? t(quotaKey) : (err instanceof Error ? err.message : t('brainstorm.toastExpandFailed')))
}
}, [selectedIdea, expandIdea, t])
const handleDismiss = useCallback(async () => {
if (!selectedIdea || selectedIdea.id === 'seed') return
try {
await dismissIdea.mutateAsync(selectedIdea.id)
setIsSheetOpen(false)
setSelectedIdea(null)
toast.success(t('brainstorm.toastDismissSuccess'))
} catch (err: any) {
toast.error(err.message || t('brainstorm.toastDismissFailed'))
}
}, [selectedIdea, dismissIdea, t])
const handleConvert = useCallback(async () => {
if (!selectedIdea || selectedIdea.id === 'seed') return
try {
await convertIdea.mutateAsync(selectedIdea.id)
toast.success(t('brainstorm.toastConvertSuccess'))
} catch (err: any) {
toast.error(err.message || t('brainstorm.toastConvertFailed'))
}
}, [selectedIdea, convertIdea, t])
const handleExport = useCallback(async () => {
try {
const note = await exportBrainstorm.mutateAsync()
toast.success(t('brainstorm.toastExportNoteSuccess'))
} catch (err: any) {
toast.error(err.message || t('brainstorm.toastExportFailed'))
}
}, [exportBrainstorm, t])
const paintNode = useCallback((node: any, ctx: CanvasRenderingContext2D, globalScale: number) => {
const label = node.name
const fontSize = Math.max(12 / globalScale, 4)
ctx.font = `${fontSize}px Sans-Serif`
const textWidth = ctx.measureText(label).width
const nodeSize = node.val
const bgDimensions: [number, number] = [textWidth + nodeSize * 0.8, nodeSize * 1.2]
node.__bckgDimensions = bgDimensions
const alpha = STATUS_ALPHA[node.status] || 1
ctx.globalAlpha = alpha
ctx.fillStyle = node.color
ctx.strokeStyle = node.borderColor
ctx.lineWidth = node.status === 'converted' ? 3 / globalScale : 1.5 / globalScale
ctx.beginPath()
ctx.roundRect(
node.x! - bgDimensions[0] / 2,
node.y! - bgDimensions[1] / 2,
bgDimensions[0],
bgDimensions[1],
nodeSize * 0.3
)
ctx.fill()
ctx.stroke()
ctx.fillStyle = node.wave === 0 ? '#000000' : '#ffffff'
ctx.textAlign = 'center'
ctx.textBaseline = 'middle'
ctx.fillText(label, node.x!, node.y!)
if (node.status === 'converted') {
ctx.fillStyle = '#22c55e'
ctx.font = `${fontSize * 1.2}px Sans-Serif`
ctx.fillText('✓', node.x! + bgDimensions[0] / 2 - fontSize * 0.5, node.y! - bgDimensions[1] / 2 + fontSize * 0.5)
}
ctx.globalAlpha = 1
}, [])
const waveLegend = useMemo(
() => [
{ label: t('brainstorm.legendSeed'), color: WAVE_COLORS[0] },
{ label: t('brainstorm.legendVariations'), color: WAVE_COLORS[1] },
{ label: t('brainstorm.legendAnalogies'), color: WAVE_COLORS[2] },
{ label: t('brainstorm.legendDisruptions'), color: WAVE_COLORS[3] },
],
[t]
)
return (
<div className="relative w-full h-full bg-zinc-950 rounded-xl overflow-hidden">
<ForceGraph2D
ref={fgRef}
graphData={graphData}
nodeCanvasObject={paintNode}
nodePointerAreaPaint={(node: any, color: string, ctx: CanvasRenderingContext2D) => {
const dims = node.__bckgDimensions
if (!dims) return
ctx.fillStyle = color
ctx.beginPath()
ctx.roundRect(node.x! - dims[0] / 2, node.y! - dims[1] / 2, dims[0], dims[1], (node.val || 10) * 0.3)
ctx.fill()
}}
onNodeClick={handleNodeClick}
linkColor={(link: any) => link.color}
linkWidth={1}
linkDirectionalArrowLength={3}
linkDirectionalArrowRelPos={1}
backgroundColor="#09090b"
nodeVal={(node: any) => node.val}
cooldownTicks={100}
enableNodeDrag={true}
enableZoomInteraction={true}
enablePanInteraction={true}
warmupTicks={50}
/>
<div className="absolute top-4 left-4 flex flex-col gap-1.5">
{waveLegend.map(w => (
<div key={w.label} className="flex items-center gap-2 text-xs text-zinc-400">
<div className="w-3 h-3 rounded-sm border border-zinc-600" style={{ backgroundColor: w.color }} />
<span>{w.label}</span>
</div>
))}
</div>
<div className="absolute top-4 right-4">
<Button
size="sm"
variant="outline"
className="bg-zinc-800 border-zinc-700 text-zinc-300 hover:bg-zinc-700 hover:text-white"
onClick={handleExport}
disabled={exportBrainstorm.isPending}
>
<Download size={14} className="mr-1" />
{exportBrainstorm.isPending ? t('brainstorm.exporting') : t('brainstorm.export')}
</Button>
</div>
<Sheet open={isSheetOpen} onOpenChange={setIsSheetOpen}>
<SheetContent className="bg-zinc-900 border-zinc-800 text-white w-96">
{selectedIdea && (
<>
<SheetHeader>
<SheetTitle className="text-white text-left">
{selectedIdea.id === 'seed' ? session.seedIdea : selectedIdea.title}
</SheetTitle>
</SheetHeader>
<div className="mt-6 space-y-4">
{selectedIdea.description && selectedIdea.id !== 'seed' && (
<div>
<p className="text-xs text-zinc-500 uppercase tracking-wider mb-1">{t('brainstorm.ideaDetailDescription')}</p>
<p className="text-sm text-zinc-300">{selectedIdea.description}</p>
</div>
)}
{selectedIdea.connectionToSeed && (
<div>
<p className="text-xs text-zinc-500 uppercase tracking-wider mb-1">{t('brainstorm.ideaDetailConnection')}</p>
<p className="text-sm text-zinc-300">{selectedIdea.connectionToSeed}</p>
</div>
)}
{(selectedIdea.noveltyScore != null && selectedIdea.noveltyScore > 0) && (
<div>
<p className="text-xs text-zinc-500 uppercase tracking-wider mb-1">{t('brainstorm.ideaDetailNovelty')}</p>
<div className="flex items-center gap-2">
<div className="flex-1 h-2 bg-zinc-800 rounded-full overflow-hidden">
<div
className="h-full bg-gradient-to-r from-orange-500 via-brand-accent to-purple-500 rounded-full"
style={{ width: `${Math.min(100, Math.max(0, selectedIdea.noveltyScore * 10))}%` }}
/>
</div>
<span className="text-sm text-zinc-400">{selectedIdea.noveltyScore}/10</span>
</div>
</div>
)}
{selectedIdea.waveNumber > 0 && (
<div>
<p className="text-xs text-zinc-500 uppercase tracking-wider mb-1">{t('brainstorm.ideaDetailWave')}</p>
<span
className="inline-block px-2 py-0.5 rounded text-xs font-medium"
style={{
backgroundColor: WAVE_COLORS[selectedIdea.waveNumber] + '30',
color: WAVE_COLORS[selectedIdea.waveNumber],
}}
>
{selectedIdea.waveNumber === 1
? t('brainstorm.waveFlavorVariation')
: selectedIdea.waveNumber === 2
? t('brainstorm.waveFlavorAnalogy')
: t('brainstorm.waveFlavorDisruption')}
</span>
</div>
)}
{selectedIdea.status === 'converted' && (
<div className="p-3 bg-green-500/10 border border-green-500/20 rounded-lg">
<p className="text-xs text-green-400 flex items-center gap-1">
<FileText size={12} />
{t('brainstorm.convertedToNoteStatus')}
</p>
</div>
)}
{selectedIdea.id !== 'seed' && selectedIdea.status === 'active' && (
<div className="flex flex-col gap-2 pt-4 border-t border-zinc-800">
<Button
onClick={handleExpand}
disabled={expandIdea.isPending}
className="w-full bg-orange-600 hover:bg-orange-700 text-white"
>
<Sparkles size={14} className="mr-1" />
{expandIdea.isPending ? t('brainstorm.deepening') : t('brainstorm.deepen')}
</Button>
<Button
onClick={handleConvert}
disabled={convertIdea.isPending}
className="w-full bg-brand-accent hover:bg-blue-700 text-white"
>
<FileText size={14} className="mr-1" />
{convertIdea.isPending ? t('brainstorm.converting') : t('brainstorm.extract')}
</Button>
<Button
onClick={handleDismiss}
disabled={dismissIdea.isPending}
variant="outline"
className="w-full border-zinc-700 text-zinc-400 hover:text-white hover:bg-zinc-800"
>
<X size={14} className="mr-1" />
{t('brainstorm.dismiss')}
</Button>
</div>
)}
</div>
</>
)}
</SheetContent>
</Sheet>
</div>
)
}