Rend les liens entre notes visibles et persistants (sync NoteLink au save, auto-save, graphe réseau rafraîchi), ajoute living blocks, Memory Echo, recherche globale, consentement IA explicite et consolide les prototypes design en architectural-grid. Co-authored-by: Cursor <cursoragent@cursor.com>
798 lines
35 KiB
TypeScript
798 lines
35 KiB
TypeScript
|
|
import React, { useState, useEffect, useMemo, useRef } from 'react';
|
|
import { motion, AnimatePresence } from 'motion/react';
|
|
import {
|
|
Zap,
|
|
Search,
|
|
ArrowRight,
|
|
History,
|
|
Plus,
|
|
Wind,
|
|
PlusCircle,
|
|
FileText,
|
|
ChevronRight,
|
|
Maximize2,
|
|
Share2,
|
|
Users,
|
|
Check,
|
|
Download,
|
|
Activity,
|
|
X
|
|
} from 'lucide-react';
|
|
import { v4 as uuidv4 } from 'uuid';
|
|
import { WaveCanvas } from './WaveCanvas';
|
|
import { BrainstormSession, BrainstormIdea, Note } from '../../types';
|
|
import { generateBrainstormWave, generateExpansion, getEmbedding, cosineSimilarity } from '../../services/geminiService';
|
|
|
|
interface BrainstormViewProps {
|
|
notes: Note[];
|
|
onConvertNote: (idea: BrainstormIdea) => void;
|
|
}
|
|
|
|
export const BrainstormView: React.FC<BrainstormViewProps> = ({ notes, onConvertNote }) => {
|
|
const [seedInput, setSeedInput] = useState('');
|
|
const [isGenerating, setIsGenerating] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [sessions, setSessions] = useState<BrainstormSession[]>([]);
|
|
const [activeSessionId, setActiveSessionId] = useState<string | null>(null);
|
|
const [ideas, setIdeas] = useState<BrainstormIdea[]>([]);
|
|
const [selectedIdeaId, setSelectedIdeaId] = useState<string | null>(null);
|
|
const [editingNodeId, setEditingNodeId] = useState<string | null>(null);
|
|
const [manualTitle, setManualTitle] = useState('');
|
|
const [shareStatus, setShareStatus] = useState<'idle' | 'copying' | 'copied'>('idle');
|
|
const [showActivity, setShowActivity] = useState(false);
|
|
const [activities, setActivities] = useState<{ id: string; type: string; message: string; timestamp: string }[]>([]);
|
|
const [collaborators, setCollaborators] = useState<{ id: string; name: string; color: string }[]>([]);
|
|
|
|
const socketRef = useRef<WebSocket | null>(null);
|
|
|
|
// Mock current user for presence
|
|
const currentUser = useMemo(() => ({
|
|
id: 'me-' + Math.random().toString(36).substr(2, 9),
|
|
name: 'Sepehr' // Derived from user email in metadata if possible, or guest
|
|
}), []);
|
|
|
|
const getInitials = (name: string) => name.split(' ').map(n => n[0]).join('').toUpperCase().slice(0, 2);
|
|
const stringToColor = (str: string) => {
|
|
let hash = 0;
|
|
for (let i = 0; i < str.length; i++) {
|
|
hash = str.charCodeAt(i) + ((hash << 5) - hash);
|
|
}
|
|
const colors = ['#f43f5e', '#ef4444', '#f59e0b', '#10b981', '#06b6d4', '#3b82f6', '#6366f1', '#8b5cf6', '#d946ef', '#f472b6'];
|
|
return colors[Math.abs(hash) % colors.length];
|
|
};
|
|
|
|
const addActivity = (message: string, type: string = 'info', broadcast: boolean = true) => {
|
|
const newActivity = {
|
|
id: uuidv4(),
|
|
type,
|
|
message,
|
|
timestamp: new Date().toLocaleTimeString()
|
|
};
|
|
setActivities(prev => [newActivity, ...prev].slice(0, 50));
|
|
if (broadcast && socketRef.current?.readyState === WebSocket.OPEN) {
|
|
socketRef.current.send(JSON.stringify({ type: 'activity', activity: newActivity }));
|
|
}
|
|
};
|
|
|
|
// WebSocket Connection
|
|
useEffect(() => {
|
|
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
const socket = new WebSocket(`${protocol}//${window.location.host}`);
|
|
socketRef.current = socket;
|
|
|
|
socket.onopen = () => {
|
|
console.log('WS Shared Brainstorm connected');
|
|
if (activeSessionId) {
|
|
socket.send(JSON.stringify({
|
|
type: 'join',
|
|
sessionId: activeSessionId,
|
|
user: { ...currentUser, color: stringToColor(currentUser.name) }
|
|
}));
|
|
}
|
|
};
|
|
|
|
socket.onmessage = (event) => {
|
|
const data = JSON.parse(event.data);
|
|
if (data.type === 'presence') {
|
|
setCollaborators(data.users);
|
|
}
|
|
if (data.type === 'idea_added') {
|
|
const newIdea = data.idea;
|
|
setIdeas(prev => {
|
|
if (prev.find(i => i.id === newIdea.id)) return prev;
|
|
return [...prev, newIdea];
|
|
});
|
|
}
|
|
if (data.type === 'idea_updated') {
|
|
const updatedIdea = data.idea;
|
|
setIdeas(prev => prev.map(i => i.id === updatedIdea.id ? updatedIdea : i));
|
|
}
|
|
if (data.type === 'activity') {
|
|
setActivities(prev => [data.activity, ...prev].slice(0, 50));
|
|
}
|
|
};
|
|
|
|
return () => {
|
|
socket.close();
|
|
};
|
|
}, []);
|
|
|
|
// Sync session joining
|
|
useEffect(() => {
|
|
if (socketRef.current?.readyState === WebSocket.OPEN && activeSessionId) {
|
|
socketRef.current.send(JSON.stringify({
|
|
type: 'join',
|
|
sessionId: activeSessionId,
|
|
user: { ...currentUser, color: stringToColor(currentUser.name) }
|
|
}));
|
|
}
|
|
}, [activeSessionId, currentUser]);
|
|
|
|
useEffect(() => {
|
|
fetch('/api/brainstorm/sessions')
|
|
.then(res => res.json())
|
|
.then(data => {
|
|
setSessions(data);
|
|
// Check for initial session from URL parameter (passed via window by App.tsx)
|
|
const initialId = (window as any).initialSessionId;
|
|
if (initialId && data.find((s: any) => s.id === initialId)) {
|
|
setActiveSessionId(initialId);
|
|
delete (window as any).initialSessionId;
|
|
}
|
|
})
|
|
.catch(err => console.error("Failed to load sessions", err));
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
if (activeSessionId) {
|
|
fetch(`/api/brainstorm/${activeSessionId}`)
|
|
.then(res => res.json())
|
|
.then(data => {
|
|
if (data.ideas) {
|
|
setIdeas(prev => {
|
|
const filtered = prev.filter(i => i.sessionId !== activeSessionId);
|
|
return [...filtered, ...data.ideas];
|
|
});
|
|
}
|
|
})
|
|
.catch(err => console.error("Failed to load ideas", err));
|
|
}
|
|
}, [activeSessionId]);
|
|
|
|
const activeSession = useMemo(() =>
|
|
sessions.find(s => s.id === activeSessionId),
|
|
[activeSessionId, sessions]);
|
|
|
|
const activeIdeas = useMemo(() =>
|
|
ideas.filter(i => i.sessionId === activeSessionId),
|
|
[activeSessionId, ideas]);
|
|
|
|
const selectedIdea = useMemo(() =>
|
|
ideas.find(i => i.id === selectedIdeaId),
|
|
[selectedIdeaId, ideas]);
|
|
|
|
useEffect(() => {
|
|
const handleRemoteStart = (e: any) => {
|
|
if (e.detail?.seed) {
|
|
handleStartBrainstorm(e.detail.seed, e.detail.sourceNoteId);
|
|
}
|
|
};
|
|
window.addEventListener('start-brainstorm', handleRemoteStart);
|
|
return () => window.removeEventListener('start-brainstorm', handleRemoteStart);
|
|
}, [notes]);
|
|
|
|
const handleStartBrainstorm = async (seed: string, sourceNoteId?: string) => {
|
|
if (!seed.trim()) return;
|
|
|
|
setIsGenerating(true);
|
|
setError(null);
|
|
|
|
try {
|
|
// 1. Create session on backend
|
|
const sessionRes = await fetch('/api/brainstorm/sessions', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({
|
|
seedIdea: seed,
|
|
sourceNoteId
|
|
})
|
|
});
|
|
const session = await sessionRes.json();
|
|
if (!sessionRes.ok) throw new Error(session.error || "Failed to create session");
|
|
|
|
setSessions(prev => [session, ...prev]);
|
|
setActiveSessionId(session.id);
|
|
setSeedInput('');
|
|
|
|
// 2. Generate waves in frontend concurrently
|
|
const contextSummaries = notes.slice(0, 5).map(n => n.title).join(', ');
|
|
|
|
const wavePromises = [1, 2, 3].map(async (num) => {
|
|
try {
|
|
const generated = await generateBrainstormWave(seed, num, contextSummaries);
|
|
return generated.map(g => ({
|
|
...g,
|
|
waveNumber: num
|
|
}));
|
|
} catch (e) {
|
|
console.error(`Wave ${num} failed`, e);
|
|
return [];
|
|
}
|
|
});
|
|
|
|
const wavesResults = await Promise.all(wavePromises);
|
|
const allNewIdeas = wavesResults.flat();
|
|
|
|
if (allNewIdeas.length === 0) {
|
|
throw new Error("No ideas were generated. Gemini might be shy today.");
|
|
}
|
|
|
|
// 3. Save ideas to backend
|
|
const ideasRes = await fetch(`/api/brainstorm/${session.id}/ideas`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ ideas: allNewIdeas })
|
|
});
|
|
const savedIdeas = await ideasRes.json();
|
|
setIdeas(prev => [...prev, ...savedIdeas]);
|
|
|
|
addActivity(`Generated ${savedIdeas.length} ideas for Wave ${allNewIdeas[0]?.waveNumber || ''}`);
|
|
|
|
// Notify others
|
|
savedIdeas.forEach((idea: any) => {
|
|
socketRef.current?.send(JSON.stringify({ type: 'idea_added', idea }));
|
|
});
|
|
|
|
} catch (err: any) {
|
|
console.error("Brainstorm failed:", err);
|
|
setError(err.message || "An unexpected error occurred while brainstorming.");
|
|
} finally {
|
|
setIsGenerating(false);
|
|
}
|
|
};
|
|
|
|
const updateIdea = async (ideaId: string, updates: Partial<BrainstormIdea>) => {
|
|
try {
|
|
const res = await fetch(`/api/brainstorm/ideas/${ideaId}`, {
|
|
method: 'PATCH',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify(updates)
|
|
});
|
|
const updated = await res.json();
|
|
setIdeas(prev => prev.map(i => i.id === ideaId ? updated : i));
|
|
|
|
// Notify others
|
|
socketRef.current?.send(JSON.stringify({ type: 'idea_updated', idea: updated }));
|
|
} catch (err) {
|
|
console.error("Update failed", err);
|
|
}
|
|
};
|
|
|
|
const handleDeepenIdea = async (idea: BrainstormIdea) => {
|
|
setIsGenerating(true);
|
|
try {
|
|
const generated = await generateExpansion(idea.title, idea.description);
|
|
const newIdeasData = generated.map(g => ({
|
|
...g,
|
|
waveNumber: Math.min(idea.waveNumber + 1, 3),
|
|
parentIdeaId: idea.id
|
|
}));
|
|
|
|
const res = await fetch(`/api/brainstorm/${idea.sessionId}/ideas`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ ideas: newIdeasData })
|
|
});
|
|
const savedIdeas = await res.json();
|
|
setIdeas(prev => [...prev, ...savedIdeas]);
|
|
addActivity(`Expanded idea: ${idea.title}`);
|
|
|
|
// Notify others
|
|
savedIdeas.forEach((i: any) => {
|
|
socketRef.current?.send(JSON.stringify({ type: 'idea_added', idea: i }));
|
|
});
|
|
} catch (err) {
|
|
console.error("Deepen failed", err);
|
|
setError("Failed to expand this idea.");
|
|
} finally {
|
|
setIsGenerating(false);
|
|
}
|
|
};
|
|
|
|
const handleDismissIdea = (ideaId: string) => {
|
|
updateIdea(ideaId, { status: 'dismissed' });
|
|
setSelectedIdeaId(null);
|
|
};
|
|
|
|
const handleConvertToNote = (idea: BrainstormIdea) => {
|
|
updateIdea(idea.id, { status: 'converted' });
|
|
onConvertNote(idea);
|
|
};
|
|
|
|
const handleManualAdd = async (title: string, parentId?: string) => {
|
|
if (!title.trim() || !activeSessionId) return;
|
|
|
|
setIsGenerating(true);
|
|
try {
|
|
let finalParentId = parentId;
|
|
let waveNumber = 1;
|
|
|
|
if (parentId) {
|
|
const p = ideas.find(i => i.id === parentId);
|
|
if (p) waveNumber = Math.min(p.waveNumber + 1, 3);
|
|
} else if (activeIdeas.length > 0) {
|
|
// Semantic auto-placement if no parent is specified
|
|
try {
|
|
const newEmbedding = await getEmbedding(title);
|
|
let bestSim = -1;
|
|
let bestParent: BrainstormIdea | null = null;
|
|
|
|
for (const idea of activeIdeas) {
|
|
const ideaEmbedding = await getEmbedding(idea.title + " " + idea.description);
|
|
const sim = cosineSimilarity(newEmbedding, ideaEmbedding);
|
|
if (sim > bestSim) {
|
|
bestSim = sim;
|
|
bestParent = idea;
|
|
}
|
|
}
|
|
|
|
if (bestParent && bestSim > 0.7) {
|
|
finalParentId = bestParent.id;
|
|
waveNumber = Math.min(bestParent.waveNumber + 1, 3);
|
|
}
|
|
} catch (e) {
|
|
console.error("Semantic placement failed", e);
|
|
}
|
|
}
|
|
|
|
const newIdeaData = [{
|
|
title: title,
|
|
description: "",
|
|
waveNumber: waveNumber,
|
|
connectionToSeed: finalParentId
|
|
? `Manual addition (auto-linked)`
|
|
: "Manual addition to root",
|
|
noveltyScore: 5,
|
|
parentIdeaId: finalParentId
|
|
}];
|
|
|
|
const res = await fetch(`/api/brainstorm/${activeSessionId}/ideas`, {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/json' },
|
|
body: JSON.stringify({ ideas: newIdeaData })
|
|
});
|
|
const saved = await res.json();
|
|
setIdeas(prev => [...prev, ...saved]);
|
|
|
|
addActivity(`Manually added idea: ${title}`);
|
|
|
|
// Notify
|
|
saved.forEach((i: any) => socketRef.current?.send(JSON.stringify({ type: 'idea_added', idea: i })));
|
|
|
|
setEditingNodeId(null);
|
|
setManualTitle('');
|
|
} catch (err) {
|
|
console.error("Manual add failed", err);
|
|
} finally {
|
|
setIsGenerating(false);
|
|
}
|
|
};
|
|
|
|
const handleInvite = () => {
|
|
if (!activeSessionId) return;
|
|
const shareUrl = `${window.location.origin}${window.location.pathname}?session=${activeSessionId}`;
|
|
navigator.clipboard.writeText(shareUrl);
|
|
setShareStatus('copied');
|
|
addActivity(`Invitation link copied to clipboard`);
|
|
setTimeout(() => setShareStatus('idle'), 2000);
|
|
};
|
|
|
|
const handleExport = () => {
|
|
if (!activeSession) return;
|
|
|
|
let markdown = `# Brainstorm : ${activeSession.seedIdea}\n\n`;
|
|
markdown += `Date : ${new Date(activeSession.createdAt).toLocaleDateString()}\n\n`;
|
|
|
|
[1, 2, 3].forEach(waveNum => {
|
|
const waveIdeas = activeIdeas.filter(i => i.waveNumber === waveNum);
|
|
if (waveIdeas.length > 0) {
|
|
markdown += `## Vague ${waveNum}\n\n`;
|
|
waveIdeas.forEach(idea => {
|
|
markdown += `### ${idea.title}\n`;
|
|
markdown += `${idea.description}\n`;
|
|
markdown += `*Score de nouveauté : ${idea.noveltyScore}/10*\n`;
|
|
markdown += `*Connexion : ${idea.connectionToSeed}*\n\n`;
|
|
});
|
|
}
|
|
});
|
|
|
|
onConvertNote({
|
|
id: uuidv4(),
|
|
title: `Brainstorm Export: ${activeSession.seedIdea}`,
|
|
description: markdown,
|
|
sessionId: activeSession.id,
|
|
waveNumber: 0,
|
|
connectionToSeed: "Export",
|
|
noveltyScore: 10,
|
|
status: 'converted'
|
|
});
|
|
|
|
addActivity(`Session exported to notes`);
|
|
};
|
|
|
|
return (
|
|
<div className="h-full flex flex-col bg-[#F8F7F2] dark:bg-[#0A0A0A] overflow-hidden">
|
|
{/* Header / Start area */}
|
|
<div className="p-12 border-b border-border/20 backdrop-blur-md bg-white/20 dark:bg-dark-paper/20 z-10 relative overflow-hidden">
|
|
{/* Architectural Grid Background */}
|
|
<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-ochre shadow-[0_0_20px_rgba(212,163,115,0.2)] flex items-center justify-center text-paper"
|
|
>
|
|
<Wind size={28} />
|
|
</motion.div>
|
|
<div className="flex-1">
|
|
<h1 className="text-4xl font-serif font-medium text-ink dark:text-dark-ink tracking-tight">Waves of Thought</h1>
|
|
<div className="flex items-center gap-2 mt-1">
|
|
<span className="w-8 h-px bg-ochre/40" />
|
|
<p className="text-[10px] text-concrete tracking-[0.3em] uppercase font-bold">Unfold dimensions of potentiality</p>
|
|
</div>
|
|
</div>
|
|
|
|
{activeSession && (
|
|
<div className="flex items-center gap-3">
|
|
<button
|
|
onClick={handleExport}
|
|
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-concrete hover:text-ochre transition-all shadow-sm"
|
|
title="Export to Note"
|
|
>
|
|
<Download size={14} />
|
|
<span className="hidden sm:inline">Export</span>
|
|
</button>
|
|
<button
|
|
onClick={() => setShowActivity(!showActivity)}
|
|
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 ${showActivity ? 'bg-ink text-paper' : 'bg-white dark:bg-white/5 text-concrete hover:text-ink'}`}
|
|
title="Show Activity"
|
|
>
|
|
<Activity size={14} />
|
|
<span className="hidden sm:inline">Activity</span>
|
|
</button>
|
|
<button
|
|
onClick={handleInvite}
|
|
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-concrete hover:text-ink transition-all shadow-sm"
|
|
>
|
|
{shareStatus === 'copied' ? <Check size={14} className="text-emerald-500" /> : <Share2 size={14} />}
|
|
{shareStatus === 'copied' ? 'Link Copied' : 'Invite'}
|
|
</button>
|
|
<div className="flex items-center -space-x-2 mr-2">
|
|
{collaborators.map((user) => (
|
|
<div
|
|
key={user.id}
|
|
className="relative group/avatar"
|
|
>
|
|
<div
|
|
className="w-8 h-8 rounded-full border-2 border-paper dark:border-dark-paper flex items-center justify-center text-[10px] font-bold text-white shadow-sm cursor-help relative z-10"
|
|
style={{ backgroundColor: user.color || '#999' }}
|
|
>
|
|
{getInitials(user.name)}
|
|
</div>
|
|
<div className="absolute bottom-full left-1/2 -translate-x-1/2 mb-2 px-2 py-1 bg-ink text-paper text-[10px] rounded font-bold whitespace-nowrap opacity-0 group-hover/avatar:opacity-100 pointer-events-none transition-opacity z-20">
|
|
{user.name}
|
|
<div className="absolute top-full left-1/2 -translate-x-1/2 border-4 border-transparent border-t-ink" />
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
|
|
<div className="flex items-center gap-1 px-3 py-2 bg-emerald-500/10 rounded-full mr-2">
|
|
<div className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse" />
|
|
<Users size={14} className="text-emerald-500" />
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="relative group">
|
|
<div className="absolute -inset-1 bg-gradient-to-r from-ochre/20 to-accent/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(seedInput)}
|
|
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-ink dark:text-dark-ink shadow-sm group-hover:shadow-md
|
|
${error ? 'border-rose-400 focus:ring-rose-100 shadow-rose-100' : 'border-border/40 focus:border-ochre/40 focus:ring-4 focus:ring-ochre/5'}`}
|
|
/>
|
|
<button
|
|
onClick={() => handleStartBrainstorm(seedInput)}
|
|
disabled={isGenerating || !seedInput.trim()}
|
|
className="absolute right-4 top-4 bottom-4 px-6 bg-ink dark:bg-ochre text-paper 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-paper/30 border-t-paper rounded-full animate-spin" />
|
|
) : (
|
|
<Plus size={24} />
|
|
)}
|
|
</button>
|
|
</div>
|
|
|
|
<AnimatePresence>
|
|
{error && (
|
|
<motion.div
|
|
initial={{ opacity: 0, y: -10 }}
|
|
animate={{ opacity: 1, y: 0 }}
|
|
exit={{ opacity: 0, y: -10 }}
|
|
className="mt-6 p-5 bg-rose-50 dark:bg-rose-500/10 border border-rose-200 dark:border-rose-500/20 rounded-2xl flex items-start gap-4 text-rose-600 dark:text-rose-400 text-sm overflow-hidden shadow-sm"
|
|
>
|
|
<div className="w-5 h-5 rounded-full bg-rose-100 dark:bg-rose-500/20 flex items-center justify-center shrink-0 mt-0.5">
|
|
<div className="w-2 h-2 rounded-full bg-rose-500" />
|
|
</div>
|
|
<div className="flex-1">
|
|
<p className="font-bold uppercase tracking-wider text-[10px] mb-1">Obstruction detected</p>
|
|
<span>{error}</span>
|
|
</div>
|
|
</motion.div>
|
|
)}
|
|
|
|
{isGenerating && !error && (
|
|
<motion.div
|
|
initial={{ opacity: 0 }}
|
|
animate={{ opacity: 1 }}
|
|
className="mt-6 flex items-center gap-4 text-ochre/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-ochre"
|
|
/>
|
|
))}
|
|
</div>
|
|
<span className="text-base tracking-tight">Gemini is harvesting seeds of thought from the digital ether...</span>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex-1 flex overflow-hidden relative">
|
|
{/* Main Canvas Area */}
|
|
<div className="flex-1 relative bg-[radial-gradient(#e5e7eb_1px,transparent_1px)] dark:bg-[radial-gradient(#ffffff10_1px,transparent_1px)] [background-size:20px_20px]">
|
|
{activeSession ? (
|
|
<div onClick={() => setSelectedIdeaId(null)} className="w-full h-full">
|
|
<WaveCanvas
|
|
session={activeSession}
|
|
ideas={activeIdeas}
|
|
onNodeSelect={(id) => {
|
|
setSelectedIdeaId(id);
|
|
}}
|
|
onPositionUpdate={(id, pos) => updateIdea(id, { position: pos })}
|
|
onAddChild={(id) => {
|
|
setSelectedIdeaId(id);
|
|
setEditingNodeId(id);
|
|
}}
|
|
onManualSubmit={handleManualAdd}
|
|
onManualCancel={() => setEditingNodeId(null)}
|
|
editingNodeId={editingNodeId}
|
|
selectedNodeId={selectedIdeaId}
|
|
relatedNotes={notes}
|
|
/>
|
|
</div>
|
|
) : (
|
|
<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-concrete animate-pulse" />
|
|
<p className="text-xl font-serif italic text-concrete">The canvas is waiting for your spark...</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Floating UI overlays */}
|
|
<AnimatePresence>
|
|
{activeSession && (
|
|
<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-paper/80 dark:bg-black/60 backdrop-blur-xl border border-border shadow-xl rounded-full flex items-center gap-6">
|
|
<div className="flex items-center gap-2">
|
|
<div className="w-2 h-2 rounded-full bg-orange-400 shadow-[0_0_8px_rgba(251,146,60,0.6)]" />
|
|
<span className="text-[10px] font-bold uppercase tracking-widest text-concrete">Wave 1</span>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<div className="w-2 h-2 rounded-full bg-blue-400 shadow-[0_0_8px_rgba(96,165,250,0.6)]" />
|
|
<span className="text-[10px] font-bold uppercase tracking-widest text-concrete">Wave 2</span>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
<div className="w-2 h-2 rounded-full bg-violet-400 shadow-[0_0_8px_rgba(167,139,250,0.6)]" />
|
|
<span className="text-[10px] font-bold uppercase tracking-widest text-concrete">Wave 3</span>
|
|
</div>
|
|
</div>
|
|
|
|
<button
|
|
onClick={() => setEditingNodeId('new')}
|
|
className="px-6 py-3 bg-paper 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-concrete hover:bg-ink hover:text-paper transition-all"
|
|
>
|
|
<Plus size={14} />
|
|
Add Manual Idea
|
|
</button>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
</div>
|
|
|
|
{/* Activity Sidebar */}
|
|
<AnimatePresence>
|
|
{showActivity && (
|
|
<motion.div
|
|
initial={{ x: '100%' }}
|
|
animate={{ x: 0 }}
|
|
exit={{ x: '100%' }}
|
|
transition={{ type: 'spring', damping: 25, stiffness: 200 }}
|
|
className="fixed right-0 top-0 h-full w-80 bg-paper dark:bg-dark-paper border-l border-border shadow-2xl z-[70] flex flex-col"
|
|
>
|
|
<div className="p-6 border-b border-border flex items-center justify-between bg-ink text-paper">
|
|
<div className="flex items-center gap-2">
|
|
<Activity size={18} />
|
|
<h3 className="font-bold uppercase tracking-widest text-xs">Flux d'activité</h3>
|
|
</div>
|
|
<button onClick={() => setShowActivity(false)} className="p-1 hover:bg-white/10 rounded-lg">
|
|
<X size={18} />
|
|
</button>
|
|
</div>
|
|
<div className="flex-1 overflow-y-auto p-6 space-y-4">
|
|
{activities.length === 0 ? (
|
|
<p className="text-xs text-concrete text-center italic mt-10">Aucune activité pour le moment</p>
|
|
) : (
|
|
activities.map((act) => (
|
|
<motion.div
|
|
key={act.id}
|
|
initial={{ opacity: 0, x: 20 }}
|
|
animate={{ opacity: 1, x: 0 }}
|
|
className="p-3 bg-white dark:bg-white/5 rounded-xl border border-border/50 relative overflow-hidden group"
|
|
>
|
|
<div className="absolute left-0 top-0 bottom-0 w-1 bg-ochre/40" />
|
|
<p className="text-[11px] font-medium text-ink dark:text-dark-ink">{act.message}</p>
|
|
<span className="text-[9px] text-concrete font-bold mt-1 block">{act.timestamp}</span>
|
|
</motion.div>
|
|
))
|
|
)}
|
|
</div>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
|
|
{/* Right Sidebar Detail Panel */}
|
|
<AnimatePresence>
|
|
{selectedIdea && (
|
|
<motion.div
|
|
initial={{ x: '100%' }}
|
|
animate={{ x: 0 }}
|
|
exit={{ x: '100%' }}
|
|
className="w-[400px] border-l border-border bg-paper dark:bg-dark-paper flex flex-col z-20 shadow-[-20px_0_40px_rgba(0,0,0,0.05)]"
|
|
>
|
|
<div className="p-8 flex-1 overflow-y-auto custom-scrollbar">
|
|
<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-200 bg-orange-50 text-orange-600' :
|
|
selectedIdea.waveNumber === 2 ? 'border-blue-200 bg-blue-50 text-blue-600' :
|
|
'border-violet-200 bg-violet-50 text-violet-600'}`}>
|
|
Vague {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">Note Created</span>
|
|
)}
|
|
<button onClick={() => setSelectedIdeaId(null)} className="p-2 hover:bg-ink/5 rounded-full transition-colors">
|
|
<ChevronRight size={20} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<h2 className="text-3xl font-serif font-medium text-ink dark:text-dark-ink 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-ochre" />
|
|
<span className="text-xs font-bold text-concrete">Novelty: {selectedIdea.noveltyScore}/10</span>
|
|
</div>
|
|
</div>
|
|
|
|
<p className="text-ink/80 dark:text-dark-ink/80 leading-relaxed font-light mb-10 text-lg">
|
|
{selectedIdea.description}
|
|
</p>
|
|
|
|
<div className="p-6 bg-slate-50 dark:bg-white/5 rounded-2xl border border-border/40 mb-10">
|
|
<h4 className="text-[10px] font-bold uppercase tracking-widest text-concrete mb-3">Origin connection</h4>
|
|
<p className="text-sm italic text-muted-ink leading-relaxed">
|
|
"{selectedIdea.connectionToSeed}"
|
|
</p>
|
|
</div>
|
|
|
|
{selectedIdea.relatedNoteIds && selectedIdea.relatedNoteIds.length > 0 && (
|
|
<div className="space-y-4 mb-10">
|
|
<h4 className="text-[10px] font-bold uppercase tracking-widest text-concrete px-1">Semantic Context</h4>
|
|
{selectedIdea.relatedNoteIds.map(noteId => {
|
|
const note = notes.find(n => n.id === noteId);
|
|
return note ? (
|
|
<div key={noteId} className="p-4 rounded-xl border border-border bg-white dark:bg-white/5 hover:border-ink/20 transition-all cursor-pointer group">
|
|
<div className="flex items-center justify-between">
|
|
<h5 className="text-sm font-medium text-ink dark:text-dark-ink truncate">{note.title}</h5>
|
|
<ArrowRight size={14} className="text-concrete group-hover:text-ink transition-colors" />
|
|
</div>
|
|
</div>
|
|
) : null;
|
|
})}
|
|
</div>
|
|
)}
|
|
|
|
<div className="grid grid-cols-1 gap-4">
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<button
|
|
onClick={() => handleDeepenIdea(selectedIdea)}
|
|
disabled={isGenerating}
|
|
className="flex flex-col items-center justify-center p-6 border-2 border-dashed border-border rounded-2xl hover:border-ochre/40 hover:bg-ochre/5 transition-all group disabled:opacity-50"
|
|
>
|
|
<Wind size={24} className="text-concrete group-hover:text-ochre mb-2" />
|
|
<span className="text-[11px] font-bold uppercase tracking-widest text-muted-ink group-hover:text-ink text-center">AI Expand</span>
|
|
</button>
|
|
<button
|
|
onClick={() => setEditingNodeId(selectedIdea.id)}
|
|
className="flex flex-col items-center justify-center p-6 border-2 border-dashed border-border rounded-2xl hover:border-ink/40 hover:bg-ink/5 transition-all group disabled:opacity-50"
|
|
>
|
|
<PlusCircle size={24} className="text-concrete group-hover:text-ink mb-2" />
|
|
<span className="text-[11px] font-bold uppercase tracking-widest text-muted-ink group-hover:text-ink text-center">Add Child</span>
|
|
</button>
|
|
<button
|
|
onClick={() => handleConvertToNote(selectedIdea)}
|
|
disabled={selectedIdea.status === 'converted'}
|
|
className="flex flex-col items-center justify-center p-6 border-2 border-dashed border-border rounded-2xl hover:border-accent/40 hover:bg-accent/5 transition-all group disabled:opacity-50 whitespace-nowrap"
|
|
>
|
|
<FileText size={24} className="text-concrete group-hover:text-accent mb-2" />
|
|
<span className="text-[11px] font-bold uppercase tracking-widest text-muted-ink group-hover:text-ink text-center">Extract Note</span>
|
|
</button>
|
|
</div>
|
|
|
|
<button
|
|
onClick={() => handleDismissIdea(selectedIdea.id)}
|
|
className="w-full py-4 text-[10px] font-bold uppercase tracking-[0.2em] text-concrete hover:text-rose-500 hover:bg-rose-500/5 rounded-xl transition-all border border-transparent hover:border-rose-500/10"
|
|
>
|
|
Not pertinent
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</motion.div>
|
|
)}
|
|
</AnimatePresence>
|
|
|
|
{/* History Rail */}
|
|
<div className="w-16 border-l border-border flex flex-col items-center py-6 gap-6 bg-paper dark:bg-dark-paper z-10">
|
|
<History size={18} className="text-concrete" />
|
|
<div className="w-px flex-1 bg-border/40" />
|
|
<div className="flex flex-col gap-3 overflow-y-auto px-2 custom-scrollbar">
|
|
{sessions.map(session => (
|
|
<button
|
|
key={session.id}
|
|
onClick={() => setActiveSessionId(session.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 === session.id ? 'bg-ink text-paper scale-110 shadow-lg' : 'bg-paper dark:bg-white/10 text-concrete hover:bg-black/5 hover:text-ink'}`}
|
|
title={session.seedIdea}
|
|
>
|
|
{session.seedIdea.charAt(0).toUpperCase()}
|
|
</button>
|
|
))}
|
|
</div>
|
|
<div className="w-px h-12 bg-border/40" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
};
|