All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 4s
Covers architecture, configuration steps, user flows, API routes, webhooks, pricing, testing with Stripe CLI, production checklist, and troubleshooting.
820 lines
39 KiB
TypeScript
820 lines
39 KiB
TypeScript
'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-brand-accent', bg: 'bg-brand-accent', text: 'text-brand-accent' },
|
|
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(`/home?openNote=${result.id}`)
|
|
}, 2000)
|
|
}
|
|
} catch {}
|
|
}
|
|
|
|
const handleExport = async () => {
|
|
setExportError(null)
|
|
try {
|
|
const result = await exportBrainstorm.mutateAsync()
|
|
if (result?.id) {
|
|
const notebookName = result._notebookName || t('brainstorm.exportDefaultNotebookName')
|
|
setExportToast({ noteTitle: result.title || t('brainstorm.exportDefaultNoteTitle'), notebookName })
|
|
setTimeout(() => {
|
|
setExportToast(null)
|
|
router.push(`/home?openNote=${result.id}`)
|
|
}, 2000)
|
|
return
|
|
}
|
|
const impact = await finalizeBrainstorm.mutateAsync()
|
|
if (impact) {
|
|
setImpactToast(impact)
|
|
setTimeout(() => setImpactToast(null), 5000)
|
|
}
|
|
} catch (err: any) {
|
|
setExportError(err?.message || t('brainstorm.exportFailedMessage'))
|
|
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-brand-accent shadow-[0_0_20px_rgba(164,113,72,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')}
|
|
</h1>
|
|
<div className="flex items-center gap-2 mt-1">
|
|
<span className="w-8 h-px bg-brand-accent/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-brand-accent 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={t('brainstorm.liveCollaborationTitle')}>
|
|
<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">{t('brainstorm.liveStatus')}</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-brand-accent 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-brand-accent/20 to-brand-accent/10 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-brand-accent/40 focus:ring-4 focus:ring-brand-accent/5"
|
|
/>
|
|
<button
|
|
onClick={() => handleStartBrainstorm()}
|
|
disabled={isGenerating || !seedInput.trim()}
|
|
className="absolute right-4 top-4 bottom-4 px-6 bg-foreground dark:bg-brand-accent 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-brand-accent/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-brand-accent"
|
|
/>
|
|
))}
|
|
</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">
|
|
{t('brainstorm.canvasWaitingHint')}
|
|
</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">
|
|
{t('brainstorm.waveBadge', { 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.addIdea')}
|
|
</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-brand-accent dark:border-blue-700 bg-brand-accent dark:bg-brand-accent/15 text-brand-accent dark:text-brand-accent'
|
|
: '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-brand-accent" />
|
|
<span className="text-xs font-bold text-muted-foreground">
|
|
{t('brainstorm.novelty')}: {selectedIdea.noveltyScore ?? t('common.notAvailable')}/10
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center gap-1.5">
|
|
{selectedIdea.createdByType === 'human' ? (
|
|
<>
|
|
<div className="w-4 h-4 rounded-full bg-brand-accent 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-brand-accent">
|
|
{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} /> {t('brainstorm.ownerBadge')}
|
|
</span>
|
|
)}
|
|
<div className="flex-1 min-w-0">
|
|
{ref.note ? (
|
|
<p className="text-sm font-medium text-foreground truncate">
|
|
{ref.note.title || t('notes.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(`/home?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-brand-accent/40 hover:bg-brand-accent/5 transition-all group disabled:opacity-50"
|
|
>
|
|
<Wind size={24} className="text-muted-foreground group-hover:text-brand-accent 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-brand-accent/5 border border-brand-accent/10 rounded-xl flex items-center gap-2">
|
|
<Globe size={14} className="text-brand-accent shrink-0" />
|
|
<span className="text-[11px] text-brand-accent">
|
|
{t('brainstorm.guestReadOnlyNotice')}
|
|
</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-brand-accent dark:bg-brand-accent/10 text-brand-accent hover:bg-blue-100 hover:text-brand-accent'
|
|
: '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')
|
|
: <>
|
|
{impactToast.notesEnriched > 0 && t('brainstorm.impactNotesEnriched', { count: impactToast.notesEnriched })}
|
|
{impactToast.notesEnriched > 0 && impactToast.notesMarkedDry > 0 && t('brainstorm.impactSeparator')}
|
|
{impactToast.notesMarkedDry > 0 && t('brainstorm.impactNotesMarkedDry', { count: impactToast.notesMarkedDry })}
|
|
</>
|
|
}
|
|
</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">
|
|
{t('brainstorm.exportNotebookPrefix')} {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" />
|
|
{t('brainstorm.exportOpening')}
|
|
</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" />
|
|
{t('brainstorm.exportOpening')}
|
|
</div>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
</div>
|
|
)
|
|
}
|