diff --git a/AGENTS.md b/AGENTS.md index a202800..579941e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -5,26 +5,27 @@ - Préfère les échanges en français, avec des explications détaillées et claires (éviter le jargon flou). - Interface : tout libellé via i18n dans les 15 fichiers `memento-note/locales/*.json` (FR et EN comme références de contenu) ; éviter le texte en dur ; traductions **contextuelles** (sens produit, pas mot à mot — ex. « connecter votre propre fournisseur ») ; lors d'une traduction complète, mettre à jour toutes les locales concernées ; si l'utilisateur demande seulement les **clés i18n**, ajouter les clés (souvent EN/FR) sans remplir les 15 locales — il traduit souvent avec un autre modèle. - Base de données : **INTERDIT TOTALEMENT** de lancer `prisma db push --force-reset`, `prisma migrate reset`, `DROP TABLE`, `TRUNCATE`, `pg_restore` avec clean, ou TOUTE commande qui vide/supprime des données — MÊME SI l'utilisateur est d'accord — sans avoir d'abord : (1) dumpé la base avec `bash /home/devparsa/dev/Momento/dump-db.sh`, (2) vérifié le dump fait au moins 1Mo, (3) obtenu un "OUI" explicite de l'utilisateur. **4 incidents de perte de données documentés (14/05, 15/05 x2, 16/05). NE JAMAIS REFAIRE ÇA.** -- Design produit : migration depuis les gabarits `architectural-grid1` (base cible) et `architectural-grid` ; avancer pas à pas avec validation ; respecter la logique liste / carte de notes puis contenu au clic comme dans la référence. -- Contraste éditeur clair face à une sidebar plus sombre ; fiabiliser la navigation du sidebar en s'alignant sur la logique des dossiers de référence design. -- Retirer les traces de bleu ou de thème obsolètes ; harmoniser les couleurs des vues (ex. Agents) avec le design courant ; revoir la cohérence des options de thème dans les paramètres. +- Design produit : migration depuis `architectural-grid1` (base) et `architectural-grid` (prototype UI courant) ; **consulter le prototype avant toute implémentation UI** ; logique liste/carte puis contenu au clic ; parité actions liste/carte (menus « … », déplacer, génération SVG, etc.) ; contraste éditeur clair / sidebar sombre ; retirer thèmes obsolètes ; **pas de refresh/revalidation complets inutiles** (aligné prototype — mutations optimistes, pas de `revalidatePath` systématique ni resync depuis `initialNotes`) ; **Memory Echo en section inline dans l'éditeur** (pas l'ancienne modale) ; si l'utilisateur hésite entre variantes UX, **trancher pour le design prototype** plutôt que multiplier les toggles. - Locale persane : dates en calendrier iranien (conversion), chiffres persans, et vérification RTL/positionnement global ; attention à ne pas confondre un nom de carnet (ex. « Persan ») avec le libellé de langue. - Flux Excalidraw / diagrammes générés : accès via notification en plus d'une simple redirection ; priorité à la mise en page et au texte contenu dans les formes ; proposer des modes visuels (ex. coloré vs plus austère) tout en visant un rendu proche du style Excalidraw (polices, look). -- L'admin doit être intégré au nouveau design (éviter l'ancienne topbar isolée). -- Ne pas supposer les réglages utilisateur (modes, options) sans preuve dans l'UI ou les données. - **Interdiction d'écrire des tests** sauf demande explicite ; en CI, seul `npm run test:unit` (`tests/unit/**`) — pas `tests/migration/` ; ne jamais générer de code superflu. -- Déploiement : privilégier le chemin rapide (artifact Next.js en CI + `Dockerfile.prebuilt`) ; éviter les rebuild Docker complets inutiles (~15 min par itération). +- Déploiement : privilégier le chemin rapide (artifact Next.js en CI + `Dockerfile.prebuilt`) ; CI/CD très robuste (pas d'image Docker obsolète en prod, pas de migrations/schéma DB via le workflow deploy) ; éviter les rebuild Docker complets inutiles (~15 min par itération) ; **ne pas pousser un déploiement quand des features clés sont inachevées** ; ne pas insister sur le déploiement tant que le produit n'est pas fini ou meilleur. +- Authentification : priorité à l'inscription/connexion via **Google OAuth** (plutôt qu'un compte email/mot de passe) ; exiger une vraie déconnexion (invalidation session/cookies — pas de reconnexion implicite, y compris en navigation privée). +- Priorité absolue à la qualité UX, même si l'implémentation est complexe (« je m'en fous si c'est complexe ») ; **ne jamais affirmer qu'un correctif ou une feature est fait sans vérification réelle** (app, prototype `architectural-grid`, ou test) — l'utilisateur sanctionne fermement les fausses déclarations. +- Livraison : **une user story à la fois**, tester et valider avec l'utilisateur avant la suivante ; suivi dans `docs/user-stories.md`. +- Quand demandé, **fournir des briefs pour un outil de design externe** plutôt que produire les maquettes UX soi-même. ## Learned Workspace Facts - Application Next.js principalement sous `memento-note/`. - Référentiels design du workspace : `architectural-grid1/` et `architectural-grid/` à la racine du repo Momento. - i18n : 15 fichiers sous `memento-note/locales/` (de, en, es, fr, it, pt, nl, pl, ru, zh, ja, ko, ar, fa, hi) ; logique sous `memento-note/lib/i18n/` ; référence `en.json` (~2218 clés) ; auditer les « non traduits » par flatten EN vs locale (souvent valeurs identiques à l'EN). -- Workflow BMad : stories sous `docs/` (ex. `3-4-host-pays-session-logic.md`), suivi sprint dans `docs/sprint-status.yaml` ; skills sous `.claude/skills/bmad-*` ; `_bmad-output/planning-artifacts` souvent vide — planification de référence dans `docs/`. +- Workflow BMad : stories sous `docs/` (ex. `3-4-host-pays-session-logic.md`), suivi sprint dans `docs/sprint-status.yaml` et stories courantes dans `docs/user-stories.md` ; skills sous `.claude/skills/bmad-*` ; `_bmad-output/planning-artifacts` souvent vide — planification de référence dans `docs/` ; préférer **une user story par feature** (pas de stories groupées). - PostgreSQL Docker (`memento-postgres`) sur le port 5433 ; Redis Docker (`memento-redis`) sur le port 6379 (voir règles projet). - Règles opérationnelles Prisma et sécurité base de données décrites dans `CLAUDE.md` à la racine du repo. -- Production : dépôt `/opt/memento` sur `192.168.1.190`, conteneur `memento-note` sur le port **3000**, URL publique **https://note.parsanet.org** (nginx + Cloudflare) ; ne pas recréer Postgres en prod (`deploy.yaml` / `deploy-prod.sh`). +- Production : dépôt `/opt/memento` sur `192.168.1.190`, conteneur `memento-note` sur le port **3000**, URL publique **https://memento-note.com** (nginx + Cloudflare ; ancien domaine note.parsanet.org) ; `NEXTAUTH_URL` aligné sur ce domaine ; email sortant via **Resend** (`SMTP_FROM` ex. `noreply@memento-note.com`, domaine vérifié sur resend.com) ; deploy (`deploy.yaml` / `deploy-prod.sh`) **sans toucher Postgres** (pas de `postgresql-client`, pas de migrations auto en prod). - CI/CD Gitea : `.gitea/workflows/ci.yaml` — CI sur `ubuntu-24.04`, deploy sur runner **`docker-host`** (sur le serveur) ; deploy manuel via `.gitea/workflows/deploy.yaml` ou `bash scripts/deploy-prod.sh`. - Migrations dans l'image prebuilt : `docker compose exec memento-note node ./node_modules/prisma/build/index.js migrate deploy` (pas `npx prisma` dans le PATH) ; helper `scripts/migrate-docker.sh`. - Vérification deploy : `GET /api/build-info` (SHA Git) ; comparer `127.0.0.1:3000` et le domaine Cloudflare — purger le cache si versions divergent ; 403 sur `/api/manifest` côté domaine = souvent Cloudflare, pas l'app. -- Guide utilisateur illustré : `docs/guide-utilisateur/README.md`, captures dans `docs/guide-utilisateur/screenshots/` ; régénération via `docs/guide-utilisateur/capture-screenshots.mjs` lancé depuis `memento-note/` (Playwright) ; URL lue depuis `NEXTAUTH_URL` ou `MOMENTO_DOC_BASE_URL`. +- Sync mutations notes entre composants : `memento-note/lib/note-change-sync.ts` (`emitNoteChange`, événement `NOTE_CHANGE_EVENT`). +- Roadmap / écart prototype vs prod : Web Clipper, Living Blocks (TipTap UniqueID), Structured Views, Flashcards IA (SM-2), Graph Knowledge Map — prototypes dans `architectural-grid/` (`ClipperSimulator.tsx`, `RevisionView.tsx`, `GraphKnowledgeMap.tsx`) ; en prod : `network-graph.tsx`, `note-document-info-panel.tsx`, `note-history-modal.tsx`, `rich-text-editor.tsx` (sans UniqueID pour l'instant). diff --git a/_bmad-output/implementation-artifacts/deferred-work.md b/_bmad-output/implementation-artifacts/deferred-work.md index 4639980..a80e60c 100644 --- a/_bmad-output/implementation-artifacts/deferred-work.md +++ b/_bmad-output/implementation-artifacts/deferred-work.md @@ -15,3 +15,12 @@ ## Deferred from: chart suggestions feature (2026-05-23) - **Build error in note-graph-view.tsx** — Variable `plainText` définie plusieurs fois (ligne 238). Fichier préexistant modifié hors de cette tâche. À corriger indépendamment. + +## Deferred from: code review of 4-4-explicit-ai-consent (2026-05-23) + +- **PUT `/api/ai/batch-organize` sans check consent** — Handler PUT applique le plan d’organisation en DB sans appeler d’API IA tierce ; hardening consent possible mais hors périmètre GDPR direct. + +## Deferred from: code review of 4-3-data-portability (2026-05-23) + +- **`lib/export/zip-builder.ts` non extrait** — logique inline dans la route ; fonctionnel mais écarte la structure prévue par la story. +- **Rate limiting absent sur `GET /api/user/export`** — vecteur d’abus (exports répétés) ; hardening ultérieur. diff --git a/architectural-grid10/.env.example b/architectural-grid/.env.example similarity index 100% rename from architectural-grid10/.env.example rename to architectural-grid/.env.example diff --git a/architectural-grid10/.gitignore b/architectural-grid/.gitignore similarity index 100% rename from architectural-grid10/.gitignore rename to architectural-grid/.gitignore diff --git a/architectural-grid_landing/BRAINSTORM_PROMPT.md b/architectural-grid/BRAINSTORM_PROMPT.md similarity index 100% rename from architectural-grid_landing/BRAINSTORM_PROMPT.md rename to architectural-grid/BRAINSTORM_PROMPT.md diff --git a/architectural-grid10/README.md b/architectural-grid/README.md similarity index 75% rename from architectural-grid10/README.md rename to architectural-grid/README.md index 0078184..1e3365d 100644 --- a/architectural-grid10/README.md +++ b/architectural-grid/README.md @@ -1,5 +1,5 @@
-GHBanner +GHBanner
# Run and deploy your AI Studio app diff --git a/architectural-grid10/index.html b/architectural-grid/index.html similarity index 100% rename from architectural-grid10/index.html rename to architectural-grid/index.html diff --git a/architectural-grid11/metadata.json b/architectural-grid/metadata.json similarity index 100% rename from architectural-grid11/metadata.json rename to architectural-grid/metadata.json diff --git a/architectural-grid12/package-lock.json b/architectural-grid/package-lock.json similarity index 100% rename from architectural-grid12/package-lock.json rename to architectural-grid/package-lock.json diff --git a/architectural-grid12/package.json b/architectural-grid/package.json similarity index 100% rename from architectural-grid12/package.json rename to architectural-grid/package.json diff --git a/architectural-grid14/server.ts b/architectural-grid/server.ts similarity index 98% rename from architectural-grid14/server.ts rename to architectural-grid/server.ts index 6df121f..d1b46e5 100644 --- a/architectural-grid14/server.ts +++ b/architectural-grid/server.ts @@ -71,7 +71,7 @@ async function startServer() { console.log(`User ${user?.name || 'Guest'} joined session: ${sessionId}`); } - if (data.type === 'idea_added' || data.type === 'idea_updated' || data.type === 'activity') { + if (data.type === 'idea_added' || data.type === 'idea_updated' || data.type === 'activity' || data.type === 'living_block_update') { if (currentRoom && rooms.has(currentRoom)) { rooms.get(currentRoom)!.forEach(client => { if (client !== ws && client.readyState === WebSocket.OPEN) { diff --git a/architectural-grid_landing/src/App.tsx b/architectural-grid/src/App.tsx similarity index 62% rename from architectural-grid_landing/src/App.tsx rename to architectural-grid/src/App.tsx index 393422c..ef2aa23 100644 --- a/architectural-grid_landing/src/App.tsx +++ b/architectural-grid/src/App.tsx @@ -5,6 +5,7 @@ import React, { useState, useMemo } from 'react'; import { motion, AnimatePresence } from 'motion/react'; +import { X } from 'lucide-react'; // Components import { Sidebar } from './components/Sidebar'; @@ -19,10 +20,15 @@ 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 & Types +// Data & Services import { CARNETS, ALL_NOTES } from './constants'; -import { NavigationView, SettingsTab, AITab, AITone, Carnet, Note, BrainstormIdea, NoteAccessLog } from './types'; +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(() => { @@ -79,6 +85,56 @@ export default function App() { return localStorage.getItem('momento-accent-color') || '#A47148'; }); + // Flashcards state with beautiful architectural starter deck + const [flashcards, setFlashcards] = useState(() => { + 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(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); @@ -93,6 +149,55 @@ export default function App() { })); }; + 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); @@ -114,9 +219,31 @@ export default function App() { 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); - return () => window.removeEventListener('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([]); const [isAISidebarOpen, setIsAISidebarOpen] = useState(false); const [isSidebarOpen, setIsSidebarOpen] = useState(false); @@ -148,6 +275,24 @@ export default function App() { } }; + 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)); }; @@ -298,7 +443,141 @@ export default function App() { }; const handleUpdateNote = (updatedNote: Note) => { - setNotes(notes.map(n => n.id === updatedNote.id ? updatedNote : n)); + 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(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) { @@ -377,6 +656,11 @@ export default function App() { 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) { @@ -436,6 +720,17 @@ export default function App() { 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} /> )} @@ -537,6 +832,7 @@ export default function App() { setActiveView('notebooks'); setActiveNoteId(noteId); }} + onOpenSidebar={() => setIsSidebarOpen(true)} /> )} @@ -560,6 +856,62 @@ export default function App() { /> )} + + {activeView === 'graph' && ( + + { + setActiveView('notebooks'); + setActiveNoteId(noteId); + const note = notes.find(n => n.id === noteId); + if (note) { + setActiveCarnetId(note.carnetId); + } + }} + onClose={() => setActiveView('notebooks')} + /> + + )} + + {activeView === 'revision' && ( + + { + 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)} + /> + + )} @@ -701,6 +1056,107 @@ export default function App() { )} + + {isSearchOpen && ( + setIsSearchOpen(false)} + notes={notes} + carnets={carnets} + onSelectNote={(noteId) => { + setActiveNoteId(noteId); + const searchHitNote = notes.find(n => n.id === noteId); + if (searchHitNote) { + setActiveCarnetId(searchHitNote.carnetId); + } + }} + /> + )} + + {isClipperSimulatorOpen && ( + setIsClipperSimulatorOpen(false)} + carnets={carnets} + activeCarnetId={activeCarnetId} + onAddNote={(newNote) => setNotes(prevNotes => [newNote, ...prevNotes])} + onTriggerToast={(title, noteId) => { + setClipperToast({ + id: String(Date.now()), + title, + noteId + }); + }} + /> + )} + + {clipperToast && ( + +
+ +

+ Note clippée — {clipperToast.title} +

+
+ +
+ + +
+
+ )} + + {toast.show && ( + +
+ +

+ {toast.message} +

+
+ +
+ + +
+
+ )} )} diff --git a/architectural-grid_landing/src/components/AISidebar.tsx b/architectural-grid/src/components/AISidebar.tsx similarity index 55% rename from architectural-grid_landing/src/components/AISidebar.tsx rename to architectural-grid/src/components/AISidebar.tsx index db5e1cd..c750035 100644 --- a/architectural-grid_landing/src/components/AISidebar.tsx +++ b/architectural-grid/src/components/AISidebar.tsx @@ -15,7 +15,8 @@ import { History, Target, Network, - Clock + Clock, + AlertCircle } from 'lucide-react'; import { motion, AnimatePresence } from 'motion/react'; import { AITab, AITone, Note, Carnet } from '../types'; @@ -30,6 +31,9 @@ interface AISidebarProps { selectedTone: AITone; setSelectedTone: (tone: AITone) => void; carnets: Carnet[]; + notes?: Note[]; + onOpenNote?: (noteId: string) => void; + onUpdateNote?: (note: Note) => void; } export const AISidebar: React.FC = ({ @@ -40,9 +44,126 @@ export const AISidebar: React.FC = ({ setAiTab, selectedTone, setSelectedTone, - carnets + carnets, + notes = [], + onOpenNote = (_noteId: string) => {}, + onUpdateNote }) => { const [selectedContextId, setSelectedContextId] = React.useState(null); + const [hoveredOrbitNode, setHoveredOrbitNode] = React.useState(null); + + const explicitWikiLinks = React.useMemo(() => [ + { source: 'n1', target: 'n1-b' }, + { source: 'n3', target: 'n3-b' }, + { source: 'bridge-1', target: 'n1' }, + { source: 'bridge-1', target: 'n2' }, + ], []); + + const CARNET_COLOR_PALETTE: { [key: string]: string } = { + '1': '#D97706', // Daily Notes - Warm Amber + '2': '#059669', // Project: Neo - Soft Emerald + '3': '#4F46E5', // Shared Docs - Rich Indigo + '4': '#0891B2', // Architecture Research - Clean Cyan + '5': '#EA580C', // History of Architecture - Deep Orange + '6': '#DB2777', // Modernism - Vibrant Rose + '7': '#65A30D', // Sustainable Design - Cool Lime + }; + + const DEFAULT_CARNET_COLOR = '#71717A'; + + const backlinks = React.useMemo(() => { + if (!activeNote || !notes) return []; + return notes.filter(n => { + if (n.id === activeNote.id || n.isDeleted) return false; + const isExplicit = explicitWikiLinks.some(link => + (link.source === n.id && link.target === activeNote.id) + ); + const isContentLink = n.content.toLowerCase().includes(`[[${activeNote.title.toLowerCase()}]]`); + return isExplicit || isContentLink; + }); + }, [activeNote, notes, explicitWikiLinks]); + + const outboundLinks = React.useMemo(() => { + if (!activeNote || !notes) return []; + return notes.filter(n => { + if (n.id === activeNote.id || n.isDeleted) return false; + const isExplicit = explicitWikiLinks.some(link => + (link.source === activeNote.id && link.target === n.id) + ); + const isContentLink = activeNote.content.toLowerCase().includes(`[[${n.title.toLowerCase()}]]`); + return isExplicit || isContentLink; + }); + }, [activeNote, notes, explicitWikiLinks]); + + const unlinkedMentions = React.useMemo(() => { + if (!activeNote || !notes) return []; + return notes.filter(n => { + if (n.id === activeNote.id || n.isDeleted) return false; + const isLinked = [...backlinks, ...outboundLinks].some(link => link.id === n.id); + if (isLinked) return false; + return n.content.toLowerCase().includes(activeNote.title.toLowerCase()); + }); + }, [activeNote, notes, backlinks, outboundLinks]); + + const orbitNodes = React.useMemo(() => { + const list: { id: string; title: string; color: string; carnetName: string; relationship: 'backlink' | 'outbound' | 'mention' }[] = []; + + backlinks.forEach(n => { + const carnet = carnets.find(c => c.id === n.carnetId); + list.push({ + id: n.id, + title: n.title, + color: CARNET_COLOR_PALETTE[n.carnetId] || DEFAULT_CARNET_COLOR, + carnetName: carnet?.name || 'Carnet', + relationship: 'backlink' + }); + }); + + outboundLinks.forEach(n => { + const carnet = carnets.find(c => c.id === n.carnetId); + list.push({ + id: n.id, + title: n.title, + color: CARNET_COLOR_PALETTE[n.carnetId] || DEFAULT_CARNET_COLOR, + carnetName: carnet?.name || 'Carnet', + relationship: 'outbound' + }); + }); + + unlinkedMentions.forEach(n => { + const carnet = carnets.find(c => c.id === n.carnetId); + list.push({ + id: n.id, + title: n.title, + color: CARNET_COLOR_PALETTE[n.carnetId] || DEFAULT_CARNET_COLOR, + carnetName: carnet?.name || 'Carnet', + relationship: 'mention' + }); + }); + + return list.slice(0, 8); + }, [backlinks, outboundLinks, unlinkedMentions, carnets]); + + const getSnippetWithHighlight = (content: string, term: string) => { + const index = content.toLowerCase().indexOf(term.toLowerCase()); + if (index === -1) { + return {content.substring(0, 80)}...; + } + const start = Math.max(0, index - 40); + const end = Math.min(content.length, index + term.length + 40); + const before = content.substring(start, index); + const match = content.substring(index, index + term.length); + const after = content.substring(index + term.length, end); + return ( + + {start > 0 && "..."} + {before} + {match} + {after} + {end < content.length && "..."} + + ); + }; return ( @@ -73,14 +194,14 @@ export const AISidebar: React.FC = ({
- {(['discussion', 'actions', 'explore', 'resources'] as AITab[]).map((tab) => ( + {(['discussion', 'actions', 'explore', 'resources', 'relations'] as AITab[]).map((tab) => ( +
+ + {/* Tab Selection */} +
+ + +
+ + {/* Search Input Box */} + {activeTab === 'search' && ( +
+
+ + setSearchQuery(e.target.value)} + placeholder="Rechercher un extrait de note..." + className="w-full bg-white dark:bg-zinc-850 border border-[#D5D2CD] dark:border-neutral-800 rounded-xl pl-9 pr-4 py-2 text-xs outline-none focus:border-blue-500 focus:ring-1 focus:ring-blue-500/20 transition-all font-sans" + autoFocus + /> +
+
+ )} + + {/* Main List */} +
+ {activeTab === 'suggestions' ? ( + blockSuggestions.length > 0 ? ( + blockSuggestions.map(block => ( + + )) + ) : ( +
+ Aucune note complémentaire disponible pour suggérer un bloc. +
+ ) + ) : ( + searchResults.length > 0 ? ( + searchResults.map(block => ( + + )) + ) : ( +
+ Aucun bloc ne correspond à votre recherche. +
+ ) + )} +
+ + + ); +}; diff --git a/architectural-grid_landing/src/components/BrainstormView/BrainstormView.tsx b/architectural-grid/src/components/BrainstormView/BrainstormView.tsx similarity index 100% rename from architectural-grid_landing/src/components/BrainstormView/BrainstormView.tsx rename to architectural-grid/src/components/BrainstormView/BrainstormView.tsx diff --git a/architectural-grid12/src/components/BrainstormView/WaveCanvas.tsx b/architectural-grid/src/components/BrainstormView/WaveCanvas.tsx similarity index 100% rename from architectural-grid12/src/components/BrainstormView/WaveCanvas.tsx rename to architectural-grid/src/components/BrainstormView/WaveCanvas.tsx diff --git a/architectural-grid/src/components/ClipperSimulator.tsx b/architectural-grid/src/components/ClipperSimulator.tsx new file mode 100644 index 0000000..4fa68c5 --- /dev/null +++ b/architectural-grid/src/components/ClipperSimulator.tsx @@ -0,0 +1,618 @@ +import React, { useState, useEffect, useRef } from 'react'; +import { + X, + Lock, + RefreshCw, + Chrome, + Check, + Loader2, + ChevronDown, + Sparkles, + ArrowUpRight, + AlertTriangle, + Globe, + Scissors, + Bookmark +} from 'lucide-react'; +import { motion, AnimatePresence } from 'motion/react'; +import { Carnet, Note, Tag } from '../types'; +import { v4 as uuidv4 } from 'uuid'; + +interface ClipperSimulatorProps { + isOpen: boolean; + onClose: () => void; + carnets: Carnet[]; + activeCarnetId: string; + onAddNote: (note: Note) => void; + onTriggerToast: (title: string, noteId: string) => void; +} + +interface MockArticle { + id: string; + title: string; + domain: string; + url: string; + favicon: string; + content: string[]; + suggestedTags: string[]; + aiGeneratedTitle: string; + aiSummary: string; +} + +const MOCK_ARTICLES: MockArticle[] = [ + { + id: 'art-1', + title: 'The Bauhaus Theory & Functional Spatial Systems', + domain: 'bauhausstudios.org', + url: 'https://bauhausstudios.org/theory/functionalism', + favicon: 'https://www.google.com/s2/favicons?domain=bauhausstudios.org&sz=64', + content: [ + 'Functionalist design operates on the direct correlation between physical geometry and spatial behavior. At the Bauhaus, teachers like Walter Gropius and Hannes Meyer postulated that an architectural object should serve its function strictly, discarding superfluous details that obscure the purity of its skeleton.', + 'The modern grid represents an honest commitment to industrial standardization. By segmenting living and working spaces into predictable, modular blocks, architects can optimize solar gain, human traffic flows, and construction material metrics.', + 'Light is the ultimate deconstructive asset within functional systems. When light pierces the rigid geometry of a modernist envelope, it shifts the perceived density of structural grids, transforming cold static steel interfaces into canvas-like elements that respond dynamically to local chronologies.' + ], + suggestedTags: ['Bauhaus', 'Functionalism', 'Spatial Design', 'German Modernism'], + aiGeneratedTitle: 'Bauhaus Functionalism & Rhythmic Grid Logic', + aiSummary: 'An exploration of how Walter Gropius and Bauhaus theorists utilized geometric grids and deconstructive light to align architectural materiality with industrial standardization and human behavioral workflows.' + }, + { + id: 'art-2', + title: 'Sustainable Wood Frameworks & Decarbonized Structures', + domain: 'ecotimber.com', + url: 'https://ecotimber.com/future/timber', + favicon: 'https://www.google.com/s2/favicons?domain=ecotimber.com&sz=64', + content: [ + 'Decarbonizing global real estate requires replacing portland cement and heavy structural steel with cross-laminated timber (CLT). CLT stands as a highly predictable engineered wood structure that sequesters atmospheric carbon dioxide directly inside the load-bearing framework of high-density buildings.', + 'Integrating CLT with parametric optimization allows for maximum material efficiency. Architects slice wood beams along precise stress lines generated by finite element analysis solvers, removing empty material volumes while keeping the building safe, functional, and durable.', + 'Passive solar energy design matches this structural honesty perfectly. By positioning CLT mass in the interior core, the building acts as a solar battery, absorbing raw passive light energy during peak hours and radiating warmth throughout the cold seasonal nights.' + ], + suggestedTags: ['Sustainabilty', 'Ecology', 'CLT Material', 'Decarbonization'], + aiGeneratedTitle: 'CLT Systems & Carbon-Neutral Frameworks', + aiSummary: 'A breakdown of high-density cross-laminated timber (CLT) integration, using parametric simulation to optimize stress distribution and passive thermal retention for modern sustainable spaces.' + } +]; + +export const ClipperSimulator: React.FC = ({ + isOpen, + onClose, + carnets, + activeCarnetId, + onAddNote, + onTriggerToast, +}) => { + const [activeArticleIdx, setActiveArticleIdx] = useState(0); + const activeArticle = MOCK_ARTICLES[activeArticleIdx]; + + // Clipper Extension Popup States + const [selectedCarnetId, setSelectedCarnetId] = useState(activeCarnetId || carnets[0]?.id || '1'); + const [selectedText, setSelectedText] = useState(''); + const [clipperState, setClipperState] = useState<'idle' | 'loading' | 'success' | 'error'>('idle'); + const [aiGeneratedTitle, setAiGeneratedTitle] = useState(''); + const [lastCreatedNoteId, setLastCreatedNoteId] = useState(''); + const [customError, setCustomError] = useState(''); + + // Dropdown UI + const [showCarnetDropdown, setShowCarnetDropdown] = useState(false); + + // Monitor text selections in the mock web page content + const handleTextSelection = () => { + const selection = window.getSelection(); + if (selection && selection.toString().trim().length > 0) { + const text = selection.toString().trim(); + // Ensure the text belongs to our mock article content + setSelectedText(text); + } + }; + + // Clear selections + const clearSelection = () => { + setSelectedText(''); + window.getSelection()?.removeAllRanges(); + }; + + // Preset highlights to make it easy to select text without highlighting with mouse + const handlePresetHighlight = (paragraph: string) => { + setSelectedText(paragraph); + }; + + // Handle the Clipper Action + const handleClip = (type: 'page' | 'selection') => { + setClipperState('loading'); + + // Simulate AI extraction and processing (summary, tags generation) + setTimeout(() => { + try { + // Occasional simulated error for retry demonstration + if (Math.random() < 0.15) { + throw new Error("Connexion réseau interrompue. L'extension n'a pas pu joindre les serveurs Momento."); + } + + const dateStr = new Date().toLocaleDateString('fr-FR', { + day: 'numeric', + month: 'short', + year: 'numeric' + }); + + const generatedTags: Tag[] = activeArticle.suggestedTags.map((tagLabel, idx) => ({ + id: `t-clip-${Date.now()}-${idx}`, + label: tagLabel, + type: 'ai' + })); + + const newNoteId = `n-clip-${Date.now()}`; + + let clipTitle = activeArticle.aiGeneratedTitle; + let clipContent = ''; + + if (type === 'selection' && selectedText) { + clipTitle = `Capture : ${activeArticle.title.substring(0, 30)}...`; + clipContent = `**[Sélection capturée]**\n\n> ${selectedText}\n\n---\n\n**Contexte initial :** ${activeArticle.aiSummary}\n\nURL Source : ${activeArticle.url}`; + } else { + clipContent = `**[Page web complète clippée]**\n\n**Résumé généré par l'IA :**\n${activeArticle.aiSummary}\n\n---\n\n**Contenu de l'article :**\n\n${activeArticle.content.join('\n\n')}\n\nURL Source : ${activeArticle.url}`; + } + + const newNote: Note = { + id: newNoteId, + carnetId: selectedCarnetId, + title: clipTitle, + content: clipContent, + imageUrl: activeArticleIdx === 0 + ? 'https://images.unsplash.com/photo-1503387762-592dea58ef23?auto=format&fit=crop&q=80&w=800&h=600' + : 'https://images.unsplash.com/photo-1486406146926-c627a92ad1ab?auto=format&fit=crop&q=80&w=800&h=600', + date: dateStr, + tags: [ + ...generatedTags, + { id: 't-web-source', label: 'Clipped', type: 'user' } + ], + // Custom clipper details + isClipped: true, + clipSourceUrl: activeArticle.url, + clipFavicon: activeArticle.favicon, + clipDate: dateStr + }; + + setAiGeneratedTitle(clipTitle); + setLastCreatedNoteId(newNoteId); + setClipperState('success'); + + // Add note to Momento Database + onAddNote(newNote); + + // Fire real-time notification toast in Momento! + onTriggerToast(clipTitle, newNoteId); + + } catch (err: any) { + setCustomError(err.message || "Erreur de connexion."); + setClipperState('error'); + } + }, 1500); + }; + + const handleResetClipper = () => { + setClipperState('idle'); + setCustomError(''); + }; + + if (!isOpen) return null; + + return ( +
+ + {/* Left column: Realistic Mock Browser Page */} +
+ {/* Mock Browser Header */} +
+ {/* Window Controls */} +
+ + ))} +
+ + {/* Live Indicator of Clipper Simulator */} +
+ + Simulateur de Capture +
+
+ + {/* Browser Address Bar */} +
+
+ + + +
+ +
+ + https:// + {activeArticle.domain} + {activeArticle.url.slice(activeArticle.url.indexOf(activeArticle.domain) + activeArticle.domain.length)} +
+ + {/* Web Extension active badge */} + +
+ + {/* Web Viewport */} +
+
+
+ + Publié sur {activeArticle.domain} +
+ +

+ {activeArticle.title} +

+ +
+ Date : Capture Temps Réel + Sélectionnez du texte ci-dessous pour le clipper +
+ + {/* Tips */} +
+

+ + Piste d'évaluation : +

+

+ Survolez et surlignez n'importe quel texte à la souris dans l'article ci-dessous pour activer instantanément l'état Sélection active dans l'extension ! Vous pouvez aussi cliquer sur un paragraphe pour le simuler : +

+
+ + {/* Main Content paragraphs */} +
+ {activeArticle.content.map((p, index) => { + const isParaSelected = selectedText === p; + return ( +

handlePresetHighlight(p)} + className={`cursor-pointer transition-all duration-300 p-2.5 rounded-lg border + ${isParaSelected + ? 'bg-accent/10 border-accent text-neutral-900 dark:text-white font-medium scale-[1.01] shadow-sm' + : 'border-transparent hover:bg-neutral-50 dark:hover:bg-neutral-900'}`} + title="Cliquer pour sélectionner ce paragraphe" + > + {p} +

+ ); + })} +
+ + {selectedText && ( +
+
+ + Sélection enregistrée ({selectedText.split(' ').length} mots) +
+ +
+ )} +
+
+
+ + {/* Right column: Simulated Browser Extension Popup Screen (Exactly 400x520px envelope styled elegantly) */} +
+ + + {/* Explicitly designed container mimicking browser overlay/extension dropdown at 400x520px target size */} +
+ {/* Extension Hub Header */} +
+
+ {/* Momento Logo with Clipper Branding */} +
+ M +
+
+ Momento + Web Clipper +
+
+ +
+ + Connecté +
+
+ + {/* Popup Dynamic Content Screen (Based on Clipper States) */} +
+ + {/* STATE: IDLE or SELECTED */} + {clipperState === 'idle' && ( + <> +
+ {/* Destination Selection with styling from the design guideline prompt */} +
+ + +
+ + + + {showCarnetDropdown && ( + + {carnets.map(c => ( + + ))} + + )} + +
+
+ + {/* Section of active webpage info */} +
+ Page active +
+ +
+

{activeArticle.title}

+

{activeArticle.url}

+
+
+
+ + {/* STATE: ACTIVE SELECTION PREVIEW (Triggered when user highlights text) */} + + {selectedText ? ( + +
+ + + Sélection détectée + + +
+

+ 「 {selectedText} 」 +

+
+ ) : ( +
+

+ Astuce : surlignez du texte à l'écran pour clipper une sélection précise de la page en tant que note. +

+
+ )} +
+
+ + {/* Buttons logic */} +
+ {selectedText && ( + + )} + + +
+ + )} + + {/* STATE: LOADING (Traitement AI, embedding & categorisation) */} + {clipperState === 'loading' && ( +
+
+
+ +
+
+

+ Analyse de la source +

+

+ Traitement en cours… +

+

+ Génération automatique des tags, résumé sémantique & calcul des embeddings en cours. +

+
+
+ )} + + {/* STATE: SUCCESS */} + {clipperState === 'success' && ( +
+
+
+ +
+ +
+ + Traitement Réussi + +

+ {aiGeneratedTitle} +

+

+ Note envoyée dans le carnet "{carnets.find(c => c.id === selectedCarnetId)?.name}". +

+
+ +
+ {activeArticle.suggestedTags.map((t, i) => ( + + + {t} + + ))} +
+
+ +
+ + + +
+
+ )} + + {/* STATE: ERROR */} + {clipperState === 'error' && ( +
+
+
+ +
+ +
+

+ Échec de la capture +

+

+ {customError || "Une erreur s'est produite lors de la transmission à votre instance."} +

+
+
+ +
+ +
+
+ )} + +
+ + {/* Simulated context details */} +
+ Momento Companion v2.1.2 • Sécurisé HTTPS TLS 1.3 +
+
+
+ +
+ ); +}; diff --git a/architectural-grid/src/components/GraphKnowledgeMap.tsx b/architectural-grid/src/components/GraphKnowledgeMap.tsx new file mode 100644 index 0000000..1fba097 --- /dev/null +++ b/architectural-grid/src/components/GraphKnowledgeMap.tsx @@ -0,0 +1,874 @@ +import React, { useEffect, useRef, useState, useMemo } from 'react'; +import * as d3 from 'd3'; +import { motion, AnimatePresence } from 'motion/react'; +import { Note, Carnet, Tag } from '../types'; +import { + Network, + Search, + Sliders, + HelpCircle, + X, + Filter, + Compass, + BookOpen, + Eye, + Sparkles, + RefreshCw, + Plus, + Minus, + Maximize2, + ChevronLeft, + Calendar, + Layers, + FileText +} from 'lucide-react'; + +interface GraphKnowledgeMapProps { + notes: Note[]; + carnets: Carnet[]; + onOpenNote: (noteId: string) => void; + onClose?: () => void; +} + +// 7 Gorgeous colors corresponding to the carnets palette +const CARNET_COLOR_PALETTE: { [key: string]: string } = { + '1': '#D97706', // Daily Notes - Warm Amber + '2': '#059669', // Project: Neo - Soft Emerald + '3': '#4F46E5', // Shared Docs - Rich Indigo + '4': '#0891B2', // Architecture Research - Clean Cyan + '5': '#EA580C', // History of Architecture - Deep Orange + '6': '#DB2777', // Modernism - Vibrant Rose + '7': '#65A30D', // Sustainable Design - Cool Lime +}; + +const DEFAULT_CARNET_COLOR = '#71717A'; // Zinc + +interface D3Node extends d3.SimulationNodeDatum { + id: string; + title: string; + carnetId: string; + carnetName: string; + color: string; + date: string; + snippet: string; + tags: Tag[]; + degree: number; +} + +interface D3Link extends d3.SimulationLinkDatum { + source: string | D3Node; + target: string | D3Node; + type: 'wikilink' | 'semantic'; + strength: number; +} + +export const GraphKnowledgeMap: React.FC = ({ + notes, + carnets, + onOpenNote, + onClose +}) => { + const containerRef = useRef(null); + const svgRef = useRef(null); + + // Settings & Toggles + const [showSemanticLinks, setShowSemanticLinks] = useState(true); + const [minSemanticStrength, setMinSemanticStrength] = useState(0.40); // threshold + const [selectedCarnetIds, setSelectedCarnetIds] = useState([]); + + // Interaction States + const [searchQuery, setSearchQuery] = useState(''); + const [hoveredNode, setHoveredNode] = useState(null); + const [activeLocalNode, setActiveLocalNode] = useState(null); + const [nodeConnections, setNodeConnections] = useState>(new Set()); + + // D3 Zoom controller ref to trigger programmatically + const d3ZoomRef = useRef | null>(null); + + // Initialize carnet filters with all carnets on mount + useEffect(() => { + setSelectedCarnetIds(carnets.map(c => c.id)); + }, [carnets]); + + // Static list of explicit links (Wikilinks) + const explicitWikiLinks = useMemo(() => { + return [ + { source: 'n1', target: 'n1-b' }, + { source: 'n3', target: 'n3-b' }, + { source: 'bridge-1', target: 'n1' }, + { source: 'bridge-1', target: 'n2' }, + ]; + }, []); + + // Filter and process notes and carnets + const filteredNotes = useMemo(() => { + return notes.filter(n => { + // Exclude trashed/deleted notes + if (n.isDeleted) return false; + // Filter by selected carnets + return selectedCarnetIds.includes(n.carnetId); + }); + }, [notes, selectedCarnetIds]); + + // Compute all links based on state (Wikilinks + Semantic if enabled) + const graphData = useMemo(() => { + const noteMap = new Map(); + filteredNotes.forEach(n => noteMap.set(n.id, n)); + + const nodes: D3Node[] = filteredNotes.map(n => { + const carnet = carnets.find(c => c.id === n.carnetId); + return { + id: n.id, + title: n.title, + carnetId: n.carnetId, + carnetName: carnet?.name || 'Carnet Inconnu', + color: CARNET_COLOR_PALETTE[n.carnetId] || DEFAULT_CARNET_COLOR, + date: n.date, + snippet: n.content.split('.').slice(0, 3).join('.') + '.', + tags: n.tags || [], + degree: 0, // calculated below + x: undefined, + y: undefined + }; + }); + + const links: D3Link[] = []; + + // 1. Add Explicit Wikilinks if both target and source are inside filtered list + explicitWikiLinks.forEach(link => { + if (noteMap.has(link.source) && noteMap.has(link.target)) { + links.push({ + source: link.source, + target: link.target, + type: 'wikilink', + strength: 1.0 + }); + } + }); + + // 2. Add Semantic Connections (Memory Echo) based on embedding similarities + if (showSemanticLinks) { + for (let i = 0; i < filteredNotes.length; i++) { + for (let j = i + 1; j < filteredNotes.length; j++) { + const ni = filteredNotes[i]; + const nj = filteredNotes[j]; + + if (ni.embedding && nj.embedding) { + // Cosine vector similarity approximation / Euclidean inverse mapping + const dist = Math.sqrt( + Math.pow(ni.embedding[0] - nj.embedding[0], 2) + + Math.pow(ni.embedding[1] - nj.embedding[1], 2) + ); + // Translate distance into similarity standard (0.0 - 1.0) + const similarity = Math.max(0, 1 - dist * 0.7); + + if (similarity >= minSemanticStrength) { + // Avoid duplicate links with explicit ones to keep display clean + const hasExplicit = explicitWikiLinks.some( + ex => (ex.source === ni.id && ex.target === nj.id) || (ex.source === nj.id && ex.target === ni.id) + ); + + if (!hasExplicit) { + links.push({ + source: ni.id, + target: nj.id, + type: 'semantic', + strength: similarity + }); + } + } + } + } + } + } + + // Calculate node connectivity degrees + nodes.forEach(node => { + const connectionsCount = links.filter(l => + l.source === node.id || l.target === node.id || + (typeof l.source === 'object' && (l.source as any).id === node.id) || + (typeof l.target === 'object' && (l.target as any).id === node.id) + ).length; + node.degree = connectionsCount; + }); + + return { nodes, links }; + }, [filteredNotes, carnets, showSemanticLinks, minSemanticStrength, selectedCarnetIds, explicitWikiLinks]); + + // Handle Note connection highlights during hover + useEffect(() => { + if (!hoveredNode) { + setNodeConnections(new Set()); + return; + } + + const connected = new Set(); + connected.add(hoveredNode.id); + + graphData.links.forEach((l: any) => { + const srcId = typeof l.source === 'object' ? l.source.id : l.source; + const tgtId = typeof l.target === 'object' ? l.target.id : l.target; + + if (srcId === hoveredNode.id) { + connected.add(tgtId); + } else if (tgtId === hoveredNode.id) { + connected.add(srcId); + } + }); + + setNodeConnections(connected); + }, [hoveredNode, graphData.links]); + + // Main D3 force layout rendering loop + 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(); + + // Base containment group + const mainGroup = svg.append("g"); + + // Configure zooming behaviors + const zoomBehavior = d3.zoom() + .scaleExtent([0.15, 5]) + .on("zoom", (event) => { + mainGroup.attr("transform", event.transform); + }); + + d3ZoomRef.current = zoomBehavior; + svg.call(zoomBehavior); + + // D3 nodes and links references mapped to copyable arrays + const simulationNodes = JSON.parse(JSON.stringify(graphData.nodes)) as D3Node[]; + const simulationLinks = graphData.links.map(l => ({ + source: l.source, + target: l.target, + type: l.type, + strength: l.strength + })) as D3Link[]; + + // Build the force simulation + const simulation = d3.forceSimulation(simulationNodes) + .force("link", d3.forceLink(simulationLinks) + .id(d => d.id) + .distance(d => d.type === 'wikilink' ? 100 : 140) + ) + .force("charge", d3.forceManyBody().strength(-240)) + .force("center", d3.forceCenter(width / 2, height / 2)) + .force("collision", d3.forceCollide().radius(d => { + // Size proportional to connections: min 8px, max 20px + const rad = 8 + Math.min(d.degree * 2.5, 12); + return rad + 24; + })); + + // Draw Links + const linkGroup = mainGroup.append("g") + .attr("class", "links-layer"); + + const link = linkGroup.selectAll("line") + .data(simulationLinks) + .enter() + .append("line") + .attr("stroke", d => d.type === 'semantic' ? '#4f46e5' : '#18181b') + .attr("stroke-opacity", d => d.type === 'semantic' ? 0.35 : 0.18) + .attr("stroke-width", d => d.type === 'semantic' ? 1.2 : 1.5) + .attr("stroke-dasharray", d => d.type === 'semantic' ? '4,4' : 'none'); + + // Draw Nodes + const nodeGroup = mainGroup.append("g") + .attr("class", "nodes-layer"); + + const node = nodeGroup.selectAll(".node") + .data(simulationNodes) + .enter() + .append("g") + .attr("class", "node cursor-pointer") + .on("click", (event, d) => { + event.stopPropagation(); + handleSelectNode(d); + }) + .on("mouseenter", (event, d) => { + setHoveredNode(d); + }) + .on("mouseleave", () => { + setHoveredNode(null); + }) + .call(d3.drag() + .on("start", dragStarted) + .on("drag", dragged) + .on("end", dragEnded) as any); + + // Create central circles + node.append("circle") + .attr("r", d => 6 + Math.min(d.degree * 1.5, 9)) + .attr("fill", d => d.color) + .attr("stroke", "rgba(255,255,255,0.95)") + .attr("stroke-width", 2) + .attr("class", "transition-all duration-300 dark:stroke-zinc-950") + .style("filter", "drop-shadow(0 2px 4px rgba(0,0,0,0.1))"); + + // Text labels overlay + node.append("text") + .attr("dy", d => 14 + Math.min(d.degree * 1.5, 9) + 4) + .attr("text-anchor", "middle") + .attr("class", "text-[10px] sm:text-[11px] font-sans font-semibold tracking-tight fill-zinc-850 dark:fill-zinc-300 select-none pointer-events-none") + .text(d => d.title.length > 22 ? d.title.substring(0, 20) + "..." : d.title) + .style("opacity", d => (d.degree > 2 || d.title.toLowerCase().includes(searchQuery.toLowerCase()) && searchQuery) ? 1 : 0.65); + + // Search query search highlight ring + if (searchQuery) { + node.filter(d => d.title.toLowerCase().includes(searchQuery.toLowerCase())) + .append("circle") + .attr("r", d => 14 + Math.min(d.degree * 1.5, 9)) + .attr("fill", "none") + .attr("stroke", "#06b6d4") + .attr("stroke-width", 2) + .attr("stroke-dasharray", "3,1") + .attr("class", "animate-[spin_20s_linear_infinite]"); + } + + // Node active local neighbor rings + if (activeLocalNode) { + const activeConns = getLocalNodeNeighbors(activeLocalNode.id); + + node.style("opacity", d => { + return activeConns.has(d.id) ? 1.0 : 0.15; + }); + + link.style("stroke-opacity", (l: any) => { + const srcId = l.source.id; + const tgtId = l.target.id; + return (activeConns.has(srcId) && activeConns.has(tgtId)) ? 0.75 : 0.05; + }); + + // Highlight the focused local hub node with a neat accent circle + node.filter(d => d.id === activeLocalNode.id) + .append("circle") + .attr("r", d => 16 + Math.min(d.degree * 1.5, 9)) + .attr("fill", "none") + .attr("stroke", "rgba(79, 70, 229, 0.4)") + .attr("stroke-width", 1.5) + .attr("stroke-opacity", 0.9); + } + // Node hover lighting state + else if (hoveredNode) { + const hoveredConns = new Set(); + hoveredConns.add(hoveredNode.id); + + graphData.links.forEach((l: any) => { + const srcId = typeof l.source === 'object' ? l.source.id : l.source; + const tgtId = typeof l.target === 'object' ? l.target.id : l.target; + + if (srcId === hoveredNode.id) { + hoveredConns.add(tgtId); + } else if (tgtId === hoveredNode.id) { + hoveredConns.add(srcId); + } + }); + + // Subdue unconnected elements to 20% opacity + node.style("opacity", d => hoveredConns.has(d.id) ? 1.0 : 0.20); + link.style("stroke-opacity", (l: any) => { + const srcId = l.source.id; + const tgtId = l.target.id; + return (srcId === hoveredNode.id || tgtId === hoveredNode.id) ? 0.8 : 0.05; + }); + + // Hover scale update for primary + node.filter(d => d.id === hoveredNode.id) + .select("circle") + .attr("transform", "scale(1.3)"); + } + // Normal / Base state + else { + node.style("opacity", 1.0); + link.style("stroke-opacity", d => d.type === 'semantic' ? 0.35 : 0.18); + } + + // Run ticks + 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})`); + }); + + // Zoom on local node view trigger + if (activeLocalNode && width && height) { + const targetNodeCopy = simulationNodes.find(n => n.id === activeLocalNode.id); + if (targetNodeCopy) { + // Step ticker synchronously to finalize force state layout + for (let i = 0; i < 40; ++i) simulation.tick(); + + const x = targetNodeCopy.x || width / 2; + const y = targetNodeCopy.y || height / 2; + + svg.transition() + .duration(850) + .ease(d3.easeCubicOut) + .call( + zoomBehavior.transform, + d3.zoomIdentity.translate(width / 2, height / 2).scale(1.65).translate(-x, -y) + ); + } + } else { + // Re-center whole graph + svg.transition() + .duration(800) + .ease(d3.easeCubicOut) + .call(zoomBehavior.transform, d3.zoomIdentity); + } + + function dragStarted(event: any, d: any) { + if (!event.active) simulation.alphaTarget(0.25).restart(); + d.fx = d.x; + d.fy = d.y; + } + + function dragged(event: any, d: any) { + d.fx = event.x; + d.fy = event.y; + } + + function dragEnded(event: any, d: any) { + if (!event.active) simulation.alphaTarget(0); + d.fx = null; + d.fy = null; + } + + return () => { + simulation.stop(); + }; + }, [graphData, showSemanticLinks, minSemanticStrength, searchQuery, activeLocalNode, hoveredNode]); + + // Compute local neighbors + const getLocalNodeNeighbors = (nodeId: string): Set => { + const list = new Set(); + list.add(nodeId); + graphData.links.forEach(l => { + if (l.source === nodeId) { + list.add(typeof l.target === 'object' ? (l.target as any).id : l.target); + } else if (l.target === nodeId) { + list.add(typeof l.source === 'object' ? (l.source as any).id : l.source); + } + }); + return list; + }; + + const handleSelectNode = (node: D3Node) => { + setActiveLocalNode(node); + }; + + const handleResetLocalView = () => { + setActiveLocalNode(null); + }; + + const handleZoom = (direction: 'in' | 'out' | 'fit') => { + if (!svgRef.current || !d3ZoomRef.current) return; + const svg = d3.select(svgRef.current); + + if (direction === 'fit') { + svg.transition().duration(500).call(d3ZoomRef.current.transform, d3.zoomIdentity); + } else { + const factor = direction === 'in' ? 1.3 : 1 / 1.3; + svg.transition().duration(400).call(d3ZoomRef.current.scaleBy, factor); + } + }; + + const toggleCarnetSelector = (carnetId: string) => { + setSelectedCarnetIds(prev => + prev.includes(carnetId) + ? prev.filter(id => id !== carnetId) + : [...prev, carnetId] + ); + }; + + const selectAllCarnets = () => { + setSelectedCarnetIds(carnets.map(c => c.id)); + }; + + const clearAllCarnets = () => { + setSelectedCarnetIds([]); + }; + + return ( +
+
+ {/* Dynamic Header Overlay */} +
+ {activeLocalNode ? ( + + ) : onClose ? ( + + ) : ( +
+ + Carte Sémantique +
+ )} + +
+ {graphData.nodes.length} Nœuds + | + {graphData.links.length} Relations +
+
+ + {/* Global Hub Search Bar */} +
+
+ setSearchQuery(e.target.value)} + placeholder="Chercher une note dans le graphe sémantique..." + className="w-full text-xs pl-9 pr-8 py-2.5 rounded-2xl border border-neutral-200 dark:border-neutral-800 bg-white/95 dark:bg-zinc-950/95 placeholder-concrete/60 shadow-lg outline-none focus:border-accent focus:ring-1 focus:ring-accent/10 transition-all text-ink dark:text-dark-ink font-medium" + /> + + + {searchQuery && ( + + )} +
+
+ + {/* Zoom controls (bottom right) */} +
+ + + + +
+ + +
+ + {/* Floating Controls Panel (top right) */} +
+
+
+ + + Paramètres du Graphe + + +
+ +
+ {/* Semantic Link Toggle Details */} +
+
+ + setShowSemanticLinks(e.target.checked)} + className="w-4 h-4 text-accent border-gray-300 rounded focus:ring-accent" + /> +
+

+ Visualiser la couche d'affinité IA générée par embeddings sémantiques (Memory Echo). +

+
+ + {/* Slider for semantic filtering threshold - Displayed only if activated */} + {showSemanticLinks && ( +
+
+ Force minimum sémantique + + {(minSemanticStrength * 100).toFixed(0)}% + +
+ +
+ 0.2 + setMinSemanticStrength(parseFloat(e.target.value))} + className="w-full h-1.5 bg-neutral-200 dark:bg-neutral-800 rounded-lg appearance-none cursor-pointer accent-indigo-600" + /> + 0.85 +
+
+ )} + + {/* Filter by Carnets with Checkboxes */} +
+
+ + + Filtrer par Carnet ({selectedCarnetIds.length}) + +
+ + + +
+
+ +
+ {carnets.map(c => { + const isChecked = selectedCarnetIds.includes(c.id); + const carnetColor = CARNET_COLOR_PALETTE[c.id] || DEFAULT_CARNET_COLOR; + return ( + + ); + })} +
+
+
+
+
+ + {/* Dynamic Tooltip Hover UI Card (In case of node hovering) */} + + {hoveredNode && !activeLocalNode && ( + +
+
+ + {hoveredNode.carnetName} + + + + Modifié le : {hoveredNode.date} + +
+ +

+ {hoveredNode.title} +

+
+ + {/* Micro Metrics stats */} +
+
+ Connexions +

{hoveredNode.degree}

+
+ +
+ Tags détectés +

{hoveredNode.tags.length || 0}

+
+
+ +
+ Cliquez pour isoler / modifier +
+
+ )} +
+ + {/* SVG Core Render canvas */} + +
+ + {/* State D: Note focus right panel slider (280px width) */} + + {activeLocalNode && ( + + {/* Panel header and close button */} +
+
+
+ + Aperçu de Note +
+ + +
+ + {/* Note details */} +
+
+ + {activeLocalNode.carnetName} +
+ +

+ {activeLocalNode.title} +

+ +

+ + Dernier update : {activeLocalNode.date} +

+
+
+ + {/* Snippet body content */} +
+
+ Résumé / Extrait +

+ "{activeLocalNode.snippet}" +

+
+ + {/* Relationship listing */} +
+ + Éléments connectés ({getLocalNodeNeighbors(activeLocalNode.id).size - 1}) + + +
+ {notes + .filter(n => n.id !== activeLocalNode.id && getLocalNodeNeighbors(activeLocalNode.id).has(n.id)) + .map(neighbor => { + return ( +
{ + const foundNode = graphData.nodes.find(v => v.id === neighbor.id); + if (foundNode) handleSelectNode(foundNode); + }} + className="flex items-center justify-between text-[10px] p-2 bg-neutral-50 dark:bg-neutral-900/60 rounded-xl hover:bg-neutral-100 cursor-pointer border border-transparent hover:border-border transition-colors group" + > + + + {neighbor.title} + + + Séléctionner + +
+ ); + })} +
+
+ + {/* Tags panel detail */} + {activeLocalNode.tags && activeLocalNode.tags.length > 0 && ( +
+ Index de tags +
+ {activeLocalNode.tags.map((t, idx) => ( + + {t.label} + + ))} +
+
+ )} +
+ + {/* CTA action bottom block */} +
+ +
+
+ )} +
+
+ ); +}; diff --git a/architectural-grid_landing/src/components/HierarchicalCarnetSelector.tsx b/architectural-grid/src/components/HierarchicalCarnetSelector.tsx similarity index 100% rename from architectural-grid_landing/src/components/HierarchicalCarnetSelector.tsx rename to architectural-grid/src/components/HierarchicalCarnetSelector.tsx diff --git a/architectural-grid/src/components/InsightsView.tsx b/architectural-grid/src/components/InsightsView.tsx new file mode 100644 index 0000000..fd76491 --- /dev/null +++ b/architectural-grid/src/components/InsightsView.tsx @@ -0,0 +1,482 @@ +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, + Menu, + FileText, + AlertCircle, + Clock, + ChevronRight, + TrendingUp, + Sliders, + CheckCircle2, + Lock +} from 'lucide-react'; +import { Note, NoteCluster, BridgeNote, ConnectionSuggestion } from '../types'; +import { runClustering, detectBridges, calculateCentroid, getMostCentralNoteTitles } 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; + onOpenSidebar?: () => void; +} + +export const InsightsView: React.FC = ({ + notes, + onUpdateNotes, + onNoteSelect, + onOpenSidebar +}) => { + const [isCalculating, setIsCalculating] = useState(false); + const [clusters, setClusters] = useState([]); + const [bridgeNotes, setBridgeNotes] = useState([]); + const [suggestions, setSuggestions] = useState([]); + const [selectedClusterId, setSelectedClusterId] = useState(null); + + // Mobile responsive view selector + const [viewMode, setViewMode] = useState<'graph' | 'dashboard'>('dashboard'); + + // Interactive automatic recalculation parameters simulator / status + const [lastSyncTime, setLastSyncTime] = useState(() => { + return new Date().toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' }); + }); + + // Track changes to notes since last calculation to show a conditions indicator + const [notesModifiedCount, setNotesModifiedCount] = useState(0); + + // Monitor edits to emulate the state "Recalcul quotidien planifié" or condition (>10 notes modified) + useEffect(() => { + // Whenever notes length or contents change, we simulate a tally + setNotesModifiedCount(prev => Math.min(prev + 1, 12)); + }, [notes.length]); + + const performAnalysis = async () => { + setIsCalculating(true); + try { + // 1. Run clustering (DBSCAN acting on density with outlier filtering, label -1 is outlier) + const { clusters: newClusters } = runClustering(notes); + + // 2. Name clusters (find the 5 notes closest to each cluster's centroid vector) + const namedClusters = await Promise.all(newClusters.map(async (c) => { + const centroid = calculateCentroid(c.noteIds, notes); + // Find the 5 most central notes (closest to the cluster centroid by cosine similarity) + const clusterNoteSummaries = getMostCentralNoteTitles(c.noteIds, centroid, notes, 5); + + const name = await nameCluster(clusterNoteSummaries); + + 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 (notes exhibiting similarity > 0.5 to >= 2 clusters) + const bridges = detectBridges(updatedNotes, namedClusters); + + // 5. Build suggestions for unconnected cluster pairs + // A pair is unconnected if there are no existing bridge notes linking them + const newSuggestions: ConnectionSuggestion[] = []; + if (namedClusters.length >= 2) { + const unconnectedPairs: { cA: NoteCluster; cB: NoteCluster }[] = []; + + for (let i = 0; i < namedClusters.length; i++) { + for (let j = i + 1; j < namedClusters.length; j++) { + const cA = namedClusters[i]; + const cB = namedClusters[j]; + + // Check if any bridge note connects these two clusters + const hasBridge = bridges.some(b => + b.connectedClusterIds.includes(cA.id) && b.connectedClusterIds.includes(cB.id) + ); + + if (!hasBridge) { + unconnectedPairs.push({ cA, cB }); + } + } + } + + // Generate bridge suggestions for the top 3 unconnected pairs + for (let k = 0; k < Math.min(unconnectedPairs.length, 3); k++) { + const { cA, cB } = unconnectedPairs[k]; + const cA_notes = updatedNotes.filter(n => cA.noteIds.includes(n.id)).map(n => n.title).slice(0, 3).join(', '); + const cB_notes = updatedNotes.filter(n => cB.noteIds.includes(n.id)).map(n => n.title).slice(0, 3).join(', '); + + const bridgeIdeas = await suggestBridgeIdeas(cA.name, cB.name, cA_notes, cB_notes); + bridgeIdeas.forEach((idea, idx) => { + newSuggestions.push({ + id: `suggestion-${cA.id}-${cB.id}-${idx}`, + ...idea, + clusterAId: cA.id, + clusterBId: cB.id + }); + }); + } + } + + setClusters(namedClusters); + setBridgeNotes(bridges); + setSuggestions(newSuggestions); + setLastSyncTime(new Date().toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' })); + setNotesModifiedCount(0); // Reset modified counter upon successful clustering recalculation + } 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 || 'Note de passage' }; + }); + }, [bridgeNotes, notes]); + + // Compute isolated clusters (ones with no bridge notes spanning to them) + const isolatedClusters = useMemo(() => { + const networkedClusterIds = new Set(bridgeNotes.flatMap(b => b.connectedClusterIds)); + return clusters.filter(c => !networkedClusterIds.has(c.id)); + }, [clusters, bridgeNotes]); + + // Find currently selected cluster info for the zoom drilldown list + const selectedCluster = useMemo(() => { + return clusters.find(c => c.id === selectedClusterId); + }, [clusters, selectedClusterId]); + + const selectedClusterNotes = useMemo(() => { + if (!selectedCluster) return []; + return notes.filter(n => selectedCluster.noteIds.includes(n.id)); + }, [notes, selectedCluster]); + + return ( +
+ {/* Header with Mobile Drawer Trigger & Responsiveness Tab controls */} +
+
+ {onOpenSidebar && ( + + )} +
+
+
+ +
+

Analyses & Cartographie

+
+

Modèles sémantiques & clusters de connaissances

+
+
+ +
+ {/* Mobile Tab Switcher */} +
+ + +
+ + +
+
+ +
+ {/* Left: Interactive Canvas Network Graph View */} +
+ +
+ + {/* Right: Insight Dashboard Column */} +
+
+ + {/* Active Cluster Inspection Drawer / Side Card */} + + {selectedCluster && ( + +
+
+
+ Focus Cluster Activé +

{selectedCluster.name}

+
+ +
+ +
+

Cet ensemble thématique réunit {selectedClusterNotes.length} notes complémentaires. Cliquez sur une note pour y accéder directement :

+
+ {selectedClusterNotes.map(note => ( + + ))} +
+
+ + )} + + + {/* Stats Highlights Header */} +
+
+
+ + Clusters Actifs +
+
+
{clusters.length}
+

Détectés sans à priori

+
+
+
+
+ + Notes-Ponts +
+
+
{bridgeNotes.length}
+

Passerelles d'idées

+
+
+
+ + {/* NEW SECTION: Auto Recalculator Control Dashboard Section */} +
+
+
+ +

Système de Recalcul

+
+ + Synchronisé + +
+ +
+
+ CRON PLANIFIÉ +

+ Quotidien (04:00) +

+
+
+ DERNIÈRE SYNCHRONISATION +

+ Aujourd'hui, {lastSyncTime} +

+
+
+ + {/* Recalcul Trigger Metrics */} +
+
+
+ Notes éditées depuis recul : + {notesModifiedCount} / 10 modifs +
+
+ +
+ Le recalcul incrémental se déclenche automatiquement si modification de {'>'} 10 notes ou variation d'embeddings {'>'} 5%. +
+
+
+ + {/* Isolated Clusters List */} +
+
+
+ +

Clusters Isolés ({isolatedClusters.length})

+
+ Sans points d'accroche +
+
+ {isolatedClusters.map(c => ( + setSelectedClusterId(c.id)} + className="p-3.5 rounded-xl bg-white dark:bg-zinc-800 border border-border/30 hover:border-black/10 flex items-center justify-between cursor-pointer" + > +
+
+ {c.name} +
+ + Non connecté + + + ))} + {isolatedClusters.length === 0 && ( +
+ Tous les clusters thématiques sont liés par au moins un point de passage sémantique ! +
+ )} +
+
+ + {/* Bridge Notes Section */} +
+
+ +

Notes-Ponts Influentes

+
+
+ {bridgeList.map(bridge => ( + onNoteSelect(bridge.noteId)} + className="p-4 rounded-xl bg-white dark:bg-zinc-800 border border-border/30 hover:border-ochre/40 hover:shadow-sm transition-all cursor-pointer group" + > +
+

{bridge.title}

+ + Lien : {(bridge.bridgeScore * 100).toFixed(0)}% + +
+
+ {bridge.connectedClusterIds.map(cid => { + const c = clusters.find(cl => cl.id === cid); + return ( +
{ + e.stopPropagation(); + setSelectedClusterId(cid); + }} + className="flex items-center gap-1.5 px-2 py-0.5 bg-black/[0.02] dark:bg-white/[0.02] border border-border/30 rounded-md hover:border-concrete/40 transition-colors" + > +
+ {c?.name} +
+ ); + })} +
+ + ))} + {bridgeList.length === 0 && !isCalculating && ( +
+ Aucune note-pont significative n'a été détectée. Créez des notes transversales pour forger de nouveaux liens créatifs. +
+ )} +
+
+ + {/* Connection Suggestions */} +
+
+ +

Opportunités de Connexion (Ponts Suggérés)

+
+
+ {suggestions.map((s) => ( +
+
+
+
A
+
B
+
+ + Relier {clusters.find(c => c.id === s.clusterAId)?.name} & {clusters.find(c => c.id === s.clusterBId)?.name} + +
+

{s.title}

+

{s.description}

+
+ + {s.reasoning} +
+
+ ))} + {isCalculating && ( +
+ {[1, 2].map(i => ( +
+ ))} +
+ )} + {!isCalculating && suggestions.length === 0 && ( +
+ Toutes vos thématiques clés sont déjà formidablement interconnectées ! +
+ )} +
+
+
+
+
+
+ ); +}; diff --git a/architectural-grid_landing/src/components/LandingPage.tsx b/architectural-grid/src/components/LandingPage.tsx similarity index 100% rename from architectural-grid_landing/src/components/LandingPage.tsx rename to architectural-grid/src/components/LandingPage.tsx diff --git a/architectural-grid/src/components/LivingBlock.tsx b/architectural-grid/src/components/LivingBlock.tsx new file mode 100644 index 0000000..51b0c07 --- /dev/null +++ b/architectural-grid/src/components/LivingBlock.tsx @@ -0,0 +1,194 @@ +import React, { useState, useEffect, useRef } from 'react'; +import { motion, AnimatePresence } from 'motion/react'; +import { Zap, HelpCircle, ArrowRight, RefreshCw, Unlink, AlertCircle } from 'lucide-react'; +import { Note } from '../types'; + +interface LivingBlockProps { + sourceNoteId: string; + blockIndex: number; + allNotes: Note[]; + hostNote: Note; + onUpdateNote: (updatedNote: Note) => void; + onOpenNote: (noteId: string) => void; + wsConnected: boolean; + broadcastLivingBlockUpdate?: (sourceNoteId: string, blockIndex: number, newText: string) => void; +} + +export const LivingBlock: React.FC = ({ + sourceNoteId, + blockIndex, + allNotes, + hostNote, + onUpdateNote, + onOpenNote, + wsConnected, + broadcastLivingBlockUpdate +}) => { + const [pulse, setPulse] = useState(false); + const pulseRef = useRef(null); + + // Locate source note and actual paragraph text + const sourceNote = allNotes.find(n => n.id === sourceNoteId); + const paragraphs = sourceNote?.content.split('\n') || []; + const rawText = paragraphs[blockIndex]; + + // Store a local cache in standard state to support the "Source Deleted Snapshot" or local typing lag minimization + const [localText, setLocalText] = useState(rawText || "Contenu de l'extrait sémantique."); + const [isDeleted, setIsDeleted] = useState(!sourceNote || rawText === undefined); + + // Sync state if source note or text updates from outside + useEffect(() => { + const isSourceMissing = !sourceNote || rawText === undefined; + setIsDeleted(isSourceMissing); + + if (!isSourceMissing && rawText !== localText) { + setLocalText(rawText); + } + }, [rawText, sourceNote]); + + // Handle pulse notification when custom update event is received from socket + useEffect(() => { + const handlePulseEvent = (e: any) => { + if (e.detail && e.detail.sourceNoteId === sourceNoteId && e.detail.blockIndex === blockIndex) { + setPulse(true); + if (pulseRef.current) clearTimeout(pulseRef.current); + pulseRef.current = setTimeout(() => { + setPulse(false); + }, 1000); + } + }; + + window.addEventListener('living-block-pulse', handlePulseEvent); + return () => { + window.removeEventListener('living-block-pulse', handlePulseEvent); + if (pulseRef.current) clearTimeout(pulseRef.current); + }; + }, [sourceNoteId, blockIndex]); + + // Edit body text and stream to central note and websockets + const handleBodyTextChange = (e: React.ChangeEvent) => { + const newText = e.target.value; + setLocalText(newText); + + if (sourceNote && !isDeleted) { + const updatedParagraphs = [...paragraphs]; + updatedParagraphs[blockIndex] = newText; + const updatedSourceNote = { + ...sourceNote, + content: updatedParagraphs.join('\n') + }; + // 1. Update state + onUpdateNote(updatedSourceNote); + + // 2. Broadcast via WS connection to other terminals + if (broadcastLivingBlockUpdate) { + broadcastLivingBlockUpdate(sourceNoteId, blockIndex, newText); + } + } + }; + + // Convert Living Block to normal local text paragraph + const handleConvertLocalText = () => { + const hostParagraphs = hostNote.content.split('\n'); + // Find matching shortcode index + const codeToSearch = `[[living-block:${sourceNoteId}:${blockIndex}]]`; + const targetIdx = hostParagraphs.findIndex(line => line.trim() === codeToSearch); + + if (targetIdx !== -1) { + hostParagraphs[targetIdx] = localText; // Replace code with snapped plain text + const updatedHostNote = { + ...hostNote, + content: hostParagraphs.join('\n') + }; + onUpdateNote(updatedHostNote); + } + }; + + // Styling helpers + const borderStyle = isDeleted + ? 'border-rose-500/60 dark:border-red-900/60 bg-rose-50/20 dark:bg-rose-950/5' + : !wsConnected + ? 'border-amber-500 dark:border-amber-700 bg-amber-50/10 dark:bg-amber-950/5' + : pulse + ? 'border-blue-500 shadow-md shadow-blue-500/15 bg-blue-50/20 dark:bg-blue-950/10' + : 'border-blue-500/80 bg-blue-50/5 dark:bg-blue-950/5'; + + return ( +
+
+ {/* Header (20px) */} +
+
+ {isDeleted ? ( + + ) : ( + + )} + + {isDeleted ? "Source déconnectée" : sourceNote?.title || "Note connectée"} + + + {/* Live syncing status badge */} + {isDeleted ? ( + + DÉCONNECTÉ + + ) : wsConnected ? ( + + LIVE + + ) : ( + + HORS-LIGNE + + )} +
+ +
+ {isDeleted ? ( + + ) : ( + <> + {!wsConnected && ( + + Synchro suspendue + + )} + + + )} +
+
+ + {/* Body content editable block */} +
+