Files
Momento/architectural-grid/src/App.tsx
Antigravity e2672cd2c2
Some checks failed
CI / Lint, Test & Build (push) Failing after 1m19s
CI / Deploy production (on server) (push) Has been skipped
feat(notes): liens internes, onglet Réseau, living blocks et consentement IA
Rend les liens entre notes visibles et persistants (sync NoteLink au save, auto-save, graphe réseau rafraîchi), ajoute living blocks, Memory Echo, recherche globale, consentement IA explicite et consolide les prototypes design en architectural-grid.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-24 14:27:29 +00:00

1166 lines
48 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* @license
* SPDX-License-Identifier: Apache-2.0
*/
import React, { useState, useMemo } from 'react';
import { motion, AnimatePresence } from 'motion/react';
import { X } from 'lucide-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';
import { LandingPage } from './components/LandingPage';
import { AuthPage } from './components/AuthPage';
import { SearchModal } from './components/SearchModal';
import { ClipperSimulator } from './components/ClipperSimulator';
import { GraphKnowledgeMap } from './components/GraphKnowledgeMap';
import { RevisionView } from './components/RevisionView';
// Data & Services
import { CARNETS, ALL_NOTES } from './constants';
import { generateFlashcardsForNote } from './services/geminiService';
import { NavigationView, SettingsTab, AITab, AITone, Carnet, Note, BrainstormIdea, NoteAccessLog, Flashcard } from './types';
export default function App() {
const [showLanding, setShowLanding] = useState(() => {
// Check if user has already "entered" the app once in this session
return !sessionStorage.getItem('momento-entered');
});
const [showAuth, setShowAuth] = useState(false);
const [authMode, setAuthMode] = useState<'login' | 'register'>('login');
const [isAuthenticated, setIsAuthenticated] = useState(false);
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 [accentColor, setAccentColor] = useState(() => {
return localStorage.getItem('momento-accent-color') || '#A47148';
});
// Flashcards state with beautiful architectural starter deck
const [flashcards, setFlashcards] = useState<Flashcard[]>(() => {
const stored = localStorage.getItem('momento-flashcards');
if (stored) {
try {
return JSON.parse(stored);
} catch (e) {
console.error("Failed to parse stored flashcards:", e);
}
}
// Beautiful default seeds for 'n1' (Grid Systems & Geometry)
const SEEDS: Flashcard[] = [
{
id: 'f1',
noteId: 'n1',
question: 'Quel est lintérêt primordial des trames géométriques en conception spatiale ?',
answer: 'Elles structurent lespace bâti en créant un sens d\'ordre, de rythme, et d\'harmonie de proportions esthétiques dans l\'environnement, facilitant la lisibilité de la structure.',
intervalDays: 1,
nextReviewDate: new Date().toISOString(), // Due today
easeFactor: 2.5,
mastered: false
},
{
id: 'f2',
noteId: 'n1',
question: 'En quoi lapproche dynamique paramétrique déforme-t-elle les grilles de construction traditionnelles ?',
answer: 'Par l\'utilisation d\'algorithmes mathématiques de déformation réactifs à des ensembles de données environnementales pour créer des géométries fluides mais structurellement ordonnées.',
intervalDays: 3,
nextReviewDate: new Date(Date.now() + 1000 * 60 * 60 * 24 * 3).toISOString(), // Due soon
easeFactor: 2.5,
mastered: true
},
{
id: 'f3',
noteId: 'n1',
question: 'Quelle est la particularité de lintégration de la lumière comme matériau despace ?',
answer: 'La soustraction du superflu permet aux reflets et à la diffraction lumineuse de créer des profondeurs visuelles changeantes sans surcharger l\'aménagement matériel.',
intervalDays: 1,
nextReviewDate: new Date().toISOString(), // Due today
easeFactor: 2.4,
mastered: false
}
];
return SEEDS;
});
const [isGeneratingFlashcards, setIsGeneratingFlashcards] = useState(false);
const [activeReviewDeckId, setActiveReviewDeckId] = useState<string | null>(null);
const [toast, setToast] = useState<{ show: boolean; message: string }>({ show: false, message: '' });
React.useEffect(() => {
document.documentElement.style.setProperty('--color-accent', accentColor);
localStorage.setItem('momento-accent-color', accentColor);
}, [accentColor]);
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 }
}));
};
const handleGenerateFlashcards = async (noteId: string) => {
const targetNote = notes.find(n => n.id === noteId);
if (!targetNote) return;
setIsGeneratingFlashcards(true);
try {
const rawContent = targetNote.content || "";
const generated = await generateFlashcardsForNote(targetNote.title, rawContent);
if (generated && generated.length > 0) {
const mappedCards: Flashcard[] = generated.map((c, i) => ({
id: `f-${noteId}-${Date.now()}-${i}`,
noteId,
question: c.question,
answer: c.answer,
intervalDays: 1,
nextReviewDate: new Date().toISOString(),
easeFactor: 2.5,
mastered: false
}));
const updatedSet = [...flashcards.filter(fc => fc.noteId !== noteId), ...mappedCards];
setFlashcards(updatedSet);
localStorage.setItem('momento-flashcards', JSON.stringify(updatedSet));
setToast({
show: true,
message: `${mappedCards.length} flashcards créées avec succès pour "${targetNote.title}".`
});
setTimeout(() => setToast(t => ({ ...t, show: false })), 4000);
} else {
setToast({
show: true,
message: "Impossible d'extraire des flashcards exploitables à partir du texte existant."
});
setTimeout(() => setToast(t => ({ ...t, show: false })), 4000);
}
} catch (err) {
console.error("AI flashcards expansion failed:", err);
setToast({
show: true,
message: "Une erreur est survenue lors de la génération avec Gemini."
});
setTimeout(() => setToast(t => ({ ...t, show: false })), 4000);
} finally {
setIsGeneratingFlashcards(false);
}
};
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);
}
};
const handleGlobalShortcut = (e: KeyboardEvent) => {
// Trigger advanced search with Ctrl+F or Cmd+F or Ctrl+P or Cmd+P
if ((e.ctrlKey || e.metaKey) && (e.key === 'f' || e.key === 'p')) {
e.preventDefault();
setIsSearchOpen(true);
}
};
const handleToggleClipper = () => {
setIsClipperSimulatorOpen(prev => !prev);
};
window.addEventListener('switch-view', handleSwitchView);
window.addEventListener('keydown', handleGlobalShortcut);
window.addEventListener('toggle-clipper-simulator', handleToggleClipper);
return () => {
window.removeEventListener('switch-view', handleSwitchView);
window.removeEventListener('keydown', handleGlobalShortcut);
window.removeEventListener('toggle-clipper-simulator', handleToggleClipper);
};
}, []);
const [isSearchOpen, setIsSearchOpen] = useState(false);
const [isClipperSimulatorOpen, setIsClipperSimulatorOpen] = useState(false);
const [clipperToast, setClipperToast] = useState<{ id: string; title: string; noteId: string } | null>(null);
const [selectedTagIds, setSelectedTagIds] = useState<string[]>([]);
const [isAISidebarOpen, setIsAISidebarOpen] = useState(false);
const [isSidebarOpen, setIsSidebarOpen] = 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
});
}
}
};
React.useEffect(() => {
if (clipperToast) {
const timer = setTimeout(() => {
setClipperToast(null);
}, 4000);
return () => clearTimeout(timer);
}
}, [clipperToast]);
const handleOpenClippedNote = (noteId: string) => {
setActiveNoteId(noteId);
const found = notes.find(n => n.id === noteId);
if (found) {
setActiveCarnetId(found.carnetId);
}
setActiveView('notebooks');
};
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(prevNotes => {
const existing = prevNotes.find(n => n.id === updatedNote.id);
if (existing && updatedNote.isVersioningEnabled !== false) {
const hasContentChanged = existing.content !== updatedNote.content;
const hasTitleChanged = existing.title !== updatedNote.title;
if (hasContentChanged || hasTitleChanged) {
const history = existing.versionHistory || [];
const lastSnapshot = history[0];
const isIdentical = lastSnapshot && lastSnapshot.content === existing.content && lastSnapshot.title === existing.title;
if (!isIdentical) {
const newSnapshot = {
id: 'v-' + Date.now(),
title: existing.title,
content: existing.content,
timestamp: new Date().toLocaleDateString('fr-FR', { day: 'numeric', month: 'short', year: 'numeric' }) + ' • ' + new Date().toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' }),
size: existing.content.length
};
const updatedWithHistory = {
...updatedNote,
versionHistory: [newSnapshot, ...history]
};
return prevNotes.map(n => n.id === updatedNote.id ? updatedWithHistory : n);
}
}
}
return prevNotes.map(n => n.id === updatedNote.id ? updatedNote : n);
});
};
// WebSocket Integration for Living Blocks
const [wsConnected, setWsConnected] = React.useState(true);
const [simulatedOffline, setSimulatedOffline] = React.useState(false);
const socketRef = React.useRef<WebSocket | null>(null);
const initWebSocket = () => {
if (simulatedOffline) return;
try {
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const socket = new WebSocket(`${protocol}//${window.location.host}`);
socketRef.current = socket;
socket.onopen = () => {
setWsConnected(true);
socket.send(JSON.stringify({
type: 'join',
sessionId: 'global-momento-session',
user: { id: 'u-1', name: 'Utilisateur Actuel' }
}));
};
socket.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
if (data.type === 'living_block_update') {
const { sourceNoteId, blockIndex, newText } = data;
setNotes(prevNotes =>
prevNotes.map(note => {
if (note.id === sourceNoteId) {
const paragraphs = note.content.split('\n');
if (paragraphs[blockIndex] !== undefined) {
paragraphs[blockIndex] = newText;
return { ...note, content: paragraphs.join('\n') };
}
}
return note;
})
);
window.dispatchEvent(new CustomEvent('living-block-pulse', {
detail: { sourceNoteId, blockIndex }
}));
}
} catch (err) {
console.error("Error parsing message", err);
}
};
socket.onclose = () => {
setWsConnected(false);
setTimeout(() => {
if (socketRef.current === socket && !simulatedOffline) {
initWebSocket();
}
}, 4000);
};
socket.onerror = () => {
setWsConnected(false);
};
} catch (e) {
console.error(e);
setWsConnected(false);
}
};
React.useEffect(() => {
initWebSocket();
return () => {
if (socketRef.current) {
socketRef.current.close();
}
};
}, [simulatedOffline]);
React.useEffect(() => {
const handleToggleSimulate = () => {
setSimulatedOffline(prev => {
const next = !prev;
if (next) {
if (socketRef.current) {
socketRef.current.close();
}
setWsConnected(false);
} else {
// Reconnect will automatically trigger because simulatedOffline changes
}
return next;
});
};
window.addEventListener('toggle-websocket-simulate', handleToggleSimulate);
return () => {
window.removeEventListener('toggle-websocket-simulate', handleToggleSimulate);
};
}, []);
const broadcastLivingBlockUpdate = (sourceNoteId: string, blockIndex: number, newText: string) => {
if (socketRef.current?.readyState === WebSocket.OPEN && !simulatedOffline) {
socketRef.current.send(JSON.stringify({
type: 'living_block_update',
sourceNoteId,
blockIndex,
newText
}));
}
};
const handleEnterApp = () => {
if (isAuthenticated) {
setShowLanding(false);
sessionStorage.setItem('momento-entered', 'true');
} else {
setAuthMode('register');
setShowAuth(true);
}
};
const handleAuthComplete = () => {
setIsAuthenticated(true);
setShowAuth(false);
setShowLanding(false);
sessionStorage.setItem('momento-entered', 'true');
};
const handleLogout = () => {
setIsAuthenticated(false);
setShowLanding(true);
sessionStorage.removeItem('momento-entered');
};
return (
<AnimatePresence mode="wait">
{showLanding && !showAuth ? (
<motion.div
key="landing"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0, scale: 1.05 }}
transition={{ duration: 0.6, ease: [0.23, 1, 0.32, 1] }}
className="w-full h-full"
>
<LandingPage
onEnter={handleEnterApp}
onLogin={() => { setAuthMode('login'); setShowAuth(true); }}
onRegister={() => { setAuthMode('register'); setShowAuth(true); }}
/>
</motion.div>
) : showAuth ? (
<motion.div
key="auth"
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -20 }}
className="w-full h-full"
>
<AuthPage
initialMode={authMode}
onAuthComplete={handleAuthComplete}
onBack={() => setShowAuth(false)}
/>
</motion.div>
) : (
<motion.div
key="app"
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
className={`h-screen flex bg-paper transition-colors duration-500 overflow-hidden font-sans ${isDarkMode ? 'dark' : ''}`}
>
<Sidebar
activeView={activeView}
isSidebarOpen={isSidebarOpen}
setIsSidebarOpen={setIsSidebarOpen}
isDarkMode={isDarkMode}
setIsDarkMode={setIsDarkMode}
setActiveView={setActiveView}
setActiveSettingsTab={setActiveSettingsTab}
onGoHome={() => setShowLanding(true)}
onLogout={handleLogout}
carnets={carnets}
notes={notes}
activeCarnetId={activeCarnetId}
activeNoteId={activeNoteId}
setActiveCarnetId={setActiveCarnetId}
setActiveNoteId={setActiveNoteId}
flashcards={flashcards}
onSelectReviewDeck={(noteId) => {
setActiveReviewDeckId(noteId);
setActiveView('revision');
}}
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}
onOpenSidebar={() => setIsSidebarOpen(true)}
onSearchClick={() => setIsSearchOpen(true)}
wsConnected={wsConnected}
broadcastLivingBlockUpdate={broadcastLivingBlockUpdate}
carnets={carnets}
flashcards={flashcards}
onTriggerReviewDeck={(noteId) => {
setActiveReviewDeckId(noteId);
setActiveView('revision');
}}
onGenerateFlashcards={handleGenerateFlashcards}
isGeneratingFlashcards={isGeneratingFlashcards}
/>
</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));
}}
onOpenSidebar={() => setIsSidebarOpen(true)}
/>
</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])}
onOpenSidebar={() => setIsSidebarOpen(true)}
/>
</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}
accentColor={accentColor}
onAccentColorChange={setAccentColor}
onLogout={handleLogout}
onOpenSidebar={() => setIsSidebarOpen(true)}
/>
</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);
}}
onOpenSidebar={() => setIsSidebarOpen(true)}
/>
</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>
)}
{activeView === 'graph' && (
<motion.div
key="graph"
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"
>
<GraphKnowledgeMap
notes={notes}
carnets={carnets}
onOpenNote={(noteId) => {
setActiveView('notebooks');
setActiveNoteId(noteId);
const note = notes.find(n => n.id === noteId);
if (note) {
setActiveCarnetId(note.carnetId);
}
}}
onClose={() => setActiveView('notebooks')}
/>
</motion.div>
)}
{activeView === 'revision' && (
<motion.div
key="revision"
initial={{ opacity: 0, scale: 1.02 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.98 }}
transition={{ duration: 0.3 }}
className="h-full w-full"
>
<RevisionView
notes={notes}
flashcards={flashcards}
onUpdateFlashcards={(updated) => {
setFlashcards(updated);
localStorage.setItem('momento-flashcards', JSON.stringify(updated));
}}
onSelectNote={(noteId) => {
setActiveView('notebooks');
setActiveNoteId(noteId);
const note = notes.find(n => n.id === noteId);
if (note) {
setActiveCarnetId(note.carnetId);
}
}}
onOpenSidebar={() => setIsSidebarOpen(true)}
initialActiveDeckId={activeReviewDeckId}
onClearActiveDeckId={() => setActiveReviewDeckId(null)}
/>
</motion.div>
)}
</AnimatePresence>
<AISidebar
isOpen={isAISidebarOpen}
setIsOpen={setIsAISidebarOpen}
activeNote={activeNote}
aiTab={aiTab}
setAiTab={setAiTab}
selectedTone={selectedTone}
setSelectedTone={setSelectedTone}
carnets={carnets}
notes={notes}
onOpenNote={setActiveNoteId}
onUpdateNote={handleUpdateNote}
/>
</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>
)}
{isSearchOpen && (
<SearchModal
isOpen={isSearchOpen}
onClose={() => setIsSearchOpen(false)}
notes={notes}
carnets={carnets}
onSelectNote={(noteId) => {
setActiveNoteId(noteId);
const searchHitNote = notes.find(n => n.id === noteId);
if (searchHitNote) {
setActiveCarnetId(searchHitNote.carnetId);
}
}}
/>
)}
{isClipperSimulatorOpen && (
<ClipperSimulator
isOpen={isClipperSimulatorOpen}
onClose={() => setIsClipperSimulatorOpen(false)}
carnets={carnets}
activeCarnetId={activeCarnetId}
onAddNote={(newNote) => setNotes(prevNotes => [newNote, ...prevNotes])}
onTriggerToast={(title, noteId) => {
setClipperToast({
id: String(Date.now()),
title,
noteId
});
}}
/>
)}
{clipperToast && (
<motion.div
initial={{ opacity: 0, y: 50, scale: 0.9 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 20, scale: 0.9 }}
className="fixed bottom-6 right-6 z-[999] bg-[#0E0E0E] text-white border border-neutral-800 rounded-xl shadow-2xl pl-5 pr-4 py-3.5 flex items-center justify-between gap-5 tracking-wide max-w-[360px]"
>
<div className="flex items-center gap-2.5">
<span className="w-2 h-2 rounded-full bg-cyan-400 animate-pulse shrink-0" />
<p className="text-xs font-semibold text-neutral-100 leading-normal">
Note clippée <span className="text-cyan-400 font-bold">{clipperToast.title}</span>
</p>
</div>
<div className="flex items-center gap-2.5 shrink-0">
<button
onClick={() => {
handleOpenClippedNote(clipperToast.noteId);
setClipperToast(null);
}}
className="text-xs font-bold uppercase tracking-wider text-cyan-400 hover:text-cyan-300 underline underline-offset-2 transition-colors"
>
Voir
</button>
<button
onClick={() => setClipperToast(null)}
className="p-1 hover:bg-white/10 text-neutral-400 hover:text-white rounded-lg transition-colors"
>
<X size={14} />
</button>
</div>
</motion.div>
)}
{toast.show && (
<motion.div
initial={{ opacity: 0, y: 50, scale: 0.9 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: 20, scale: 0.9 }}
className="fixed bottom-6 right-6 z-[999] bg-[#0E0E0E] text-white border border-neutral-800 rounded-xl shadow-2xl pl-5 pr-4 py-3.5 flex items-center justify-between gap-5 tracking-wide max-w-[420px]"
>
<div className="flex items-center gap-2.5">
<span className="w-2 h-2 rounded-full bg-accent animate-pulse shrink-0" />
<p className="text-xs font-semibold text-neutral-100 leading-normal">
{toast.message}
</p>
</div>
<div className="flex items-center gap-2.5 shrink-0">
<button
onClick={() => {
setActiveView('revision');
setToast({ show: false, message: '' });
}}
className="text-xs font-bold uppercase tracking-wider text-accent hover:text-accent/80 underline underline-offset-2 transition-colors cursor-pointer"
>
Voir
</button>
<button
onClick={() => setToast({ show: false, message: '' })}
className="p-1 hover:bg-white/10 text-neutral-400 hover:text-white rounded-lg transition-colors cursor-pointer"
>
<X size={14} />
</button>
</div>
</motion.div>
)}
</AnimatePresence>
</motion.div>
)}
</AnimatePresence>
);
}