feat: brainstorm sessions, PDF document Q&A, embedding fixes, and UI improvements
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 7s

- Add brainstorm feature with collaborative canvas, AI idea generation, live cursors, playback, and export
- Add PDF upload/extraction/ingestion pipeline with pgvector document search (RAG)
- Add document Q&A overlay with streaming chat and PDF preview
- Add note attachments UI with status polling, grid layout, and auto-scroll
- Add task extraction AI tool and agent executor improvements
- Fix NoteEmbedding missing updatedAt column, re-index 66 notes with 1536-dim embeddings
- Fix brainstorm 'Create Note' button: add success toast and redirect to created note
- Fix memory echo notification infinite polling
- Fix chat route to always include document_search tool
- Add brainstorm i18n keys across all 14 locales
- Add socket server for real-time brainstorm collaboration
- Add hierarchical notebook selector and organize notebook dialog improvements
- Add sidebar brainstorm section with session management
- Update prisma schema with brainstorm tables, attachments, and document chunks
This commit is contained in:
Antigravity
2026-05-14 17:43:21 +00:00
parent 195e845f0a
commit 1fcea6ed7d
228 changed files with 57656 additions and 1059 deletions

View File

@@ -0,0 +1,619 @@
/**
* @license
* SPDX-License-Identifier: Apache-2.0
*/
import React, { useState, useMemo } from 'react';
import { motion, AnimatePresence } from 'motion/react';
// Components
import { Sidebar } from './components/Sidebar';
import { NotebooksView } from './components/NotebooksView';
import { AgentsView } from './components/AgentsView';
import { SettingsView } from './components/SettingsView';
import { TrashView } from './components/TrashView';
import { BrainstormView } from './components/BrainstormView/BrainstormView';
import { InsightsView } from './components/InsightsView';
import { TemporalView } from './components/TemporalView';
import { AISidebar } from './components/AISidebar';
import { SlashMenu } from './components/SlashMenu';
// Data & Types
import { CARNETS, ALL_NOTES } from './constants';
import { NavigationView, SettingsTab, AITab, AITone, Carnet, Note, BrainstormIdea, NoteAccessLog } from './types';
export default function App() {
const [activeView, setActiveView] = useState<NavigationView>('notebooks');
const [activeSettingsTab, setActiveSettingsTab] = useState<SettingsTab>('general');
const [selectedAgentId, setSelectedAgentId] = useState<string | null>(null);
const [isDarkMode, setIsDarkMode] = useState(false);
const [carnets, setCarnets] = useState<Carnet[]>(CARNETS);
const [notes, setNotes] = useState<Note[]>(ALL_NOTES);
const [accessLogs, setAccessLogs] = useState<NoteAccessLog[]>([
// Note n1: 14-day cycle
{ noteId: 'n1', accessedAt: new Date(Date.now() - 70 * 24 * 60 * 60 * 1000).toISOString(), action: 'view' },
{ noteId: 'n1', accessedAt: new Date(Date.now() - 56 * 24 * 60 * 60 * 1000).toISOString(), action: 'view' },
{ noteId: 'n1', accessedAt: new Date(Date.now() - 42 * 24 * 60 * 60 * 1000).toISOString(), action: 'view' },
{ noteId: 'n1', accessedAt: new Date(Date.now() - 28 * 24 * 60 * 60 * 1000).toISOString(), action: 'view' },
{ noteId: 'n1', accessedAt: new Date(Date.now() - 14 * 24 * 60 * 60 * 1000).toISOString(), action: 'view' },
{ noteId: 'n1', accessedAt: new Date(Date.now() - 1 * 24 * 60 * 60 * 1000).toISOString(), action: 'view' },
// Note n2: 7-day cycle
{ noteId: 'n2', accessedAt: new Date(Date.now() - 35 * 24 * 60 * 60 * 1000).toISOString(), action: 'view' },
{ noteId: 'n2', accessedAt: new Date(Date.now() - 28 * 24 * 60 * 60 * 1000).toISOString(), action: 'view' },
{ noteId: 'n2', accessedAt: new Date(Date.now() - 21 * 24 * 60 * 60 * 1000).toISOString(), action: 'view' },
{ noteId: 'n2', accessedAt: new Date(Date.now() - 14 * 24 * 60 * 60 * 1000).toISOString(), action: 'view' },
{ noteId: 'n2', accessedAt: new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString(), action: 'view' },
{ noteId: 'n2', accessedAt: new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString(), action: 'view' },
// Note n3: 3-day cycle (frequent check)
{ noteId: 'n3', accessedAt: new Date(Date.now() - 12 * 24 * 60 * 60 * 1000).toISOString(), action: 'view' },
{ noteId: 'n3', accessedAt: new Date(Date.now() - 9 * 24 * 60 * 60 * 1000).toISOString(), action: 'view' },
{ noteId: 'n3', accessedAt: new Date(Date.now() - 6 * 24 * 60 * 60 * 1000).toISOString(), action: 'view' },
{ noteId: 'n3', accessedAt: new Date(Date.now() - 3 * 24 * 60 * 60 * 1000).toISOString(), action: 'view' },
{ noteId: 'n3', accessedAt: new Date(Date.now() - 1 * 24 * 60 * 60 * 1000).toISOString(), action: 'view' },
]);
const logNoteAccess = (noteId: string, action: 'view' | 'edit' | 'search_hit' = 'view') => {
const newLog: NoteAccessLog = {
noteId,
accessedAt: new Date().toISOString(),
action
};
setAccessLogs(prev => [...prev, newLog]);
};
const [activeCarnetId, setActiveCarnetId] = useState('4');
const [activeNoteId, setActiveNoteId] = useState<string | null>(null);
const [brainstormSeed, setBrainstormSeed] = useState<string | null>(null);
const handleBrainstormNote = (note: Note) => {
setActiveView('brainstorm');
// We'll use a small delay or a ref to pass this to BrainstormView if needed,
// but better to just share state or use a CustomEvent
window.dispatchEvent(new CustomEvent('start-brainstorm', {
detail: { seed: note.title, sourceNoteId: note.id }
}));
};
React.useEffect(() => {
if (activeNoteId) {
logNoteAccess(activeNoteId);
}
}, [activeNoteId]);
React.useEffect(() => {
// Check for session in URL
const params = new URLSearchParams(window.location.search);
const session = params.get('session');
if (session) {
setActiveView('brainstorm');
// We pass it via a global property or custom event since BrainstormView will fetch sessions
(window as any).initialSessionId = session;
}
const handleSwitchView = (e: any) => {
if (e.detail) {
setActiveView(e.detail as NavigationView);
}
};
window.addEventListener('switch-view', handleSwitchView);
return () => window.removeEventListener('switch-view', handleSwitchView);
}, []);
const [selectedTagIds, setSelectedTagIds] = useState<string[]>([]);
const [isAISidebarOpen, setIsAISidebarOpen] = useState(false);
const [aiTab, setAiTab] = useState<AITab>('discussion');
const [selectedTone, setSelectedTone] = useState<AITone>('Professional');
// Modal States
const [showNewCarnetModal, setShowNewCarnetModal] = useState<{ isOpen: boolean; parentId?: string; isRenaming?: boolean; carnetId?: string }>({ isOpen: false });
const [showNewNoteModal, setShowNewNoteModal] = useState(false);
const [slashMenu, setSlashMenu] = useState<{ isOpen: boolean; top: number; left: number } | null>(null);
// Form States
const [newCarnetName, setNewCarnetName] = useState('');
const [newNoteTitle, setNewNoteTitle] = useState('');
const [newNoteContent, setNewNoteContent] = useState('');
const handleEditorKeyDown = (e: React.KeyboardEvent) => {
if (e.key === '/') {
const selection = window.getSelection();
if (selection && selection.rangeCount > 0) {
const range = selection.getRangeAt(0);
const rect = range.getBoundingClientRect();
setSlashMenu({
isOpen: true,
top: rect.bottom + window.scrollY,
left: rect.left + window.scrollX
});
}
}
};
const togglePin = (noteId: string) => {
setNotes(notes.map(n => n.id === noteId ? { ...n, isPinned: !n.isPinned } : n));
};
const filteredNotes = useMemo(() => {
let result = notes.filter(n => n.carnetId === activeCarnetId && !n.isDeleted);
if (selectedTagIds.length > 0) {
result = result.filter(note =>
selectedTagIds.every(tagId => note.tags?.some(tag => tag.id === tagId))
);
}
return [...result].sort((a, b) => {
if (a.isPinned && !b.isPinned) return -1;
if (!a.isPinned && b.isPinned) return 1;
return 0;
});
}, [activeCarnetId, notes]);
const activeNote = useMemo(() =>
notes.find(n => n.id === activeNoteId),
[activeNoteId, notes]);
const activeCarnet = useMemo(() =>
carnets.find(c => c.id === activeCarnetId),
[activeCarnetId, carnets]);
const handleAddCarnet = (e: React.FormEvent) => {
e.preventDefault();
if (!newCarnetName.trim()) return;
if (showNewCarnetModal.isRenaming && showNewCarnetModal.carnetId) {
setCarnets(carnets.map(c => c.id === showNewCarnetModal.carnetId ? { ...c, name: newCarnetName, initial: newCarnetName.charAt(0).toUpperCase() } : c));
setShowNewCarnetModal({ isOpen: false });
setNewCarnetName('');
return;
}
const newCarnet: Carnet = {
id: Date.now().toString(),
name: newCarnetName,
initial: newCarnetName.charAt(0).toUpperCase(),
type: 'Project',
parentId: showNewCarnetModal.parentId
};
setCarnets([...carnets, newCarnet]);
setNewCarnetName('');
setShowNewCarnetModal({ isOpen: false });
setActiveCarnetId(newCarnet.id);
};
const handleDeleteCarnet = (id: string) => {
if (window.confirm('Déplacer ce carnet et ses sous-carnets vers la corbeille ?')) {
const idsToDelete = new Set<string>([id]);
const addChildren = (parentId: string) => {
carnets.forEach(c => {
if (c.parentId === parentId) {
idsToDelete.add(c.id);
addChildren(c.id);
}
});
};
addChildren(id);
const deletedAt = new Date().toISOString();
setCarnets(carnets.map(c => idsToDelete.has(c.id) ? { ...c, isDeleted: true, deletedAt } : c));
setNotes(notes.map(n => idsToDelete.has(n.carnetId) ? { ...n, isDeleted: true, deletedAt } : n));
if (idsToDelete.has(activeCarnetId)) {
setActiveCarnetId('1');
}
}
};
const handleDeleteNote = (id: string) => {
const deletedAt = new Date().toISOString();
setNotes(notes.map(n => n.id === id ? { ...n, isDeleted: true, deletedAt } : n));
if (activeNoteId === id) setActiveNoteId(null);
};
const handleRestoreCarnet = (id: string) => {
setCarnets(carnets.map(c => c.id === id ? { ...c, isDeleted: false, deletedAt: undefined } : c));
// Optionally restore linked notes too? User might expect that.
setNotes(notes.map(n => n.carnetId === id ? { ...n, isDeleted: false, deletedAt: undefined } : n));
};
const handleRestoreNote = (id: string) => {
setNotes(notes.map(n => n.id === id ? { ...n, isDeleted: false, deletedAt: undefined } : n));
};
const handlePermanentDeleteNote = (id: string) => {
setNotes(notes.filter(n => n.id !== id));
};
const handlePermanentDeleteCarnet = (id: string) => {
const idsToDelete = new Set<string>([id]);
const addChildren = (parentId: string) => {
carnets.forEach(c => {
if (c.parentId === parentId) {
idsToDelete.add(c.id);
addChildren(c.id);
}
});
};
addChildren(id);
setCarnets(carnets.filter(c => !idsToDelete.has(c.id)));
setNotes(notes.filter(n => !idsToDelete.has(n.carnetId)));
};
const handleAddNote = (e: React.FormEvent) => {
e.preventDefault();
if (!newNoteTitle.trim() || !newNoteContent.trim()) return;
const newNote: Note = {
id: `n-${Date.now()}`,
carnetId: activeCarnetId,
title: newNoteTitle,
date: new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric', year: 'numeric' }).format(new Date()),
content: newNoteContent,
imageUrl: 'https://images.unsplash.com/photo-1487958449943-2429e8be8625?auto=format&fit=crop&q=80&w=800&h=600',
tags: []
};
setNotes([newNote, ...notes]);
setNewNoteTitle('');
setNewNoteContent('');
setShowNewNoteModal(false);
setActiveNoteId(newNote.id);
};
const handleConvertIdeaToNote = (idea: BrainstormIdea) => {
const newNote: Note = {
id: `n-gen-${Date.now()}`,
carnetId: activeCarnetId,
title: idea.title,
date: new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric', year: 'numeric' }).format(new Date()),
content: `${idea.description}\n\n---\n**Connection to seed:** ${idea.connectionToSeed}\n**Novelty Score:** ${idea.noveltyScore}/10`,
imageUrl: 'https://images.unsplash.com/photo-1497366216548-37526070297c?auto=format&fit=crop&q=80&w=800&h=600',
tags: [{ id: 't-ai', label: 'AI Generated', type: 'ai' }]
};
setNotes([newNote, ...notes]);
setActiveView('notebooks');
setActiveNoteId(newNote.id);
};
const handleUpdateNote = (updatedNote: Note) => {
setNotes(notes.map(n => n.id === updatedNote.id ? updatedNote : n));
};
return (
<div className={`h-screen flex bg-paper transition-colors duration-500 overflow-hidden font-sans ${isDarkMode ? 'dark' : ''}`}>
<Sidebar
activeView={activeView}
isDarkMode={isDarkMode}
setIsDarkMode={setIsDarkMode}
setActiveView={setActiveView}
carnets={carnets}
notes={notes}
activeCarnetId={activeCarnetId}
activeNoteId={activeNoteId}
setActiveCarnetId={setActiveCarnetId}
setActiveNoteId={setActiveNoteId}
setShowNewCarnetModal={(show, parentId, isRenaming, carnetId) => {
setShowNewCarnetModal({ isOpen: show, parentId, isRenaming, carnetId });
if (isRenaming && carnetId) {
const carnet = carnets.find(c => c.id === carnetId);
if (carnet) setNewCarnetName(carnet.name);
} else {
setNewCarnetName('');
}
}}
onDeleteCarnet={handleDeleteCarnet}
onMoveCarnet={(draggedId, targetId) => {
if (draggedId === targetId) return;
// Basic circular check
const isDescendant = (parentId: string, potentialChildId: string): boolean => {
const childIds = carnets.filter(c => c.parentId === parentId).map(c => c.id);
if (childIds.includes(potentialChildId)) return true;
return childIds.some(id => isDescendant(id, potentialChildId));
};
if (targetId && isDescendant(draggedId, targetId)) {
console.warn("Cannot move a notebook inside its own descendant");
return;
}
setCarnets(prev => prev.map(c => c.id === draggedId ? { ...c, parentId: targetId } : c));
}}
/>
<main className="flex-1 relative overflow-hidden flex bg-paper dark:bg-dark-paper transition-colors duration-500">
<AnimatePresence mode="wait">
{(activeView === 'notebooks' || activeView === 'shared' || activeView === 'reminders') && (
<motion.div
key={activeView}
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -20 }}
transition={{ duration: 0.3 }}
className="h-full w-full"
>
<NotebooksView
activeNoteId={activeNoteId}
activeCarnet={activeCarnet}
filteredNotes={filteredNotes}
activeNote={activeNote}
setActiveNoteId={setActiveNoteId}
togglePin={togglePin}
setShowNewNoteModal={setShowNewNoteModal}
isAISidebarOpen={isAISidebarOpen}
setIsAISidebarOpen={setIsAISidebarOpen}
selectedTagIds={selectedTagIds}
setSelectedTagIds={setSelectedTagIds}
allNotes={notes}
activeCarnetId={activeCarnetId}
setShowNewCarnetModal={(show, parentId, isRenaming, carnetId) => setShowNewCarnetModal({ isOpen: show, parentId, isRenaming, carnetId })}
onDeleteNote={handleDeleteNote}
onBrainstormNote={handleBrainstormNote}
onUpdateNote={handleUpdateNote}
/>
</motion.div>
)}
{activeView === 'trash' && (
<motion.div
key="trash"
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -20 }}
transition={{ duration: 0.3 }}
className="h-full w-full"
>
<TrashView
deletedNotes={notes.filter(n => n.isDeleted)}
deletedCarnets={carnets.filter(c => c.isDeleted)}
onRestoreNote={handleRestoreNote}
onRestoreCarnet={handleRestoreCarnet}
onPermanentDeleteNote={handlePermanentDeleteNote}
onPermanentDeleteCarnet={handlePermanentDeleteCarnet}
onEmptyTrash={() => {
setNotes(notes.filter(n => !n.isDeleted));
setCarnets(carnets.filter(c => !c.isDeleted));
}}
/>
</motion.div>
)}
{activeView === 'agents' && (
<motion.div
key="agents"
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -20 }}
transition={{ duration: 0.3 }}
className="h-full w-full"
>
<AgentsView
selectedAgentId={selectedAgentId}
setSelectedAgentId={setSelectedAgentId}
carnets={carnets}
notes={notes}
onAddNote={(note) => setNotes([note, ...notes])}
/>
</motion.div>
)}
{activeView === 'settings' && (
<motion.div
key="settings"
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -20 }}
transition={{ duration: 0.3 }}
className="h-full w-full"
>
<SettingsView
activeSettingsTab={activeSettingsTab}
setActiveSettingsTab={setActiveSettingsTab}
/>
</motion.div>
)}
{activeView === 'brainstorm' && (
<motion.div
key="brainstorm"
initial={{ opacity: 0, scale: 1.05 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
transition={{ duration: 0.4, ease: [0.23, 1, 0.32, 1] }}
className="h-full w-full"
>
<BrainstormView
notes={notes}
onConvertNote={handleConvertIdeaToNote}
/>
</motion.div>
)}
{activeView === 'insights' && (
<motion.div
key="insights"
initial={{ opacity: 0, scale: 1.05 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
transition={{ duration: 0.4, ease: [0.23, 1, 0.32, 1] }}
className="h-full w-full"
>
<InsightsView
notes={notes}
onUpdateNotes={setNotes}
onNoteSelect={(noteId) => {
setActiveView('notebooks');
setActiveNoteId(noteId);
}}
/>
</motion.div>
)}
{activeView === 'temporal' && (
<motion.div
key="temporal"
initial={{ opacity: 0, scale: 1.05 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
transition={{ duration: 0.4, ease: [0.23, 1, 0.32, 1] }}
className="h-full w-full"
>
<TemporalView
notes={notes}
accessLogs={accessLogs}
onNoteSelect={(noteId) => {
setActiveView('notebooks');
setActiveNoteId(noteId);
}}
/>
</motion.div>
)}
</AnimatePresence>
<AISidebar
isOpen={isAISidebarOpen}
setIsOpen={setIsAISidebarOpen}
activeNote={activeNote}
aiTab={aiTab}
setAiTab={setAiTab}
selectedTone={selectedTone}
setSelectedTone={setSelectedTone}
carnets={carnets}
/>
</main>
{/* Modals */}
<AnimatePresence>
{showNewCarnetModal.isOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={() => setShowNewCarnetModal({ isOpen: false })}
className="absolute inset-0 bg-ink/40 backdrop-blur-sm"
/>
<motion.div
initial={{ opacity: 0, scale: 0.9, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.9, y: 20 }}
className="relative w-full max-w-md bg-paper dark:bg-dark-paper border border-border shadow-2xl rounded-2xl p-8"
>
<h3 className="text-2xl font-serif font-medium text-ink dark:text-dark-ink mb-2">
{showNewCarnetModal.isRenaming ? 'Rename Carnet' : (showNewCarnetModal.parentId ? 'Create Sub-Carnet' : 'Create New Carnet')}
</h3>
{showNewCarnetModal.parentId && !showNewCarnetModal.isRenaming && (
<p className="text-[10px] text-concrete uppercase tracking-widest font-bold mb-6">
Inside: {carnets.find(c => c.id === showNewCarnetModal.parentId)?.name}
</p>
)}
<form onSubmit={handleAddCarnet} className="space-y-6">
<div>
<label className="block text-[11px] uppercase tracking-widest font-bold text-muted-ink mb-2">Notebook Name</label>
<input
autoFocus
type="text"
value={newCarnetName}
onChange={(e) => setNewCarnetName(e.target.value)}
placeholder="E.g., Sustainable Patterns"
className="w-full bg-white dark:bg-[#2A2A2A] border border-border rounded-lg px-4 py-3 outline-none focus:border-ink transition-colors font-serif italic text-lg text-ink dark:text-dark-ink"
/>
</div>
<div className="flex gap-4 pt-4">
<button
type="button"
onClick={() => {
setShowNewCarnetModal({ isOpen: false });
setNewCarnetName('');
}}
className="flex-1 py-3 border border-border rounded-lg text-sm font-medium hover:bg-slate-50 dark:hover:bg-white/5 transition-colors text-ink dark:text-dark-ink"
>
Cancel
</button>
<button
type="submit"
className="flex-1 py-3 bg-ink dark:bg-ochre text-paper rounded-lg text-sm font-medium hover:opacity-90 transition-opacity"
>
{showNewCarnetModal.isRenaming ? 'Rename' : 'Create Notebook'}
</button>
</div>
</form>
</motion.div>
</div>
)}
{showNewNoteModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
onClick={() => setShowNewNoteModal(false)}
className="absolute inset-0 bg-ink/40 backdrop-blur-sm"
/>
<motion.div
initial={{ opacity: 0, scale: 0.9, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.9, y: 20 }}
className="relative w-full max-w-2xl bg-paper dark:bg-dark-paper border border-border shadow-2xl rounded-2xl p-10"
>
<AnimatePresence>
{slashMenu?.isOpen && (
<SlashMenu
position={{ top: slashMenu.top, left: slashMenu.left }}
onSelect={(type) => { console.log(type); setSlashMenu(null); }}
onClose={() => setSlashMenu(null)}
/>
)}
</AnimatePresence>
<h3 className="text-3xl font-serif font-medium text-ink dark:text-dark-ink mb-8">Add Architectural Note</h3>
<form onSubmit={handleAddNote} className="space-y-8">
<div>
<label className="block text-[11px] uppercase tracking-widest font-bold text-muted-ink mb-2">Concept Title</label>
<input
autoFocus
type="text"
value={newNoteTitle}
onChange={(e) => setNewNoteTitle(e.target.value)}
placeholder="Enter the title of your study..."
className="w-full bg-white dark:bg-[#2A2A2A] border border-border rounded-lg px-5 py-4 outline-none focus:border-ink transition-colors font-serif text-2xl text-ink dark:text-dark-ink"
/>
</div>
<div>
<label className="block text-[11px] uppercase tracking-widest font-bold text-muted-ink mb-2">Observations & Analysis</label>
<textarea
value={newNoteContent}
onChange={(e) => setNewNoteContent(e.target.value)}
onKeyDown={handleEditorKeyDown}
placeholder="Describe the spatial logic, materiality, and light interactions... (Type '/' for commands)"
rows={6}
className="w-full bg-white dark:bg-[#2A2A2A] border border-border rounded-lg px-5 py-4 outline-none focus:border-ink transition-colors font-light leading-relaxed resize-none text-ink dark:text-dark-ink"
/>
</div>
<div className="flex gap-4 pt-4">
<button
type="button"
onClick={() => setShowNewNoteModal(false)}
className="flex-1 py-4 border border-border rounded-lg text-sm font-medium hover:bg-slate-50 dark:hover:bg-white/5 transition-colors text-ink dark:text-dark-ink"
>
Cancel
</button>
<button
type="submit"
className="flex-1 py-4 bg-ink dark:bg-ochre text-paper rounded-lg text-sm font-medium hover:opacity-90 transition-opacity"
>
Save Note
</button>
</div>
</form>
</motion.div>
</div>
)}
</AnimatePresence>
</div>
);
}

View File

@@ -0,0 +1,466 @@
import React from 'react';
import {
Sparkles,
ChevronRight,
MessageSquare,
FileCode,
Globe,
Send,
Scissors,
Zap,
Languages,
Layout,
ArrowRightLeft,
BookOpen,
History,
Target,
Network,
Clock
} from 'lucide-react';
import { motion, AnimatePresence } from 'motion/react';
import { AITab, AITone, Note, Carnet } from '../types';
import { HierarchicalCarnetSelector } from './HierarchicalCarnetSelector';
interface AISidebarProps {
isOpen: boolean;
setIsOpen: (open: boolean) => void;
activeNote: Note | undefined;
aiTab: AITab;
setAiTab: (tab: AITab) => void;
selectedTone: AITone;
setSelectedTone: (tone: AITone) => void;
carnets: Carnet[];
}
export const AISidebar: React.FC<AISidebarProps> = ({
isOpen,
setIsOpen,
activeNote,
aiTab,
setAiTab,
selectedTone,
setSelectedTone,
carnets
}) => {
const [selectedContextId, setSelectedContextId] = React.useState<string | null>(null);
return (
<AnimatePresence>
{isOpen && (
<motion.aside
initial={{ x: 400, opacity: 0 }}
animate={{ x: 0, opacity: 1 }}
exit={{ x: 400, opacity: 0 }}
transition={{ type: 'spring', damping: 25, stiffness: 200 }}
className="w-[400px] border-l border-border bg-white shadow-2xl flex flex-col z-50 shrink-0 relative"
>
<div className="p-6 border-b border-border space-y-2">
<div className="flex items-center justify-between">
<h3 className="flex items-center gap-2 font-serif text-xl font-medium text-ink">
<Sparkles size={18} className="text-ochre" />
IA Assistant
</h3>
<button
onClick={() => setIsOpen(false)}
className="p-1 hover:bg-slate-100 rounded-full transition-colors text-muted-ink"
>
<ChevronRight size={20} />
</button>
</div>
<p className="text-[11px] text-muted-ink uppercase tracking-wider font-medium opacity-60 truncate">
"{activeNote?.title}"
</p>
</div>
<div className="flex border-b border-border px-2">
{(['discussion', 'actions', 'explore', 'resources'] as AITab[]).map((tab) => (
<button
key={tab}
onClick={() => setAiTab(tab)}
className={`flex-1 py-3 text-[10px] uppercase tracking-[0.2em] font-bold transition-all relative
${aiTab === tab ? 'text-manganese' : 'text-muted-ink hover:text-ink/60'}`}
>
{tab}
{aiTab === tab && (
<motion.div
layoutId="activeTab"
className="absolute bottom-0 left-0 right-0 h-0.5 bg-ochre"
/>
)}
</button>
))}
</div>
<div className="flex-1 overflow-y-auto p-6 custom-scrollbar">
<AnimatePresence mode="wait">
{aiTab === 'explore' && (
<motion.div
key="explore"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
className="space-y-6"
>
<div className="flex items-center gap-2 mb-2">
<div className="h-px flex-1 bg-border/40" />
<h4 className="text-[10px] uppercase tracking-[0.25em] font-bold text-muted-ink whitespace-nowrap">Intelligence Modules</h4>
<div className="h-px flex-1 bg-border/40" />
</div>
<div className="space-y-3">
<button
onClick={() => {
// These will be handled in App.tsx by observing activeView
window.dispatchEvent(new CustomEvent('switch-view', { detail: 'brainstorm' }));
}}
className="w-full group relative p-5 rounded-2xl bg-white border border-border hover:border-ochre/30 transition-all text-left overflow-hidden"
>
<div className="absolute top-0 right-0 p-4 opacity-5 group-hover:opacity-10 transition-opacity">
<Zap size={60} className="text-ochre" />
</div>
<div className="relative flex items-center gap-4">
<div className="p-3 bg-ochre/10 rounded-xl text-ochre group-hover:bg-ochre group-hover:text-white transition-colors">
<Zap size={20} fill="currentColor" />
</div>
<div>
<h5 className="font-bold text-ink text-sm">Brainstorm Wave</h5>
<p className="text-[10px] text-muted-ink uppercase tracking-tight">Unfold dimensions of thought</p>
</div>
</div>
</button>
<button
onClick={() => {
window.dispatchEvent(new CustomEvent('switch-view', { detail: 'insights' }));
}}
className="w-full group relative p-5 rounded-2xl bg-white border border-border hover:border-indigo-500/30 transition-all text-left overflow-hidden"
>
<div className="absolute top-0 right-0 p-4 opacity-5 group-hover:opacity-10 transition-opacity">
<Network size={60} className="text-indigo-500" />
</div>
<div className="relative flex items-center gap-4">
<div className="p-3 bg-indigo-500/10 rounded-xl text-indigo-500 group-hover:bg-indigo-500 group-hover:text-white transition-colors">
<Network size={20} />
</div>
<div>
<h5 className="font-bold text-ink text-sm">Semantic Network</h5>
<p className="text-[10px] text-muted-ink uppercase tracking-tight">Detect clusters and bridges</p>
</div>
</div>
</button>
<button
onClick={() => {
window.dispatchEvent(new CustomEvent('switch-view', { detail: 'temporal' }));
}}
className="w-full group relative p-5 rounded-2xl bg-white border border-border hover:border-rose-500/30 transition-all text-left overflow-hidden"
>
<div className="absolute top-0 right-0 p-4 opacity-5 group-hover:opacity-10 transition-opacity">
<Clock size={60} className="text-rose-500" />
</div>
<div className="relative flex items-center gap-4">
<div className="p-3 bg-rose-500/10 rounded-xl text-rose-500 group-hover:bg-rose-500 group-hover:text-white transition-colors">
<Clock size={20} />
</div>
<div>
<h5 className="font-bold text-ink text-sm">Temporal Forecast</h5>
<p className="text-[10px] text-muted-ink uppercase tracking-tight">Predict relevance recurrence</p>
</div>
</div>
</button>
</div>
<div className="p-6 rounded-2xl bg-slate-50 dark:bg-white/5 border border-dashed border-border mt-6">
<p className="text-[10px] text-muted-ink leading-relaxed font-medium italic text-center">
Ces modules utilisent les embeddings du modèle Gemini pour analyser graphiquement vos pensées.
</p>
</div>
</motion.div>
)}
{aiTab === 'discussion' && (
<motion.div
key="discussion"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
className="space-y-8"
>
<div className="h-64 flex flex-col items-center justify-center text-center space-y-4 text-muted-ink/40">
<div className="w-16 h-16 rounded-full border border-dashed border-muted-ink/20 flex items-center justify-center">
<MessageSquare size={24} />
</div>
<p className="text-xs font-serif italic leading-relaxed px-8">Posez une question à l'Assistant pour commencer.</p>
</div>
<div className="space-y-4">
<div className="space-y-3">
<label className="text-[10px] uppercase tracking-[0.2em] font-bold text-muted-ink">Source du Contexte</label>
<div className="space-y-3">
<div className="w-full p-3 bg-glass border border-border rounded-lg text-xs flex items-center justify-between cursor-default backdrop-blur-sm">
<div className="flex items-center gap-2">
<FileCode size={14} className="text-blueprint" />
<span className="font-medium text-ink">Note Active</span>
</div>
<div className="text-[8px] bg-blueprint/10 text-blueprint px-1.5 py-0.5 rounded uppercase font-bold tracking-tighter italic">Auto</div>
</div>
<div className="flex items-center gap-2 px-2">
<div className="h-px flex-1 bg-border/40" />
<span className="text-[9px] font-bold text-concrete uppercase tracking-widest">+ Carnet</span>
<div className="h-px flex-1 bg-border/40" />
</div>
<HierarchicalCarnetSelector
carnets={carnets}
selectedId={selectedContextId}
onSelect={setSelectedContextId}
placeholder="Inclure un carnet..."
className="w-full"
/>
</div>
</div>
<div className="space-y-3">
<label className="text-[10px] uppercase tracking-[0.2em] font-bold text-muted-ink">Ton d'écriture</label>
<div className="grid grid-cols-2 gap-2">
{(['Professional', 'Creative', 'Academic', 'Casual'] as AITone[]).map((tone) => (
<button
key={tone}
onClick={() => setSelectedTone(tone)}
className={`p-3 rounded-xl border text-[11px] font-medium transition-all
${selectedTone === tone ? 'bg-manganese text-paper border-manganese shadow-lg shadow-manganese/10' : 'bg-glass border-border text-muted-ink hover:border-ink/20'}`}
>
{tone.toUpperCase().substring(0, 3)}
</button>
))}
</div>
</div>
</div>
</motion.div>
)}
{aiTab === 'actions' && (
<motion.div
key="actions"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
className="space-y-8"
>
<div className="space-y-8">
<div>
<div className="flex items-center gap-2 mb-4">
<div className="h-px flex-1 bg-border/40" />
<h4 className="text-[10px] uppercase tracking-[0.25em] font-bold text-muted-ink whitespace-nowrap">Transformations</h4>
<div className="h-px flex-1 bg-border/40" />
</div>
<div className="grid grid-cols-2 gap-2">
{[
{ icon: <Sparkles size={14} />, label: 'Clarifier', color: 'ochre' },
{ icon: <Scissors size={14} />, label: 'Raccourcir', color: 'rust' },
{ icon: <Zap size={14} />, label: 'Améliorer', color: 'sage' },
{ icon: <Languages size={14} />, label: 'Traduire', color: 'slate' },
].map((action, i) => (
<button
key={i}
className="flex flex-col items-center gap-3 p-4 bg-glass border border-border rounded-xl transition-all group hover:border-ink/20"
>
<div className={`p-2 rounded-lg bg-slate-50 dark:bg-white/10 transition-colors group-hover:bg-manganese group-hover:text-paper shadow-sm text-ink/60`}>
{action.icon}
</div>
<span className="text-[10px] font-bold text-ink/80 uppercase tracking-widest">{action.label}</span>
</button>
))}
<button className="col-span-2 flex items-center justify-center gap-3 py-3 px-4 bg-glass border border-border rounded-xl text-[10px] font-bold text-ink/80 hover:bg-slate-50 dark:hover:bg-white/10 transition-colors hover:border-ink/20 uppercase tracking-widest">
<FileCode size={14} className="text-muted-ink" />
Convertir en Markdown
</button>
</div>
</div>
<div className="space-y-4">
<div className="flex items-center gap-2 mb-2">
<div className="h-px flex-1 bg-border/40" />
<h4 className="text-[10px] uppercase tracking-[0.25em] font-bold text-muted-ink whitespace-nowrap">Generation Tools</h4>
<div className="h-px flex-1 bg-border/40" />
</div>
<div className="group relative p-6 rounded-2xl bg-white border border-border hover:border-blueprint/30 transition-all duration-500 overflow-hidden">
<div className="absolute top-0 right-0 p-4 opacity-5 group-hover:opacity-10 transition-opacity">
<Layout size={80} className="text-blueprint" />
</div>
<div className="relative space-y-5">
<div className="flex items-center gap-3">
<div className="p-2 bg-slate-50 rounded-lg text-blueprint">
<Layout size={18} />
</div>
<div className="space-y-0.5">
<h5 className="text-sm font-bold text-ink leading-none">Présentation</h5>
<p className="text-[10px] text-muted-ink uppercase tracking-tight">Convertir en slides interactives</p>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<span className="text-[9px] uppercase tracking-[0.2em] font-bold text-muted-ink/60 px-1">Thème</span>
<select className="w-full bg-glass dark:bg-black/20 border border-border rounded-lg px-2 py-2 text-xs outline-none focus:ring-1 ring-blueprint/10 transition-all cursor-pointer">
<option>Architectural Mono</option>
<option>Vibrant Tech</option>
<option>Minimal Silk</option>
</select>
</div>
<div className="space-y-1.5">
<span className="text-[9px] uppercase tracking-[0.2em] font-bold text-muted-ink/60 px-1">Style</span>
<select className="w-full bg-glass dark:bg-black/20 border border-border rounded-lg px-2 py-2 text-xs outline-none focus:ring-1 ring-blueprint/10 transition-all cursor-pointer">
<option>Professional</option>
<option>Creative</option>
<option>Brutalist</option>
</select>
</div>
</div>
<button className="w-full py-3.5 bg-blueprint text-paper rounded-xl text-[11px] font-bold flex items-center justify-center gap-2 hover:opacity-90 transition-all shadow-lg shadow-blueprint/20 uppercase tracking-[0.2em]">
Générer
<ArrowRightLeft size={14} className="opacity-60" />
</button>
</div>
</div>
<div className="group relative p-6 rounded-2xl bg-white border border-border hover:border-sage/30 transition-all duration-500 overflow-hidden">
<div className="absolute top-0 right-0 p-4 opacity-5 group-hover:opacity-10 transition-opacity">
<BookOpen size={80} className="text-sage" />
</div>
<div className="relative space-y-5">
<div className="flex items-center gap-3">
<div className="p-2 bg-slate-50 rounded-lg text-sage">
<BookOpen size={18} />
</div>
<div className="space-y-0.5">
<h5 className="text-sm font-bold text-ink leading-none">Diagramme</h5>
<p className="text-[10px] text-muted-ink uppercase tracking-tight">Visualisation de structure</p>
</div>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1.5">
<span className="text-[9px] uppercase tracking-[0.2em] font-bold text-muted-ink/60 px-1">Type</span>
<select className="w-full bg-glass dark:bg-black/20 border border-border rounded-lg px-2 py-2 text-xs outline-none focus:ring-1 ring-sage/10 transition-all cursor-pointer">
<option>Logic Flow</option>
<option>Mind Map</option>
<option>Hierarchy</option>
</select>
</div>
<div className="space-y-1.5">
<span className="text-[9px] uppercase tracking-[0.2em] font-bold text-muted-ink/60 px-1">Style</span>
<select className="w-full bg-glass dark:bg-black/20 border border-border rounded-lg px-2 py-2 text-xs outline-none focus:ring-1 ring-sage/10 transition-all cursor-pointer">
<option>Draft</option>
<option>Polished</option>
<option>Handwritten</option>
</select>
</div>
</div>
<button className="w-full py-3.5 bg-sage text-paper rounded-xl text-[11px] font-bold flex items-center justify-center gap-2 hover:opacity-90 transition-all shadow-lg shadow-sage/20 uppercase tracking-[0.2em]">
Tracer
<ArrowRightLeft size={14} className="opacity-60" />
</button>
</div>
</div>
</div>
<div className="flex flex-col items-center gap-2 opacity-20 py-4">
<History size={16} />
<span className="text-[10px] font-bold uppercase tracking-widest whitespace-nowrap">Auto-Save Enabled</span>
</div>
</div>
</motion.div>
)}
{aiTab === 'resources' && (
<motion.div
key="resources"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
className="space-y-8"
>
<div className="space-y-6">
<div className="space-y-2">
<label className="text-[10px] uppercase tracking-[0.2em] font-bold text-muted-ink">URL (Optionnel)</label>
<div className="relative">
<input type="text" placeholder="https://..." className="w-full bg-glass border border-border rounded-lg pl-3 pr-10 py-3 text-xs outline-none focus:border-blueprint transition-colors" />
<Globe size={14} className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-ink/40" />
</div>
</div>
<div className="space-y-2">
<label className="text-[10px] uppercase tracking-[0.2em] font-bold text-muted-ink">Texte de la ressource</label>
<textarea
rows={8}
placeholder="Collez votre texte ici (markdown, HTML, texte brut...)"
className="w-full bg-glass border border-border rounded-lg p-4 text-xs outline-none focus:border-blueprint transition-colors resize-none leading-relaxed"
/>
</div>
<div className="space-y-3">
<label className="text-[10px] uppercase tracking-[0.2em] font-bold text-muted-ink">Mode d'intégration</label>
<div className="grid grid-cols-3 gap-2">
{[
{ id: 'replace', label: 'Remplacer', sub: 'Direct, sans IA' },
{ id: 'append', label: 'Compléter', sub: 'Ajoute sans réécrire' },
{ id: 'merge', label: 'Fusionner', sub: 'Réécrit et intègre' },
].map((mode) => (
<button key={mode.id} className={`flex flex-col items-center justify-center p-3 rounded-lg border transition-all text-center ${mode.id === 'append' ? 'bg-sage/10 border-sage/50 ring-1 ring-sage/10' : 'bg-white border-border hover:bg-slate-50'}`}>
<span className={`text-[11px] font-bold ${mode.id === 'append' ? 'text-sage' : 'text-ink'}`}>{mode.label}</span>
<span className="text-[8px] text-muted-ink opacity-60 leading-tight mt-1 font-medium">{mode.sub}</span>
</button>
))}
</div>
</div>
<button className="w-full py-4 bg-blueprint text-white rounded-xl text-sm font-bold flex items-center justify-center gap-3 hover:opacity-90 transition-opacity shadow-lg shadow-blueprint/20">
<Sparkles size={18} />
Générer l'aperçu
</button>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
<AnimatePresence>
{aiTab === 'discussion' && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 20 }}
className="p-6 bg-white border-t border-border"
>
<div className="relative">
<textarea
rows={3}
placeholder="Posez une question sur cette note..."
className="w-full bg-glass backdrop-blur-sm border border-border rounded-2xl p-4 pr-12 text-sm outline-none focus:border-blueprint transition-colors resize-none leading-relaxed font-light"
/>
<div className="absolute right-3 bottom-3 flex gap-2">
<button className="p-2 text-muted-ink hover:text-ink rounded-lg transition-colors">
<Globe size={16} />
</button>
<button className="p-2 bg-blueprint text-white rounded-lg transition-transform hover:scale-105 active:scale-95 shadow-lg shadow-blueprint/10">
<Send size={16} />
</button>
</div>
</div>
<p className="text-[9px] text-muted-ink text-center mt-3 uppercase tracking-widest font-bold opacity-30 italic">Maj+Entrée = nouvelle ligne</p>
</motion.div>
)}
</AnimatePresence>
</motion.aside>
)}
</AnimatePresence>
);
};

View File

@@ -0,0 +1,385 @@
import React from 'react';
import {
Plus,
ArrowLeft,
Clock,
Activity,
Trash2,
Edit3,
Play,
Eye,
Microscope,
Globe,
Layers,
Zap,
BookOpen,
Sparkles,
ChevronDown,
Info,
Check,
ClipboardCheck,
ListTodo,
ArrowRight,
Loader2
} from 'lucide-react';
import { motion, AnimatePresence } from 'motion/react';
import { Carnet, Note } from '../types';
import { HierarchicalCarnetSelector } from './HierarchicalCarnetSelector';
import { extractActionItems } from '../services/geminiService';
import { v4 as uuidv4 } from 'uuid';
interface AgentsViewProps {
selectedAgentId: string | null;
setSelectedAgentId: (id: string | null) => void;
carnets: Carnet[];
notes: Note[];
onAddNote?: (note: Note) => void;
}
export const AgentsView: React.FC<AgentsViewProps> = ({
selectedAgentId,
setSelectedAgentId,
carnets,
notes,
onAddNote
}) => {
const [selectedCarnetForAgent, setSelectedCarnetForAgent] = React.useState<string | null>('4');
const [agentType, setAgentType] = React.useState<'Surveillant' | 'Personnalisé' | 'Slides' | 'Diagramme' | 'Tasks'>('Tasks');
const [isRunningAgent, setIsRunningAgent] = React.useState(false);
const [agentResult, setAgentResult] = React.useState<string | null>(null);
const handleRunTaskAgent = async () => {
setIsRunningAgent(true);
setAgentResult(null);
// Get notes from carnet
const filteredNotes = notes.filter(n => n.carnetId === selectedCarnetForAgent && !n.isDeleted);
try {
const result = await extractActionItems(filteredNotes);
setAgentResult(result);
} catch (e) {
console.error(e);
} finally {
setIsRunningAgent(false);
}
};
const handleSaveResult = () => {
if (!agentResult || !onAddNote) return;
const carnetName = carnets.find(c => c.id === selectedCarnetForAgent)?.name || 'General';
const newNote: Note = {
id: uuidv4(),
carnetId: selectedCarnetForAgent || '1',
title: `Consolidated Actions: ${carnetName}`,
date: new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric', year: 'numeric' }).format(new Date()),
content: agentResult,
imageUrl: 'https://images.unsplash.com/photo-1484480974693-6ca0a78fb36b?auto=format&fit=crop&q=80&w=800&h=600',
tags: [{ id: 't-tasks', label: 'Action Items', type: 'ai' }]
};
onAddNote(newNote);
alert('Note de tâches créée avec succès !');
setSelectedAgentId(null);
};
return (
<div className="h-full flex flex-col overflow-y-auto custom-scrollbar bg-[#F9F8F6] dark:bg-dark-paper space-y-12">
{!selectedAgentId ? (
<>
<header className="px-12 pt-12 pb-8 flex flex-col gap-6 sticky top-0 bg-paper/80 backdrop-blur-md z-30">
<div className="flex justify-between items-end">
<div className="space-y-1">
<h1 className="text-4xl font-serif font-medium tracking-tight text-ink">Mes Agents</h1>
<p className="text-sm text-muted-ink font-light">Automatisez vos tâches de veille et de recherche.</p>
</div>
<button className="px-6 py-2.5 bg-ink text-paper text-sm font-medium rounded-xl hover:opacity-90 transition-all flex items-center gap-3 shadow-lg shadow-ink/10">
<Plus size={18} />
Nouvel Agent
</button>
</div>
<div className="flex items-center gap-8 border-b border-ink/5 pt-4">
{['Tous', 'Veilleur', 'Chercheur', 'Surveillant', 'Personnalisé'].map((tag, i) => (
<button key={i} className={`pb-4 text-xs font-bold uppercase tracking-widest transition-all relative ${i === 0 ? 'text-ink' : 'text-muted-ink hover:text-ink/60'}`}>
{tag}
{i === 0 && <motion.div layoutId="activeAgentTag" className="absolute bottom-0 left-0 right-0 h-0.5 bg-ink" />}
</button>
))}
</div>
</header>
<div className="px-12 flex-1 pb-20 space-y-12">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{[
{ id: 'a1', icon: <Eye size={20} className="text-amber-600" />, title: 'Surveillant de Notes', status: 'Réussi', type: 'SURVEILLANT', meta: 'Hebdomadaire • 6 exéc.', desc: 'Analyse les notes récentes dun carnet et suggère des compléments, références et liens.' },
{ id: 'a2', icon: <Microscope size={20} className="text-indigo-600" />, title: 'Chercheur de Sujet', status: 'Réussi', type: 'CHERCHEUR', meta: 'Hebdomadaire • 14 exéc.', desc: 'Recherche des informations approfondies sur les derniers modèles de Deepseek et voir lavis des utilisateurs.' },
{ id: 'a3', icon: <Globe size={20} className="text-emerald-600" />, title: 'Veille IA', status: 'Réussi', type: 'VEILLEUR', meta: 'Quotidien • 20 exéc.', desc: 'Scrape les flux RSS de 6 sites IA (The Verge, TechCrunch...) et génère un résumé.' },
{ id: 'a4', icon: <ClipboardCheck size={20} className="text-rose-500" />, title: 'Action Miner', status: 'Inactif', type: 'TASKS', meta: 'À la demande', desc: 'Scan vos notes pour extraire automatiquement les tâches, assignés et deadlines.' },
].map((agent, i) => (
<div
key={i}
onClick={() => setSelectedAgentId(agent.id)}
className="bg-white dark:bg-white/5 border border-border rounded-2xl p-6 space-y-6 hover:border-ink/20 transition-all group cursor-pointer shadow-sm relative overflow-hidden"
>
<div className="flex items-start justify-between">
<div className="flex items-center gap-4">
<div className="p-3 bg-slate-50 dark:bg-white/10 rounded-xl group-hover:bg-ink group-hover:text-paper transition-all">
{agent.icon}
</div>
<div className="space-y-1">
<h4 className="text-[13px] font-bold text-ink">{agent.title}</h4>
<p className="text-[10px] font-bold uppercase tracking-widest text-muted-ink opacity-60">{agent.type}</p>
</div>
</div>
<div className="flex items-center gap-2" onClick={(e) => e.stopPropagation()}>
<label className="relative inline-flex items-center cursor-pointer">
<input type="checkbox" className="sr-only peer" defaultChecked />
<div className="w-8 h-4 bg-gray-200 rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-0.5 after:left-[2px] after:bg-white after:border-gray-300 after:border after:rounded-full after:h-3 after:w-3 after:transition-all peer-checked:bg-emerald-500"></div>
</label>
</div>
</div>
<p className="text-xs text-muted-ink leading-relaxed line-clamp-3">
{agent.desc}
</p>
<div className="space-y-3">
<div className="flex items-center justify-between text-[10px] text-muted-ink font-medium">
<div className="flex items-center gap-4">
<span className="flex items-center gap-1"><Clock size={10} /> {agent.meta.split('•')[0]}</span>
<span>{agent.meta.split('•')[1]}</span>
</div>
</div>
<div className="flex items-center justify-between text-[10px] text-muted-ink font-medium">
<div className="flex items-center gap-2">
<span className="uppercase tracking-tight">Prochaine exécution</span>
<span className="text-ink">Hebdomadaire</span>
</div>
<div className="flex items-center gap-2">
<span className="uppercase tracking-tight">Dernier statut</span>
<span className="text-emerald-600 flex items-center gap-1"><Activity size={8} /> {agent.status}</span>
</div>
</div>
</div>
<div className="grid grid-cols-3 gap-2 border-t border-border pt-4">
<button className="py-2 border border-border rounded-lg hover:bg-slate-50 dark:hover:bg-white/5 flex items-center justify-center transition-colors text-muted-ink hover:text-ink"><Edit3 size={14} /> <span className="ml-2 text-[10px] font-bold uppercase">Modifier</span></button>
<button
onClick={(e) => { e.stopPropagation(); }}
className="py-2 border border-border rounded-lg hover:bg-slate-50 dark:hover:bg-white/5 flex items-center justify-center transition-colors text-muted-ink hover:text-ink"
>
<Play size={14} className="fill-current" />
</button>
<button
onClick={(e) => { e.stopPropagation(); }}
className="py-2 border border-border rounded-lg hover:bg-rose-50 hover:text-rose-600 hover:border-rose-100 flex items-center justify-center transition-colors text-muted-ink"
>
<Trash2 size={14} />
</button>
</div>
</div>
))}
</div>
<div className="space-y-8">
<div className="flex items-center gap-4">
<h5 className="text-[10px] font-bold uppercase tracking-[0.3em] text-muted-ink whitespace-nowrap">Modèles</h5>
<div className="h-px w-full bg-border/40" />
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{[
{ title: 'Veille IA', desc: 'Scrape les flux RSS de 6 sites IA et génère un résumé hebdomadaire.', icon: <Globe size={18} /> },
{ title: 'Veille Tech', desc: 'Crée un résumé quotidien des news Hacker News et Product Hunt.', icon: <Zap size={18} /> },
{ title: 'Veille Dev', desc: 'Surveille les repos GitHub pour détecter les nouvelles releases.', icon: <Layers size={18} /> },
].map((model, i) => (
<div key={i} className="bg-white/40 dark:bg-white/5 border border-dashed border-border rounded-2xl p-6 group cursor-pointer hover:bg-white dark:hover:bg-white/10 hover:border-ink/20 transition-all">
<div className="w-8 h-8 rounded-lg bg-slate-50 dark:bg-white/10 flex items-center justify-center text-muted-ink group-hover:bg-ink group-hover:text-paper mb-4 transition-all">
{model.icon}
</div>
<h4 className="text-[13px] font-bold text-ink mb-2">{model.title}</h4>
<p className="text-xs text-muted-ink leading-relaxed mb-4">{model.desc}</p>
<button className="text-[11px] font-bold uppercase tracking-widest text-ink hover:opacity-60 transition-opacity flex items-center gap-2">
<Plus size={14} /> Installer
</button>
</div>
))}
</div>
</div>
</div>
</>
) : (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="flex-1 flex flex-col"
>
<header className="px-12 py-10 border-b border-border bg-white dark:bg-paper backdrop-blur-md sticky top-0 z-30">
<div className="flex items-center justify-between max-w-5xl mx-auto">
<button
onClick={() => setSelectedAgentId(null)}
className="flex items-center gap-3 text-xs font-bold uppercase tracking-widest text-muted-ink hover:text-ink transition-colors"
>
<ArrowLeft size={16} />
Retour
</button>
<div className="flex items-center gap-4">
<button className="px-5 py-2 text-xs font-bold uppercase tracking-widest border border-border rounded-xl hover:bg-slate-50 dark:hover:bg-white/5 transition-all">
Logs
</button>
<button className="px-6 py-2 bg-ink text-paper text-xs font-bold uppercase tracking-widest rounded-xl hover:opacity-90 transition-all shadow-lg shadow-ink/10">
Enregistrer
</button>
</div>
</div>
</header>
<div className="flex-1 px-12 py-16 max-w-5xl mx-auto w-full space-y-24">
<section className="space-y-12">
<div className="text-center space-y-4">
<p className="text-[10px] font-bold uppercase tracking-[0.4em] text-concrete">Sélectionnez le type d'agent</p>
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 max-w-4xl mx-auto">
{[
{ id: 'Surveillant', icon: <Eye size={18} />, label: 'Surveillant', desc: 'Surveille un carnet et analyse les notes' },
{ id: 'Tasks', icon: <ListTodo size={18} />, label: 'Action Items', desc: 'Extrait les tâches et deadlines' },
{ id: 'Slides', icon: <Layers size={18} />, label: 'Slides', desc: 'Crée une présentation PowerPoint à partir de notes' },
{ id: 'Diagramme', icon: <Zap size={18} />, label: 'Diagramme', desc: 'Crée un diagramme Excalidraw à partir de notes' },
].map((type) => (
<button
key={type.id}
onClick={() => setAgentType(type.id as any)}
className={`p-6 rounded-2xl border-2 transition-all flex flex-col items-center gap-3 text-center group relative
${agentType === type.id ? 'border-blueprint bg-white shadow-xl shadow-blueprint/10' : 'border-border bg-white/50 hover:bg-white'}`}
>
<div className={`p-3 rounded-xl transition-all ${agentType === type.id ? 'bg-blueprint text-white' : 'bg-slate-50 text-concrete group-hover:text-ink'}`}>
{type.icon}
</div>
<div className="space-y-1">
<p className="text-[13px] font-bold text-ink">{type.label}</p>
<p className="text-[10px] text-muted-ink leading-tight">{type.desc}</p>
</div>
<div className={`absolute top-4 right-4 w-5 h-5 rounded-full border-2 flex items-center justify-center transition-all
${agentType === type.id ? 'border-blueprint' : 'border-border opacity-20'}`}>
{agentType === type.id && <div className="w-2 h-2 bg-blueprint rounded-full" />}
</div>
</button>
))}
</div>
</div>
</section>
<section className="space-y-10">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-[10px] font-bold uppercase tracking-[0.3em] text-concrete">
CONFIGURATION <Info size={12} className="opacity-40" />
</div>
<button className="flex items-center gap-2 px-6 py-2 border-2 border-rose-100 bg-rose-50 rounded-xl text-rose-500 text-[11px] font-bold uppercase tracking-widest hover:bg-rose-100 transition-colors">
<Trash2 size={14} /> Supprimer
</button>
</div>
<div className="bg-white dark:bg-white/5 border border-border/60 rounded-[32px] p-12 space-y-12 shadow-sm">
<div className="space-y-6">
<div className="flex items-center gap-2">
<label className="text-[11px] font-bold uppercase tracking-widest text-concrete">DESCRIPTION (OPTIONEL)</label>
<Info size={12} className="text-concrete/40" />
</div>
<textarea
className="w-full bg-slate-50 dark:bg-black/20 border border-border/40 rounded-2xl p-6 text-sm outline-none focus:ring-4 ring-blueprint/5 focus:border-blueprint/40 transition-all font-light leading-relaxed resize-none text-ink"
placeholder="Décrivez brièvement le rôle de cet agent..."
defaultValue="Lit une note et génère un diagramme visuel dans le Lab Excalidraw."
/>
</div>
<div className="space-y-6">
<div className="flex items-center gap-2">
<label className="text-[11px] font-bold uppercase tracking-widest text-concrete">CARNET À SURVEILLER</label>
<Info size={12} className="text-concrete/40" />
</div>
<HierarchicalCarnetSelector
carnets={carnets}
selectedId={selectedCarnetForAgent}
onSelect={setSelectedCarnetForAgent}
/>
</div>
<div className="space-y-6">
<div className="flex items-center gap-2">
<label className="text-[11px] font-bold uppercase tracking-widest text-concrete">NOTES À ANALYSER</label>
<Info size={12} className="text-concrete/40" />
</div>
<div className="bg-slate-50 dark:bg-black/20 border border-border/40 rounded-2xl overflow-hidden divide-y divide-border/20">
{[
'Résumé du conteneur LXC devSandbox',
'Connexion SSH sans mot de passe à devSandbox',
'Gateway token (blank to generate)',
'Procédure d\'accès à openclaw',
'Derniers commits du repo Momento'
].map((note, i) => (
<label key={i} className="flex items-center gap-4 px-6 py-4 cursor-pointer hover:bg-white/50 transition-colors group">
<div className={`w-5 h-5 rounded border transition-all flex items-center justify-center
${i === 0 ? 'bg-blueprint border-blueprint text-white' : 'bg-white border-border group-hover:border-blueprint/40'}`}>
{i === 0 && <Check size={12} />}
</div>
<input type="checkbox" className="hidden" defaultChecked={i === 0} />
<span className={`text-[13px] transition-colors ${i === 0 ? 'font-medium text-ink' : 'text-muted-ink'}`}>{note}</span>
</label>
))}
</div>
<p className="text-[10px] text-concrete/60 italic font-medium">{1} note(s) sélectionnée(s)</p>
</div>
<div className="space-y-8">
<div className="flex items-center gap-2">
<label className="text-[11px] font-bold uppercase tracking-widest text-concrete">TYPE DE DIAGRAMME</label>
</div>
<div className="grid grid-cols-2 md:grid-cols-2 gap-3">
{[
'Auto (détection métier)', 'Flowchart (processus)',
'Mindmap (idées)', 'Organigramme (équipes)',
'Timeline / roadmap', 'Process map (opérations)',
'Architecture cloud (zones/RG)'
].map((type, i) => (
<button key={i} className={`px-6 py-4 rounded-xl border text-[13px] text-left transition-all
${i === 0 ? 'border-ink bg-slate-50 font-bold text-ink ring-2 ring-ink/5' : 'border-border text-concrete hover:border-concrete/40 hover:bg-slate-50/50'}`}>
{type}
</button>
))}
</div>
</div>
<div className="pt-8 border-t border-border flex flex-col items-center gap-6">
{agentResult ? (
<div className="w-full space-y-4">
<div className="flex items-center justify-between">
<h5 className="text-[10px] font-bold uppercase tracking-widest text-concrete">Résultat de l'extraction</h5>
<button onClick={handleSaveResult} className="text-blueprint hover:underline text-[10px] font-bold uppercase tracking-widest flex items-center gap-1">
<ArrowRight size={12} /> Sauvegarder dans une note
</button>
</div>
<div className="bg-slate-50 dark:bg-black/20 p-6 rounded-2xl border border-blueprint/20 font-serif text-sm text-ink leading-relaxed whitespace-pre-wrap max-h-96 overflow-y-auto">
{agentResult}
</div>
</div>
) : (
<button
onClick={handleRunTaskAgent}
disabled={isRunningAgent}
className="px-12 py-4 bg-blueprint text-white rounded-2xl text-sm font-bold uppercase tracking-[0.2em] hover:opacity-90 transition-all shadow-xl shadow-blueprint/20 flex items-center gap-4 disabled:opacity-50"
>
{isRunningAgent ? <Loader2 size={18} className="animate-spin" /> : <Play size={18} fill="currentColor" />}
Lancer l'extraction d'actions
</button>
)}
<p className="text-[10px] text-concrete/60 italic">Cet agent analysera toutes les notes du carnet sélectionné.</p>
</div>
</div>
</section>
</div>
</motion.div>
)}
</div>
);
};

View File

@@ -0,0 +1,797 @@
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-blueprint/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-blueprint/40 hover:bg-blueprint/5 transition-all group disabled:opacity-50 whitespace-nowrap"
>
<FileText size={24} className="text-concrete group-hover:text-blueprint 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>
);
};

View File

@@ -0,0 +1,348 @@
import React, { useEffect, useRef } from 'react';
import * as d3 from 'd3';
import { BrainstormSession, BrainstormIdea, Note } from '../../types';
interface WaveCanvasProps {
session: BrainstormSession;
ideas: BrainstormIdea[];
onNodeSelect: (id: string) => void;
onPositionUpdate: (id: string, pos: { x: number; y: number }) => void;
onAddChild: (id: string) => void;
onManualSubmit: (title: string, parentId?: string) => void;
onManualCancel: () => void;
editingNodeId: string | null;
selectedNodeId: string | null;
relatedNotes: Note[];
}
export const WaveCanvas: React.FC<WaveCanvasProps> = ({
session,
ideas,
onNodeSelect,
onPositionUpdate,
onAddChild,
onManualSubmit,
onManualCancel,
editingNodeId,
selectedNodeId,
relatedNotes
}) => {
const svgRef = useRef<SVGSVGElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const [inputValue, setInputValue] = React.useState('');
useEffect(() => {
if (editingNodeId && inputRef.current) {
inputRef.current.focus();
}
}, [editingNodeId]);
useEffect(() => {
if (!svgRef.current || !containerRef.current) return;
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");
// Zoom behavior
const zoom = d3.zoom<SVGSVGElement, unknown>()
.scaleExtent([0.1, 5])
.on("zoom", (event) => {
g.attr("transform", event.transform);
});
svg.call(zoom);
// Initial transform to center
svg.call(zoom.transform, d3.zoomIdentity.translate(centerX, centerY).scale(0.8));
// Data structures for d3
interface D3Node extends d3.SimulationNodeDatum {
id: string;
type: 'root' | 'idea' | 'note';
wave?: number;
title: string;
color: string;
radius: number;
status?: string;
}
interface D3Link extends d3.SimulationLinkDatum<D3Node> {
source: string | D3Node;
target: string | D3Node;
type: 'wave' | 'context' | 'parent';
}
const nodes: D3Node[] = [];
const links: D3Link[] = [];
// Root node
const rootNode: D3Node = {
id: 'root',
type: 'root',
title: session.seedIdea,
color: '#141414',
radius: 40,
fx: 0,
fy: 0
};
nodes.push(rootNode);
// Idea nodes
const colors = {
1: '#fb923c', // orange
2: '#60a5fa', // blue
3: '#a78bfa' // violet
};
ideas.forEach(idea => {
nodes.push({
id: idea.id,
type: 'idea',
wave: idea.waveNumber,
title: idea.title,
color: colors[idea.waveNumber as 1|2|3] || '#94a3b8',
radius: 28,
status: idea.status,
x: idea.position?.x,
y: idea.position?.y
});
if (idea.parentIdeaId) {
links.push({
source: idea.parentIdeaId,
target: idea.id,
type: 'parent'
});
} else {
links.push({
source: 'root',
target: idea.id,
type: 'wave'
});
}
});
// Radial layout forces
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;
if (d.id.includes('-')) return (d.wave || 1) * 200 + 100; // Deepened ideas push out
return (d.wave || 1) * 200;
}, 0, 0).strength(0.8))
.force("collision", d3.forceCollide<D3Node>().radius(d => d.radius + 30));
// Drawing rings
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", "#e2e8f0")
.attr("stroke-width", 1)
.attr("stroke-dasharray", "4,4")
.style("opacity", 0.5);
// Links
const link = g.append("g")
.selectAll("line")
.data(links)
.enter()
.append("line")
.attr("stroke", d => d.type === 'wave' ? "#cbd5e1" : d.type === 'parent' ? "#fde047" : "#94a3b8")
.attr("stroke-width", d => d.type === 'wave' ? 1.5 : 2)
.attr("stroke-dasharray", d => d.type === 'parent' ? "none" : "4,4");
// Nodes
const node = g.append("g")
.selectAll(".node")
.data(nodes)
.enter()
.append("g")
.attr("class", "node")
.style("opacity", d => d.status === 'dismissed' ? 0.4 : 1)
.on("click", (event, d) => {
event.stopPropagation();
if (d.type === 'idea') onNodeSelect(d.id);
})
.on("dblclick", (event, d) => {
event.stopPropagation();
if (d.type === 'idea') {
onNodeSelect(d.id);
onAddChild(d.id);
}
})
.call(d3.drag<SVGGElement, D3Node>()
.on("start", dragstarted)
.on("drag", dragged)
.on("end", dragended) as any);
node.append("circle")
.attr("r", d => d.radius)
.attr("fill", d => d.status === 'converted' ? '#ecfdf5' : (d.type === 'root' ? '#141414' : '#fff'))
.attr("stroke", d => d.status === 'converted' ? '#10b981' : d.color)
.attr("stroke-width", d => d.id === selectedNodeId ? 4 : 2)
.attr("class", "cursor-pointer transition-all hover:scale-110")
.style("filter", d => d.id === selectedNodeId ? `drop-shadow(0 0 12px ${d.color}cc)` : "none");
// Plus icon for selected node
node.filter(d => d.id === selectedNodeId && d.type === 'idea')
.append("circle")
.attr("r", 10)
.attr("cx", 20)
.attr("cy", -20)
.attr("fill", "#141414")
.attr("stroke", "#fff")
.attr("stroke-width", 1)
.attr("class", "cursor-pointer")
.on("click", (event, d) => {
event.stopPropagation();
onAddChild(d.id);
});
node.filter(d => d.id === selectedNodeId && d.type === 'idea')
.append("text")
.attr("x", 20)
.attr("y", -17)
.attr("text-anchor", "middle")
.attr("fill", "#fff")
.attr("class", "text-[14px] font-bold pointer-events-none")
.text("+");
// ForeignObject for inline input
const editGroup = node.filter(d => d.id === editingNodeId && d.type === 'idea')
.append("foreignObject")
.attr("width", 200)
.attr("height", 80)
.attr("x", -100)
.attr("y", 40)
.append("xhtml:div")
.attr("class", "bg-paper dark:bg-black p-2 border border-border rounded-xl shadow-2xl");
editGroup.append("input")
.attr("type", "text")
.attr("placeholder", "Nouvelle idée...")
.attr("class", "w-full bg-white dark:bg-[#1A1A1A] border-none outline-none text-xs font-bold uppercase tracking-tight p-2 rounded-lg")
.on("keydown", (event) => {
if (event.key === 'Enter') {
onManualSubmit(event.target.value, editingNodeId!);
event.target.value = '';
}
if (event.key === 'Escape') {
onManualCancel();
}
})
.on("blur", () => {
// Optional: onManualCancel();
});
// Special case for root addition input (if editingNodeId is 'new')
if (editingNodeId === 'new') {
g.append("foreignObject")
.attr("width", 200)
.attr("height", 80)
.attr("x", -100)
.attr("y", -120) // Floating above center
.append("xhtml:div")
.attr("class", "bg-paper dark:bg-black p-2 border border-border rounded-xl shadow-2xl animate-bounce")
.append("input")
.attr("type", "text")
.attr("autoFocus", "true")
.attr("placeholder", "Idée libre...")
.attr("class", "w-full bg-white dark:bg-[#1A1A1A] border-none outline-none text-xs font-bold uppercase tracking-tight p-2 rounded-lg")
.on("keydown", (event) => {
if (event.key === 'Enter') {
onManualSubmit(event.target.value);
event.target.value = '';
}
if (event.key === 'Escape') onManualCancel();
});
}
// State indicators (converted)
node.filter(d => d.status === 'converted')
.append("path")
.attr("d", d3.symbol().type(d3.symbolCircle).size(150))
.attr("fill", "#10b981");
// Icons/Text in nodes
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' ? "#94a3b8" : "#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);
if (rootNode) {
g.append("text")
.attr("text-anchor", "middle")
.attr("dy", 80)
.attr("class", "text-2xl font-serif italic fill-ink dark:fill-dark-ink pointer-events-none shadow-sm")
.text(session.seedIdea);
}
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})`);
});
function dragstarted(event: any, d: D3Node) {
if (!event.active) simulation.alphaTarget(0.3).restart();
d.fx = d.x;
d.fy = d.y;
}
function dragged(event: any, d: D3Node) {
d.fx = event.x;
d.fy = event.y;
}
function dragended(event: any, d: D3Node) {
if (!event.active) simulation.alphaTarget(0);
d.fx = null;
d.fy = null;
if (d.type === 'idea') {
onPositionUpdate(d.id, { x: event.x, y: event.y });
}
}
return () => {
simulation.stop();
};
}, [session, ideas, selectedNodeId, editingNodeId, onNodeSelect]);
return (
<div ref={containerRef} className="w-full h-full relative cursor-grab active:cursor-grabbing">
<svg ref={svgRef} className="w-full h-full" />
<div className="absolute top-6 left-6 pointer-events-none">
<p className="text-[10px] font-bold tracking-[0.3em] uppercase text-concrete opacity-40">Spatial Exploration Mode</p>
</div>
</div>
);
};

View File

@@ -0,0 +1,208 @@
import React, { useState, useMemo } from 'react';
import {
ChevronRight,
ChevronDown,
Folder,
FolderOpen,
Check,
Search
} from 'lucide-react';
import { Carnet } from '../types';
import { motion, AnimatePresence } from 'motion/react';
interface HierarchicalCarnetSelectorProps {
carnets: Carnet[];
selectedId: string | null;
onSelect: (id: string) => void;
className?: string;
placeholder?: string;
}
export const HierarchicalCarnetSelector: React.FC<HierarchicalCarnetSelectorProps> = ({
carnets,
selectedId,
onSelect,
className = "",
placeholder = "Sélectionner un carnet..."
}) => {
const [isOpen, setIsOpen] = useState(false);
const [searchQuery, setSearchQuery] = useState("");
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set(['1', '4'])); // Default expand some
const selectedCarnet = carnets.find(c => c.id === selectedId);
// Derive the path for display
const path = useMemo(() => {
if (!selectedCarnet) return [];
const trail: Carnet[] = [];
let current = selectedCarnet;
while (current) {
trail.unshift(current);
if (!current.parentId) break;
const parent = carnets.find(c => c.id === current.parentId);
if (!parent) break;
current = parent;
}
return trail;
}, [selectedCarnet, carnets]);
const toggleExpand = (e: React.MouseEvent, id: string) => {
e.stopPropagation();
const newExpanded = new Set(expandedIds);
if (newExpanded.has(id)) {
newExpanded.delete(id);
} else {
newExpanded.add(id);
}
setExpandedIds(newExpanded);
};
const filteredCarnets = useMemo(() => {
if (!searchQuery) return carnets;
return carnets.filter(c => c.name.toLowerCase().includes(searchQuery.toLowerCase()));
}, [carnets, searchQuery]);
const renderTree = (parentId?: string, level = 0) => {
const children = carnets.filter(c => c.parentId === parentId);
if (children.length === 0) return null;
return (
<div className={level > 0 ? "ml-4 border-l border-border/40 pl-2" : ""}>
{children.map(carnet => {
const isExpanded = expandedIds.has(carnet.id) || searchQuery.length > 0;
const hasChildren = carnets.some(c => c.parentId === carnet.id);
const isSelected = selectedId === carnet.id;
// If searching and this carnet doesn't match AND none of its children match, skip it
if (searchQuery && !carnet.name.toLowerCase().includes(searchQuery.toLowerCase())) {
const hasMatchingChild = (id: string): boolean => {
const childrenOfId = carnets.filter(c => c.parentId === id);
return childrenOfId.some(c => c.name.toLowerCase().includes(searchQuery.toLowerCase()) || hasMatchingChild(c.id));
};
if (!hasMatchingChild(carnet.id)) return null;
}
return (
<div key={carnet.id} className="select-none">
<div
onClick={() => {
onSelect(carnet.id);
if (!searchQuery) setIsOpen(false);
}}
className={`flex items-center gap-2.5 px-2 py-1.5 rounded-lg cursor-pointer transition-all group
${isSelected ? 'bg-blueprint/10 text-blueprint font-bold' : 'hover:bg-slate-50 dark:hover:bg-white/5 text-ink'}`}
>
<div className="w-4 flex items-center justify-center">
{hasChildren ? (
<button
onClick={(e) => toggleExpand(e, carnet.id)}
className="p-1 hover:bg-black/5 dark:hover:bg-white/5 rounded transition-colors"
>
{isExpanded ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
</button>
) : null}
</div>
<div className={`p-1 rounded ${isSelected ? 'bg-blueprint/20' : 'bg-slate-100 dark:bg-white/5 group-hover:bg-white/40'}`}>
{isExpanded && hasChildren ? <FolderOpen size={13} /> : <Folder size={13} />}
</div>
<span className="text-[13px] truncate flex-1">{carnet.name}</span>
{isSelected && <Check size={14} className="opacity-60" />}
</div>
<AnimatePresence>
{isExpanded && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
className="overflow-hidden"
>
{renderTree(carnet.id, level + 1)}
</motion.div>
)}
</AnimatePresence>
</div>
);
})}
</div>
);
};
return (
<div className={`relative ${className}`}>
<div
onClick={() => setIsOpen(!isOpen)}
className="w-full bg-slate-50 dark:bg-black/20 border border-border/80 rounded-xl px-4 py-4 text-sm outline-none focus:ring-4 ring-blueprint/5 focus:border-blueprint/40 transition-all cursor-pointer text-ink flex items-center gap-3"
>
<Folder size={16} className="text-blueprint/60 shrink-0" />
<div className="flex-1 flex items-center gap-1 min-w-0">
{path.length > 0 ? (
<div className="flex items-center gap-1.5 truncate">
{path.map((item, i) => (
<React.Fragment key={item.id}>
{i > 0 && <span className="text-concrete/40 text-[10px]">/</span>}
<span className={`truncate ${i === path.length - 1 ? 'font-bold' : 'text-concrete'}`}>
{item.name}
</span>
</React.Fragment>
))}
</div>
) : (
<span className="text-concrete italic">{placeholder}</span>
)}
</div>
<ChevronDown size={14} className={`transition-transform duration-300 text-concrete shrink-0 ${isOpen ? 'rotate-180' : ''}`} />
</div>
<AnimatePresence>
{isOpen && (
<>
<div
className="fixed inset-0 z-[60]"
onClick={() => setIsOpen(false)}
/>
<motion.div
initial={{ opacity: 0, y: 10, scale: 0.98 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 10, scale: 0.98 }}
className="absolute z-[70] mt-2 w-full bg-white dark:bg-dark-paper border border-border shadow-2xl rounded-2xl overflow-hidden flex flex-col min-w-[280px]"
>
<div className="p-3 border-b border-border/40 bg-slate-50/50">
<div className="relative">
<Search size={14} className="absolute left-3 top-1/2 -translate-y-1/2 text-concrete" />
<input
autoFocus
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Filtrer les carnets..."
className="w-full bg-white border border-border rounded-lg pl-9 pr-4 py-2 text-xs outline-none focus:border-blueprint transition-colors"
/>
</div>
</div>
<div className="max-h-72 overflow-y-auto custom-scrollbar p-2">
{renderTree(undefined)}
</div>
<div className="p-2 border-t border-border/40 bg-slate-50/30 flex justify-between items-center px-4">
<span className="text-[9px] font-bold text-concrete uppercase tracking-widest">
Structure des carnets
</span>
<button
onClick={() => setIsOpen(false)}
className="text-[10px] font-bold text-blueprint hover:underline"
>
Fermer
</button>
</div>
</motion.div>
</>
)}
</AnimatePresence>
</div>
);
};

View File

@@ -0,0 +1,248 @@
import React, { useState, useEffect, useMemo } from 'react';
import { motion, AnimatePresence } from 'motion/react';
import {
Network,
Lightbulb,
Layers,
Sparkles,
ArrowRight,
RefreshCw,
Trophy,
Zap,
Tag,
Link as LinkIcon
} from 'lucide-react';
import { Note, NoteCluster, BridgeNote, ConnectionSuggestion } from '../types';
import { runClustering, detectBridges, calculateCentroid } from '../services/clusteringService';
import { nameCluster, suggestBridgeIdeas } from '../services/geminiService';
import { NetworkGraph } from './NetworkGraph';
interface InsightsViewProps {
notes: Note[];
onUpdateNotes: (updatedNotes: Note[]) => void;
onNoteSelect: (noteId: string) => void;
}
export const InsightsView: React.FC<InsightsViewProps> = ({
notes,
onUpdateNotes,
onNoteSelect
}) => {
const [isCalculating, setIsCalculating] = useState(false);
const [clusters, setClusters] = useState<NoteCluster[]>([]);
const [bridgeNotes, setBridgeNotes] = useState<BridgeNote[]>([]);
const [suggestions, setSuggestions] = useState<ConnectionSuggestion[]>([]);
const [selectedClusterId, setSelectedClusterId] = useState<string | null>(null);
const performAnalysis = async () => {
setIsCalculating(true);
try {
// 1. Run clustering
const { clusters: newClusters } = runClustering(notes);
// 2. Name clusters (first 5 unique notes per cluster)
const namedClusters = await Promise.all(newClusters.map(async (c) => {
const clusterNoteSummaries = notes
.filter(n => c.noteIds.includes(n.id))
.slice(0, 5)
.map(n => n.title);
const name = await nameCluster(clusterNoteSummaries);
const centroid = calculateCentroid(c.noteIds, notes);
return { ...c, name, centroid };
}));
// 3. Update notes with cluster IDs
const updatedNotes = notes.map(n => {
const cluster = namedClusters.find(c => c.noteIds.includes(n.id));
return { ...n, clusterId: cluster?.id };
});
onUpdateNotes(updatedNotes);
// 4. Detect bridges
const bridges = detectBridges(updatedNotes, namedClusters);
// 5. Build suggestions for isolated cluster pairs
// For demo, we'll just pick a few interesting pairs
const newSuggestions: ConnectionSuggestion[] = [];
if (namedClusters.length >= 2) {
// Find clusters with no mutual bridge notes or low connectivity
for (let i = 0; i < Math.min(namedClusters.length, 3); i++) {
for (let j = i + 1; j < Math.min(namedClusters.length, 3); j++) {
const cA = namedClusters[i];
const cB = namedClusters[j];
const cA_notes = updatedNotes.filter(n => cA.noteIds.includes(n.id)).map(n => n.title).join(', ');
const cB_notes = updatedNotes.filter(n => cB.noteIds.includes(n.id)).map(n => n.title).join(', ');
const bridgeIdeas = await suggestBridgeIdeas(cA.name, cB.name, cA_notes, cB_notes);
bridgeIdeas.forEach((idea, idx) => {
newSuggestions.push({
id: `suggestion-${i}-${j}-${idx}`,
...idea,
clusterAId: cA.id,
clusterBId: cB.id
});
});
}
}
}
setClusters(namedClusters);
setBridgeNotes(bridges);
setSuggestions(newSuggestions);
} catch (error) {
console.error("Analysis failed:", error);
} finally {
setIsCalculating(false);
}
};
useEffect(() => {
if (notes.some(n => n.embedding) && clusters.length === 0) {
performAnalysis();
}
}, [notes]);
const bridgeList = useMemo(() => {
return bridgeNotes.map(b => {
const note = notes.find(n => n.id === b.noteId);
return { ...b, title: note?.title || 'Unknown Note' };
});
}, [bridgeNotes, notes]);
return (
<div className="h-full flex flex-col bg-paper dark:bg-[#0D0D0D] overflow-hidden">
{/* Header */}
<div className="p-8 border-b border-border/40 flex items-center justify-between backdrop-blur-xl bg-white/40 dark:bg-black/20 z-10">
<div>
<div className="flex items-center gap-3 mb-1">
<div className="w-8 h-8 rounded-lg bg-indigo-500/10 flex items-center justify-center text-indigo-500">
<Sparkles size={18} />
</div>
<h1 className="text-2xl font-serif font-medium text-ink dark:text-dark-ink">Semantic Insights</h1>
</div>
<p className="text-[11px] text-concrete tracking-[0.2em] uppercase font-bold">Discovering the hidden architecture of your knowledge</p>
</div>
<button
onClick={performAnalysis}
disabled={isCalculating}
className="flex items-center gap-2 px-6 py-2.5 bg-ink text-paper dark:bg-white dark:text-black rounded-full text-xs font-bold uppercase tracking-widest hover:scale-105 active:scale-95 transition-all disabled:opacity-50"
>
{isCalculating ? <RefreshCw size={14} className="animate-spin" /> : <RefreshCw size={14} />}
{isCalculating ? 'Mapping...' : 'Re-sync Network'}
</button>
</div>
<div className="flex-1 flex overflow-hidden">
{/* Left: Graph View */}
<div className="flex-[1.5] p-6 relative">
<NetworkGraph
notes={notes}
clusters={clusters}
bridgeNotes={bridgeNotes}
onNoteSelect={onNoteSelect}
/>
</div>
{/* Right: Insight Dashboard */}
<div className="flex-1 border-l border-border/40 flex flex-col h-full bg-paper/50 dark:bg-black/10 backdrop-blur-sm overflow-hidden">
<div className="p-8 flex-1 overflow-y-auto custom-scrollbar space-y-12">
{/* Stats Summary */}
<div className="grid grid-cols-2 gap-4">
<div className="p-5 rounded-2xl bg-white dark:bg-white/5 border border-border shadow-sm">
<div className="flex items-center gap-2 text-indigo-500 mb-2">
<Layers size={14} />
<span className="text-[10px] font-bold uppercase tracking-widest">Clusters</span>
</div>
<div className="text-3xl font-serif font-medium text-ink dark:text-dark-ink">{clusters.length}</div>
</div>
<div className="p-5 rounded-2xl bg-white dark:bg-white/5 border border-border shadow-sm">
<div className="flex items-center gap-2 text-ochre mb-2">
<Trophy size={14} />
<span className="text-[10px] font-bold uppercase tracking-widest">Bridge Notes</span>
</div>
<div className="text-3xl font-serif font-medium text-ink dark:text-dark-ink">{bridgeNotes.length}</div>
</div>
</div>
{/* Bridge Notes Section */}
<section>
<div className="flex items-center gap-2 mb-6 px-1">
<Zap size={16} className="text-ochre" />
<h3 className="text-sm font-bold uppercase tracking-widest text-ink dark:text-dark-ink">Powerful Bridge Notes</h3>
</div>
<div className="space-y-3">
{bridgeList.map(bridge => (
<motion.div
key={bridge.noteId}
whileHover={{ x: 4 }}
onClick={() => onNoteSelect(bridge.noteId)}
className="p-4 rounded-xl bg-white dark:bg-white/5 border border-border hover:border-ochre/40 transition-all cursor-pointer group"
>
<div className="flex items-center justify-between mb-2">
<h4 className="text-sm font-medium text-ink dark:text-dark-ink truncate flex-1">{bridge.title}</h4>
<span className="text-[10px] font-bold text-ochre bg-ochre/10 px-2 py-0.5 rounded-full">
Score: {(bridge.bridgeScore * 100).toFixed(0)}%
</span>
</div>
<div className="flex items-center gap-2">
{bridge.connectedClusterIds.map(cid => {
const c = clusters.find(cl => cl.id === cid);
return (
<div key={cid} className="flex items-center gap-1">
<div className="w-1.5 h-1.5 rounded-full" style={{ backgroundColor: c?.color }} />
<span className="text-[9px] text-concrete font-medium whitespace-nowrap">{c?.name}</span>
</div>
);
})}
</div>
</motion.div>
))}
{bridgeList.length === 0 && !isCalculating && (
<div className="text-xs text-concrete italic">No significant bridge notes found yet. Deepen your research to find new connections.</div>
)}
</div>
</section>
{/* Connection Suggestions */}
<section>
<div className="flex items-center gap-2 mb-6 px-1">
<Lightbulb size={16} className="text-indigo-500" />
<h3 className="text-sm font-bold uppercase tracking-widest text-ink dark:text-dark-ink">Missing Links (AI Generated)</h3>
</div>
<div className="space-y-4">
{suggestions.map((s, idx) => (
<div key={s.id} className="p-6 rounded-2xl bg-gradient-to-br from-indigo-500/5 to-transparent border border-indigo-500/10 hover:border-indigo-500/30 transition-all">
<div className="flex items-center gap-3 mb-4">
<div className="flex -space-x-2">
<div className="w-6 h-6 rounded-full border-2 border-paper bg-indigo-500 flex items-center justify-center text-[10px] text-white">A</div>
<div className="w-6 h-6 rounded-full border-2 border-paper bg-ochre flex items-center justify-center text-[10px] text-white">B</div>
</div>
<span className="text-[9px] font-bold uppercase tracking-widest text-indigo-500/60">Bridging {clusters.find(c => c.id === s.clusterAId)?.name} & {clusters.find(c => c.id === s.clusterBId)?.name}</span>
</div>
<h4 className="text-base font-serif font-medium text-ink dark:text-dark-ink mb-2">{s.title}</h4>
<p className="text-xs text-muted-ink leading-relaxed mb-4">{s.description}</p>
<div className="p-3 bg-white/40 dark:bg-white/5 rounded-xl border border-border/40 text-[10px] italic text-concrete flex gap-2">
<Zap size={12} className="shrink-0" />
<span>{s.reasoning}</span>
</div>
</div>
))}
{isCalculating && (
<div className="animate-pulse space-y-4">
{[1, 2].map(i => (
<div key={i} className="h-32 bg-indigo-500/5 rounded-2xl border border-indigo-500/10" />
))}
</div>
)}
</div>
</section>
</div>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,173 @@
import React, { useEffect, useRef } from 'react';
import * as d3 from 'd3';
import { Note, NoteCluster, BridgeNote } from '../types';
interface NetworkGraphProps {
notes: Note[];
clusters: NoteCluster[];
bridgeNotes: BridgeNote[];
onNoteSelect: (id: string) => void;
}
export const NetworkGraph: React.FC<NetworkGraphProps> = ({
notes,
clusters,
bridgeNotes,
onNoteSelect
}) => {
const svgRef = useRef<SVGSVGElement>(null);
const containerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!svgRef.current || !containerRef.current) return;
const width = containerRef.current.clientWidth;
const height = containerRef.current.clientHeight;
const svg = d3.select(svgRef.current);
svg.selectAll("*").remove();
const g = svg.append("g");
const zoom = d3.zoom<SVGSVGElement, unknown>()
.scaleExtent([0.1, 4])
.on("zoom", (event) => {
g.attr("transform", event.transform);
});
svg.call(zoom);
// Filter notes with embeddings and cluster assignments
const visibleNotes = notes.filter(n => n.embedding && n.clusterId);
interface D3Node extends d3.SimulationNodeDatum {
id: string;
title: string;
clusterId: string;
color: string;
isBridge: boolean;
radius: number;
}
interface D3Link extends d3.SimulationLinkDatum<D3Node> {
source: string;
target: string;
strength: number;
}
const bridgeSet = new Set(bridgeNotes.map(b => b.noteId));
const nodes: D3Node[] = visibleNotes.map(n => {
const cluster = clusters.find(c => c.id === n.clusterId);
const isBridge = bridgeSet.has(n.id);
return {
id: n.id,
title: n.title,
clusterId: n.clusterId!,
color: cluster?.color || '#cbd5e1',
isBridge,
radius: isBridge ? 12 : 8
};
});
const links: D3Link[] = [];
// Only connect strong links
for (let i = 0; i < visibleNotes.length; i++) {
for (let j = i + 1; j < visibleNotes.length; j++) {
const ni = visibleNotes[i];
const nj = visibleNotes[j];
if (ni.clusterId === nj.clusterId) {
links.push({ source: ni.id, target: nj.id, strength: 0.5 });
}
}
}
const simulation = d3.forceSimulation<D3Node>(nodes)
.force("link", d3.forceLink<D3Node, D3Link>(links).id(d => d.id).distance(100))
.force("charge", d3.forceManyBody().strength(-200))
.force("center", d3.forceCenter(width / 2, height / 2))
.force("collision", d3.forceCollide<D3Node>().radius(d => d.radius + 10));
// Links
const link = g.append("g")
.selectAll("line")
.data(links)
.enter()
.append("line")
.attr("stroke", "#e2e8f0")
.attr("stroke-opacity", 0.6)
.attr("stroke-width", 1);
// Nodes
const node = g.append("g")
.selectAll(".node")
.data(nodes)
.enter()
.append("g")
.attr("class", "node cursor-pointer")
.on("click", (event, d) => onNoteSelect(d.id))
.call(d3.drag<SVGGElement, D3Node>()
.on("start", dragstarted)
.on("drag", dragged)
.on("end", dragended) as any);
node.append("circle")
.attr("r", d => d.radius)
.attr("fill", d => d.color)
.attr("stroke", d => d.isBridge ? "#D4AF37" : "#fff")
.attr("stroke-width", d => d.isBridge ? 3 : 2)
.style("filter", d => d.isBridge ? "drop-shadow(0 0 4px rgba(212, 175, 55, 0.4))" : "none");
node.append("text")
.attr("dy", d => d.radius + 14)
.attr("text-anchor", "middle")
.attr("class", "text-[10px] fill-concrete dark:fill-concrete/60 font-medium pointer-events-none")
.text(d => d.title.length > 20 ? d.title.substring(0, 20) + "..." : d.title);
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})`);
});
function dragstarted(event: any, d: D3Node) {
if (!event.active) simulation.alphaTarget(0.3).restart();
d.fx = d.x;
d.fy = d.y;
}
function dragged(event: any, d: D3Node) {
d.fx = event.x;
d.fy = event.y;
}
function dragended(event: any, d: D3Node) {
if (!event.active) simulation.alphaTarget(0);
d.fx = null;
d.fy = null;
}
return () => simulation.stop();
}, [notes, clusters, bridgeNotes, onNoteSelect]);
return (
<div ref={containerRef} className="w-full h-full bg-paper dark:bg-[#121212] rounded-3xl overflow-hidden border border-border/40 relative">
<div className="absolute top-6 left-6 z-10 flex flex-wrap gap-3 max-w-[300px]">
{clusters.map(c => (
<div key={c.id} className="flex items-center gap-1.5 px-2 py-1 bg-white/80 dark:bg-white/5 backdrop-blur-sm border border-border rounded-full shadow-sm">
<div className="w-2 h-2 rounded-full" style={{ backgroundColor: c.color }} />
<span className="text-[9px] font-bold uppercase tracking-widest text-concrete whitespace-nowrap">{c.name}</span>
</div>
))}
</div>
<svg ref={svgRef} className="w-full h-full" />
</div>
);
};

View File

@@ -0,0 +1,647 @@
import React from 'react';
import {
Plus,
Search,
Share2,
Pin,
ChevronRight,
ArrowLeft,
MoreVertical,
Sparkles,
Tag as TagIcon,
X,
BookOpen,
Edit3,
Eye,
Trash2,
Wind,
FileText,
Paperclip,
Loader2,
MessageSquare
} from 'lucide-react';
import { motion, AnimatePresence } from 'motion/react';
import { Note, Carnet, Tag, Attachment } from '../types';
import { SlashMenu } from './SlashMenu';
import { parseDocument } from '../services/geminiService';
import { v4 as uuidv4 } from 'uuid';
interface NotebooksViewProps {
activeNoteId: string | null;
activeCarnet: Carnet | undefined;
filteredNotes: Note[];
activeNote: Note | undefined;
setActiveNoteId: (id: string | null) => void;
togglePin: (id: string) => void;
setShowNewNoteModal: (show: boolean) => void;
isAISidebarOpen: boolean;
setIsAISidebarOpen: (open: boolean) => void;
selectedTagIds: string[];
setSelectedTagIds: (ids: string[]) => void;
allNotes: Note[];
activeCarnetId: string;
setShowNewCarnetModal: (show: boolean, parentId?: string, isRenaming?: boolean, carnetId?: string) => void;
onDeleteNote: (id: string) => void;
onBrainstormNote: (note: Note) => void;
onUpdateNote?: (note: Note) => void;
}
export const NotebooksView: React.FC<NotebooksViewProps> = ({
activeNoteId,
activeCarnet,
filteredNotes,
activeNote,
setActiveNoteId,
togglePin,
setShowNewNoteModal,
isAISidebarOpen,
setIsAISidebarOpen,
selectedTagIds,
setSelectedTagIds,
allNotes,
activeCarnetId,
setShowNewCarnetModal,
onDeleteNote,
onBrainstormNote,
onUpdateNote
}) => {
const [isTagsExpanded, setIsTagsExpanded] = React.useState(false);
const [tagSearchQuery, setTagSearchQuery] = React.useState('');
const [isEditing, setIsEditing] = React.useState(false);
const [slashMenu, setSlashMenu] = React.useState<{ isOpen: boolean; top: number; left: number } | null>(null);
const [isAnalyzing, setIsAnalyzing] = React.useState<string | null>(null);
const [activeDocQnA, setActiveDocQnA] = React.useState<Attachment | null>(null);
const fileInputRef = React.useRef<HTMLInputElement>(null);
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file || !activeNote) return;
const newAttachment: Attachment = {
id: uuidv4(),
name: file.name,
type: file.name.endsWith('.pdf') ? 'pdf' : (file.name.endsWith('.docx') ? 'docx' : 'other'),
url: URL.createObjectURL(file), // Local preview url
isProcessed: false
};
const updatedNote = {
...activeNote,
attachments: [...(activeNote.attachments || []), newAttachment]
};
onUpdateNote?.(updatedNote);
// Auto-analyze
setIsAnalyzing(newAttachment.id);
const content = await parseDocument(newAttachment.url, newAttachment.name);
const processedAttachment = { ...newAttachment, content, isProcessed: true };
const finalNote = {
...activeNote,
attachments: [...(activeNote.attachments || []), processedAttachment]
};
onUpdateNote?.(finalNote);
setIsAnalyzing(null);
};
const handleEditorKeyDown = (e: React.KeyboardEvent) => {
if (e.key === '/') {
const selection = window.getSelection();
if (selection && selection.rangeCount > 0) {
const range = selection.getRangeAt(0);
const rect = range.getBoundingClientRect();
setSlashMenu({
isOpen: true,
top: rect.bottom + window.scrollY,
left: rect.left + window.scrollX
});
}
}
};
const insertCommand = (type: string) => {
console.log(`Command selected: ${type}`);
setSlashMenu(null);
};
const availableTags = React.useMemo(() => {
const carnetNotes = allNotes.filter(n => n.carnetId === activeCarnetId);
const tagsMap = new Map<string, Tag>();
carnetNotes.forEach(note => {
note.tags?.forEach(tag => {
tagsMap.set(tag.id, tag);
});
});
return Array.from(tagsMap.values()).sort((a, b) => {
// AI tags first, then alphabetical
if (a.type === 'ai' && b.type !== 'ai') return -1;
if (a.type !== 'ai' && b.type === 'ai') return 1;
return a.label.localeCompare(b.label);
});
}, [allNotes, activeCarnetId]);
const visibleTags = React.useMemo(() => {
let filtered = availableTags;
if (tagSearchQuery) {
filtered = availableTags.filter(t =>
t.label.toLowerCase().includes(tagSearchQuery.toLowerCase())
);
} else if (!isTagsExpanded) {
filtered = availableTags.slice(0, 10);
// Ensure selected tags are always visible even if not in the first 10
selectedTagIds.forEach(id => {
if (!filtered.find(t => t.id === id)) {
const tag = availableTags.find(t => t.id === id);
if (tag) filtered.push(tag);
}
});
}
return filtered;
}, [availableTags, isTagsExpanded, tagSearchQuery, selectedTagIds]);
const toggleTag = (tagId: string) => {
if (selectedTagIds.includes(tagId)) {
setSelectedTagIds(selectedTagIds.filter(id => id !== tagId));
} else {
setSelectedTagIds([...selectedTagIds, tagId]);
}
};
if (!activeNoteId) {
return (
<div className="h-full flex flex-col overflow-y-auto">
<header className="px-12 pt-12 pb-8 flex flex-col gap-6 sticky top-0 bg-paper/80 backdrop-blur-md z-30">
<div className="flex justify-between items-start">
<h1 className="text-4xl font-serif font-medium tracking-tight text-ink leading-tight pr-12">
{activeCarnet?.name} {filteredNotes[0]?.date || 'Oct 26'}
</h1>
</div>
<div className="flex items-center justify-between border-b border-ink/5 pb-4">
<div className="flex items-center gap-6">
<button
onClick={() => setShowNewNoteModal(true)}
className="flex items-center gap-2 text-[13px] text-ink font-medium hover:opacity-70 transition-opacity"
>
<Plus size={16} />
<span>Add Note</span>
</button>
<button
onClick={() => setShowNewCarnetModal(true, activeCarnetId)}
className="flex items-center gap-2 text-[13px] text-concrete font-medium hover:text-ink transition-all"
>
<BookOpen size={16} />
<span>New Sub-Carnet</span>
</button>
<button className="flex items-center gap-2 text-[13px] text-ink font-medium hover:opacity-70 transition-opacity">
<Search size={16} />
<span>Search</span>
</button>
</div>
<button className="flex items-center gap-2 text-[13px] text-ink font-medium hover:opacity-70 transition-opacity">
<Share2 size={16} />
<span>Share</span>
</button>
</div>
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3 text-[10px] font-bold uppercase tracking-[0.2em] text-concrete">
<TagIcon size={12} />
<span>Filter by Tags</span>
{selectedTagIds.length > 0 && (
<span className="bg-blueprint/10 text-blueprint px-2 py-0.5 rounded-full text-[9px] lowercase tracking-normal">
{selectedTagIds.length} active
</span>
)}
</div>
{availableTags.length > 10 && (
<div className="relative group">
<input
type="text"
placeholder="Search tags..."
className="bg-transparent border-b border-border/40 text-[10px] outline-none focus:border-blueprint/40 py-1 px-2 w-32 transition-all focus:w-48 placeholder:text-concrete/40"
onChange={(e) => setTagSearchQuery(e.target.value)}
/>
</div>
)}
</div>
<div className="flex flex-wrap gap-2 items-center min-h-[32px]">
<AnimatePresence mode="popLayout">
{visibleTags.map(tag => {
const isActive = selectedTagIds.includes(tag.id);
return (
<motion.button
layout
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.9 }}
key={tag.id}
onClick={() => toggleTag(tag.id)}
className={`px-3 py-1.5 rounded-full text-[10px] font-bold uppercase tracking-wider transition-all border flex items-center gap-2
${isActive
? 'bg-ink text-paper border-ink shadow-lg shadow-ink/10'
: 'bg-white/40 border-border text-concrete hover:border-concrete/40 hover:bg-white/60'}`}
>
{tag.type === 'ai' && (
<Sparkles
size={10}
className={isActive ? 'text-blueprint' : 'text-blueprint/60'}
/>
)}
{tag.label}
{isActive && <X size={10} />}
</motion.button>
);
})}
</AnimatePresence>
{availableTags.length > 10 && !tagSearchQuery && (
<button
onClick={() => setIsTagsExpanded(!isTagsExpanded)}
className="px-3 py-1.5 text-[10px] font-bold uppercase tracking-wider text-concrete/60 hover:text-ink transition-colors border border-dashed border-border rounded-full"
>
{isTagsExpanded ? 'Show less' : `+ ${availableTags.length - 10} more`}
</button>
)}
{selectedTagIds.length > 0 && (
<button
onClick={() => setSelectedTagIds([])}
className="px-3 py-1.5 text-[10px] font-bold uppercase tracking-wider text-rust hover:underline ml-auto"
>
Clear all
</button>
)}
</div>
</div>
</header>
<div className="px-12 flex-1 pb-20">
<div className="max-w-3xl space-y-16">
{filteredNotes.map((note, index) => (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.1 * index, duration: 0.8 }}
key={note.id}
className="space-y-4 group cursor-pointer relative"
onClick={() => setActiveNoteId(note.id)}
>
<h2 className="text-2xl font-serif font-medium text-ink flex items-center justify-between">
<span className="flex items-center gap-3">
{note.isPinned && <Pin size={18} className="text-amber-500 fill-amber-500" />}
{note.title}
</span>
<div className="flex items-center gap-2">
<button
onClick={(e) => {
e.stopPropagation();
onBrainstormNote(note);
}}
className="p-2 rounded-full opacity-0 group-hover:opacity-60 hover:opacity-100 hover:bg-ochre/10 text-ochre transition-all"
title="Brainstorm this concept"
>
<Wind size={16} />
</button>
<button
onClick={(e) => {
e.stopPropagation();
togglePin(note.id);
}}
className={`p-2 rounded-full transition-all ${note.isPinned ? 'text-amber-600 bg-amber-50' : 'opacity-0 group-hover:opacity-60 hover:bg-slate-100 text-ink'}`}
>
<Pin size={16} />
</button>
<button
onClick={(e) => {
e.stopPropagation();
onDeleteNote(note.id);
}}
className="p-2 rounded-full opacity-0 group-hover:opacity-60 hover:opacity-100 hover:bg-rose-50 text-rose-500 transition-all"
>
<Trash2 size={16} />
</button>
<button className="opacity-0 group-hover:opacity-40 transition-opacity">
<ChevronRight size={20} />
</button>
</div>
</h2>
<div className="flex flex-col md:flex-row gap-8 items-start">
<div className="w-full md:w-56 aspect-[4/3] bg-white/50 dark:bg-white/5 border border-border overflow-hidden rounded shadow-sm flex-shrink-0">
<img
src={note.imageUrl}
alt={note.title}
className="w-full h-full object-cover mix-blend-multiply opacity-80 grayscale contrast-125 hover:grayscale-0 hover:opacity-100 transition-all duration-500"
referrerPolicy="no-referrer"
/>
</div>
<div className="space-y-3">
<div className="flex flex-wrap gap-2 mb-2">
{note.tags?.map(tag => (
<div
key={tag.id}
className={`px-2 py-0.5 rounded text-[9px] font-bold uppercase tracking-wider border flex items-center gap-1.5
${tag.type === 'ai'
? 'bg-blueprint/5 border-blueprint/20 text-blueprint'
: 'bg-concrete/5 border-border text-concrete'}`}
>
{tag.type === 'ai' && <Sparkles size={8} />}
{tag.label}
</div>
))}
</div>
<p className="text-[14px] leading-relaxed text-ink/80 font-light max-w-lg line-clamp-4">
{note.content}
</p>
<span className="text-[11px] text-muted-ink uppercase tracking-widest font-medium">Read more</span>
</div>
</div>
</motion.div>
))}
{filteredNotes.length === 0 && (
<div className="h-64 flex flex-col items-center justify-center text-center space-y-4">
<p className="font-serif text-xl italic text-muted-ink">This notebook is waiting for its first vision.</p>
<button
onClick={() => setShowNewNoteModal(true)}
className="px-6 py-2 border border-ink text-[13px] uppercase tracking-[0.2em] hover:bg-ink hover:text-paper transition-all"
>
Begin Drawing
</button>
</div>
)}
</div>
</div>
<footer className="px-12 py-6 border-t border-ink/5 text-center mt-auto">
<p className="text-[11px] text-muted-ink uppercase tracking-[0.2em] font-medium">
&copy; 2024 Architectural Grid. All rights reserved.
</p>
</footer>
</div>
);
}
return (
<div className="h-full flex overflow-hidden transition-all duration-500">
<div className="flex-1 flex flex-col overflow-y-auto bg-white dark:bg-paper">
<div className="px-12 py-8 flex items-center justify-between sticky top-0 bg-white/90 dark:bg-paper/90 backdrop-blur-sm z-40 border-b border-border">
<button
onClick={() => setActiveNoteId(null)}
className="flex items-center gap-2 text-ink hover:opacity-60 transition-opacity"
>
<ArrowLeft size={18} />
<span className="text-sm font-medium">Back to collection</span>
</button>
<div className="flex items-center gap-4">
<button
onClick={() => fileInputRef.current?.click()}
className="flex items-center gap-2 px-3 py-1.5 rounded-full border border-border text-muted-ink hover:text-ink transition-all"
title="Add attachment"
>
<Paperclip size={16} />
<input
type="file"
ref={fileInputRef}
onChange={handleFileUpload}
className="hidden"
accept=".pdf,.docx,.txt"
/>
</button>
<button
onClick={() => onBrainstormNote(activeNote!)}
className="flex items-center gap-2 px-3 py-1.5 rounded-full border border-ochre/30 text-ochre hover:bg-ochre/5 transition-all"
>
<Wind size={16} />
<span className="text-xs font-bold uppercase tracking-widest">Brainstorm</span>
</button>
<button
onClick={() => setIsEditing(!isEditing)}
className={`flex items-center gap-2 px-4 py-1.5 rounded-lg border transition-all duration-300
${isEditing ? 'bg-blueprint text-white border-blueprint shadow-lg shadow-blueprint/20' : 'border-border text-ink hover:bg-slate-50'}`}
>
{isEditing ? <Eye size={16} /> : <Edit3 size={16} />}
<span className="text-xs font-bold uppercase tracking-widest">{isEditing ? 'Visualiser' : 'Modifier'}</span>
</button>
<button
onClick={() => togglePin(activeNoteId!)}
className={`p-2 rounded-full transition-all ${activeNote?.isPinned ? 'text-amber-600 bg-amber-50 dark:bg-ochre/10' : 'text-muted-ink hover:text-ink'}`}
title={activeNote?.isPinned ? "Unpin note" : "Pin note"}
>
<Pin size={18} className={activeNote?.isPinned ? 'fill-amber-600' : ''} />
</button>
<button
onClick={() => setIsAISidebarOpen(!isAISidebarOpen)}
className={`flex items-center gap-2 px-3 py-1.5 rounded-full border transition-all duration-300
${isAISidebarOpen ? 'bg-ink text-paper border-ink' : 'border-border text-ink hover:bg-white/50 dark:hover:bg-white/5'}`}
>
<Sparkles size={16} />
<span className="text-xs font-medium">AI Assistant</span>
</button>
<button className="p-2 text-muted-ink hover:text-red-500 transition-colors">
<Trash2 size={18} />
</button>
<button className="p-2 text-muted-ink hover:text-ink transition-colors">
<MoreVertical size={18} />
</button>
</div>
</div>
<div className="max-w-4xl mx-auto w-full px-12 py-16 space-y-12 relative">
<AnimatePresence>
{slashMenu?.isOpen && (
<SlashMenu
position={{ top: slashMenu.top, left: slashMenu.left }}
onSelect={(type) => insertCommand(type)}
onClose={() => setSlashMenu(null)}
/>
)}
</AnimatePresence>
<div className="space-y-4">
<div className="flex items-center gap-3 text-[12px] text-muted-ink uppercase tracking-[.25em] font-bold">
<span className="text-blueprint">{activeCarnet?.name}</span>
<ChevronRight size={10} className="text-concrete" />
<span className="text-concrete">{activeNote?.date}</span>
</div>
{isEditing ? (
<input
type="text"
defaultValue={activeNote?.title}
className="w-full text-5xl md:text-6xl font-serif font-bold text-ink leading-tight bg-transparent border-none outline-none focus:ring-0 placeholder:text-concrete/20"
placeholder="Titre de la note..."
/>
) : (
<h1 className="text-5xl md:text-6xl font-serif font-bold text-ink leading-tight">
{activeNote?.title}
</h1>
)}
<div className="flex flex-wrap gap-2 pt-2">
{activeNote?.tags?.map(tag => (
<div
key={tag.id}
className={`px-3 py-1 rounded-full text-[10px] font-bold uppercase tracking-widest border flex items-center gap-2
${tag.type === 'ai'
? 'bg-blueprint/5 border-blueprint/20 text-blueprint'
: 'bg-paper border-border text-concrete'}`}
>
{tag.type === 'ai' && <Sparkles size={12} />}
{tag.label}
{tag.type === 'ai' && (
<div className="w-1.5 h-1.5 rounded-full bg-blueprint animate-pulse" />
)}
</div>
))}
</div>
</div>
<div className="aspect-[16/9] w-full bg-slate-100 dark:bg-white/5 rounded-xl overflow-hidden shadow-2xl relative group/img">
<img
src={activeNote?.imageUrl}
alt={activeNote?.title}
className="w-full h-full object-cover transition-transform duration-700 group-hover/img:scale-105"
referrerPolicy="no-referrer"
/>
<div className="absolute inset-0 bg-gradient-to-t from-ink/20 to-transparent pointer-events-none" />
</div>
<div className="max-w-2xl mx-auto w-full space-y-8 pb-40">
{activeNote?.attachments && activeNote.attachments.length > 0 && (
<div className="space-y-4">
<h4 className="text-[10px] uppercase font-bold tracking-widest text-concrete border-b border-border pb-2">Pièces jointes ({activeNote.attachments.length})</h4>
<div className="grid grid-cols-2 gap-4">
{activeNote.attachments.map(att => (
<div key={att.id} className="p-3 border border-border rounded-xl bg-white/40 flex items-center justify-between group">
<div className="flex items-center gap-3">
<div className="p-2 bg-blueprint/10 text-blueprint rounded-lg">
<FileText size={16} />
</div>
<div className="overflow-hidden">
<p className="text-xs font-bold text-ink truncate w-32">{att.name}</p>
<p className="text-[9px] uppercase font-medium text-concrete">{att.type}</p>
</div>
</div>
<div className="flex items-center gap-1">
{isAnalyzing === att.id ? (
<Loader2 size={14} className="animate-spin text-blueprint" />
) : (
<button
onClick={() => setActiveDocQnA(att)}
className="p-2 hover:bg-blueprint/10 text-blueprint rounded-lg transition-colors opacity-0 group-hover:opacity-100"
title="Converser avec ce document"
>
<MessageSquare size={14} />
</button>
)}
</div>
</div>
))}
</div>
</div>
)}
{isEditing ? (
<textarea
defaultValue={activeNote?.content}
onKeyDown={handleEditorKeyDown}
className="w-full min-h-[500px] text-lg leading-relaxed text-ink/90 font-serif bg-transparent border-none outline-none focus:ring-0 resize-none placeholder:text-concrete/20"
placeholder="Commencez à écrire... Tapez '/' pour les commandes."
/>
) : (
<div className="space-y-8">
<p className="text-xl md:text-2xl font-serif leading-relaxed text-ink italic">
{activeNote?.content.split('.')[0]}.
</p>
<div className="h-px bg-border w-32" />
<div className="space-y-6">
{activeNote?.content.split('\n').map((line, i) => (
<p key={i} className="text-lg leading-relaxed text-ink/80 font-light text-justify selection:bg-blueprint/20">
{line}
</p>
))}
{activeNote?.id.startsWith('n-') && (
<p className="text-lg leading-relaxed text-ink/80 font-light text-justify border-l-2 border-blueprint/20 pl-6 italic">
Architectural grids serve as the invisible scaffolding upon which spatial experiences are constructed. Beyond mere structural repetition, they facilitate a rhythmic dialogue between materiality and void. In this exploration, we examine how light fractures these rigid boundaries, creating a dynamic interplay that evolves with the passage of time.
</p>
)}
</div>
</div>
)}
</div>
</div>
</div>
{/* Document Q&A Overlay */}
<AnimatePresence>
{activeDocQnA && (
<div className="fixed inset-0 z-[100] flex items-center justify-center p-8 bg-ink/30 backdrop-blur-md">
<motion.div
initial={{ scale: 0.95, opacity: 0 }}
animate={{ scale: 1, opacity: 1 }}
exit={{ scale: 0.95, opacity: 0 }}
className="w-full max-w-4xl h-full bg-paper dark:bg-dark-paper border border-border rounded-[32px] shadow-2xl flex overflow-hidden"
>
{/* Document Preview (Mock) */}
<div className="flex-1 bg-slate-50 dark:bg-black/20 border-r border-border p-12 overflow-y-auto custom-scrollbar">
<div className="flex items-center gap-4 mb-8">
<div className="p-3 bg-blueprint text-white rounded-xl">
<FileText size={24} />
</div>
<div>
<h3 className="text-xl font-serif font-bold text-ink">{activeDocQnA.name}</h3>
<p className="text-[10px] uppercase font-bold tracking-widest text-concrete">DOCUMENT SOURCE ANALYSÉ</p>
</div>
</div>
<div className="prose prose-sm prose-slate dark:prose-invert max-w-none">
<div className="whitespace-pre-wrap font-serif text-lg leading-relaxed text-ink/80 italic">
{activeDocQnA.content || "Analyse du contenu en cours..."}
</div>
</div>
</div>
{/* Chat Side */}
<div className="w-[400px] flex flex-col bg-white dark:bg-paper">
<div className="p-6 border-b border-border flex items-center justify-between">
<div className="flex items-center gap-2">
<Sparkles size={18} className="text-blueprint" />
<h4 className="text-xs font-bold uppercase tracking-widest text-ink">Expert Document</h4>
</div>
<button onClick={() => setActiveDocQnA(null)} className="p-2 hover:bg-slate-50 dark:hover:bg-white/5 rounded-full text-concrete">
<X size={20} />
</button>
</div>
<div className="flex-1 p-6 overflow-y-auto space-y-6">
<div className="p-4 bg-blueprint/5 border border-blueprint/10 rounded-2xl">
<p className="text-xs text-blueprint font-medium leading-relaxed">
Bonjour ! J'ai analysé ce document. Posez-moi n'importe quelle question sur son contenu, les chiffres clés ou les concepts abordés.
</p>
</div>
</div>
<div className="p-6 border-t border-border">
<div className="relative">
<textarea
placeholder="Poser une question au document..."
className="w-full bg-slate-50 dark:bg-black/20 border border-border rounded-xl p-4 pr-12 text-xs outline-none focus:border-blueprint transition-all resize-none"
rows={3}
/>
<button className="absolute right-3 bottom-3 p-2 bg-blueprint text-white rounded-lg shadow-lg shadow-blueprint/10">
<Sparkles size={16} />
</button>
</div>
</div>
</div>
</motion.div>
</div>
)}
</AnimatePresence>
</div>
);
};

View File

@@ -0,0 +1,66 @@
import React from 'react';
import { motion, AnimatePresence } from 'motion/react';
import { SettingsTab } from '../types';
import { SettingsHeader } from './settings/SettingsHeader';
import { GeneralTab } from './settings/GeneralTab';
import { AITab } from './settings/AITab';
import { AppearanceTab } from './settings/AppearanceTab';
interface SettingsViewProps {
activeSettingsTab: SettingsTab;
setActiveSettingsTab: (tab: SettingsTab) => void;
}
export const SettingsView: React.FC<SettingsViewProps> = ({
activeSettingsTab,
setActiveSettingsTab
}) => {
return (
<div className="h-full flex flex-col bg-paper dark:bg-dark-paper overflow-y-auto custom-scrollbar relative font-sans">
<div className="absolute inset-0 opacity-[0.04] pointer-events-none grainy-bg mix-blend-multiply dark:mix-blend-overlay" />
<div className="relative z-10 flex flex-col min-h-full">
<SettingsHeader
activeTab={activeSettingsTab}
setActiveTab={setActiveSettingsTab}
/>
<div className="flex-1 px-12 pb-24 h-full">
<div className="max-w-6xl mx-auto">
<AnimatePresence mode="wait">
{activeSettingsTab === 'general' && (
<GeneralTab key="general" />
)}
{activeSettingsTab === 'ai' && (
<AITab key="ai" />
)}
{activeSettingsTab === 'appearance' && (
<AppearanceTab key="appearance" />
)}
{['profile', 'data', 'mcp', 'about'].includes(activeSettingsTab) && (
<motion.div
key="placeholder"
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
className="h-[50vh] flex flex-col items-center justify-center border border-dashed border-border rounded-[32px] space-y-6 bg-white/20 dark:bg-white/5"
>
<div className="w-16 h-16 rounded-3xl border border-dashed border-concrete/20 flex items-center justify-center text-concrete/40 bg-paper/50">
<span className="text-2xl font-serif italic text-concrete">?</span>
</div>
<div className="text-center space-y-1">
<p className="text-ink font-bold text-sm tracking-tight">Section en développement</p>
<p className="text-concrete italic text-[11px] font-light">Le module {activeSettingsTab} sera disponible prochainement.</p>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,569 @@
import React from 'react';
import {
Plus,
Archive,
Settings,
ChevronRight,
BookOpen,
Bot,
Microscope,
Activity,
Pin,
Moon,
Sun,
Bell,
Lock,
Edit3,
Trash2,
Users,
Clock,
GripVertical,
Wind,
Network
} from 'lucide-react';
import { motion, AnimatePresence } from 'motion/react';
import { NavigationView, Carnet, Note } from '../types';
interface NoteLinkProps {
note: Note;
isActive: boolean;
onClick: () => void;
}
const NoteLink: React.FC<NoteLinkProps> = ({ note, isActive, onClick }) => (
<motion.button
initial={{ opacity: 0, x: -10 }}
animate={{ opacity: 1, x: 0 }}
onClick={onClick}
className={`w-full flex items-center gap-2 pl-12 pr-4 py-2 text-[12px] transition-colors rounded-lg
${isActive ? 'bg-white/50 dark:bg-white/10 text-ink font-medium' : 'text-muted-ink hover:text-ink hover:bg-white/30'}`}
>
<div className="flex items-center gap-2 flex-1 truncate">
<div className={`w-1.5 h-1.5 rounded-full shrink-0 ${isActive ? 'bg-ink' : 'bg-transparent border border-muted-ink/30'}`} />
<span className="truncate">{note.title}</span>
</div>
{note.isPinned && <Pin size={10} className="text-amber-500 shrink-0" />}
</motion.button>
);
interface SidebarItemProps {
carnet: Carnet;
isActive: boolean;
notes: Note[];
activeNoteId: string | null;
onCarnetClick: () => void;
onNoteClick: (noteId: string) => void;
onAddSubCarnet: () => void;
onRename: () => void;
onDelete: () => void;
children?: React.ReactNode;
level: number;
isExpanded: boolean;
toggleExpand: () => void;
onMove?: (draggedId: string, targetId?: string) => void;
}
const SidebarItem: React.FC<SidebarItemProps> = ({
carnet,
isActive,
notes,
activeNoteId,
onCarnetClick,
onNoteClick,
onAddSubCarnet,
onRename,
onDelete,
children,
level,
isExpanded,
toggleExpand,
onMove
}) => {
const hasChildren = React.Children.count(children) > 0;
return (
<div className="space-y-0.5">
<div
className="flex items-center group relative h-10"
style={{ paddingLeft: `${level * 12}px` }}
>
{/* Subtle Drag Handle */}
<div className="absolute left-[-2px] opacity-0 group-hover:opacity-40 cursor-grab active:cursor-grabbing text-concrete transition-opacity z-10">
<GripVertical size={10} />
</div>
{/* Hierarchy Guide Line */}
{level > 0 && (
<div className="absolute left-[8px] top-[-10px] bottom-1/2 w-px bg-border/40" />
)}
{level > 0 && (
<div className="absolute left-[8px] top-1/2 w-[8px] h-px bg-border/40" />
)}
<div className="flex-1 flex items-center gap-1">
{hasChildren ? (
<button
onClick={(e) => {
e.stopPropagation();
toggleExpand();
}}
className="p-1 hover:bg-ink/5 dark:hover:bg-white/5 rounded-md transition-colors text-muted-ink"
>
<motion.div animate={{ rotate: isExpanded ? 90 : 0 }} transition={{ duration: 0.2 }}>
<ChevronRight size={14} />
</motion.div>
</button>
) : (
<div className="w-6" /> // Spacer for alignment
)}
<motion.div
whileHover={{ x: 2 }}
className={`flex-1 flex items-center gap-2 px-2 py-1.5 rounded-lg transition-all duration-300 group/item cursor-pointer relative
${isActive ? 'bg-white shadow-sm border border-border/40 dark:bg-white/10' : 'hover:bg-white/40 dark:hover:bg-white/5'}`}
onClick={onCarnetClick}
onDragOver={(e) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
e.currentTarget.classList.add('bg-blueprint/5', 'ring-1', 'ring-blueprint/20');
}}
onDragLeave={(e) => {
e.currentTarget.classList.remove('bg-blueprint/5', 'ring-1', 'ring-blueprint/20');
}}
onDrop={(e) => {
e.preventDefault();
e.currentTarget.classList.remove('bg-blueprint/5', 'ring-1', 'ring-blueprint/20');
const draggedId = e.dataTransfer.getData('carnetId');
console.log('Dropped carnet:', draggedId, 'on target:', carnet.id);
if (draggedId && draggedId !== carnet.id) {
onMove?.(draggedId, carnet.id);
}
}}
draggable
onDragStart={(e) => {
console.log('Starting drag for carnet:', carnet.id);
e.dataTransfer.setData('carnetId', carnet.id);
e.dataTransfer.effectAllowed = 'move';
const ghost = e.currentTarget.cloneNode(true) as HTMLElement;
ghost.style.position = 'absolute';
ghost.style.top = '-1000px';
ghost.style.opacity = '0.5';
document.body.appendChild(ghost);
e.dataTransfer.setDragImage(ghost, 0, 0);
setTimeout(() => document.body.removeChild(ghost), 0);
}}
>
{/* active indicator dot */}
{isActive && (
<motion.div
layoutId="active-indicator"
className="absolute -left-1 w-1 h-4 bg-blueprint rounded-full"
/>
)}
<div className={`w-6 h-6 rounded-md flex items-center justify-center text-[10px] font-bold border transition-all
${isActive ? 'bg-blueprint text-white border-blueprint' : 'bg-paper dark:bg-white/10 text-concrete border-border dark:border-white/10'}`}>
{carnet.initial}
</div>
<div className="flex-1 text-left flex items-center gap-2 min-w-0">
<span className={`text-[12px] font-medium transition-colors truncate ${isActive ? 'text-ink' : 'text-muted-ink group-hover:text-ink'}`}>
{carnet.name}
</span>
{carnet.isPrivate && <Lock size={10} className="text-concrete/60 shrink-0" />}
</div>
<div className="flex items-center gap-1 opacity-0 group-hover/item:opacity-100 transition-opacity shrink-0">
<button
onClick={(e) => {
e.stopPropagation();
onAddSubCarnet();
}}
className="p-1 hover:bg-ink/10 dark:hover:bg-white/10 rounded-md transition-all text-concrete hover:text-ink"
title="Add sub-carnet"
>
<Plus size={10} />
</button>
<button
onClick={(e) => {
e.stopPropagation();
onRename();
}}
className="p-1 hover:bg-ink/10 dark:hover:bg-white/10 rounded-md transition-all text-concrete hover:text-ink"
title="Rename"
>
<Edit3 size={10} />
</button>
<button
onClick={(e) => {
e.stopPropagation();
onDelete();
}}
className="p-1 hover:bg-red-50 dark:hover:bg-red-900/20 rounded-md transition-all text-concrete hover:text-red-500"
title="Delete"
>
<Trash2 size={10} />
</button>
{notes.length > 0 && (
<span className="text-[9px] font-bold text-concrete/40 px-1.5 border border-border/40 rounded-full group-hover:text-concrete transition-colors">
{notes.length}
</span>
)}
</div>
</motion.div>
</div>
</div>
<AnimatePresence initial={false}>
{(isExpanded || (isActive && !hasChildren)) && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: 'auto', opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.3, ease: [0.23, 1, 0.32, 1] }}
className="overflow-hidden"
>
<div className="relative" style={{ marginLeft: `${(level + 1) * 12 + 10}px` }}>
{/* Vertical line for nested content */}
<div className="absolute left-[-6px] top-0 bottom-4 w-px bg-border/30" />
<div className="space-y-1 py-1">
{children}
{isActive && !hasChildren && notes.map(note => (
<NoteLink
key={note.id}
note={note}
isActive={activeNoteId === note.id}
onClick={() => onNoteClick(note.id)}
/>
))}
{isActive && !hasChildren && notes.length === 0 && (
<p className="pl-8 py-2 text-[10px] italic text-concrete/40 font-light">
No notes found
</p>
)}
</div>
</div>
</motion.div>
)}
</AnimatePresence>
</div>
);
};
interface SidebarProps {
activeView: NavigationView;
isDarkMode: boolean;
setIsDarkMode: (val: boolean) => void;
setActiveView: (view: NavigationView) => void;
carnets: Carnet[];
notes: Note[];
activeCarnetId: string;
activeNoteId: string | null;
setActiveCarnetId: (id: string) => void;
setActiveNoteId: (id: string | null) => void;
setShowNewCarnetModal: (show: boolean, parentId?: string, isRenaming?: boolean, carnetId?: string) => void;
onDeleteCarnet: (id: string) => void;
onMoveCarnet: (draggedId: string, targetId?: string) => void;
}
export const Sidebar: React.FC<SidebarProps> = ({
activeView,
isDarkMode,
setIsDarkMode,
setActiveView,
carnets,
notes,
activeCarnetId,
activeNoteId,
setActiveCarnetId,
setActiveNoteId,
setShowNewCarnetModal,
onDeleteCarnet,
onMoveCarnet
}) => {
const [expandedIds, setExpandedIds] = React.useState<Set<string>>(new Set(['4'])); // Default expand Research
const toggleExpand = (id: string) => {
const newSet = new Set(expandedIds);
if (newSet.has(id)) newSet.delete(id);
else newSet.add(id);
setExpandedIds(newSet);
};
const renderCarnetTree = (parentId: string | undefined = undefined, level: number = 0) => {
return carnets
.filter(c => c.parentId === parentId && !c.isDeleted)
.map(carnet => (
<SidebarItem
key={carnet.id}
carnet={carnet}
isActive={activeCarnetId === carnet.id}
notes={notes.filter(n => n.carnetId === carnet.id && !n.isDeleted)}
activeNoteId={activeNoteId}
level={level}
isExpanded={expandedIds.has(carnet.id)}
toggleExpand={() => toggleExpand(carnet.id)}
onAddSubCarnet={() => {
if (!expandedIds.has(carnet.id)) toggleExpand(carnet.id);
setShowNewCarnetModal(true, carnet.id);
}}
onRename={() => {
setShowNewCarnetModal(true, undefined, true, carnet.id);
}}
onDelete={() => {
onDeleteCarnet(carnet.id);
}}
onCarnetClick={() => {
setActiveCarnetId(carnet.id);
setActiveNoteId(null);
// Auto expand when clicking
if (!expandedIds.has(carnet.id)) toggleExpand(carnet.id);
}}
onNoteClick={(id) => {
setActiveCarnetId(carnet.id);
setActiveNoteId(id);
}}
onMove={onMoveCarnet}
>
{renderCarnetTree(carnet.id, level + 1)}
</SidebarItem>
));
};
return (
<aside className="w-80 bg-white/30 dark:bg-[#151515] backdrop-blur-md border-r border-border p-6 flex flex-col z-20 shrink-0 transition-colors duration-500">
<div className="mb-10 flex items-center justify-between">
<div className="w-10 h-10 rounded-full bg-slate-200 dark:bg-white/10 border border-border flex items-center justify-center text-ink font-serif text-lg shadow-sm">
A
</div>
<div className="flex items-center gap-2">
<button
onClick={() => setIsDarkMode(!isDarkMode)}
className="p-2 text-muted-ink hover:text-ink transition-all bg-white/50 dark:bg-white/10 rounded-full border border-border dark:border-white/10"
>
{isDarkMode ? <Sun size={14} /> : <Moon size={14} />}
</button>
<button className="p-2 text-muted-ink hover:text-ink transition-all relative group bg-white/50 dark:bg-white/10 rounded-full border border-border dark:border-white/10">
<Bell size={14} />
<span className="absolute -top-1 -right-1 w-4 h-4 bg-rose-500 text-white text-[9px] font-bold flex items-center justify-center rounded-full border border-white shadow-sm">
3
</span>
</button>
<div className="flex bg-white/50 dark:bg-white/10 p-1 rounded-full border border-border dark:border-white/10 transition-all">
<button
onClick={() => setActiveView('notebooks')}
className={`p-1.5 rounded-full transition-all ${activeView === 'notebooks' ? 'bg-ink text-paper shadow-sm' : 'text-muted-ink hover:text-ink'}`}
title="Carnets"
>
<BookOpen size={14} />
</button>
<button
onClick={() => setActiveView('reminders')}
className={`p-1.5 rounded-full transition-all ${activeView === 'reminders' ? 'bg-ink text-paper shadow-sm' : 'text-muted-ink hover:text-ink'}`}
title="Rappels"
>
<Clock size={14} />
</button>
<button
onClick={() => setActiveView('agents')}
className={`p-1.5 rounded-full transition-all ${activeView === 'agents' ? 'bg-ink text-paper shadow-sm' : 'text-muted-ink hover:text-ink'}`}
title="Agents"
>
<Bot size={14} />
</button>
</div>
</div>
</div>
<div className="flex-1 overflow-y-auto space-y-8 -mx-2 px-2 py-4 custom-scrollbar">
{activeView === 'notebooks' ? (
<div className="space-y-6">
<div
className="flex items-center justify-between px-4 py-1 rounded-lg transition-colors group/header"
onDragOver={(e) => {
e.preventDefault();
e.dataTransfer.dropEffect = 'move';
e.currentTarget.classList.add('bg-blueprint/5', 'ring-1', 'ring-blueprint/20');
}}
onDragLeave={(e) => {
e.currentTarget.classList.remove('bg-blueprint/5', 'ring-1', 'ring-blueprint/20');
}}
onDrop={(e) => {
e.preventDefault();
e.currentTarget.classList.remove('bg-blueprint/5', 'ring-1', 'ring-blueprint/20');
const draggedId = e.dataTransfer.getData('carnetId');
console.log('Dropped carnet on root:', draggedId);
if (draggedId) {
onMoveCarnet(draggedId, undefined);
}
}}
>
<p className="text-[10px] font-bold text-concrete tracking-[0.2em] uppercase">
Architecture Grid
</p>
<button
onClick={() => setShowNewCarnetModal(true)}
className="p-1 hover:bg-paper dark:hover:bg-white/5 rounded-md text-concrete hover:text-ink transition-colors"
title="New Carnet"
>
<Plus size={14} />
</button>
</div>
<nav className="space-y-0.5">
{renderCarnetTree()}
</nav>
</div>
) : activeView === 'shared' ? (
<div className="space-y-6">
<p className="text-[10px] font-bold text-concrete tracking-[0.2em] uppercase px-4">
Partagé avec moi
</p>
<div className="px-4 py-8 text-center border border-dashed border-border rounded-2xl bg-paper/30">
<Users size={24} className="mx-auto text-concrete/40 mb-3" />
<p className="text-[11px] text-concrete italic">Aucun document partagé pour le moment.</p>
</div>
</div>
) : activeView === 'reminders' ? (
<div className="space-y-6">
<p className="text-[10px] font-bold text-concrete tracking-[0.2em] uppercase px-4">
Rappels programmés
</p>
<div className="px-4 py-8 text-center border border-dashed border-border rounded-2xl bg-paper/30">
<Clock size={24} className="mx-auto text-concrete/40 mb-3" />
<p className="text-[11px] text-concrete italic">Aucun rappel actif.</p>
</div>
</div>
) : activeView === 'agents' ? (
<div>
<p className="text-[10px] font-bold text-muted-ink tracking-widest uppercase mb-4 px-4">
Intelligence OS
</p>
<div className="space-y-1">
{[
{ id: 'a1', name: 'Mes Agents', icon: <Bot size={16} /> },
{ id: 'a2', name: 'Le Lab AI', icon: <Microscope size={16} /> },
{ id: 'a3', name: 'Activités', icon: <Activity size={16} /> },
].map(item => (
<button
key={item.id}
className={`w-full flex items-center gap-3 px-4 py-3 rounded-xl transition-all duration-300 group
${item.id === 'a1' ? 'active-nav-item' : 'text-muted-ink hover:bg-white/40 dark:hover:bg-white/5 hover:text-ink'}`}
>
<div className={`w-8 h-8 rounded-full flex items-center justify-center border transition-colors
${item.id === 'a1' ? 'bg-ink text-paper border-ink' : 'bg-white/60 dark:bg-white/10 border-border dark:border-white/10 group-hover:border-ink/20'}`}>
{item.icon}
</div>
<span className="text-[13px] font-medium">{item.name}</span>
</button>
))}
</div>
<div className="mt-8 space-y-1">
<p className="text-[10px] font-bold text-concrete tracking-[0.2em] uppercase px-4 mb-2">
Capabilities
</p>
<button
onClick={() => setActiveView('brainstorm')}
className="w-full flex items-center gap-3 px-4 py-3 text-[13px] transition-all font-medium group rounded-xl text-muted-ink hover:text-ochre hover:bg-ochre/5"
>
<div className="w-8 h-8 rounded-full flex items-center justify-center border bg-white/60 dark:bg-white/10 border-border dark:border-white/10 group-hover:border-ochre/20">
<Wind size={16} />
</div>
<span className="flex-1 text-left">Brainstorm Wave</span>
</button>
<button
onClick={() => setActiveView('insights')}
className="w-full flex items-center gap-3 px-4 py-3 text-[13px] transition-all font-medium group rounded-xl text-muted-ink hover:text-indigo-500 hover:bg-indigo-500/5"
>
<div className="w-8 h-8 rounded-full flex items-center justify-center border bg-white/60 dark:bg-white/10 border-border dark:border-white/10 group-hover:border-indigo-500/20">
<Network size={16} />
</div>
<span className="flex-1 text-left">Semantic Network</span>
</button>
<button
onClick={() => setActiveView('temporal')}
className="w-full flex items-center gap-3 px-4 py-3 text-[13px] transition-all font-medium group rounded-xl text-muted-ink hover:text-rose-500 hover:bg-rose-500/5"
>
<div className="w-8 h-8 rounded-full flex items-center justify-center border bg-white/60 dark:bg-white/10 border-border dark:border-white/10 group-hover:border-rose-500/20">
<Clock size={16} />
</div>
<span className="flex-1 text-left">Temporal Forecast</span>
</button>
</div>
</div>
) : null}
</div>
<div className="pt-4 border-t border-border/40 mt-auto pb-4">
<div className="px-2 space-y-0.5">
<button
onClick={() => setActiveView('shared')}
className={`w-full flex items-center gap-3 px-3 py-2 text-[12px] transition-all font-medium group rounded-xl ${activeView === 'shared' ? 'bg-blueprint/5 text-blueprint' : 'text-muted-ink hover:text-ink hover:bg-black/5'}`}
>
<Users size={14} className={activeView === 'shared' ? 'text-blueprint' : 'text-muted-ink group-hover:text-ink'} />
<span className="flex-1 text-left">Partagé</span>
</button>
<button className="w-full flex items-center gap-3 px-3 py-2 text-[12px] text-muted-ink hover:text-ink hover:bg-black/5 transition-all font-medium group rounded-xl">
<Archive size={14} className="text-muted-ink group-hover:text-ink" />
<span className="flex-1 text-left">Archives</span>
</button>
<button
onClick={() => setActiveView('trash')}
className={`w-full flex items-center gap-3 px-3 py-2 text-[12px] transition-all font-medium group rounded-xl ${activeView === 'trash' ? 'bg-rose-50 text-rose-500' : 'text-muted-ink hover:text-rose-500 hover:bg-rose-50/50'}`}
>
<Trash2 size={14} className={activeView === 'trash' ? 'text-rose-500' : 'text-muted-ink group-hover:text-rose-500'} />
<span className="flex-1 text-left">Corbeille</span>
{notes.some(n => n.isDeleted) && (
<div className="w-1.5 h-1.5 rounded-full bg-rose-400" />
)}
</button>
<div className="my-4 pt-4 border-t border-border/20">
<p className="text-[10px] font-bold text-muted-ink tracking-[0.2em] uppercase px-3 mb-2 opacity-60">Intelligence</p>
<button
onClick={() => setActiveView('brainstorm')}
className={`w-full flex items-center gap-3 px-3 py-2 text-[12px] transition-all font-medium group rounded-xl ${activeView === 'brainstorm' ? 'bg-ochre/10 text-ochre' : 'text-muted-ink hover:text-ochre hover:bg-ochre/5'}`}
>
<Wind size={14} className={activeView === 'brainstorm' ? 'text-ochre' : 'text-muted-ink group-hover:text-ochre'} />
<span className="flex-1 text-left">Brainstorm Wave</span>
</button>
<button
onClick={() => setActiveView('insights')}
className={`w-full flex items-center gap-3 px-3 py-2 text-[12px] transition-all font-medium group rounded-xl ${activeView === 'insights' ? 'bg-indigo-500/10 text-indigo-500' : 'text-muted-ink hover:text-indigo-500 hover:bg-indigo-500/5'}`}
>
<Network size={14} className={activeView === 'insights' ? 'text-indigo-500' : 'text-muted-ink group-hover:text-indigo-500'} />
<span className="flex-1 text-left">Semantic Network</span>
</button>
<button
onClick={() => setActiveView('temporal')}
className={`w-full flex items-center gap-3 px-3 py-2 text-[12px] transition-all font-medium group rounded-xl ${activeView === 'temporal' ? 'bg-rose-500/10 text-rose-500' : 'text-muted-ink hover:text-rose-500 hover:bg-rose-500/5'}`}
>
<Clock size={14} className={activeView === 'temporal' ? 'text-rose-500' : 'text-muted-ink group-hover:text-rose-500'} />
<span className="flex-1 text-left">Temporal Forecast</span>
</button>
</div>
<div className="my-2 h-px bg-border/20 mx-2" />
<button
onClick={() => setActiveView('settings')}
className={`w-full flex items-center gap-3 px-3 py-2 text-[12px] transition-all font-medium group rounded-xl ${activeView === 'settings' ? 'bg-ink text-paper shadow-sm' : 'text-muted-ink hover:text-ink hover:bg-black/5'}`}
>
<Settings size={14} className={activeView === 'settings' ? 'text-paper' : 'text-muted-ink group-hover:text-ink'} />
<span className="flex-1 text-left">Paramètres</span>
</button>
</div>
</div>
</aside>
);
};

View File

@@ -0,0 +1,65 @@
import React from 'react';
import {
Heading1,
Heading2,
List,
Quote,
Code,
Image as ImageIcon,
Type,
Sparkles
} from 'lucide-react';
import { motion, AnimatePresence } from 'motion/react';
interface SlashMenuProps {
position: { top: number; left: number };
onSelect: (type: string) => void;
onClose: () => void;
}
export const SlashMenu: React.FC<SlashMenuProps> = ({ position, onSelect, onClose }) => {
const commands = [
{ id: 'h1', label: 'Titre Principal', icon: <Heading1 size={14} />, desc: 'Grand titre de section' },
{ id: 'h2', label: 'Sous-titre', icon: <Heading2 size={14} />, desc: 'Titre de niveau 2' },
{ id: 'bullet', label: 'Liste à puces', icon: <List size={14} />, desc: 'Liste simple' },
{ id: 'quote', label: 'Citation', icon: <Quote size={14} />, desc: 'Bloc de texte mis en avant' },
{ id: 'code', label: 'Bloc de Code', icon: <Code size={14} />, desc: 'Code ou texte technique' },
{ id: 'image', label: 'Image', icon: <ImageIcon size={14} />, desc: 'Insérer un visuel' },
{ id: 'ai-summary', label: 'Résumé IA', icon: <Sparkles size={14} />, desc: 'Générer un résumé court', special: true },
];
return (
<>
<div className="fixed inset-0 z-[60]" onClick={onClose} />
<motion.div
initial={{ opacity: 0, scale: 0.95, y: 10 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.95, y: 10 }}
className="fixed z-[70] w-64 bg-white dark:bg-[#1A1A1A] border border-border shadow-2xl rounded-xl overflow-hidden py-2"
style={{ top: position.top, left: position.left }}
>
<div className="px-3 py-2 text-[10px] font-bold text-concrete uppercase tracking-widest border-b border-border/40 mb-1">
Commandes rapides
</div>
<div className="max-h-80 overflow-y-auto custom-scrollbar">
{commands.map((cmd) => (
<button
key={cmd.id}
onClick={() => onSelect(cmd.id)}
className="w-full flex items-start gap-3 px-3 py-2 hover:bg-paper dark:hover:bg-white/5 transition-colors group text-left"
>
<div className={`p-2 rounded-lg border border-border transition-colors group-hover:border-ink/20
${cmd.special ? 'bg-blueprint/10 text-blueprint border-blueprint/20' : 'bg-white/50 dark:bg-white/5 text-ink'}`}>
{cmd.icon}
</div>
<div className="space-y-0.5">
<p className="text-xs font-bold text-ink">{cmd.label}</p>
<p className="text-[10px] text-muted-ink leading-tight">{cmd.desc}</p>
</div>
</button>
))}
</div>
</motion.div>
</>
);
};

View File

@@ -0,0 +1,169 @@
import React, { useMemo } from 'react';
import { motion } from 'motion/react';
import {
Clock,
Calendar,
TrendingUp,
Zap,
History,
ArrowRight,
Sparkles,
PieChart
} from 'lucide-react';
import { Note, NoteAccessLog, NotePrediction } from '../types';
import { predictNextAccess, detectAccessCycle } from '../services/temporalService';
interface TemporalViewProps {
notes: Note[];
accessLogs: NoteAccessLog[];
onNoteSelect: (id: string) => void;
}
export const TemporalView: React.FC<TemporalViewProps> = ({ notes, accessLogs, onNoteSelect }) => {
const predictions = useMemo(() => {
return notes
.map(note => {
const noteLogs = accessLogs.filter(l => l.noteId === note.id);
const prediction = predictNextAccess(note, noteLogs);
return { note, prediction };
})
.filter(p => p.prediction !== null) as { note: Note; prediction: NotePrediction }[];
}, [notes, accessLogs]);
const cyclicalNotes = useMemo(() => {
return notes
.map(note => {
const noteLogs = accessLogs.filter(l => l.noteId === note.id);
const cycle = detectAccessCycle(noteLogs);
return { note, cycle };
})
.filter(p => p.cycle !== null) as { note: Note; cycle: number }[];
}, [notes, accessLogs]);
return (
<div className="h-full flex flex-col bg-paper dark:bg-[#0A0A0A] overflow-hidden">
<div className="p-8 border-b border-border/40 backdrop-blur-xl bg-white/40 dark:bg-black/20 z-10">
<div className="flex items-center gap-3 mb-1">
<div className="w-8 h-8 rounded-lg bg-rose-500/10 flex items-center justify-center text-rose-500">
<Clock size={18} />
</div>
<h1 className="text-2xl font-serif font-medium text-ink dark:text-dark-ink">Temporal Forecast</h1>
</div>
<p className="text-[11px] text-concrete tracking-[0.2em] uppercase font-bold">Predicting the recurrence of insight</p>
</div>
<div className="flex-1 overflow-y-auto custom-scrollbar p-8">
<div className="max-w-5xl mx-auto space-y-12">
{/* Daily Briefing / Predictions */}
<section>
<div className="flex items-center gap-2 mb-6">
<Sparkles size={16} className="text-rose-400" />
<h3 className="text-sm font-bold uppercase tracking-widest text-ink dark:text-dark-ink">Intelligence Briefing</h3>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{predictions.map(({ note, prediction }) => (
<motion.div
key={note.id}
whileHover={{ y: -4 }}
onClick={() => onNoteSelect(note.id)}
className="p-6 rounded-2xl bg-white dark:bg-white/5 border border-border/60 hover:border-rose-400/40 transition-all cursor-pointer shadow-sm relative overflow-hidden group"
>
<div className="absolute top-0 right-0 p-3">
<TrendingUp size={14} className="text-rose-400 opacity-40 group-hover:opacity-100 transition-opacity" />
</div>
<div className="flex items-center gap-2 mb-4">
<span className="px-2 py-1 bg-rose-500/10 text-rose-500 text-[10px] font-bold rounded-full uppercase tracking-widest">
Coming Up
</span>
<span className="text-[10px] text-concrete font-medium">
{new Date(prediction.predictedRelevanceDate).toLocaleDateString(undefined, { month: 'short', day: 'numeric' })}
</span>
</div>
<h4 className="text-lg font-serif font-medium text-ink dark:text-dark-ink mb-2 group-hover:text-rose-500 transition-colors uppercase tracking-tight">{note.title}</h4>
<p className="text-xs text-muted-ink leading-relaxed mb-6 italic">
"{prediction.reason}"
</p>
<div className="pt-4 border-t border-border/40 flex items-center justify-between">
<span className="text-[10px] font-bold text-concrete uppercase tracking-widest">Confidence {Math.round(prediction.confidence * 100)}%</span>
<ArrowRight size={14} className="text-concrete group-hover:text-ink" />
</div>
</motion.div>
))}
{predictions.length === 0 && (
<div className="col-span-full p-12 text-center bg-paper dark:bg-white/5 border-2 border-dashed border-border rounded-3xl">
<Calendar size={40} className="mx-auto text-concrete/40 mb-4" />
<h5 className="text-ink dark:text-dark-ink font-medium mb-1">No upcoming predictions</h5>
<p className="text-xs text-concrete">The system needs more usage data to find cyclical patterns in your research.</p>
</div>
)}
</div>
</section>
{/* Cyclical Patterns */}
<section>
<div className="flex items-center gap-2 mb-6">
<History size={16} className="text-indigo-400" />
<h3 className="text-sm font-bold uppercase tracking-widest text-ink dark:text-dark-ink">Detected Cycles</h3>
</div>
<div className="space-y-3">
{cyclicalNotes.map(({ note, cycle }) => (
<div key={note.id} className="flex items-center gap-4 p-4 rounded-xl bg-slate-50 dark:bg-white/5 border border-border/40">
<div className="w-12 h-12 rounded-xl bg-white dark:bg-black/20 border border-border flex items-center justify-center flex-col shadow-sm">
<span className="text-xs font-bold text-ink dark:text-dark-ink">{Math.round(cycle)}</span>
<span className="text-[8px] font-bold text-concrete uppercase">days</span>
</div>
<div className="flex-1">
<h5 className="text-sm font-medium text-ink dark:text-dark-ink">{note.title}</h5>
<div className="flex items-center gap-3">
<span className="text-[10px] text-concrete">Recurring theme in your architectural process</span>
<div className="h-1 w-24 bg-border/40 rounded-full overflow-hidden">
<div className="h-full bg-rose-400" style={{ width: '65%' }}></div>
</div>
</div>
</div>
<button onClick={() => onNoteSelect(note.id)} className="p-2 hover:bg-black/5 rounded-full">
<ArrowRight size={16} className="text-concrete" />
</button>
</div>
))}
</div>
</section>
{/* Productivity Stats */}
<section className="bg-ink dark:bg-white/5 rounded-3xl p-8 text-paper relative overflow-hidden">
<div className="relative z-10 grid grid-cols-1 md:grid-cols-3 gap-8">
<div>
<h6 className="text-[10px] font-bold uppercase tracking-[0.2em] opacity-60 mb-2">Memory Strength</h6>
<div className="text-4xl font-serif">84%</div>
<p className="text-[10px] opacity-60 mt-2">Active connections in your semantic network</p>
</div>
<div>
<h6 className="text-[10px] font-bold uppercase tracking-[0.2em] opacity-60 mb-2">Peak Cycle</h6>
<div className="text-4xl font-serif">28 Days</div>
<p className="text-[10px] opacity-60 mt-2">The rhythm of your creative output</p>
</div>
<div className="flex flex-col justify-center">
<div className="flex items-center gap-2 mb-4">
<div className="w-2 h-2 rounded-full bg-rose-400"></div>
<span className="text-xs font-medium">4 Notes resurfacing this week</span>
</div>
<button className="w-full py-2 bg-paper text-ink rounded-xl text-[10px] font-bold uppercase tracking-widest hover:scale-105 transition-transform">
Build Morning Briefing
</button>
</div>
</div>
<div className="absolute top-0 right-0 w-64 h-64 bg-rose-500/10 blur-[80px] rounded-full -mr-20 -mt-20"></div>
</section>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,218 @@
import React from 'react';
import {
Trash2,
RotateCcw,
X,
FileText,
Folder,
Search,
ChevronRight,
Clock,
AlertCircle
} from 'lucide-react';
import { motion, AnimatePresence } from 'motion/react';
import { Note, Carnet } from '../types';
interface TrashViewProps {
deletedNotes: Note[];
deletedCarnets: Carnet[];
onRestoreNote: (id: string) => void;
onRestoreCarnet: (id: string) => void;
onPermanentDeleteNote: (id: string) => void;
onPermanentDeleteCarnet: (id: string) => void;
onEmptyTrash: () => void;
}
export const TrashView: React.FC<TrashViewProps> = ({
deletedNotes,
deletedCarnets,
onRestoreNote,
onRestoreCarnet,
onPermanentDeleteNote,
onPermanentDeleteCarnet,
onEmptyTrash
}) => {
const [searchQuery, setSearchQuery] = React.useState('');
const [filterType, setFilterType] = React.useState<'all' | 'notes' | 'carnets'>('all');
const getDaysRemaining = (dateString?: string) => {
if (!dateString) return 30;
const deletedDate = new Date(dateString);
const now = new Date();
const diffTime = now.getTime() - deletedDate.getTime();
const diffDays = Math.floor(diffTime / (1000 * 60 * 60 * 24));
return Math.max(0, 30 - diffDays);
};
const filteredItems = React.useMemo(() => {
const items = [
...deletedNotes.map(n => ({ ...n, itemType: 'note' as const })),
...deletedCarnets.map(c => ({ ...c, itemType: 'carnet' as const }))
];
return items
.filter(item => {
const matchesSearch = ('title' in item ? item.title : item.name).toLowerCase().includes(searchQuery.toLowerCase());
const matchesType = filterType === 'all' || (filterType === 'notes' && item.itemType === 'note') || (filterType === 'carnets' && item.itemType === 'carnet');
return matchesSearch && matchesType;
})
.sort((a, b) => {
const dateA = a.deletedAt ? new Date(a.deletedAt).getTime() : 0;
const dateB = b.deletedAt ? new Date(b.deletedAt).getTime() : 0;
return dateB - dateA;
});
}, [deletedNotes, deletedCarnets, searchQuery, filterType]);
return (
<div className="h-full flex flex-col bg-[#F9F8F6] dark:bg-dark-paper">
<header className="px-12 pt-12 pb-8 flex flex-col gap-6 sticky top-0 bg-[#F9F8F6]/80 backdrop-blur-md z-30 border-b border-border/20">
<div className="flex items-center justify-between">
<div className="space-y-1">
<h1 className="text-4xl font-serif font-medium text-ink flex items-center gap-4">
Corbeille <Trash2 size={28} className="text-rose-400 opacity-40" />
</h1>
<p className="text-[10px] text-concrete font-bold uppercase tracking-[0.3em] opacity-60">
Auto-suppression après 30 jours
</p>
</div>
{filteredItems.length > 0 && (
<button
onClick={() => {
if (window.confirm('Vider la corbeille ? Cette action est irréversible.')) {
onEmptyTrash();
}
}}
className="px-6 py-3 bg-paper border border-border text-rose-500 rounded-2xl text-[10px] font-bold uppercase tracking-widest hover:bg-rose-50 hover:border-rose-100 transition-all shadow-sm"
>
Vider tout
</button>
)}
</div>
<div className="flex items-center gap-6">
<div className="group relative flex-1 max-w-xl">
<Search className="absolute left-4 top-1/2 -translate-y-1/2 text-concrete group-focus-within:text-ink transition-colors" size={16} />
<input
type="text"
placeholder="Rechercher..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full bg-white dark:bg-white/5 border border-border/40 rounded-2xl pl-12 pr-6 py-4 text-sm outline-none focus:ring-4 ring-ink/5 transition-all shadow-sm"
/>
</div>
<div className="flex bg-white dark:bg-white/5 p-1 rounded-2xl border border-border shadow-sm">
{(['all', 'notes', 'carnets'] as const).map((type) => (
<button
key={type}
onClick={() => setFilterType(type)}
className={`px-6 py-3 rounded-xl text-[10px] font-bold uppercase tracking-widest transition-all
${filterType === type ? 'bg-ink text-paper shadow-lg' : 'text-concrete hover:text-ink'}`}
>
{type === 'all' ? 'Tous' : type === 'notes' ? 'Notes' : 'Carnets'}
</button>
))}
</div>
</div>
</header>
<main className="flex-1 px-12 py-12 overflow-y-auto custom-scrollbar">
{filteredItems.length > 0 ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
<AnimatePresence mode="popLayout">
{filteredItems.map((item) => {
const daysLeft = getDaysRemaining(item.deletedAt);
return (
<motion.div
key={item.id}
layout
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.9 }}
className="bg-white dark:bg-white/5 border border-border/60 rounded-[32px] p-8 group hover:shadow-2xl hover:border-blueprint/20 transition-all relative overflow-hidden flex flex-col"
>
{/* Countdown Progress Bar */}
<div className="absolute top-0 left-0 w-full h-1 bg-slate-100 overflow-hidden">
<motion.div
initial={{ width: 0 }}
animate={{ width: `${(daysLeft / 30) * 100}%` }}
className={`h-full ${daysLeft < 5 ? 'bg-rose-500' : 'bg-blueprint'}`}
/>
</div>
<div className="flex justify-between items-start mb-6">
<div className={`p-3 rounded-2xl ${item.itemType === 'note' ? 'bg-blueprint/10 text-blueprint' : 'bg-concrete/10 text-concrete'}`}>
{item.itemType === 'note' ? <FileText size={20} /> : <Folder size={20} />}
</div>
<div className="flex items-center gap-2">
<button
onClick={() => item.itemType === 'note' ? onRestoreNote(item.id) : onRestoreCarnet(item.id)}
className="flex items-center gap-2 px-4 py-2 bg-emerald-50 text-emerald-600 rounded-xl text-[10px] font-bold uppercase tracking-widest hover:bg-emerald-100 transition-colors"
>
<RotateCcw size={12} /> Restaurer
</button>
<button
onClick={() => item.itemType === 'note' ? onPermanentDeleteNote(item.id) : onPermanentDeleteCarnet(item.id)}
className="p-2 hover:bg-rose-50 text-rose-500 rounded-xl transition-colors"
title="Supprimer définitivement"
>
<X size={16} />
</button>
</div>
</div>
<div className="space-y-2 mb-8 flex-1">
<h3 className="text-base font-serif font-medium text-ink leading-tight">
{'title' in item ? item.title : item.name}
</h3>
<div className="flex items-center gap-3">
<div className={`text-[9px] font-bold uppercase tracking-widest px-2 py-0.5 rounded border ${daysLeft < 5 ? 'border-rose-200 text-rose-500 bg-rose-50' : 'border-blueprint/20 text-blueprint bg-blueprint/5'}`}>
{daysLeft} JOURS RESTANTS
</div>
<span className="text-[10px] text-concrete font-medium uppercase tracking-tight flex items-center gap-1">
<Clock size={10} /> {('deletedAt' in item && item.deletedAt) ? new Date(item.deletedAt).toLocaleDateString() : ''}
</span>
</div>
</div>
{item.itemType === 'note' && 'content' in item ? (
<div className="text-[12px] text-concrete line-clamp-3 leading-relaxed opacity-60 font-light border-t border-border/40 pt-4">
{item.content.replace(/[#*`]/g, '')}
</div>
) : (
<div className="border-t border-border/40 pt-4">
<div className="text-[9px] font-bold text-concrete/40 uppercase tracking-widest">
Contenu du dossier préservé
</div>
</div>
)}
</motion.div>
);
})}
</AnimatePresence>
</div>
) : (
<div className="h-full flex flex-col items-center justify-center text-center space-y-6 opacity-40">
<div className="p-8 rounded-full bg-slate-100 border-2 border-dashed border-border flex items-center justify-center">
<Trash2 size={64} className="text-concrete" />
</div>
<div className="space-y-2">
<h2 className="text-2xl font-serif text-ink italic">Corbeille vide</h2>
<p className="text-sm text-concrete max-w-xs">
Les éléments que vous supprimez apparaîtront ici. Ils seront conservés pendant 30 jours avant suppression définitive.
</p>
</div>
</div>
)}
</main>
<footer className="px-12 py-6 bg-white/50 border-t border-border flex items-center gap-4">
<AlertCircle size={14} className="text-concrete" />
<p className="text-[10px] text-concrete font-medium uppercase tracking-widest">
Conseil : La restauration d'un carnet restaurera également toutes les notes à l'intérieur.
</p>
</footer>
</div>
);
};

View File

@@ -0,0 +1,152 @@
import React from 'react';
import { Sparkles, Edit3, MessageCircle, Languages, Tag, History, FlaskConical } from 'lucide-react';
import { motion } from 'motion/react';
const AISettingCard = ({ icon, title, description, defaultChecked = false }: any) => (
<div className="bg-white/40 dark:bg-white/5 border border-border rounded-2xl p-6 flex items-center justify-between group hover:shadow-xl hover:shadow-blueprint/5 transition-all duration-300">
<div className="flex items-center gap-5">
<div className="p-3 bg-paper dark:bg-blueprint/10 rounded-2xl text-blueprint group-hover:bg-blueprint group-hover:text-white group-hover:scale-110 transition-all duration-300 border border-blueprint/20">
{icon}
</div>
<div className="space-y-1">
<h4 className="text-[13px] font-bold text-ink">{title}</h4>
<p className="text-[10px] text-muted-ink leading-relaxed pr-4 line-clamp-2">{description}</p>
</div>
</div>
<label className="relative inline-flex items-center cursor-pointer shrink-0 ml-4">
<input type="checkbox" className="sr-only peer" defaultChecked={defaultChecked} />
<div className="w-11 h-6 bg-gray-200 dark:bg-white/10 rounded-full peer peer-checked:after:translate-x-[20px] peer-checked:after:border-white after:content-[''] after:absolute after:top-[4px] after:left-[4px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all duration-300 ease-in-out peer-checked:bg-blueprint"></div>
</label>
</div>
);
export const AITab: React.FC = () => {
return (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className="space-y-16 pb-20"
>
<div className="space-y-10">
<h3 className="text-[10px] font-bold uppercase tracking-[0.4em] text-muted-ink opacity-60">Configurez vos fonctionnalités IA et préférences</h3>
<div className="space-y-6">
<h4 className="text-sm font-bold text-ink border-b border-border/40 pb-4">Fonctionnalités IA</h4>
<div className="grid grid-cols-1 md:grid-cols-2 gap-5">
<AISettingCard
icon={<Edit3 size={18} />}
title="Suggestions de titre"
description="Suggérer des titres pour les notes sans titre après 50+ mots"
defaultChecked
/>
<AISettingCard
icon={<Sparkles size={18} />}
title="IA Note"
description="Active le bouton de chat IA et les outils d'amélioration du texte"
defaultChecked
/>
<AISettingCard
icon={<MessageCircle size={18} />}
title="💡 J'ai remarqué quelque chose..."
description="Aperçu quotidien de vos notes"
defaultChecked
/>
<AISettingCard
icon={<Languages size={18} />}
title="Détection de langue"
description="Détecte automatiquement la langue de vos notes"
defaultChecked
/>
<AISettingCard
icon={<Tag size={18} />}
title="Suggestion des labels"
description="Suggère et applique des étiquettes automatiquement à vos notes"
defaultChecked
/>
<AISettingCard
icon={<History size={18} />}
title="Historique des notes"
description="Active les snapshots de versions et la restauration depuis History"
defaultChecked
/>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-8 pt-6">
{/* Fréquence */}
<div className="bg-white/40 dark:bg-white/5 border border-border rounded-2xl p-8 space-y-10">
<div className="space-y-1.5 text-left text-blueprint">
<h4 className="text-sm font-bold">Fréquence</h4>
<p className="text-[10px] opacity-60 uppercase tracking-wider font-semibold">Fréquence d'analyse des connexions</p>
</div>
<div className="space-y-6">
<label className="flex items-center gap-4 cursor-pointer group">
<input type="radio" name="freq" className="sr-only peer" defaultChecked />
<div className="w-5 h-5 rounded-full border-2 border-border peer-checked:border-blueprint flex items-center justify-center p-0.5 transition-all">
<div className="w-full h-full rounded-full bg-transparent peer-checked:bg-blueprint" />
</div>
<span className="text-sm font-medium text-ink group-hover:opacity-70 transition-opacity">Quotidienne</span>
</label>
<label className="flex items-center gap-4 cursor-pointer group">
<input type="radio" name="freq" className="sr-only peer" />
<div className="w-5 h-5 rounded-full border-2 border-border peer-checked:border-blueprint flex items-center justify-center p-0.5 transition-all">
<div className="w-full h-full rounded-full bg-transparent peer-checked:bg-blueprint" />
</div>
<span className="text-sm font-medium text-ink group-hover:opacity-70 transition-opacity">Hebdomadaire</span>
</label>
</div>
</div>
{/* Mode d'historique */}
<div className="bg-white/40 dark:bg-white/5 border border-border rounded-2xl p-8 space-y-10">
<div className="space-y-1.5 text-left text-blueprint">
<h4 className="text-sm font-bold">Mode d'historique</h4>
<p className="text-[10px] opacity-60 uppercase tracking-wider font-semibold">Gestion des snapshots</p>
</div>
<div className="space-y-6">
<label className="flex items-center gap-4 cursor-pointer group">
<input type="radio" name="hist" className="sr-only peer" defaultChecked />
<div className="w-5 h-5 rounded-full border-2 border-border peer-checked:border-blueprint flex items-center justify-center p-0.5 transition-all">
<div className="w-full h-full rounded-full bg-transparent peer-checked:bg-blueprint" />
</div>
<div className="space-y-0.5">
<p className="text-sm font-bold text-ink">Manuel (bouton commit)</p>
<p className="text-[10px] text-muted-ink">Créer des snapshots manuellement</p>
</div>
</label>
<label className="flex items-center gap-4 cursor-pointer group">
<input type="radio" name="hist" className="sr-only peer" />
<div className="w-5 h-5 rounded-full border-2 border-border peer-checked:border-blueprint flex items-center justify-center p-0.5 transition-all">
<div className="w-full h-full rounded-full bg-transparent peer-checked:bg-blueprint" />
</div>
<div className="space-y-0.5">
<p className="text-sm font-bold text-ink">Automatique (intelligent)</p>
<p className="text-[10px] text-muted-ink">Snapshots automatiques avec détection</p>
</div>
</label>
</div>
</div>
</div>
{/* Mode Démo */}
<div className="bg-ochre/5 dark:bg-ochre/10 border border-ochre/20 rounded-2xl p-8 flex items-center justify-between group transition-all duration-300 hover:bg-ochre/10">
<div className="flex items-center gap-6">
<div className="p-3 bg-paper dark:bg-ochre/20 rounded-2xl text-ochre border border-ochre/30">
<FlaskConical size={20} />
</div>
<div className="space-y-1.5 text-left">
<h4 className="text-sm font-bold text-ink flex items-center gap-3">
🧪 Mode Démo
</h4>
<p className="text-[11px] text-muted-ink leading-relaxed font-medium">Accélère Memory Echo pour les tests. Les connexions apparaissent instantanément.</p>
</div>
</div>
<label className="relative inline-flex items-center cursor-pointer shrink-0 ml-4">
<input type="checkbox" className="sr-only peer" />
<div className="w-11 h-6 bg-gray-200 dark:bg-white/10 rounded-full peer peer-checked:after:translate-x-[20px] peer-checked:after:border-white after:content-[''] after:absolute after:top-[4px] after:left-[4px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all duration-300 ease-in-out peer-checked:bg-ochre"></div>
</label>
</div>
</div>
</motion.div>
);
};

View File

@@ -0,0 +1,85 @@
import React from 'react';
import { Palette, Type, LayoutGrid, Maximize } from 'lucide-react';
import { motion } from 'motion/react';
const AppearanceSelect = ({ icon, title, description, options, defaultValue }: any) => (
<div className="bg-white/40 dark:bg-white/5 border border-border rounded-2xl p-8 space-y-8 group transition-all duration-300 hover:shadow-xl hover:shadow-slate/5">
<div className="flex items-center gap-5">
<div className="p-3 bg-paper dark:bg-white/10 rounded-2xl text-slate border border-border group-hover:scale-110 transition-transform duration-300">
{icon}
</div>
<div className="space-y-0.5 text-left">
<h4 className="text-base font-bold text-ink">{title}</h4>
<p className="text-[11px] text-concrete leading-tight">{description}</p>
</div>
</div>
<div className="relative group/select">
<select
defaultValue={defaultValue}
className="w-full bg-white/50 dark:bg-black/40 border border-border rounded-xl px-5 py-4 text-sm outline-none focus:ring-1 ring-slate/20 appearance-none cursor-pointer text-ink font-bold transition-all hover:bg-white dark:hover:bg-black/60"
>
{options.map((opt: string) => (
<option key={opt}>{opt}</option>
))}
</select>
<div className="absolute right-5 top-1/2 -translate-y-1/2 pointer-events-none text-concrete group-hover/select:text-slate transition-colors">
<svg width="10" height="6" viewBox="0 0 10 6" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1 1L5 5L9 1" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
</div>
</div>
</div>
);
export const AppearanceTab: React.FC = () => {
return (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className="space-y-16 pb-20"
>
<div className="space-y-10">
<h3 className="text-[10px] font-bold uppercase tracking-[0.4em] text-concrete opacity-60">Personnaliser l'apparence de l'application</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-8">
<AppearanceSelect
icon={<Palette size={20} />}
title="Thème"
description="Sélectionner le mode visuel"
options={['Clair', 'Sombre', 'Système']}
defaultValue="Clair"
/>
<AppearanceSelect
icon={<Type size={20} />}
title="Taille de la police"
description="Ajustez la lisibilité globale de l'interface"
options={['Petite', 'Moyenne', 'Grande']}
defaultValue="Moyenne"
/>
<AppearanceSelect
icon={<Type size={20} />}
title="Famille de polices"
description="La typographie définit l'âme de l'application"
options={['Inter', 'JetBrains Mono', 'Public Sans', 'Outfit']}
defaultValue="JetBrains Mono"
/>
<AppearanceSelect
icon={<LayoutGrid size={20} />}
title="Affichage des notes"
description="Gestion visuelle de la grille de composition"
options={['Cartes (grille)', 'Liste', 'Tableau']}
defaultValue="Cartes (grille)"
/>
<AppearanceSelect
icon={<Maximize size={20} />}
title="Taille des notes"
description="Structure de la mise en page des éléments"
options={['Taille uniforme', 'Variable (Masonry)']}
defaultValue="Taille uniforme"
/>
</div>
</div>
</motion.div>
);
};

View File

@@ -0,0 +1,82 @@
import React from 'react';
import { Globe, Bell } from 'lucide-react';
import { motion } from 'motion/react';
export const GeneralTab: React.FC = () => {
return (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
className="space-y-12"
>
<div className="space-y-4">
<h3 className="text-[10px] font-bold uppercase tracking-[0.3em] text-concrete">Paramètres généraux de l'application</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* Langue */}
<div className="bg-white/40 dark:bg-white/5 border border-border rounded-xl p-8 space-y-6">
<div className="flex items-center gap-5">
<div className="p-3 bg-paper dark:bg-white/10 rounded-2xl text-slate border border-border">
<Globe size={18} />
</div>
<div className="space-y-0.5">
<h4 className="text-base font-bold text-ink">Langue</h4>
<p className="text-[11px] text-concrete">Sélectionner une langue</p>
</div>
</div>
<div className="relative group">
<select className="w-full bg-white/50 dark:bg-black/40 border border-border rounded-xl px-5 py-3.5 text-sm outline-none focus:ring-1 ring-blueprint/20 appearance-none cursor-pointer transition-all hover:bg-white dark:hover:bg-black/60 text-ink font-medium">
<option>Français</option>
<option>English</option>
<option>Español</option>
</select>
<div className="absolute right-5 top-1/2 -translate-y-1/2 pointer-events-none opacity-40 text-concrete">
<svg width="10" height="6" viewBox="0 0 10 6" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M1 1L5 5L9 1" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"/>
</svg>
</div>
</div>
</div>
{/* Notifications */}
<div className="bg-white/40 dark:bg-white/5 border border-border rounded-xl p-8 space-y-6">
<div className="flex items-center gap-5">
<div className="p-3 bg-paper dark:bg-white/10 rounded-2xl text-slate border border-border">
<Bell size={18} />
</div>
<div className="space-y-0.5">
<h4 className="text-base font-bold text-ink">Notifications</h4>
<p className="text-[11px] text-concrete">Gérez vos préférences de notifications</p>
</div>
</div>
<div className="space-y-6 divide-y divide-border/40 text-left">
<div className="flex items-center justify-between pt-0">
<div className="space-y-1">
<p className="text-xs font-bold text-ink">Notifications par email</p>
<p className="text-[10px] text-concrete leading-relaxed">Recevoir des notifications importantes par email</p>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input type="checkbox" className="sr-only peer" />
<div className="w-11 h-6 bg-gray-200 dark:bg-white/10 rounded-full peer peer-checked:after:translate-x-[20px] peer-checked:after:border-white after:content-[''] after:absolute after:top-[4px] after:left-[4px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all duration-300 ease-in-out peer-checked:bg-slate"></div>
</label>
</div>
<div className="flex items-center justify-between pt-6">
<div className="space-y-1">
<p className="text-xs font-bold text-ink">Notifications bureau</p>
<p className="text-[10px] text-concrete leading-relaxed">Recevoir des notifications dans votre navigateur</p>
</div>
<label className="relative inline-flex items-center cursor-pointer">
<input type="checkbox" className="sr-only peer" defaultChecked />
<div className="w-11 h-6 bg-gray-200 dark:bg-white/10 rounded-full peer peer-checked:after:translate-x-[20px] peer-checked:after:border-white after:content-[''] after:absolute after:top-[4px] after:left-[4px] after:bg-white after:rounded-full after:h-4 after:w-4 after:transition-all duration-300 ease-in-out peer-checked:bg-slate"></div>
</label>
</div>
</div>
</div>
</div>
</div>
</motion.div>
);
};

View File

@@ -0,0 +1,51 @@
import React from 'react';
import { Settings, Sparkles, Palette, User, Database, Code, Info } from 'lucide-react';
import { motion } from 'motion/react';
import { SettingsTab } from '../../types';
interface SettingsHeaderProps {
activeTab: SettingsTab;
setActiveTab: (tab: SettingsTab) => void;
}
export const SettingsHeader: React.FC<SettingsHeaderProps> = ({ activeTab, setActiveTab }) => {
const tabs = [
{ id: 'general', label: 'Paramètres généraux', icon: <Settings size={14} /> },
{ id: 'ai', label: 'Paramètres IA', icon: <Sparkles size={14} /> },
{ id: 'appearance', label: 'Apparence', icon: <Palette size={14} /> },
{ id: 'profile', label: 'Profil', icon: <User size={14} /> },
{ id: 'data', label: 'Gestion des données', icon: <Database size={14} /> },
{ id: 'mcp', label: 'Paramètres MCP', icon: <Code size={14} /> },
{ id: 'about', label: 'À propos', icon: <Info size={14} /> },
];
return (
<header className="px-12 pt-20 pb-16 space-y-12">
<div className="space-y-4">
<h1 className="text-[64px] font-serif text-ink tracking-tight leading-none italic font-medium">Paramètres</h1>
<p className="text-[10px] font-bold uppercase tracking-[0.4em] text-concrete opacity-60">Configuration & Préférences</p>
</div>
<nav className="flex items-center gap-1 border-b border-border/40 pb-px">
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id as SettingsTab)}
className={`flex items-center gap-2.5 px-6 py-5 text-[10px] font-bold uppercase tracking-[0.18em] transition-all relative whitespace-nowrap
${activeTab === tab.id ? 'text-ink' : 'text-concrete hover:text-ink/60'}`}
>
<span className={activeTab === tab.id ? 'text-ink' : 'text-concrete'}>{tab.icon}</span>
{tab.label}
{activeTab === tab.id && (
<motion.div
layoutId="activeSettingsTabLine"
className="absolute bottom-0 left-0 right-0 h-0.5 bg-ink"
transition={{ type: 'spring', bounce: 0.1, duration: 0.8 }}
/>
)}
</button>
))}
</nav>
</header>
);
};

View File

@@ -0,0 +1,93 @@
import { Carnet, Note } from './types';
export const CARNETS: Carnet[] = [
{ id: '1', name: 'Daily Notes', initial: 'D', type: 'Private', isPrivate: true },
{ id: '2', name: 'Project: Neo', initial: 'P', type: 'Project' },
{ id: '3', name: 'Shared Docs', initial: 'S', type: 'Shared' },
{ id: '4', name: 'Architecture Research', initial: 'A', type: 'Project' },
{ id: '5', name: 'History of Architecture', initial: 'H', type: 'Project', parentId: '4' },
{ id: '6', name: 'Modernism', initial: 'M', type: 'Project', parentId: '5' },
{ id: '7', name: 'Sustainable Design', initial: 'S', type: 'Project', parentId: '4' },
];
export const ALL_NOTES: Note[] = [
{
id: 'n1',
carnetId: '4',
title: 'Grid Systems & Geometry',
date: 'Oct 26, 2024',
content: 'Grid Systems are the foundation of cognitive design. We use geometric blocks to define spaces. The repetitive structure creates a sense of order and rhythm in the built environment.',
imageUrl: 'https://images.unsplash.com/photo-1503387762-592dea58ef23?auto=format&fit=crop&q=80&w=800&h=600',
tags: [
{ id: 't1', label: 'Architecture', type: 'user' },
{ id: 't2', label: 'Systems', type: 'ai' }
],
embedding: [0.1, 0.1]
},
{
id: 'n1-b',
carnetId: '4',
title: 'Parametric Grids',
date: 'Oct 27, 2024',
content: 'Parametricism allows us to deform traditional grid systems. By using mathematical algorithms, we can create fluid yet structured geometries that respond to environmental data.',
imageUrl: 'https://images.unsplash.com/photo-1511225070737-5af5ac9a690d?auto=format&fit=crop&q=80&w=800&h=600',
tags: [{ id: 't1', label: 'Geometry', type: 'user' }],
embedding: [0.12, 0.08]
},
{
id: 'n2',
carnetId: '4',
title: 'Sustainable Materiality',
date: 'Oct 24, 2024',
content: 'Exploring cross-laminated timber (CLT) as a sustainable alternative to concrete. Material choice is key to carbon-neutral construction. The warmth of wood contrasts with the coldness of steel.',
imageUrl: 'https://images.unsplash.com/photo-1486406146926-c627a92ad1ab?auto=format&fit=crop&q=80&w=800&h=600',
tags: [
{ id: 't3', label: 'Materials', type: 'user' },
{ id: 't4', label: 'Sustainabilty', type: 'ai' }
],
embedding: [0.8, 0.8]
},
{
id: 'n2-b',
carnetId: '7',
title: 'Solar Passive Design',
date: 'Oct 25, 2024',
content: 'Using orientation to maximize natural heat. Sustainable architecture must prioritize passive systems over active ones. Thermal mass and insulation are critical factors.',
imageUrl: 'https://images.unsplash.com/photo-1509391366360-fe5bb5843e0c?auto=format&fit=crop&q=80&w=800&h=600',
tags: [{ id: 't4', label: 'Sustainabilty', type: 'user' }],
embedding: [0.85, 0.75]
},
{
id: 'n3',
carnetId: '4',
title: 'Light & Minimalist Space',
date: 'Oct 22, 2024',
content: 'Minimalism is about the subtraction of the unnecessary. Light becomes a material in itself. Reflections on glass and white surfaces create depth without clutter.',
imageUrl: 'https://images.unsplash.com/photo-1497366216548-37526070297c?auto=format&fit=crop&q=80&w=800&h=600',
tags: [
{ id: 't5', label: 'Lighting', type: 'user' },
{ id: 't6', label: 'Atmosphere', type: 'ai' }
],
embedding: [0.2, 0.8]
},
{
id: 'n3-b',
carnetId: '6',
title: 'The Glass House Study',
date: 'Oct 23, 2024',
content: 'Analyzing the transparency of the Glass House. The boundary between interior and exterior is blurred. A pure expression of modernist ideals and minimal structure.',
imageUrl: 'https://images.unsplash.com/photo-1464938050520-ef2270bb8ce8?auto=format&fit=crop&q=80&w=800&h=600',
tags: [{ id: 't6', label: 'Modernism', type: 'user' }],
embedding: [0.25, 0.85]
},
{
id: 'bridge-1',
carnetId: '4',
title: 'Geometric Ecology',
date: 'Oct 28, 2024',
content: 'Can we use grid systems to optimize sustainable solar collection? This note bridges the gap between rigid geometry and ecological necessity. Structured sustainability.',
imageUrl: 'https://images.unsplash.com/photo-1464146072230-91cabc968276?auto=format&fit=crop&q=80&w=800&h=600',
tags: [{ id: 't1', label: 'Bridge', type: 'ai' }],
embedding: [0.45, 0.45] // Center point
}
];

View File

@@ -0,0 +1,98 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=Playfair+Display:ital,wght@0,400;0,700;1,400&family=JetBrains+Mono:wght@400;500&display=swap');
@import "tailwindcss";
@theme {
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
--font-serif: "Playfair Display", serif;
--font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, monospace;
/* Foundation */
--color-paper: #F2F0E9;
--color-ink: #1C1C1C;
--color-muted-ink: rgba(28, 28, 28, 0.6);
--color-border: rgba(28, 28, 28, 0.1);
--color-concrete: #8D8D8D;
/* Architectural Accents */
--color-blueprint: #75B2D6;
--color-slate: #4A4E69;
--color-ochre: #D4A373;
--color-sage: #A3B18A;
--color-rust: #9B2226;
--color-glass: rgba(255, 255, 255, 0.4);
/* Dark Theme Aliases */
--color-dark-paper: #0D0D0D;
--color-dark-ink: #EAEAEA;
--color-dark-muted: rgba(234, 234, 234, 0.5);
--color-dark-border: rgba(234, 234, 234, 0.1);
}
@layer base {
body {
@apply bg-paper text-ink font-sans antialiased transition-colors duration-500;
}
.dark body {
@apply bg-dark-paper;
}
.dark {
--color-paper: #121212;
--color-ink: #EAEAEA;
--color-muted-ink: rgba(234, 234, 234, 0.6);
--color-border: rgba(255, 255, 255, 0.08);
--color-glass: rgba(0, 0, 0, 0.4);
--color-concrete: #555555;
}
}
.paper-texture {
background-color: var(--color-paper);
background-image: url("https://www.transparenttextures.com/patterns/natural-paper.png");
}
/* Custom Scrollbar - Architectural Minimalist */
.custom-scrollbar::-webkit-scrollbar {
width: 3px;
}
.custom-scrollbar::-webkit-scrollbar-track {
background: transparent;
}
.custom-scrollbar::-webkit-scrollbar-thumb {
background: rgba(28, 28, 28, 0.08);
border-radius: 10px;
}
.custom-scrollbar:hover::-webkit-scrollbar-thumb {
background: rgba(28, 28, 28, 0.2);
}
.ai-glass {
background: rgba(255, 255, 255, 0.85);
backdrop-filter: blur(20px);
border: 1px solid rgba(255, 255, 255, 0.5);
}
.dark .ai-glass {
background: rgba(30, 30, 30, 0.85);
border: 1px solid rgba(255, 255, 255, 0.1);
}
.sidebar-shadow {
box-shadow: 1px 0 10px rgba(0, 0, 0, 0.05);
}
.active-nav-item {
background: linear-gradient(to right, rgba(255, 255, 255, 0.8), rgba(255, 255, 255, 0.4));
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
border: 1px solid rgba(255, 255, 255, 0.5);
}
.dark .active-nav-item {
background: rgba(255, 255, 255, 0.08);
border: 1px solid rgba(255, 255, 255, 0.1);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.2);
}

View File

@@ -0,0 +1,10 @@
import {StrictMode} from 'react';
import {createRoot} from 'react-dom/client';
import App from './App.tsx';
import './index.css';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
);

View File

@@ -0,0 +1,228 @@
import { Note, NoteCluster, BridgeNote } from '../types';
import { cosineSimilarity } from './geminiService';
export function dbscan(notes: Note[], eps: number, minPts: number): number[] {
const n = notes.length;
const labels = new Array(n).fill(-1); // -1 = noise, 0+ = cluster id
let clusterId = 0;
for (let i = 0; i < n; i++) {
if (labels[i] !== -1) continue;
const neighbors = getNeighbors(i, notes, eps);
if (neighbors.length < minPts) {
labels[i] = -1; // remains noise for now
continue;
}
labels[i] = clusterId;
const queue = neighbors.filter(idx => idx !== i);
for (let j = 0; j < queue.length; j++) {
const pIdx = queue[j];
if (labels[pIdx] === -1) {
labels[pIdx] = clusterId; // noisy point becomes border point
}
if (labels[pIdx] !== -1 && labels[pIdx] < clusterId) {
// This should not happen in standard DBSCAN unless we re-visit
}
if (labels[pIdx] === clusterId && labels[pIdx] !== -1) {
// Skip if already processed in this cluster
}
// If it was already labeled, skip re-neighboring
const pWasNoise = labels[pIdx] === -1;
if (labels[pIdx] === -1) labels[pIdx] = clusterId;
// If point was not processed
if (pWasNoise || labels[pIdx] === clusterId ) {
// This is a simplified queue processing
}
}
// Standard DBSCAN expansion
expandCluster(i, neighbors, labels, clusterId, notes, eps, minPts);
clusterId++;
}
return labels;
}
function expandCluster(pIdx: number, neighbors: number[], labels: number[], clusterId: number, notes: Note[], eps: number, minPts: number) {
let i = 0;
while (i < neighbors.length) {
const qIdx = neighbors[i];
if (labels[qIdx] === -1) {
labels[qIdx] = clusterId;
} else if (labels[qIdx] === undefined || labels[qIdx] === -1) {
// unreachable
}
if (labels[qIdx] === clusterId || labels[qIdx] === -1) {
const qNeighbors = getNeighbors(qIdx, notes, eps);
if (qNeighbors.length >= minPts) {
for(const qn of qNeighbors) {
if (labels[qn] === -1) {
labels[qn] = clusterId;
neighbors.push(qn);
} else if (!labels.hasOwnProperty(qn)) {
// logic error
}
}
}
}
i++;
}
}
// Clean DBSCAN implementation
export function runClustering(notes: Note[], eps: number = 0.15, minPts: number = 2): { labels: number[], clusters: NoteCluster[] } {
const validNotes = notes.filter(n => n.embedding && n.embedding.length > 0);
if (validNotes.length === 0) return { labels: [], clusters: [] };
const n = validNotes.length;
const labels = new Array(n).fill(-1);
let cId = 0;
for (let i = 0; i < n; i++) {
if (labels[i] !== -1) continue;
const neighbors = findNeighbors(i, validNotes, eps);
if (neighbors.length < minPts) {
labels[i] = -1;
} else {
labels[i] = cId;
expand(i, neighbors, labels, cId, validNotes, eps, minPts);
cId++;
}
}
const clusters: NoteCluster[] = [];
const colorPalette = ['#F87171', '#60A5FA', '#34D399', '#FBBF24', '#A78BFA', '#F472B6', '#2DD4BF'];
for (let i = 0; i < cId; i++) {
const noteIds = validNotes.filter((_, idx) => labels[idx] === i).map(n => n.id);
clusters.push({
id: `cluster-${i}`,
name: `Cluster ${i + 1}`,
noteIds,
color: colorPalette[i % colorPalette.length]
});
}
return { labels, clusters };
}
function findNeighbors(idx: number, notes: Note[], eps: number): number[] {
const neighbors: number[] = [];
const targetEmbedding = notes[idx].embedding!;
for (let i = 0; i < notes.length; i++) {
const sim = cosineSimilarity(targetEmbedding, notes[i].embedding!);
const dist = 1 - sim;
if (dist <= eps) {
neighbors.push(i);
}
}
return neighbors;
}
function expand(rootIdx: number, neighbors: number[], labels: number[], cId: number, notes: Note[], eps: number, minPts: number) {
const queue = [...neighbors];
for (let i = 0; i < queue.length; i++) {
const qIdx = queue[i];
if (labels[qIdx] === -1) {
labels[qIdx] = cId;
}
if (labels[qIdx] !== -1 && labels[qIdx] !== cId) continue;
if (labels[qIdx] === cId) {
// already visited but let's check neighbors if we just added it
}
// If point was noise, it now belongs to cluster, but we don't necessarily expand from it unless it's a core point
// This is the standard DBSCAN: noise points can become border points
}
// Re-implementing correctly
let head = 0;
while(head < queue.length) {
const qIdx = queue[head];
if (labels[qIdx] === -1) labels[qIdx] = cId;
if (labels[qIdx] === cId) {
const qNeighbors = findNeighbors(qIdx, notes, eps);
if (qNeighbors.length >= minPts) {
for(const qn of qNeighbors) {
if (labels[qn] === -1) {
labels[qn] = cId;
queue.push(qn);
}
}
}
}
head++;
}
}
function getNeighbors(idx: number, notes: Note[], eps: number): number[] {
const neighbors: number[] = [];
const target = notes[idx].embedding!;
for (let i = 0; i < notes.length; i++) {
if (!notes[i].embedding) continue;
const dist = 1 - cosineSimilarity(target, notes[i].embedding!);
if (dist <= eps) neighbors.push(i);
}
return neighbors;
}
export function detectBridges(notes: Note[], clusters: NoteCluster[], threshold: number = 0.5): BridgeNote[] {
const bridges: BridgeNote[] = [];
const validNotes = notes.filter(n => n.embedding);
for (const note of validNotes) {
const connectedClusters = new Set<string>();
for (const cluster of clusters) {
// Check if note has strong links to ANY note in this cluster
const clusterNotes = notes.filter(n => cluster.noteIds.includes(n.id) && n.embedding);
const hasStrongLink = clusterNotes.some(cn => cosineSimilarity(note.embedding!, cn.embedding!) > threshold);
if (hasStrongLink) {
connectedClusters.add(cluster.id);
}
}
if (connectedClusters.size >= 2) {
bridges.push({
noteId: note.id,
connectedClusterIds: Array.from(connectedClusters),
bridgeScore: connectedClusters.size / Math.max(clusters.length, 1)
});
}
}
return bridges.sort((a, b) => b.bridgeScore - a.bridgeScore);
}
export function calculateCentroid(noteIds: string[], allNotes: Note[]): number[] | undefined {
const clusterNotes = allNotes.filter(n => noteIds.includes(n.id) && n.embedding);
if (clusterNotes.length === 0) return undefined;
const embeddingDim = clusterNotes[0].embedding!.length;
const centroid = new Array(embeddingDim).fill(0);
for (const note of clusterNotes) {
for (let i = 0; i < embeddingDim; i++) {
centroid[i] += note.embedding![i];
}
}
for (let i = 0; i < embeddingDim; i++) {
centroid[i] /= clusterNotes.length;
}
return centroid;
}

View File

@@ -0,0 +1,253 @@
import { GoogleGenAI, Type } from "@google/genai";
import { BrainstormIdea } from "../types";
const ai = new GoogleGenAI({ apiKey: process.env.GEMINI_API_KEY });
const BRAINSTORM_SCHEMA = {
type: Type.OBJECT,
properties: {
ideas: {
type: Type.ARRAY,
items: {
type: Type.OBJECT,
properties: {
title: { type: Type.STRING },
description: { type: Type.STRING },
connection_to_seed: { type: Type.STRING },
novelty_score: { type: Type.NUMBER }
},
required: ["title", "description", "connection_to_seed", "novelty_score"]
}
}
},
required: ["ideas"]
};
const SUGGESTIONS_SCHEMA = {
type: Type.OBJECT,
properties: {
suggestions: {
type: Type.ARRAY,
items: {
type: Type.OBJECT,
properties: {
title: { type: Type.STRING },
description: { type: Type.STRING },
reasoning: { type: Type.STRING }
},
required: ["title", "description", "reasoning"]
}
}
},
required: ["suggestions"]
};
export async function generateBrainstormWave(
seedIdea: string,
waveNumber: number,
contextSummaries: string = ""
): Promise<Partial<BrainstormIdea>[]> {
const waveDescriptions = [
"", // index 0 unused
"VAGUE 1 (proximité directe) : Sous-aspects, reformulations, variations de l'idée. Reste dans le même domaine.",
"VAGUE 2 (analogies) : Trouve des parallèles dans d'autres domaines. Comment cette idée se manifeste-t-elle ailleurs ? Quelles techniques d'autres industries pourraient s'appliquer ?",
"VAGUE 3 (disruption) : Inverse l'idée. Pousse-la à l'extrême. Combine-la avec un domaine totalement non lié. Que se passe-t-il si l'opposé est vrai ?"
];
const prompt = `
Idée seed : "${seedIdea}"
Contexte : ${contextSummaries}
Génère 5 idées pour la VAGUE ${waveNumber} : ${waveDescriptions[waveNumber]}
Format JSON selon le schéma.
`;
try {
const response = await ai.models.generateContent({
model: "gemini-3-flash-preview",
contents: [{ role: "user", parts: [{ text: prompt }] }],
config: {
systemInstruction: "Tu es un expert en brainstorming. Réponds uniquement en JSON valide.",
responseMimeType: "application/json",
responseSchema: BRAINSTORM_SCHEMA,
temperature: 1.0
}
});
const resText = response.text;
if (!resText) return [];
const parsed = JSON.parse(resText.replace(/^```json\n?/, '').replace(/\n?```$/, '').trim());
const ideas = Array.isArray(parsed.ideas) ? parsed.ideas : (Array.isArray(parsed) ? parsed : []);
return ideas.map((item: any) => ({
title: item.title,
description: item.description,
connectionToSeed: item.connection_to_seed,
noveltyScore: item.novelty_score,
waveNumber
}));
} catch (error) {
console.error(`Error generating brainstorm wave ${waveNumber}:`, error);
throw error;
}
}
export async function generateExpansion(parentIdeaTitle: string, parentIdeaDescription: string): Promise<Partial<BrainstormIdea>[]> {
const prompt = `
Idée source : "${parentIdeaTitle} - ${parentIdeaDescription}"
Génère 3 idées d'extension ou de sous-aspects.
Format JSON.
`;
try {
const response = await ai.models.generateContent({
model: "gemini-3-flash-preview",
contents: [{ role: "user", parts: [{ text: prompt }] }],
config: {
systemInstruction: "Tu es un expert en brainstorming. Réponds uniquement en JSON valide.",
responseMimeType: "application/json",
responseSchema: BRAINSTORM_SCHEMA,
temperature: 1.0
}
});
const resText = response.text;
if (!resText) return [];
const parsed = JSON.parse(resText.replace(/^```json\n?/, '').replace(/\n?```$/, '').trim());
const ideas = Array.isArray(parsed.ideas) ? parsed.ideas : (Array.isArray(parsed) ? parsed : []);
return ideas.map((item: any) => ({
title: item.title,
description: item.description,
connectionToSeed: item.connection_to_seed,
noveltyScore: item.novelty_score
}));
} catch (error) {
console.error("Error generating expansion:", error);
throw error;
}
}
export async function getEmbedding(text: string): Promise<number[]> {
try {
const result = await ai.models.embedContent({
model: 'gemini-embedding-2-preview',
contents: [text],
});
return result.embeddings[0].values;
} catch (error) {
console.error("Error generating embedding:", error);
throw error;
}
}
export function cosineSimilarity(a: number[], b: number[]): number {
if (!a || !b || a.length !== b.length) return 0;
const dotProduct = a.reduce((sum, val, i) => sum + val * b[i], 0);
const magnitudeA = Math.sqrt(a.reduce((sum, val) => sum + val * val, 0));
const magnitudeB = Math.sqrt(b.reduce((sum, val) => sum + val * val, 0));
if (magnitudeA === 0 || magnitudeB === 0) return 0;
return dotProduct / (magnitudeA * magnitudeB);
}
export async function nameCluster(noteSummaries: string[]): Promise<string> {
const prompt = `Quel thème commun relie ces notes ? Donne un nom court (2-4 mots).\nNotes :\n${noteSummaries.join('\n- ')}`;
try {
const result = await ai.models.generateContent({
model: "gemini-3-flash-preview",
contents: prompt
});
return result.text.trim();
} catch (error) {
console.error("Error naming cluster:", error);
return "Thematic Cluster";
}
}
export async function suggestBridgeIdeas(
clusterAName: string,
clusterBName: string,
clusterASummaries: string,
clusterBSummaries: string
): Promise<any[]> {
const prompt = `
Cluster A (${clusterAName}) contient des notes sur : ${clusterASummaries}
Cluster B (${clusterBName}) contient des notes sur : ${clusterBSummaries}
Ces deux clusters ne sont pas connectés. Propose 3 idées
de "notes pont" qui pourraient créer un lien créatif entre eux.
Pour chaque idée : titre, description, pourquoi ça connecte les deux.
Format JSON.
`;
try {
const response = await ai.models.generateContent({
model: "gemini-3-flash-preview",
contents: prompt,
config: {
responseMimeType: "application/json",
responseSchema: SUGGESTIONS_SCHEMA
}
});
const parsed = JSON.parse(response.text);
return Array.isArray(parsed.suggestions) ? parsed.suggestions : [];
} catch (error) {
console.error("Error suggesting bridge ideas:", error);
return [];
}
}
export async function parseDocument(fileUrl: string, fileName: string): Promise<string> {
const prompt = `Extraits et résume le texte de ce document nommé "${fileName}".
Si c'est un PDF, ignore les éléments purement graphiques et concentre-toi sur le contenu sémantique.
Fais une extraction structurée.`;
try {
// In a real scenario, we would use media upload.
// Here we simulate the extraction.
const response = await ai.models.generateContent({
model: "gemini-3-flash-preview",
contents: [{ role: "user", parts: [{ text: prompt }] }],
config: {
systemInstruction: "Tu es un expert en extraction de texte et analyse de documents.",
temperature: 0.2
}
});
return response.text || "Échec de l'extraction du texte.";
} catch (error) {
console.error("Error parsing document:", error);
return "Erreur lors de l'analyse du document.";
}
}
export async function extractActionItems(notes: { title: string; content: string }[]): Promise<string> {
const notesContext = notes.map(n => `TITLE: ${n.title}\nCONTENT: ${n.content}`).join('\n\n---\n\n');
const prompt = `
Analyse les notes suivantes et extrais la liste des actions à accomplir (TODOs).
Pour chaque tâche, identifie si possible l'assigné et la date limite.
Présente le résultat sous forme d'un tableau Markdown structuré ou d'une liste claire.
Si aucune tâche n'est trouvée, indique-le.
Notes:
${notesContext}
`;
try {
const response = await ai.models.generateContent({
model: "gemini-3-flash-preview",
contents: prompt,
config: {
systemInstruction: "Tu es un agent spécialisé dans l'organisation et la gestion de tâches. Ton but est d'être précis et exhaustif.",
temperature: 0.1
}
});
return response.text;
} catch (error) {
console.error("Error extracting action items:", error);
return "Erreur lors de l'extraction des tâches.";
}
}

View File

@@ -0,0 +1,76 @@
import { Note, NoteAccessLog, NotePrediction } from '../types';
/**
* Simulates finding the dominant frequency in access logs for a specific note
* returning the period in days.
*/
export function detectAccessCycle(logs: NoteAccessLog[]): number | null {
if (logs.length < 5) return null;
const accessDays = logs
.map(log => new Date(log.accessedAt).getTime())
.sort((a, b) => a - b);
const intervals: number[] = [];
for (let i = 1; i < accessDays.length; i++) {
intervals.push((accessDays[i] - accessDays[i - 1]) / (1000 * 60 * 60 * 24));
}
// Simple heuristic: if intervals are consistently around a value, that's our cycle
// We'll calculate the median interval
const sortedIntervals = [...intervals].sort((a, b) => a - b);
const median = sortedIntervals[Math.floor(sortedIntervals.length / 2)];
// Check if enough intervals are close to median
const withinThreshold = intervals.filter(v => Math.abs(v - median) < Math.max(2, median * 0.2));
if (withinThreshold.length >= intervals.length * 0.6) {
return median;
}
return null;
}
export function predictNextAccess(note: Note, logs: NoteAccessLog[]): NotePrediction | null {
const cycleDays = detectAccessCycle(logs);
if (!cycleDays) return null;
const lastAccess = new Date(logs[logs.length - 1].accessedAt);
const nextAccessDate = new Date(lastAccess.getTime() + cycleDays * 24 * 60 * 60 * 1000);
const now = new Date();
const daysUntilNext = (nextAccessDate.getTime() - now.getTime()) / (1000 * 60 * 60 * 24);
// Only predict if it's coming up in the next 2 weeks
if (daysUntilNext > 0 && daysUntilNext < 14) {
return {
noteId: note.id,
predictedRelevanceDate: nextAccessDate.toISOString(),
confidence: 0.7,
reason: `Historical access pattern suggests a ${Math.round(cycleDays)}-day cycle.`,
generatedAt: now.toISOString()
};
}
return null;
}
export function getCoaccessedNotes(baseNoteId: string, logs: NoteAccessLog[], allNotes: Note[]): Note[] {
const WINDOW_MS = 30 * 60 * 1000; // 30 minutes
const baseNoteLogs = logs.filter(l => l.noteId === baseNoteId);
const coaccessedIds = new Set<string>();
baseNoteLogs.forEach(baseLog => {
const baseTime = new Date(baseLog.accessedAt).getTime();
logs.forEach(otherLog => {
if (otherLog.noteId === baseNoteId) return;
const otherTime = new Date(otherLog.accessedAt).getTime();
if (Math.abs(baseTime - otherTime) < WINDOW_MS) {
coaccessedIds.add(otherLog.noteId);
}
});
});
return allNotes.filter(n => coaccessedIds.has(n.id));
}

View File

@@ -0,0 +1,112 @@
export type NavigationView = 'notebooks' | 'agents' | 'settings' | 'shared' | 'reminders' | 'trash' | 'brainstorm' | 'insights' | 'temporal';
export type AITone = 'Professional' | 'Creative' | 'Academic' | 'Casual';
export type AITab = 'discussion' | 'actions' | 'resources' | 'explore';
export type SettingsTab = 'general' | 'ai' | 'appearance' | 'profile' | 'data' | 'mcp' | 'about';
export interface Tag {
id: string;
label: string;
type: 'ai' | 'user';
}
export interface Attachment {
id: string;
name: string;
type: 'pdf' | 'docx' | 'image' | 'other';
url: string;
content?: string; // Extracted text
isProcessed?: boolean;
}
export interface Note {
id: string;
carnetId: string;
title: string;
content: string;
imageUrl: string;
date: string;
tags: Tag[];
attachments?: Attachment[];
isPinned?: boolean;
isDeleted?: boolean;
deletedAt?: string;
embedding?: number[];
clusterId?: string;
}
export interface NoteCluster {
id: string;
name: string;
noteIds: string[];
centroid? : number[];
color: string;
}
export interface BridgeNote {
noteId: string;
connectedClusterIds: string[];
bridgeScore: number;
}
export interface ConnectionSuggestion {
id: string;
title: string;
description: string;
reasoning: string;
clusterAId: string;
clusterBId: string;
}
export interface BrainstormSession {
id: string;
seedIdea: string;
sourceNoteId?: string;
contextNoteIds?: string[];
exportedNoteId?: string;
createdAt: string;
updatedAt: string;
userId: string;
}
export type BrainstormIdeaStatus = 'active' | 'dismissed' | 'converted';
export interface BrainstormIdea {
id: string;
sessionId: string;
waveNumber: 1 | 2 | 3;
title: string;
description: string;
connectionToSeed: string;
noveltyScore: number; // 1-10
parentIdeaId?: string;
convertedToNoteId?: string;
relatedNoteIds?: string[];
status: BrainstormIdeaStatus;
position?: { x: number; y: number };
}
export interface Carnet {
id: string;
name: string;
initial: string;
type: 'Private' | 'Project' | 'Shared';
isPrivate?: boolean;
parentId?: string;
isDeleted?: boolean;
deletedAt?: string;
}
export interface NoteAccessLog {
noteId: string;
accessedAt: string;
action: 'view' | 'edit' | 'search_hit';
}
export interface NotePrediction {
noteId: string;
predictedRelevanceDate: string;
confidence: number;
reason: string;
generatedAt: string;
}