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