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

- 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:
Antigravity
2026-05-14 17:43:21 +00:00
parent 195e845f0a
commit 1fcea6ed7d
228 changed files with 57656 additions and 1059 deletions

View File

@@ -0,0 +1,120 @@
'use client'
import React from 'react'
import { motion, AnimatePresence } from 'motion/react'
import { Activity, Lightbulb, UserPlus, X, Zap, Eye } from 'lucide-react'
import type { BrainstormActivityItem } from '@/types/brainstorm'
interface ActivityFeedProps {
activities: BrainstormActivityItem[]
isOpen: boolean
onToggle: () => void
t: (key: string) => string | undefined
}
function getActionIcon(action: string) {
switch (action) {
case 'manual_idea': return <Lightbulb size={12} className="text-memento-blue" />
case 'wave_generated': return <Zap size={12} className="text-orange-500" />
case 'joined': return <UserPlus size={12} className="text-emerald-500" />
case 'idea_dismissed': return <X size={12} className="text-rose-500" />
case 'invite_created': return <UserPlus size={12} className="text-violet-500" />
default: return <Activity size={12} className="text-muted-foreground" />
}
}
function getActionLabel(action: string, t: (key: string) => string | undefined): string {
const key = `brainstorm.activity.${action}`
const translated = t(key)
if (translated) return translated
switch (action) {
case 'manual_idea': return 'added an idea'
case 'wave_generated': return 'generated a wave'
case 'joined': return 'joined the session'
case 'idea_dismissed': return 'dismissed an idea'
case 'invite_created': return 'created an invite'
default: return action
}
}
function timeAgo(dateStr: string, t: (key: string) => string | undefined): string {
const diff = Date.now() - new Date(dateStr).getTime()
const mins = Math.floor(diff / 60000)
if (mins < 1) return t('brainstorm.justNow') || 'just now'
if (mins < 60) return `${mins}m`
const hours = Math.floor(mins / 60)
if (hours < 24) return `${hours}h`
const days = Math.floor(hours / 24)
return `${days}d`
}
export function ActivityFeed({ activities, isOpen, onToggle, t }: ActivityFeedProps) {
return (
<AnimatePresence>
{isOpen && (
<motion.div
initial={{ x: 320, opacity: 0 }}
animate={{ x: 0, opacity: 1 }}
exit={{ x: 320, opacity: 0 }}
transition={{ type: 'spring', damping: 25, stiffness: 300 }}
className="absolute top-0 right-16 bottom-0 w-[320px] bg-white/95 dark:bg-[#1A1A1A]/95 backdrop-blur-xl border-l border-border z-30 flex flex-col shadow-[-20px_0_40px_rgba(0,0,0,0.05)]"
>
<div className="p-6 border-b border-border/40 flex items-center justify-between">
<div className="flex items-center gap-2">
<Activity size={14} className="text-orange-500" />
<h3 className="text-[10px] font-bold uppercase tracking-[0.2em] text-foreground">
{t('brainstorm.activityTitle') || 'Activity'}
</h3>
</div>
<button
onClick={onToggle}
className="p-1.5 hover:bg-foreground/5 rounded-full transition-colors"
>
<X size={14} className="text-muted-foreground" />
</button>
</div>
<div className="flex-1 overflow-y-auto">
{activities.length === 0 ? (
<div className="p-6 text-center">
<p className="text-xs italic text-muted-foreground">
{t('brainstorm.noActivity') || 'No activity yet'}
</p>
</div>
) : (
<div className="py-2">
{activities.map((item, idx) => (
<motion.div
key={item.id}
initial={{ opacity: 0, x: 10 }}
animate={{ opacity: 1, x: 0 }}
transition={{ delay: idx * 0.03 }}
className="px-6 py-3 flex items-start gap-3 hover:bg-foreground/[0.02] transition-colors"
>
<div className="mt-0.5 w-5 h-5 rounded-full bg-foreground/5 flex items-center justify-center shrink-0">
{getActionIcon(item.action)}
</div>
<div className="flex-1 min-w-0">
<p className="text-xs text-foreground leading-relaxed">
<span className="font-semibold">
{item.user?.name || 'AI'}
</span>{' '}
{getActionLabel(item.action, t)}
{item.details?.ideaTitle && (
<span className="text-muted-foreground italic"> « {item.details.ideaTitle.length > 30 ? item.details.ideaTitle.substring(0, 30) + '…' : item.details.ideaTitle} »</span>
)}
</p>
<p className="text-[10px] text-muted-foreground mt-0.5">
{timeAgo(item.createdAt, t)}
</p>
</div>
</motion.div>
))}
</div>
)}
</div>
</motion.div>
)}
</AnimatePresence>
)
}

View File

@@ -0,0 +1,370 @@
'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 } from '@/hooks/use-brainstorm'
import { Sheet, SheetContent, SheetHeader, SheetTitle } from '@/components/ui/sheet'
import { Button } from '@/components/ui/button'
import { Sparkles, X, FileText, Download, ChevronDown } from 'lucide-react'
import { toast } from 'sonner'
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 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: 'Original seed idea',
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])
const handleNodeClick = useCallback((node: any) => {
setSelectedIdea(node.idea)
setIsSheetOpen(true)
}, [])
const handleExpand = useCallback(async () => {
if (!selectedIdea || selectedIdea.id === 'seed') return
try {
await expandIdea.mutateAsync(selectedIdea.id)
toast.success('Ideas expanded!')
} catch (err: any) {
toast.error(err.message || 'Failed to expand')
}
}, [selectedIdea, expandIdea])
const handleDismiss = useCallback(async () => {
if (!selectedIdea || selectedIdea.id === 'seed') return
try {
await dismissIdea.mutateAsync(selectedIdea.id)
setIsSheetOpen(false)
setSelectedIdea(null)
toast.success('Idea dismissed')
} catch (err: any) {
toast.error(err.message || 'Failed to dismiss')
}
}, [selectedIdea, dismissIdea])
const handleConvert = useCallback(async () => {
if (!selectedIdea || selectedIdea.id === 'seed') return
try {
await convertIdea.mutateAsync(selectedIdea.id)
toast.success('Idea converted to note!')
} catch (err: any) {
toast.error(err.message || 'Failed to convert')
}
}, [selectedIdea, convertIdea])
const handleExport = useCallback(async () => {
try {
const note = await exportBrainstorm.mutateAsync()
toast.success('Exported as note!')
} catch (err: any) {
toast.error(err.message || 'Failed to export')
}
}, [exportBrainstorm])
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 = [
{ label: 'Seed', color: WAVE_COLORS[0] },
{ label: 'Variations', color: WAVE_COLORS[1] },
{ label: 'Analogies', color: WAVE_COLORS[2] },
{ label: 'Disruptions', color: WAVE_COLORS[3] },
]
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 ? 'Exporting...' : '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">Description</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">Connection</p>
<p className="text-sm text-zinc-300">{selectedIdea.connectionToSeed}</p>
</div>
)}
{selectedIdea.noveltyScore && (
<div>
<p className="text-xs text-zinc-500 uppercase tracking-wider mb-1">Novelty</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-memento-blue to-purple-500 rounded-full"
style={{ width: `${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">Wave</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 ? 'Variation' : selectedIdea.waveNumber === 2 ? 'Analogy' : 'Disruption'}
</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} />
Converted to note
</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 ? 'Generating...' : 'Dig Deeper'}
</Button>
<Button
onClick={handleConvert}
disabled={convertIdea.isPending}
className="w-full bg-memento-blue hover:bg-blue-700 text-white"
>
<FileText size={14} className="mr-1" />
{convertIdea.isPending ? 'Converting...' : 'Create Note'}
</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" />
Dismiss
</Button>
</div>
)}
</div>
</>
)}
</SheetContent>
</Sheet>
</div>
)
}

View File

@@ -0,0 +1,84 @@
'use client'
import React, { useState } from 'react'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Sparkles } from 'lucide-react'
import { useLanguage } from '@/lib/i18n'
interface BrainstormCreateDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
onSubmit: (seedIdea: string) => void
isLoading?: boolean
}
export function BrainstormCreateDialog({
open,
onOpenChange,
onSubmit,
isLoading,
}: BrainstormCreateDialogProps) {
const { t } = useLanguage()
const [seedIdea, setSeedIdea] = useState('')
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (!seedIdea.trim()) return
onSubmit(seedIdea.trim())
setSeedIdea('')
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md bg-white dark:bg-zinc-900 border-border">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Sparkles size={18} className="text-orange-500" />
{t('brainstorm.newBrainstorm')}
</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="text-xs text-muted-foreground uppercase tracking-wider mb-1 block">
{t('brainstorm.seedLabel')}
</label>
<textarea
value={seedIdea}
onChange={(e) => setSeedIdea(e.target.value)}
placeholder={t('brainstorm.ideaPromptDetailed')}
className="w-full h-28 px-3 py-2 text-sm border border-border rounded-lg bg-transparent focus:outline-none focus:ring-2 focus:ring-orange-500/30 resize-none"
autoFocus
/>
</div>
<div className="flex justify-end gap-2">
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
>
{t('brainstorm.cancel')}
</Button>
<Button
type="submit"
disabled={!seedIdea.trim() || isLoading}
className="bg-orange-600 hover:bg-orange-700 text-white"
>
{isLoading ? (
<>
<Sparkles size={14} className="mr-1 animate-spin" />
{t('brainstorm.generating')}
</>
) : (
<>
<Sparkles size={14} className="mr-1" />
{t('brainstorm.startBrainstorm')}
</>
)}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,817 @@
'use client'
import React, { useState, useMemo, useEffect, useRef, useCallback } from 'react'
import { useRouter, useSearchParams } from 'next/navigation'
import { useSession } from 'next-auth/react'
import { motion, AnimatePresence } from 'motion/react'
import {
Zap,
History,
Plus,
Wind,
FileText,
ChevronRight,
UserPlus,
Activity,
Download,
Share2,
Check,
Users,
Globe,
Lock,
} from 'lucide-react'
import dynamic from 'next/dynamic'
import { useLanguage } from '@/lib/i18n'
import {
useBrainstormSession,
useBrainstormSessions,
useCreateBrainstorm,
useExpandIdea,
useDismissIdea,
useConvertIdea,
useExportBrainstorm,
useFinalizeBrainstorm,
useDeleteBrainstorm,
useJoinBrainstorm,
useAddManualIdea,
useBrainstormActivity,
useUpdateBrainstormSettings,
} from '@/hooks/use-brainstorm'
import { useBrainstormSocket } from '@/hooks/use-brainstorm-socket'
import { LiveCursors, PresenceAvatars, useCursorTracking } from '@/components/brainstorm/live-cursors'
import { ActivityFeed } from '@/components/brainstorm/activity-feed'
import { BrainstormShareDialog } from '@/components/brainstorm/brainstorm-share-dialog'
import { GhostCursor } from '@/components/brainstorm/ghost-cursor'
import { PlaybackBar } from '@/components/brainstorm/playback-bar'
import type { BrainstormIdea, BrainstormNoteRef, BrainstormActivityItem } from '@/types/brainstorm'
const WaveCanvas = dynamic(
() =>
import('@/components/brainstorm/wave-canvas').then((m) => ({
default: m.WaveCanvas,
})),
{ ssr: false }
)
function CursorTrackerEffect({ containerRef, moveCursor }: { containerRef: React.RefObject<HTMLDivElement | null>; moveCursor: (c: { x: number; y: number } | null) => void }) {
useCursorTracking(containerRef, moveCursor)
return null
}
const WAVE_COLORS: Record<number, { border: string; bg: string; text: string }> = {
1: { border: 'border-orange-200', bg: 'bg-orange-50', text: 'text-orange-600' },
2: { border: 'border-memento-blue', bg: 'bg-memento-blue', text: 'text-memento-blue' },
3: { border: 'border-violet-200', bg: 'bg-violet-50', text: 'text-violet-600' },
}
export function BrainstormPage() {
const router = useRouter()
const searchParams = useSearchParams()
const { t, language } = useLanguage()
const { data: authSession } = useSession()
const urlSessionId = searchParams.get('session')
const urlSeed = searchParams.get('seed')
const urlSourceNoteId = searchParams.get('sourceNoteId')
const urlInviteToken = searchParams.get('invite')
const [seedInput, setSeedInput] = useState('')
const [selectedIdeaId, setSelectedIdeaId] = useState<string | null>(null)
const [activeSessionId, setActiveSessionId] = useState<string | null>(urlSessionId)
const [autoStarted, setAutoStarted] = useState(false)
useEffect(() => {
if (urlSessionId && urlSessionId !== activeSessionId) {
setActiveSessionId(urlSessionId)
}
}, [urlSessionId])
const [showActivityFeed, setShowActivityFeed] = useState(false)
const [showShareDialog, setShowShareDialog] = useState(false)
const [manualEditCount, setManualEditCount] = useState(0)
const [shareStatus, setShareStatus] = useState<'idle' | 'copying' | 'copied'>('idle')
const { data: sessions, isLoading: sessionsLoading } = useBrainstormSessions()
const { data: sessionResult, isLoading: sessionLoading } = useBrainstormSession(activeSessionId)
const session = sessionResult?.session || null
const sessionMeta = sessionResult?.meta
const isGuest = sessionMeta?.role === 'guest'
const canEdit = sessionMeta?.canEdit ?? true
const updateSettings = useUpdateBrainstormSettings(activeSessionId || '')
const createBrainstorm = useCreateBrainstorm()
const expandIdea = useExpandIdea(activeSessionId || '')
const dismissIdea = useDismissIdea(activeSessionId || '')
const convertIdea = useConvertIdea(activeSessionId || '')
const exportBrainstorm = useExportBrainstorm(activeSessionId || '')
const finalizeBrainstorm = useFinalizeBrainstorm(activeSessionId || '')
const deleteBrainstorm = useDeleteBrainstorm()
const joinMutation = useJoinBrainstorm()
const addManualIdea = useAddManualIdea(activeSessionId || '')
const { data: activities } = useBrainstormActivity(activeSessionId)
const [impactToast, setImpactToast] = useState<{ notesEnriched: number; notesMarkedDry: number } | null>(null)
const [exportError, setExportError] = useState<string | null>(null)
const [exportToast, setExportToast] = useState<{ noteTitle: string; notebookName: string } | null>(null)
const [convertToast, setConvertToast] = useState<{ noteTitle: string; noteId: string } | null>(null)
const [remoteMove, setRemoteMove] = useState<{ ideaId: string; x: number; y: number; _seq: number } | null>(null)
const [playbackIdeas, setPlaybackIdeas] = useState<any[] | null>(null)
const canvasContainerRef = useRef<HTMLDivElement>(null)
const moveSeq = useRef(0)
const { others: socketOthers, moveCursor, activities: socketActivities, socketRef, aiProcessingNodeId } = useBrainstormSocket(
activeSessionId,
authSession?.user?.id || null,
authSession?.user?.name || null,
useCallback((data: { ideaId: string; positionX: number; positionY: number }) => {
moveSeq.current++
setRemoteMove({ ideaId: data.ideaId, x: data.positionX, y: data.positionY, _seq: moveSeq.current })
}, [])
)
const mergedActivities: BrainstormActivityItem[] = useMemo(() => {
const restActivities: BrainstormActivityItem[] = (activities || []) as BrainstormActivityItem[]
const socketActs = (socketActivities || []).map((a: any) => ({
id: `${a.userId}-${a.action}-${Date.now()}-${Math.random()}`,
action: a.action,
details: a.details,
createdAt: new Date().toISOString(),
user: { name: a.userName || null, image: null },
}))
const seen = new Set<string>()
const merged = [...socketActs, ...restActivities]
return merged.filter((a) => {
const key = `${a.action}-${a.user?.name}-${a.details?.ideaTitle || ''}`
if (seen.has(key)) return false
seen.add(key)
return true
}).slice(0, 50)
}, [activities, socketActivities])
const selectedIdea = useMemo(() => {
if (!selectedIdeaId || !session) return null
return session.ideas.find((i) => i.id === selectedIdeaId) || null
}, [selectedIdeaId, session])
useEffect(() => {
if (urlSeed && !autoStarted && !activeSessionId && !createBrainstorm.isPending) {
setAutoStarted(true)
createBrainstorm.mutateAsync({
seedIdea: urlSeed,
sourceNoteId: urlSourceNoteId || undefined,
locale: language,
}).then((result) => {
setActiveSessionId(result.session.id)
router.replace('/brainstorm?session=' + result.session.id)
}).catch(() => {})
}
}, [urlSeed, autoStarted, activeSessionId])
useEffect(() => {
if (urlInviteToken && !autoStarted) {
setAutoStarted(true)
joinMutation.mutateAsync(urlInviteToken).then((result) => {
setActiveSessionId(result.sessionId)
router.replace('/brainstorm?session=' + result.sessionId)
}).catch(() => {})
}
}, [urlInviteToken])
const handleStartBrainstorm = async (seed?: string) => {
const input = seed || seedInput
if (!input.trim()) return
try {
const result = await createBrainstorm.mutateAsync({ seedIdea: input.trim(), locale: language })
setActiveSessionId(result.session.id)
setSeedInput('')
} catch {}
}
const handleDelete = async (sessionId: string) => {
try {
await deleteBrainstorm.mutateAsync(sessionId)
if (activeSessionId === sessionId) {
setActiveSessionId(null)
setSelectedIdeaId(null)
}
} catch {}
}
const handleDeepen = async (idea: BrainstormIdea) => {
try {
await expandIdea.mutateAsync({ ideaId: idea.id, locale: language })
} catch {}
}
const handleDismiss = async (ideaId: string) => {
try {
await dismissIdea.mutateAsync(ideaId)
setSelectedIdeaId(null)
} catch {}
}
const handleConvert = async (idea: BrainstormIdea) => {
try {
const result = await convertIdea.mutateAsync(idea.id)
if (result?.id) {
setConvertToast({ noteTitle: result.title || idea.title, noteId: result.id })
setTimeout(() => {
setConvertToast(null)
router.push(`/?openNote=${result.id}`)
}, 2000)
}
} catch {}
}
const handleExport = async () => {
setExportError(null)
try {
const result = await exportBrainstorm.mutateAsync()
if (result?.id) {
const notebookName = result._notebookName || 'Brainstorm'
setExportToast({ noteTitle: result.title || 'Synthèse', notebookName })
setTimeout(() => {
setExportToast(null)
router.push(`/?openNote=${result.id}`)
}, 2000)
return
}
const impact = await finalizeBrainstorm.mutateAsync()
if (impact) {
setImpactToast(impact)
setTimeout(() => setImpactToast(null), 5000)
}
} catch (err: any) {
setExportError(err?.message || 'Export failed')
setTimeout(() => setExportError(null), 4000)
}
}
const handlePositionUpdate = async (id: string, pos: { x: number; y: number }) => {
if (!activeSessionId) return
socketRef.current?.emit('idea:moved', { ideaId: id, positionX: pos.x, positionY: pos.y, userId: authSession?.user?.id || '' })
try {
await fetch(`/api/brainstorm/${activeSessionId}/update-position`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ ideaId: id, positionX: pos.x, positionY: pos.y }),
})
} catch {}
}
const handleCreateIdea = useCallback(({ title, parentIdeaId }: { title: string; parentIdeaId?: string; x: number; y: number }) => {
addManualIdea.mutate({ title, parentIdeaId, locale: language })
}, [addManualIdea, language])
const isGenerating = createBrainstorm.isPending || expandIdea.isPending
return (
<div className="h-full flex flex-col bg-[#F8F7F2] dark:bg-[#0A0A0A] overflow-hidden">
<div className="p-12 border-b border-border/20 backdrop-blur-md bg-white/20 dark:bg-black/20 z-10 relative overflow-hidden">
<div
className="absolute inset-0 pointer-events-none opacity-[0.03] dark:opacity-[0.05]"
style={{
backgroundImage:
'linear-gradient(#000 1px, transparent 1px), linear-gradient(90deg, #000 1px, transparent 1px)',
backgroundSize: '40px 40px',
}}
/>
<div className="max-w-4xl mx-auto relative">
<div className="flex items-center gap-5 mb-8">
<motion.div
animate={{ rotate: isGenerating ? 360 : 0 }}
transition={{
repeat: isGenerating ? Infinity : 0,
duration: 20,
ease: 'linear',
}}
className="w-14 h-14 rounded-2xl bg-orange-500 shadow-[0_0_20px_rgba(249,115,22,0.2)] flex items-center justify-center text-white"
>
<Wind size={28} />
</motion.div>
<div className="flex-1">
<h1 className="text-4xl font-serif font-medium text-foreground tracking-tight">
{t('brainstorm.title') || 'Waves of Thought'}
</h1>
<div className="flex items-center gap-2 mt-1">
<span className="w-8 h-px bg-orange-400/40" />
<p className="text-[10px] text-muted-foreground tracking-[0.3em] uppercase font-bold">
{t('brainstorm.subtitle') || 'Unfold dimensions of potentiality'}
</p>
</div>
</div>
{session && !isGuest && (
<div className="flex items-center gap-3">
<button
onClick={handleExport}
disabled={exportBrainstorm.isPending}
className="flex items-center gap-2 px-4 py-2 bg-white dark:bg-white/5 border border-border rounded-xl text-xs font-bold uppercase tracking-widest text-muted-foreground hover:text-orange-500 transition-all shadow-sm disabled:opacity-50"
title={t('brainstorm.export') || 'Export'}
>
<Download size={14} />
<span className="hidden sm:inline">{t('brainstorm.export') || 'Export'}</span>
</button>
<button
onClick={() => setShowActivityFeed(!showActivityFeed)}
className={`flex items-center gap-2 px-4 py-2 border border-border rounded-xl text-xs font-bold uppercase tracking-widest transition-all shadow-sm ${showActivityFeed ? 'bg-foreground text-background' : 'bg-white dark:bg-white/5 text-muted-foreground hover:text-foreground'}`}
>
<Activity size={14} />
<span className="hidden sm:inline">{t('brainstorm.activityTitle') || 'Activity'}</span>
</button>
<button
onClick={() => setShowShareDialog(true)}
className="flex items-center gap-2 px-4 py-2 bg-white dark:bg-white/5 border border-border rounded-xl text-xs font-bold uppercase tracking-widest text-muted-foreground hover:text-foreground transition-all shadow-sm"
>
<Share2 size={14} />
<span className="hidden sm:inline">{t('brainstorm.invite') || 'Invite'}</span>
</button>
<div className="flex items-center px-3 py-1.5 bg-white dark:bg-white/5 border border-border rounded-xl shadow-sm transition-all hover:border-emerald-500/30">
<div className="flex items-center gap-2 mr-3" title="Live Collaboration">
<div className="relative flex h-2 w-2">
<span className="animate-ping absolute inline-flex h-full w-full rounded-full bg-emerald-400 opacity-75"></span>
<span className="relative inline-flex rounded-full h-2 w-2 bg-emerald-500 shadow-[0_0_8px_rgba(16,185,129,0.6)]"></span>
</div>
<span className="text-[10px] font-bold uppercase tracking-widest text-emerald-600 dark:text-emerald-400 hidden sm:inline-block">Live</span>
</div>
<div className="flex items-center group/avatars">
{authSession?.user?.name && (
<div
className="w-6 h-6 rounded-full flex items-center justify-center text-[10px] font-bold text-white ring-2 ring-white dark:ring-[#1A1A1A] shadow-sm bg-memento-blue z-10 transition-transform hover:scale-110 hover:z-20"
title={`${authSession.user.name} (You)`}
>
{authSession.user.name.charAt(0).toUpperCase()}
</div>
)}
{socketOthers.slice(0, 3).map((user, idx) => (
<div
key={user.userId}
className="w-6 h-6 rounded-full flex items-center justify-center text-[10px] font-bold text-white ring-2 ring-white dark:ring-[#1A1A1A] shadow-sm transition-transform hover:scale-110 hover:z-20"
style={{ backgroundColor: user.color, marginLeft: '-8px', zIndex: 9 - idx }}
title={user.name}
>
{user.name.charAt(0).toUpperCase()}
</div>
))}
{socketOthers.length > 3 && (
<div
className="w-6 h-6 rounded-full flex items-center justify-center text-[10px] font-bold text-muted-foreground bg-black/5 dark:bg-white/10 ring-2 ring-white dark:ring-[#1A1A1A] shadow-sm"
style={{ marginLeft: '-8px', zIndex: 5 }}
title={`${socketOthers.length - 3} other participants`}
>
+{socketOthers.length - 3}
</div>
)}
</div>
</div>
</div>
)}
</div>
<div className="relative group">
<div className="absolute -inset-1 bg-gradient-to-r from-orange-500/20 to-memento-blue/20 rounded-[28px] blur-xl opacity-0 group-focus-within:opacity-100 transition-opacity duration-700" />
<input
type="text"
value={seedInput}
onChange={(e) => setSeedInput(e.target.value)}
onKeyDown={(e) => e.key === 'Enter' && handleStartBrainstorm()}
placeholder={t('brainstorm.placeholder') || 'Enter a concept to unfold...'}
className="w-full relative bg-white dark:bg-[#1A1A1A] border-2 rounded-2xl px-8 py-7 pr-20 outline-none transition-all text-2xl font-serif italic text-foreground shadow-sm group-hover:shadow-md border-border/40 focus:border-orange-400/40 focus:ring-4 focus:ring-orange-500/5"
/>
<button
onClick={() => handleStartBrainstorm()}
disabled={isGenerating || !seedInput.trim()}
className="absolute right-4 top-4 bottom-4 px-6 bg-foreground dark:bg-orange-500 text-background rounded-xl disabled:opacity-50 transition-all hover:scale-[1.02] active:scale-[0.98] flex items-center justify-center gap-2 min-w-[70px] shadow-lg"
>
{isGenerating ? (
<div className="w-6 h-6 border-3 border-white/30 border-t-white rounded-full animate-spin" />
) : (
<Plus size={24} />
)}
</button>
</div>
<AnimatePresence>
{createBrainstorm.isPending && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
className="mt-6 flex items-center gap-4 text-orange-500/80 italic font-serif"
>
<div className="flex gap-1.5">
{[0.2, 0.4, 0.6].map((d, i) => (
<motion.div
key={i}
animate={{ scale: [1, 1.5, 1], opacity: [0.3, 1, 0.3] }}
transition={{ duration: 1.5, repeat: Infinity, delay: d }}
className="w-1.5 h-1.5 rounded-full bg-orange-500"
/>
))}
</div>
<span className="text-base tracking-tight">
{t('brainstorm.generating') || 'AI is harvesting seeds of thought...'}
</span>
</motion.div>
)}
</AnimatePresence>
</div>
</div>
<div className="flex-1 flex overflow-hidden relative">
<div className="flex-1 relative" ref={canvasContainerRef}>
{session && <LiveCursors others={socketOthers} />}
<GhostCursor
isActive={isGenerating || !!aiProcessingNodeId}
targetId={aiProcessingNodeId || (expandIdea.isPending ? selectedIdeaId : null)}
containerRef={canvasContainerRef}
/>
<CursorTrackerEffect containerRef={canvasContainerRef} moveCursor={moveCursor} />
{sessionLoading ? (
<div className="absolute inset-0 flex items-center justify-center">
<div className="w-8 h-8 border-2 border-foreground/20 border-t-foreground rounded-full animate-spin" />
</div>
) : session ? (
<WaveCanvas
session={session}
onNodeSelect={setSelectedIdeaId}
onPositionUpdate={handlePositionUpdate}
selectedNodeId={selectedIdeaId}
onCreateIdea={handleCreateIdea}
remoteMove={remoteMove}
manualEditTrigger={manualEditCount}
playbackIdeas={playbackIdeas}
/>
) : (
<div className="absolute inset-0 flex items-center justify-center pointer-events-none opacity-20 flex-col gap-6">
<Wind size={120} strokeWidth={1} className="text-muted-foreground animate-pulse" />
<p className="text-xl font-serif italic text-muted-foreground">
The canvas is waiting for your spark...
</p>
</div>
)}
<AnimatePresence>
{session && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="absolute bottom-6 left-6 flex gap-2"
>
<div className="px-4 py-2 bg-[#F4F1EA] dark:bg-black/60 backdrop-blur-xl border border-border shadow-xl rounded-full flex items-center gap-6">
{[1, 2, 3].map((w) => (
<div key={w} className="flex items-center gap-2">
<div
className="w-2 h-2 rounded-full"
style={{
backgroundColor: WAVE_COLORS[w]?.border?.replace('border-', '') === 'orange-200' ? '#fb923c' : w === 2 ? '#60a5fa' : '#a78bfa',
boxShadow: `0 0 8px ${w === 1 ? 'rgba(251,146,60,0.6)' : w === 2 ? 'rgba(96,165,250,0.6)' : 'rgba(167,139,250,0.6)'}`,
}}
/>
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
Wave {w}
</span>
</div>
))}
</div>
{canEdit && (
<button
onClick={() => setManualEditCount((c) => c + 1)}
className="px-6 py-3 bg-[#F4F1EA] dark:bg-black/60 backdrop-blur-xl border border-border shadow-xl rounded-full flex items-center gap-2 text-[10px] font-bold uppercase tracking-widest text-muted-foreground hover:bg-foreground hover:text-background transition-all"
>
<Plus size={14} />
{t('brainstorm.addManualIdea') || 'Add Manual Idea'}
</button>
)}
</motion.div>
)}
</AnimatePresence>
{session && (
<ActivityFeed
activities={mergedActivities}
isOpen={showActivityFeed}
onToggle={() => setShowActivityFeed(!showActivityFeed)}
t={t}
/>
)}
{session && activeSessionId && (
<PlaybackBar
sessionId={activeSessionId}
onSnapshotSelect={setPlaybackIdeas}
onExitPlayback={() => setPlaybackIdeas(null)}
/>
)}
</div>
<AnimatePresence>
{selectedIdea && (
<motion.div
initial={{ x: '100%' }}
animate={{ x: 0 }}
exit={{ x: '100%' }}
className="w-[400px] border-l border-border bg-white dark:bg-[#1A1A1A] flex flex-col z-20 shadow-[-20px_0_40px_rgba(0,0,0,0.05)]"
>
<div className="p-8 flex-1 overflow-y-auto">
<div className="flex items-center justify-between mb-8">
<div
className={`px-3 py-1 rounded-full text-[10px] font-bold uppercase tracking-widest border ${
selectedIdea.waveNumber === 1
? 'border-orange-300 dark:border-orange-700 bg-orange-50 dark:bg-orange-500/15 text-orange-600 dark:text-orange-400'
: selectedIdea.waveNumber === 2
? 'border-memento-blue dark:border-blue-700 bg-memento-blue dark:bg-memento-blue/15 text-memento-blue dark:text-memento-blue'
: 'border-violet-300 dark:border-violet-700 bg-violet-50 dark:bg-violet-500/15 text-violet-600 dark:text-violet-400'
}`}
>
{t('brainstorm.wave') || 'Wave'} {selectedIdea.waveNumber}
</div>
<div className="flex items-center gap-2">
{selectedIdea.status === 'converted' && (
<span className="text-[10px] font-bold text-emerald-500 uppercase tracking-widest bg-emerald-500/10 px-2 py-1 rounded-full">
{t('brainstorm.noteCreated') || 'Note Created'}
</span>
)}
<button
onClick={() => setSelectedIdeaId(null)}
className="p-2 hover:bg-foreground/5 rounded-full transition-colors text-muted-foreground"
>
<ChevronRight size={20} />
</button>
</div>
</div>
<h2 className="text-3xl font-serif font-medium text-foreground mb-2">
{selectedIdea.title}
</h2>
<div className="flex items-center gap-4 mb-8">
<div className="flex items-center gap-1">
<Zap size={14} className="text-orange-500" />
<span className="text-xs font-bold text-muted-foreground">
{t('brainstorm.novelty') || 'Novelty'}: {selectedIdea.noveltyScore || 'N/A'}/10
</span>
</div>
<div className="flex items-center gap-1.5">
{selectedIdea.createdByType === 'human' ? (
<>
<div className="w-4 h-4 rounded-full bg-memento-blue flex items-center justify-center text-[8px] font-bold text-white">
{(selectedIdea as any).creator?.name?.charAt(0)?.toUpperCase() || 'U'}
</div>
<span className="text-[10px] font-bold uppercase tracking-widest text-memento-blue">
{t('brainstorm.humanIdea') || 'Human'}
</span>
</>
) : (
<>
<span className="text-violet-500 text-xs"></span>
<span className="text-[10px] font-bold uppercase tracking-widest text-violet-500">
{t('brainstorm.aiIdea') || 'AI'}
</span>
</>
)}
</div>
</div>
<p className="text-foreground/80 leading-relaxed font-light mb-10 text-lg">
{selectedIdea.description}
</p>
{selectedIdea.connectionToSeed && (
<div className="p-6 bg-slate-50 dark:bg-white/[0.03] rounded-2xl border border-border/40 mb-10">
<h4 className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground mb-3">
{t('brainstorm.originConnection') || 'Origin connection'}
</h4>
<p className="text-sm italic text-muted-foreground leading-relaxed">
&ldquo;{selectedIdea.connectionToSeed}&rdquo;
</p>
</div>
)}
{selectedIdea.noteRefs && selectedIdea.noteRefs.length > 0 && (
<div className="space-y-3 mb-10">
<h4 className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground flex items-center gap-1">
{t('brainstorm.ideaOrigin') || 'Origin of the idea'}
</h4>
{selectedIdea.noteRefs.map((ref: BrainstormNoteRef) => {
const isPositive = ref.relation === 'derived_from' || ref.relation === 'extends'
const isNegative = ref.relation === 'opposes'
const badgeColor = isPositive
? 'bg-emerald-500/10 dark:bg-emerald-500/20 text-emerald-600 dark:text-emerald-400 border-emerald-200 dark:border-emerald-700'
: isNegative
? 'bg-rose-500/10 dark:bg-rose-500/20 text-rose-600 dark:text-rose-400 border-rose-200 dark:border-rose-700'
: 'bg-amber-500/10 dark:bg-amber-500/20 text-amber-600 dark:text-amber-400 border-amber-200 dark:border-amber-700'
const relLabel = t(`brainstorm.${ref.relation}`) || ref.relation
const vis = (ref as any).visibility || 'participants'
const isRestricted = vis === 'owner_only'
return (
<div
key={ref.id}
className="p-4 rounded-xl border border-border bg-white dark:bg-white/5"
>
<div className="flex items-start gap-3">
<span className={`shrink-0 px-2 py-0.5 rounded-full text-[9px] font-bold uppercase tracking-wider border ${badgeColor}`}>
{relLabel}
</span>
{isRestricted && !isGuest && (
<span className="shrink-0 px-1.5 py-0.5 rounded-full text-[8px] font-bold uppercase tracking-wider border border-amber-200 dark:border-amber-700 bg-amber-500/10 text-amber-600 dark:text-amber-400 flex items-center gap-0.5">
<Lock size={8} /> Owner
</span>
)}
<div className="flex-1 min-w-0">
{ref.note ? (
<p className="text-sm font-medium text-foreground truncate">
{ref.note.title || 'Untitled'}
</p>
) : (
<p className="text-sm italic text-muted-foreground">
{t('brainstorm.noNoteLink') || 'Purely generative idea'}
</p>
)}
<p className="text-xs italic text-muted-foreground mt-1 leading-relaxed">
{ref.explanation}
</p>
</div>
{ref.noteId && (
<button
onClick={() => router.push(`/?openNote=${ref.noteId}`)}
className="shrink-0 px-2 py-1 text-[9px] font-bold uppercase tracking-wider rounded-lg bg-foreground/5 hover:bg-foreground/10 text-muted-foreground hover:text-foreground transition-all"
>
{t('brainstorm.viewNote') || 'View'}
</button>
)}
</div>
</div>
)
})}
</div>
)}
{canEdit && (
<div className="grid grid-cols-1 gap-4">
<div className="grid grid-cols-2 gap-4">
<button
onClick={() => handleDeepen(selectedIdea)}
disabled={expandIdea.isPending}
className="flex flex-col items-center justify-center p-6 border-2 border-dashed border-border rounded-2xl hover:border-orange-400/40 hover:bg-orange-500/5 transition-all group disabled:opacity-50"
>
<Wind size={24} className="text-muted-foreground group-hover:text-orange-500 mb-2" />
<span className="text-[11px] font-bold uppercase tracking-widest text-muted-foreground group-hover:text-foreground">
{expandIdea.isPending ? (t('brainstorm.deepening') || 'Generating...') : (t('brainstorm.deepen') || 'Deepen')}
</span>
</button>
<button
onClick={() => handleConvert(selectedIdea)}
disabled={selectedIdea.status === 'converted' || convertIdea.isPending}
className="flex flex-col items-center justify-center p-6 border-2 border-dashed border-border rounded-2xl hover:border-emerald-400/40 hover:bg-emerald-500/5 transition-all group disabled:opacity-50"
>
<FileText size={24} className="text-muted-foreground group-hover:text-emerald-500 mb-2" />
<span className="text-[11px] font-bold uppercase tracking-widest text-muted-foreground group-hover:text-foreground">
{convertIdea.isPending ? (t('brainstorm.converting') || 'Converting...') : (t('brainstorm.extract') || 'Create Note')}
</span>
</button>
</div>
<button
onClick={() => handleDismiss(selectedIdea.id)}
className="w-full py-4 text-[10px] font-bold uppercase tracking-[0.2em] text-muted-foreground hover:text-rose-500 hover:bg-rose-500/5 rounded-xl transition-all border border-transparent hover:border-rose-500/10"
>
{t('brainstorm.dismiss') || 'Not pertinent'}
</button>
</div>
)}
{isGuest && (
<div className="mt-6 px-4 py-3 bg-orange-500/5 border border-orange-500/10 rounded-xl flex items-center gap-2">
<Globe size={14} className="text-orange-500 shrink-0" />
<span className="text-[11px] text-orange-600 dark:text-orange-400">
Vous consultez ce brainstorm en tant qu'invité. Connectez-vous pour modifier.
</span>
</div>
)}
</div>
</motion.div>
)}
</AnimatePresence>
<div className="w-16 border-l border-border flex flex-col items-center py-6 gap-6 bg-white dark:bg-[#1A1A1A] z-10">
<History size={18} className="text-muted-foreground" />
<div className="w-px flex-1 bg-border/40" />
<div className="flex flex-col gap-3 overflow-y-auto px-2">
{sessions?.map((s) => (
<button
key={s.id}
onClick={() => setActiveSessionId(s.id)}
className={`w-10 h-10 min-h-[40px] rounded-xl flex items-center justify-center text-xs font-bold transition-all shrink-0 ${
activeSessionId === s.id
? 'bg-foreground text-background scale-110 shadow-lg'
: (s as any)._owned === false
? 'bg-memento-blue dark:bg-memento-blue/10 text-memento-blue hover:bg-blue-100 hover:text-memento-blue'
: 'bg-white dark:bg-white/10 text-muted-foreground hover:bg-foreground/5 hover:text-foreground'
}`}
title={s.seedIdea}
>
{s.seedIdea.charAt(0).toUpperCase()}
</button>
))}
</div>
<div className="w-px h-12 bg-border/40" />
</div>
</div>
{activeSessionId && session && (
<BrainstormShareDialog
open={showShareDialog}
onOpenChange={setShowShareDialog}
sessionId={activeSessionId}
seedIdea={session.seedIdea}
isPublic={(session as any).isPublic || false}
guestCanEdit={(session as any).guestCanEdit || false}
/>
)}
<AnimatePresence>
{impactToast && (
<motion.div
initial={{ y: 100, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: 100, opacity: 0 }}
className="fixed bottom-8 left-1/2 -translate-x-1/2 z-50 px-6 py-4 bg-foreground text-background rounded-2xl shadow-2xl flex items-center gap-4 text-sm font-medium"
>
<span>
{impactToast.notesEnriched === 0 && impactToast.notesMarkedDry === 0
? (t('brainstorm.linkCopied') || 'Invite link copied!')
: <>
{impactToast.notesEnriched > 0 && `${impactToast.notesEnriched} note(s) enriched`}
{impactToast.notesEnriched > 0 && impactToast.notesMarkedDry > 0 && ' · '}
{impactToast.notesMarkedDry > 0 && `${impactToast.notesMarkedDry} note(s) marked dry`}
</>
}
</span>
<button onClick={() => setImpactToast(null)} className="text-background/60 hover:text-background">
</button>
</motion.div>
)}
</AnimatePresence>
<AnimatePresence>
{exportError && (
<motion.div
initial={{ y: 100, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: 100, opacity: 0 }}
className="fixed bottom-8 left-1/2 -translate-x-1/2 z-50 px-6 py-4 bg-rose-500 text-white rounded-2xl shadow-2xl flex items-center gap-4 text-sm font-medium"
>
<span>{exportError}</span>
<button onClick={() => setExportError(null)} className="text-white/60 hover:text-white">
</button>
</motion.div>
)}
</AnimatePresence>
<AnimatePresence>
{exportToast && (
<motion.div
initial={{ y: 100, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: 100, opacity: 0 }}
className="fixed bottom-8 left-1/2 -translate-x-1/2 z-50 px-6 py-4 bg-emerald-600 text-white rounded-2xl shadow-2xl flex items-center gap-3 text-sm font-medium"
>
<Wind size={18} />
<div className="flex flex-col">
<span className="font-bold">{exportToast.noteTitle}</span>
<span className="text-[11px] text-emerald-100">Carnet : {exportToast.notebookName}</span>
</div>
<div className="ml-3 flex items-center gap-1.5 text-emerald-200 text-[10px]">
<div className="w-1.5 h-1.5 rounded-full bg-emerald-300 animate-pulse" />
Ouverture…
</div>
</motion.div>
)}
</AnimatePresence>
<AnimatePresence>
{convertToast && (
<motion.div
initial={{ y: 100, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
exit={{ y: 100, opacity: 0 }}
className="fixed bottom-8 left-1/2 -translate-x-1/2 z-50 px-6 py-4 bg-emerald-600 text-white rounded-2xl shadow-2xl flex items-center gap-3 text-sm font-medium"
>
<FileText size={18} />
<div className="flex flex-col">
<span className="font-bold">{convertToast.noteTitle}</span>
<span className="text-[11px] text-emerald-100">{t('brainstorm.noteCreated') || 'Note Created'}</span>
</div>
<div className="ml-3 flex items-center gap-1.5 text-emerald-200 text-[10px]">
<div className="w-1.5 h-1.5 rounded-full bg-emerald-300 animate-pulse" />
Ouverture
</div>
</motion.div>
)}
</AnimatePresence>
</div>
)
}

View File

@@ -0,0 +1,296 @@
'use client'
import React, { useState, useTransition, useEffect, useRef, useCallback } from 'react'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from '@/components/ui/dialog'
import { UserPlus, Check, AlertCircle, Globe, Lock, Copy } from 'lucide-react'
import { createBrainstormShare } from '@/app/actions/brainstorm'
import { useUpdateBrainstormSettings } from '@/hooks/use-brainstorm'
interface BrainstormShareDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
sessionId: string
seedIdea: string
isPublic?: boolean
guestCanEdit?: boolean
}
interface UserResult {
id: string
name: string | null
email: string
image: string | null
}
const MESSAGES: Record<string, { text: string; type: 'success' | 'info' }> = {
invited: { text: 'Invitation envoyée !', type: 'success' },
re_invited: { text: 'Invitation renvoyée !', type: 'success' },
already_shared: {
text: 'Cette personne a déjà accès à ce brainstorm.',
type: 'info',
},
already_pending: {
text: 'Une invitation est déjà en attente pour cette personne.',
type: 'info',
},
}
export function BrainstormShareDialog({
open,
onOpenChange,
sessionId,
seedIdea,
isPublic = false,
guestCanEdit = false,
}: BrainstormShareDialogProps) {
const [query, setQuery] = useState('')
const [results, setResults] = useState<UserResult[]>([])
const [showDropdown, setShowDropdown] = useState(false)
const [feedback, setFeedback] = useState<{
text: string
type: 'success' | 'error' | 'info'
} | null>(null)
const [isPending, startTransition] = useTransition()
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const dropdownRef = useRef<HTMLDivElement>(null)
const [publicEnabled, setPublicEnabled] = useState(isPublic)
const [guestEditEnabled, setGuestEditEnabled] = useState(guestCanEdit)
const [linkCopied, setLinkCopied] = useState(false)
const updateSettings = useUpdateBrainstormSettings(sessionId)
useEffect(() => {
setPublicEnabled(isPublic)
setGuestEditEnabled(guestCanEdit)
}, [isPublic, guestCanEdit])
useEffect(() => {
if (!open) {
setQuery('')
setResults([])
setShowDropdown(false)
setFeedback(null)
}
}, [open])
const searchUsers = useCallback(async (q: string) => {
if (q.length < 2) {
setResults([])
setShowDropdown(false)
return
}
try {
const res = await fetch(`/api/users/search?q=${encodeURIComponent(q)}`)
const data = await res.json()
setResults(data.users || [])
setShowDropdown(data.users?.length > 0)
} catch {
setResults([])
}
}, [])
const handleInputChange = (value: string) => {
setQuery(value)
setFeedback(null)
if (debounceRef.current) clearTimeout(debounceRef.current)
debounceRef.current = setTimeout(() => searchUsers(value), 200)
}
const selectUser = (user: UserResult) => {
setQuery(user.email)
setShowDropdown(false)
setResults([])
}
const handleShare = (e: React.FormEvent) => {
e.preventDefault()
if (!query.trim()) return
setShowDropdown(false)
setFeedback(null)
startTransition(async () => {
try {
const result = await createBrainstormShare(sessionId, query.trim())
if (result.message && MESSAGES[result.message]) {
const m = MESSAGES[result.message]
setFeedback({ text: m.text, type: m.type })
} else {
setFeedback({ text: 'Invitation envoyée !', type: 'success' })
}
if (result.message === 'invited' || result.message === 're_invited') {
setQuery('')
}
} catch (err: any) {
setFeedback({ text: err.message || 'Erreur', type: 'error' })
}
})
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md bg-white dark:bg-[#1A1A1A] border-border rounded-2xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-foreground">
<div className="w-7 h-7 rounded-lg bg-orange-500/10 flex items-center justify-center">
<UserPlus size={14} className="text-orange-500" />
</div>
<span className="font-serif">Partager le brainstorm</span>
</DialogTitle>
<DialogDescription className="text-muted-foreground text-xs italic font-serif truncate">
{seedIdea.length > 60 ? seedIdea.substring(0, 60) + '…' : seedIdea}
</DialogDescription>
</DialogHeader>
<form onSubmit={handleShare} className="space-y-3 mt-2">
<div className="relative" ref={dropdownRef}>
<label className="text-[10px] font-bold uppercase tracking-[0.15em] text-muted-foreground mb-1.5 block">
Rechercher une personne
</label>
<input
type="text"
value={query}
onChange={(e) => handleInputChange(e.target.value)}
onFocus={() => results.length > 0 && setShowDropdown(true)}
onBlur={() => setTimeout(() => setShowDropdown(false), 150)}
placeholder="Nom ou email…"
className="w-full px-4 py-3 text-sm border border-border rounded-xl bg-transparent focus:outline-none focus:ring-2 focus:ring-orange-500/20 focus:border-orange-500/40 transition-all"
autoFocus
/>
{showDropdown && results.length > 0 && (
<div className="absolute z-50 top-full left-0 right-0 mt-1 bg-white dark:bg-[#1A1A1A] border border-border rounded-xl shadow-xl overflow-hidden">
{results.map((user) => (
<button
key={user.id}
type="button"
onMouseDown={(e) => {
e.preventDefault()
selectUser(user)
}}
className="w-full px-4 py-2.5 flex items-center gap-3 hover:bg-orange-500/5 transition-colors text-left"
>
<div className="w-8 h-8 rounded-full bg-orange-500/10 flex items-center justify-center text-xs font-bold text-orange-600 shrink-0">
{(user.name || user.email).charAt(0).toUpperCase()}
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-foreground truncate">
{user.name || 'Sans nom'}
</p>
<p className="text-[11px] text-muted-foreground truncate">
{user.email}
</p>
</div>
</button>
))}
</div>
)}
</div>
{feedback && (
<div
className={`flex items-center gap-1.5 px-1 text-[11px] font-medium ${
feedback.type === 'success'
? 'text-emerald-500'
: feedback.type === 'error'
? 'text-rose-500'
: 'text-amber-500'
}`}
>
{feedback.type === 'success' ? (
<Check size={12} />
) : (
<AlertCircle size={12} />
)}
{feedback.text}
</div>
)}
<button
type="submit"
disabled={!query.trim() || isPending}
className="w-full py-3 bg-orange-500 hover:bg-orange-600 text-white text-[10px] font-bold uppercase tracking-[0.15em] rounded-xl disabled:opacity-50 transition-all flex items-center justify-center gap-1.5"
>
<UserPlus size={12} />
{isPending ? 'Envoi…' : 'Partager'}
</button>
</form>
<p className="text-[10px] text-muted-foreground/60 text-center mt-1">
La personne recevra une notification pour accepter ou refuser.
</p>
<div className="border-t border-border mt-4 pt-4">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
{publicEnabled ? (
<Globe size={14} className="text-emerald-500" />
) : (
<Lock size={14} className="text-muted-foreground" />
)}
<span className="text-[10px] font-bold uppercase tracking-[0.15em] text-muted-foreground">
Lien public
</span>
</div>
<button
onClick={() => {
const next = !publicEnabled
setPublicEnabled(next)
if (!next) setGuestEditEnabled(false)
updateSettings.mutate({ isPublic: next, guestCanEdit: next ? guestEditEnabled : false })
}}
className={`relative w-10 h-5 rounded-full transition-colors ${publicEnabled ? 'bg-emerald-500' : 'bg-border'}`}
>
<div
className={`absolute top-0.5 w-4 h-4 rounded-full bg-white shadow-sm transition-transform ${publicEnabled ? 'translate-x-5' : 'translate-x-0.5'}`}
/>
</button>
</div>
{publicEnabled && (
<>
<div className="flex items-center gap-2 mb-3">
<input
readOnly
value={`${typeof window !== 'undefined' ? window.location.origin : ''}/brainstorm?session=${sessionId}`}
className="flex-1 px-3 py-2 text-[11px] bg-foreground/5 border border-border rounded-lg text-muted-foreground truncate"
/>
<button
onClick={() => {
navigator.clipboard.writeText(`${window.location.origin}/brainstorm?session=${sessionId}`)
setLinkCopied(true)
setTimeout(() => setLinkCopied(false), 2000)
}}
className="px-3 py-2 text-[10px] font-bold uppercase tracking-wider bg-foreground/5 hover:bg-foreground/10 rounded-lg transition-colors flex items-center gap-1"
>
{linkCopied ? <Check size={12} className="text-emerald-500" /> : <Copy size={12} />}
</button>
</div>
<div className="flex items-center justify-between">
<span className="text-[10px] text-muted-foreground">
Autoriser les invités à modifier
</span>
<button
onClick={() => {
const next = !guestEditEnabled
setGuestEditEnabled(next)
updateSettings.mutate({ guestCanEdit: next })
}}
className={`relative w-10 h-5 rounded-full transition-colors ${guestEditEnabled ? 'bg-orange-500' : 'bg-border'}`}
>
<div
className={`absolute top-0.5 w-4 h-4 rounded-full bg-white shadow-sm transition-transform ${guestEditEnabled ? 'translate-x-5' : 'translate-x-0.5'}`}
/>
</button>
</div>
</>
)}
</div>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,123 @@
'use client'
import React, { useEffect, useState, useRef } from 'react'
import { motion, AnimatePresence } from 'motion/react'
interface GhostCursorProps {
isActive: boolean
containerRef: React.RefObject<HTMLDivElement | null>
targetId?: string | null
}
export function GhostCursor({ isActive, containerRef, targetId }: GhostCursorProps) {
const [position, setPosition] = useState({ x: 0, y: 0 })
const [visible, setVisible] = useState(false)
const intervalRef = useRef<NodeJS.Timeout | null>(null)
useEffect(() => {
if (!isActive) {
setVisible(false)
if (intervalRef.current) clearInterval(intervalRef.current)
return
}
const container = containerRef.current
if (!container) return
setVisible(true)
// Initialisation sécurisée
const initialRect = container.getBoundingClientRect()
const initialCx = initialRect.width > 0 ? initialRect.width / 2 : window.innerWidth / 2
const initialCy = initialRect.height > 0 ? initialRect.height / 2 : window.innerHeight / 2
setPosition({
x: initialCx + (Math.random() - 0.5) * 200,
y: initialCy + (Math.random() - 0.5) * 200,
})
let angle = Math.random() * Math.PI * 2
let targetX = initialCx + Math.cos(angle) * 250
let targetY = initialCy + Math.sin(angle) * 250
intervalRef.current = setInterval(() => {
// Recalculer les dimensions à chaque tick pour s'adapter aux redimensionnements
const currentContainer = containerRef.current
if (!currentContainer) return
const containerRect = currentContainer.getBoundingClientRect()
const cx = containerRect.width / 2
const cy = containerRect.height / 2
let currentTargetX = targetX;
let currentTargetY = targetY;
if (targetId) {
const nodeElement = document.querySelector(`[data-id="${targetId}"]`);
if (nodeElement) {
const nodeRect = nodeElement.getBoundingClientRect();
currentTargetX = nodeRect.left - containerRect.left + nodeRect.width / 2;
currentTargetY = nodeRect.top - containerRect.top + nodeRect.height / 2;
}
}
setPosition(prev => {
const dx = currentTargetX - prev.x
const dy = currentTargetY - prev.y
const dist = Math.sqrt(dx * dx + dy * dy)
if (!targetId && dist < 20) {
angle = Math.random() * Math.PI * 2
const radius = 150 + Math.random() * 200
targetX = cx + Math.cos(angle) * radius
targetY = cy + Math.sin(angle) * radius
}
const speed = targetId ? 0.15 : 0.06;
let newX = prev.x + dx * speed;
let newY = prev.y + dy * speed;
// Protection Anti-NaN qui bloquait le curseur en haut à gauche
if (isNaN(newX)) newX = cx;
if (isNaN(newY)) newY = cy;
return {
x: newX,
y: newY,
}
})
}, 50)
return () => {
if (intervalRef.current) clearInterval(intervalRef.current)
}
}, [isActive, containerRef, targetId])
return (
<AnimatePresence>
{visible && (
<motion.div
initial={{ opacity: 0, scale: 0.5 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.5 }}
className="absolute pointer-events-none z-50"
style={{ transform: `translate(${position.x}px, ${position.y}px)` }}
>
<div className="relative">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<path d="M0 0L16 6L8 8L6 16L0 0Z" fill="#a78bfa" />
</svg>
<div className="absolute -top-1 -right-1 w-3 h-3">
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-violet-400 opacity-50" />
<span className="relative inline-flex rounded-full h-3 w-3 bg-violet-500" />
</div>
<div className="mt-3 ml-3 px-2 py-0.5 rounded-full text-[10px] font-bold text-white whitespace-nowrap shadow-lg bg-gradient-to-r from-violet-500 to-purple-600">
AI
</div>
</div>
</motion.div>
)}
</AnimatePresence>
)
}

View File

@@ -0,0 +1,210 @@
'use client'
import React, { useState } from 'react'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog'
import { UserPlus, Link, Mail, Check, Copy } from 'lucide-react'
interface InviteDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
onInviteByEmail: (email: string, role: 'editor' | 'viewer') => Promise<any>
onInviteByLink: () => Promise<any>
seedIdea: string
isLoading: boolean
t: (key: string) => string | undefined
}
export function InviteDialog({
open,
onOpenChange,
onInviteByEmail,
onInviteByLink,
seedIdea,
isLoading,
t,
}: InviteDialogProps) {
const [tab, setTab] = useState<'email' | 'link'>('email')
const [email, setEmail] = useState('')
const [role, setRole] = useState<'editor' | 'viewer'>('editor')
const [linkCopied, setLinkCopied] = useState(false)
const [emailSent, setEmailSent] = useState(false)
const [localLoading, setLocalLoading] = useState(false)
const handleEmailInvite = async (e: React.FormEvent) => {
e.preventDefault()
if (!email.trim()) return
setLocalLoading(true)
try {
const res = await onInviteByEmail(email.trim(), role)
if (res?.invitedUser) {
setEmailSent(true)
setTimeout(() => {
setEmailSent(false)
setEmail('')
}, 2000)
}
} finally {
setLocalLoading(false)
}
}
const handleLinkCopy = async () => {
setLocalLoading(true)
try {
const res = await onInviteByLink()
if (res?.inviteUrl) {
const url = window.location.origin + res.inviteUrl
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(url)
} else {
const ta = document.createElement('textarea')
ta.value = url
ta.style.position = 'fixed'
ta.style.opacity = '0'
document.body.appendChild(ta)
ta.select()
document.execCommand('copy')
document.body.removeChild(ta)
}
setLinkCopied(true)
setTimeout(() => setLinkCopied(false), 3000)
}
} finally {
setLocalLoading(false)
}
}
const busy = isLoading || localLoading
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md bg-white dark:bg-[#1A1A1A] border-border rounded-2xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-foreground">
<div className="w-7 h-7 rounded-lg bg-emerald-500/10 flex items-center justify-center">
<UserPlus size={14} className="text-emerald-500" />
</div>
<span className="font-serif">{t('brainstorm.inviteTitle') || 'Invite to brainstorm'}</span>
</DialogTitle>
<DialogDescription className="text-muted-foreground text-xs italic font-serif">
{seedIdea.length > 60 ? seedIdea.substring(0, 60) + '…' : seedIdea}
</DialogDescription>
</DialogHeader>
<div className="flex gap-1 p-1 bg-foreground/5 rounded-xl mt-2">
<button
onClick={() => setTab('email')}
className={`flex-1 flex items-center justify-center gap-1.5 py-2 px-3 rounded-lg text-[10px] font-bold uppercase tracking-[0.1em] transition-all ${
tab === 'email'
? 'bg-white dark:bg-[#2A2A2A] text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'
}`}
>
<Mail size={12} />
{t('brainstorm.inviteByEmail') || 'Email'}
</button>
<button
onClick={() => setTab('link')}
className={`flex-1 flex items-center justify-center gap-1.5 py-2 px-3 rounded-lg text-[10px] font-bold uppercase tracking-[0.1em] transition-all ${
tab === 'link'
? 'bg-white dark:bg-[#2A2A2A] text-foreground shadow-sm'
: 'text-muted-foreground hover:text-foreground'
}`}
>
<Link size={12} />
{t('brainstorm.inviteByLink') || 'Link'}
</button>
</div>
{tab === 'email' ? (
<form onSubmit={handleEmailInvite} className="space-y-4 mt-2">
<div>
<label className="text-[10px] font-bold uppercase tracking-[0.15em] text-muted-foreground mb-1.5 block">
{t('brainstorm.inviteEmailLabel') || 'Email address'}
</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder={t('brainstorm.inviteEmailPlaceholder') || 'colleague@email.com'}
className="w-full px-4 py-3 text-sm border border-border rounded-xl bg-transparent focus:outline-none focus:ring-2 focus:ring-emerald-500/20 focus:border-emerald-500/40 transition-all"
autoFocus
/>
</div>
<div>
<label className="text-[10px] font-bold uppercase tracking-[0.15em] text-muted-foreground mb-1.5 block">
{t('brainstorm.inviteRoleLabel') || 'Role'}
</label>
<div className="flex gap-2">
<button
type="button"
onClick={() => setRole('editor')}
className={`flex-1 py-2.5 rounded-xl text-[10px] font-bold uppercase tracking-[0.1em] border transition-all ${
role === 'editor'
? 'border-emerald-500/40 bg-emerald-500/5 text-emerald-600'
: 'border-border text-muted-foreground hover:border-emerald-500/20'
}`}
>
{t('brainstorm.roleEditor') || 'Editor'}
</button>
<button
type="button"
onClick={() => setRole('viewer')}
className={`flex-1 py-2.5 rounded-xl text-[10px] font-bold uppercase tracking-[0.1em] border transition-all ${
role === 'viewer'
? 'border-memento-blue/40 bg-memento-blue/5 text-memento-blue'
: 'border-border text-muted-foreground hover:border-memento-blue/20'
}`}
>
{t('brainstorm.roleViewer') || 'Viewer'}
</button>
</div>
</div>
<button
type="submit"
disabled={!email.trim() || busy}
className="w-full py-3 bg-emerald-500 hover:bg-emerald-600 text-white text-[10px] font-bold uppercase tracking-[0.15em] rounded-xl disabled:opacity-50 transition-all flex items-center justify-center gap-1.5"
>
{emailSent ? (
<>
<Check size={12} />
{t('brainstorm.inviteSent') || 'Invitation sent!'}
</>
) : (
<>
<Mail size={12} />
{busy ? '...' : (t('brainstorm.sendInvite') || 'Send invitation')}
</>
)}
</button>
</form>
) : (
<div className="space-y-4 mt-2">
<p className="text-xs text-muted-foreground leading-relaxed">
{t('brainstorm.linkDescription') || 'Anyone with this link can join the brainstorm session.'}
</p>
<button
onClick={handleLinkCopy}
disabled={busy}
className="w-full py-3 bg-foreground/5 hover:bg-foreground/10 text-foreground text-[10px] font-bold uppercase tracking-[0.15em] rounded-xl transition-all flex items-center justify-center gap-1.5 border border-border"
>
{linkCopied ? (
<>
<Check size={12} className="text-emerald-500" />
<span className="text-emerald-500">{t('brainstorm.linkCopied') || 'Link copied!'}</span>
</>
) : (
<>
<Copy size={12} />
{busy ? '...' : (t('brainstorm.copyLink') || 'Copy invite link')}
</>
)}
</button>
</div>
)}
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,93 @@
'use client'
import React from 'react'
import { PresenceUser } from '@/hooks/use-brainstorm-socket'
function Cursor({ x, y, name, color }: { x: number; y: number; name: string; color: string }) {
return (
<div
className="absolute pointer-events-none z-50 transition-all duration-75"
style={{ transform: `translate(${x}px, ${y}px)` }}
>
<svg width="16" height="16" viewBox="0 0 16 16" fill="none">
<path d="M0 0L16 6L8 8L6 16L0 0Z" fill={color} />
</svg>
<div
className="mt-3 ml-3 px-2 py-0.5 rounded-full text-[10px] font-bold text-white whitespace-nowrap shadow-lg"
style={{ backgroundColor: color }}
>
{name}
</div>
</div>
)
}
export function LiveCursors({ others }: { others: PresenceUser[] }) {
return (
<div className="absolute inset-0 pointer-events-none z-50">
{others.map((user) => {
if (!user.cursor) return null
return (
<Cursor
key={user.userId}
x={user.cursor.x}
y={user.cursor.y}
name={user.name}
color={user.color}
/>
)
})}
</div>
)
}
export function useCursorTracking(
containerRef: React.RefObject<HTMLDivElement | null>,
moveCursor: (cursor: { x: number; y: number } | null) => void
) {
React.useEffect(() => {
const container = containerRef.current
if (!container) return
const handleMove = (e: MouseEvent) => {
const rect = container.getBoundingClientRect()
moveCursor({ x: e.clientX - rect.left, y: e.clientY - rect.top })
}
const handleLeave = () => {
moveCursor(null)
}
container.addEventListener('mousemove', handleMove)
container.addEventListener('mouseleave', handleLeave)
return () => {
container.removeEventListener('mousemove', handleMove)
container.removeEventListener('mouseleave', handleLeave)
}
}, [containerRef, moveCursor])
}
export function PresenceAvatars({ others }: { others: PresenceUser[] }) {
if (others.length === 0) return null
return (
<div className="flex items-center gap-1">
{others.slice(0, 4).map((user) => {
const initial = (user.name || '?').charAt(0).toUpperCase()
return (
<div
key={user.userId}
className="w-6 h-6 rounded-full flex items-center justify-center text-[9px] font-bold text-white border-2 border-white dark:border-zinc-900 shadow-sm"
style={{ backgroundColor: user.color, marginLeft: '-6px' }}
title={user.name}
>
{initial}
</div>
)
})}
{others.length > 4 && (
<span className="text-[9px] text-muted-foreground ml-1">+{others.length - 4}</span>
)}
</div>
)
}

View File

@@ -0,0 +1,98 @@
'use client'
import React, { useState } from 'react'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog'
import { Lightbulb } from 'lucide-react'
interface ManualIdeaDialogProps {
open: boolean
onOpenChange: (open: boolean) => void
onSubmit: (title: string, description?: string) => void
isLoading?: boolean
parentIdeaTitle?: string | null
t: (key: string) => string | undefined
}
export function ManualIdeaDialog({
open,
onOpenChange,
onSubmit,
isLoading,
parentIdeaTitle,
t,
}: ManualIdeaDialogProps) {
const [title, setTitle] = useState('')
const [description, setDescription] = useState('')
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault()
if (!title.trim()) return
onSubmit(title.trim(), description.trim() || undefined)
setTitle('')
setDescription('')
onOpenChange(false)
}
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md bg-white dark:bg-[#1A1A1A] border-border rounded-2xl">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-foreground">
<div className="w-7 h-7 rounded-lg bg-memento-blue/10 flex items-center justify-center">
<Lightbulb size={14} className="text-memento-blue" />
</div>
<span className="font-serif">{t('brainstorm.addIdea') || 'Add an idea'}</span>
</DialogTitle>
<DialogDescription className="text-muted-foreground text-xs">
{parentIdeaTitle
? `${t('brainstorm.respondsTo') || 'Responds to'} « ${parentIdeaTitle.length > 40 ? parentIdeaTitle.substring(0, 40) + '…' : parentIdeaTitle} »`
: (t('brainstorm.manualIdeaDesc') || 'Share your idea with the brainstorm canvas')
}
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4 mt-2">
<div>
<label className="text-[10px] font-bold uppercase tracking-[0.15em] text-muted-foreground mb-1.5 block">
{t('brainstorm.manualIdeaTitle') || 'Title'}
</label>
<input
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder={t('brainstorm.manualIdeaTitlePlaceholder') || 'Your idea in a few words...'}
className="w-full px-4 py-3 text-sm border border-border rounded-xl bg-transparent focus:outline-none focus:ring-2 focus:ring-memento-blue/20 focus:border-memento-blue/40 transition-all font-serif"
autoFocus
/>
</div>
<div>
<label className="text-[10px] font-bold uppercase tracking-[0.15em] text-muted-foreground mb-1.5 block">
{t('brainstorm.manualIdeaDescLabel') || 'Description (optional)'}
</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder={t('brainstorm.manualIdeaDescPlaceholder') || 'Elaborate on your idea...'}
className="w-full h-24 px-4 py-3 text-sm border border-border rounded-xl bg-transparent focus:outline-none focus:ring-2 focus:ring-memento-blue/20 focus:border-memento-blue/40 resize-none transition-all"
/>
</div>
<div className="flex justify-end gap-2 pt-2">
<button
type="button"
onClick={() => onOpenChange(false)}
className="px-4 py-2 text-[10px] font-bold uppercase tracking-[0.15em] text-muted-foreground hover:text-foreground rounded-xl hover:bg-foreground/5 transition-all"
>
{t('brainstorm.cancel') || 'Cancel'}
</button>
<button
type="submit"
disabled={!title.trim() || isLoading}
className="px-5 py-2.5 bg-memento-blue hover:bg-memento-blue text-white text-[10px] font-bold uppercase tracking-[0.15em] rounded-xl disabled:opacity-50 transition-all flex items-center gap-1.5"
>
<Lightbulb size={12} />
{isLoading ? (t('brainstorm.adding') || 'Adding...') : (t('brainstorm.addIdea') || 'Add idea')}
</button>
</div>
</form>
</DialogContent>
</Dialog>
)
}

View File

@@ -0,0 +1,197 @@
'use client'
import React, { useState, useCallback, useRef, useEffect } from 'react'
import { motion, AnimatePresence } from 'motion/react'
import { Play, Pause, SkipBack, SkipForward, ChevronDown, ChevronUp, RotateCcw } from 'lucide-react'
import { useBrainstormSnapshots } from '@/hooks/use-brainstorm'
interface SnapshotIdea {
id: string
title: string
waveNumber: number
positionX: number | null
positionY: number | null
parentIdeaId: string | null
noveltyScore: number | null
createdByType: string | null
status: string
}
interface Snapshot {
id: string
step: number
label: string | null
ideaGraph: string
createdAt: string
}
interface PlaybackBarProps {
sessionId: string
onSnapshotSelect: (ideas: SnapshotIdea[]) => void
onExitPlayback: () => void
}
export function PlaybackBar({ sessionId, onSnapshotSelect, onExitPlayback }: PlaybackBarProps) {
const { data: snapshots } = useBrainstormSnapshots(sessionId)
const [isOpen, setIsOpen] = useState(false)
const [currentStep, setCurrentStep] = useState(-1)
const [isPlaying, setIsPlaying] = useState(false)
const playIntervalRef = useRef<ReturnType<typeof setInterval> | null>(null)
const parsedSnapshots: (Snapshot & { ideas: SnapshotIdea[] })[] = React.useMemo(() => {
return (snapshots || []).map((s: Snapshot) => ({
...s,
ideas: JSON.parse(s.ideaGraph) as SnapshotIdea[],
}))
}, [snapshots])
const handleStepChange = useCallback((step: number) => {
if (step === -1) {
setCurrentStep(-1)
onExitPlayback()
return
}
const snapshot = parsedSnapshots[step]
if (snapshot) {
setCurrentStep(step)
onSnapshotSelect(snapshot.ideas)
}
}, [parsedSnapshots, onSnapshotSelect, onExitPlayback])
const togglePlay = useCallback(() => {
if (isPlaying) {
setIsPlaying(false)
if (playIntervalRef.current) clearInterval(playIntervalRef.current)
return
}
setIsPlaying(true)
const startStep = currentStep === -1 ? 0 : currentStep + 1
let step = startStep
playIntervalRef.current = setInterval(() => {
if (step >= parsedSnapshots.length) {
setIsPlaying(false)
if (playIntervalRef.current) clearInterval(playIntervalRef.current)
return
}
handleStepChange(step)
step++
}, 1500)
}, [isPlaying, currentStep, parsedSnapshots.length, handleStepChange])
useEffect(() => {
return () => {
if (playIntervalRef.current) clearInterval(playIntervalRef.current)
}
}, [])
if (!parsedSnapshots || parsedSnapshots.length === 0) return null
const isLive = currentStep === -1
return (
<AnimatePresence>
<motion.div
initial={{ y: 60 }}
animate={{ y: isOpen ? 0 : 40 }}
className="absolute bottom-0 left-0 right-16 z-30"
>
<div className="mx-6 mb-4 bg-white/90 dark:bg-[#1A1A1A]/90 backdrop-blur-xl border border-border rounded-2xl shadow-2xl overflow-hidden">
<button
onClick={() => setIsOpen(!isOpen)}
className="w-full px-5 py-2.5 flex items-center justify-between hover:bg-foreground/5 transition-colors"
>
<div className="flex items-center gap-3">
<div className={`w-2 h-2 rounded-full ${isLive ? 'bg-emerald-500 animate-pulse' : isPlaying ? 'bg-orange-500 animate-pulse' : 'bg-muted-foreground'}`} />
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">
{isLive ? 'Live' : `Step ${currentStep + 1}/${parsedSnapshots.length}`}
</span>
{parsedSnapshots[currentStep]?.label && (
<span className="text-[10px] text-muted-foreground/60 truncate max-w-[200px]">
{parsedSnapshots[currentStep].label}
</span>
)}
</div>
<div className="flex items-center gap-2">
{isLive ? (
<span className="text-[9px] font-bold text-emerald-500 uppercase tracking-wider">Live</span>
) : (
<button
onClick={(e) => { e.stopPropagation(); handleStepChange(-1) }}
className="p-1.5 hover:bg-foreground/10 rounded-lg transition-colors"
title="Return to live"
>
<RotateCcw size={12} className="text-muted-foreground" />
</button>
)}
{isOpen ? <ChevronDown size={14} /> : <ChevronUp size={14} />}
</div>
</button>
{isOpen && (
<motion.div
initial={{ height: 0 }}
animate={{ height: 'auto' }}
className="border-t border-border"
>
<div className="px-5 py-3 flex items-center gap-3">
<button
onClick={() => handleStepChange(Math.max(0, currentStep - 1))}
disabled={currentStep <= 0}
className="p-2 hover:bg-foreground/5 rounded-lg transition-colors disabled:opacity-30"
>
<SkipBack size={14} />
</button>
<button
onClick={togglePlay}
className="p-2 hover:bg-foreground/5 rounded-lg transition-colors"
>
{isPlaying ? <Pause size={14} /> : <Play size={14} />}
</button>
<button
onClick={() => handleStepChange(Math.min(parsedSnapshots.length - 1, currentStep + 1))}
disabled={currentStep >= parsedSnapshots.length - 1}
className="p-2 hover:bg-foreground/5 rounded-lg transition-colors disabled:opacity-30"
>
<SkipForward size={14} />
</button>
<div className="flex-1 mx-3">
<input
type="range"
min={-1}
max={parsedSnapshots.length - 1}
value={currentStep}
onChange={(e) => handleStepChange(parseInt(e.target.value))}
className="w-full h-1.5 bg-border rounded-full appearance-none cursor-pointer accent-orange-500"
/>
<div className="flex justify-between mt-1">
<span className="text-[8px] text-muted-foreground/40">Live</span>
<span className="text-[8px] text-muted-foreground/40">{parsedSnapshots.length} steps</span>
</div>
</div>
</div>
<div className="px-5 pb-3 flex gap-1 overflow-x-auto">
{parsedSnapshots.map((s, idx) => (
<button
key={s.id}
onClick={() => handleStepChange(idx)}
className={`shrink-0 px-2.5 py-1 rounded-lg text-[9px] font-bold uppercase tracking-wider transition-all ${
idx === currentStep
? 'bg-orange-500 text-white'
: 'bg-foreground/5 text-muted-foreground hover:bg-foreground/10'
}`}
>
{s.label ? (s.label.length > 20 ? s.label.substring(0, 20) + '…' : s.label) : `#${idx + 1}`}
</button>
))}
</div>
</motion.div>
)}
</div>
</motion.div>
</AnimatePresence>
)
}

View 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>
)
}