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>
1166 lines
48 KiB
TypeScript
1166 lines
48 KiB
TypeScript
/**
|
||
* @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 l’intérêt primordial des trames géométriques en conception spatiale ?',
|
||
answer: 'Elles structurent l’espace 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 l’approche 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 l’intégration de la lumière comme matériau d’espace ?',
|
||
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>
|
||
);
|
||
}
|