feat(notes): liens internes, onglet Réseau, living blocks et consentement IA
Rend les liens entre notes visibles et persistants (sync NoteLink au save, auto-save, graphe réseau rafraîchi), ajoute living blocks, Memory Echo, recherche globale, consentement IA explicite et consolide les prototypes design en architectural-grid. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
482
architectural-grid/src/components/InsightsView.tsx
Normal file
482
architectural-grid/src/components/InsightsView.tsx
Normal file
@@ -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<InsightsViewProps> = ({
|
||||
notes,
|
||||
onUpdateNotes,
|
||||
onNoteSelect,
|
||||
onOpenSidebar
|
||||
}) => {
|
||||
const [isCalculating, setIsCalculating] = useState(false);
|
||||
const [clusters, setClusters] = useState<NoteCluster[]>([]);
|
||||
const [bridgeNotes, setBridgeNotes] = useState<BridgeNote[]>([]);
|
||||
const [suggestions, setSuggestions] = useState<ConnectionSuggestion[]>([]);
|
||||
const [selectedClusterId, setSelectedClusterId] = useState<string | null>(null);
|
||||
|
||||
// Mobile responsive view selector
|
||||
const [viewMode, setViewMode] = useState<'graph' | 'dashboard'>('dashboard');
|
||||
|
||||
// Interactive automatic recalculation parameters simulator / status
|
||||
const [lastSyncTime, setLastSyncTime] = useState<string>(() => {
|
||||
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 (
|
||||
<div className="h-full flex flex-col bg-[#F9F8F6] dark:bg-dark-paper overflow-hidden font-sans">
|
||||
{/* Header with Mobile Drawer Trigger & Responsiveness Tab controls */}
|
||||
<div className="p-6 sm:p-8 border-b border-border/20 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between sticky top-0 bg-[#F9F8F6]/80 dark:bg-dark-paper/80 backdrop-blur-md z-30">
|
||||
<div className="flex items-center gap-4">
|
||||
{onOpenSidebar && (
|
||||
<button
|
||||
onClick={onOpenSidebar}
|
||||
className="lg:hidden p-2 -ml-2 text-ink dark:text-dark-ink hover:bg-black/5 dark:hover:bg-white/5 rounded-lg transition-colors"
|
||||
>
|
||||
<Menu size={20} />
|
||||
</button>
|
||||
)}
|
||||
<div>
|
||||
<div className="flex items-center gap-3 mb-1">
|
||||
<div className="w-8 h-8 rounded-lg bg-ochre/10 flex items-center justify-center text-ochre">
|
||||
<Sparkles size={18} />
|
||||
</div>
|
||||
<h1 className="text-xl sm:text-2xl font-serif font-medium text-ink dark:text-dark-ink">Analyses & Cartographie</h1>
|
||||
</div>
|
||||
<p className="text-[10px] text-concrete tracking-[0.25em] uppercase font-bold">Modèles sémantiques & clusters de connaissances</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between sm:justify-end gap-3">
|
||||
{/* Mobile Tab Switcher */}
|
||||
<div className="flex lg:hidden p-1 bg-black/5 dark:bg-white/5 rounded-xl self-center shrink-0">
|
||||
<button
|
||||
onClick={() => setViewMode('graph')}
|
||||
className={`px-3 py-1.5 text-[10px] font-bold uppercase tracking-wider rounded-lg transition-all ${viewMode === 'graph' ? 'bg-white dark:bg-black text-ink shadow-sm' : 'text-concrete'}`}
|
||||
>
|
||||
Réseau Graphique
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setViewMode('dashboard')}
|
||||
className={`px-3 py-1.5 text-[10px] font-bold uppercase tracking-wider rounded-lg transition-all ${viewMode === 'dashboard' ? 'bg-white dark:bg-black text-ink shadow-sm' : 'text-concrete'}`}
|
||||
>
|
||||
Analyses & Ponts
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={performAnalysis}
|
||||
disabled={isCalculating}
|
||||
className="flex items-center gap-2 px-5 py-2.5 bg-ink text-paper dark:bg-white dark:text-black rounded-full text-xs font-bold uppercase tracking-widest hover:scale-102 active:scale-98 transition-all disabled:opacity-50 shadow-sm"
|
||||
>
|
||||
{isCalculating ? <RefreshCw size={13} className="animate-spin" /> : <RefreshCw size={13} />}
|
||||
{isCalculating ? 'Calcul...' : 'Re-analyser'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 flex overflow-hidden">
|
||||
{/* Left: Interactive Canvas Network Graph View */}
|
||||
<div className={`flex-[1.4] p-6 relative ${viewMode === 'graph' ? 'block' : 'hidden lg:block'}`}>
|
||||
<NetworkGraph
|
||||
notes={notes}
|
||||
clusters={clusters}
|
||||
bridgeNotes={bridgeNotes}
|
||||
onNoteSelect={onNoteSelect}
|
||||
selectedClusterId={selectedClusterId}
|
||||
onClusterSelect={setSelectedClusterId}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Right: Insight Dashboard Column */}
|
||||
<div className={`flex-1 border-l border-border/20 flex flex-col h-full bg-[#fcfbfa] dark:bg-zinc-900/10 backdrop-blur-sm overflow-hidden ${viewMode === 'dashboard' ? 'flex' : 'hidden lg:flex'}`}>
|
||||
<div className="p-6 sm:p-8 flex-1 overflow-y-auto custom-scrollbar space-y-10">
|
||||
|
||||
{/* Active Cluster Inspection Drawer / Side Card */}
|
||||
<AnimatePresence>
|
||||
{selectedCluster && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: -20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -20 }}
|
||||
className="p-6 rounded-2xl bg-white dark:bg-zinc-800 border-2 border-ochre/30 shadow-md relative overflow-hidden"
|
||||
>
|
||||
<div className="absolute top-0 left-0 w-2 h-full" style={{ backgroundColor: selectedCluster.color }} />
|
||||
<div className="flex items-center justify-between gap-4 mb-4">
|
||||
<div className="space-y-1 pl-2">
|
||||
<span className="text-[9px] font-bold uppercase tracking-widest text-ochre">Focus Cluster Activé</span>
|
||||
<h3 className="text-lg font-serif font-semibold text-ink dark:text-dark-ink">{selectedCluster.name}</h3>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setSelectedClusterId(null)}
|
||||
className="p-1 px-2.5 bg-black/5 dark:bg-white/5 hover:bg-black/10 text-xs font-bold rounded-lg uppercase tracking-wider transition-colors"
|
||||
>
|
||||
Fermer
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="pl-2 space-y-3">
|
||||
<p className="text-xs text-concrete">Cet ensemble thématique réunit {selectedClusterNotes.length} notes complémentaires. Cliquez sur une note pour y accéder directement :</p>
|
||||
<div className="space-y-2 max-h-[180px] overflow-y-auto custom-scrollbar pr-1">
|
||||
{selectedClusterNotes.map(note => (
|
||||
<button
|
||||
key={note.id}
|
||||
onClick={() => onNoteSelect(note.id)}
|
||||
className="w-full text-left p-2.5 rounded-lg bg-black/5 hover:bg-black/10 dark:bg-white/5 dark:hover:bg-white/10 text-xs font-medium text-ink dark:text-dark-ink flex items-center justify-between gap-3 group transition-all"
|
||||
>
|
||||
<span className="truncate group-hover:translate-x-1 transition-transform">{note.title || 'Note sans titre'}</span>
|
||||
<ChevronRight size={12} className="text-concrete" />
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
|
||||
{/* Stats Highlights Header */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="p-5 rounded-2xl bg-white dark:bg-zinc-800/40 border border-border/40 shadow-sm flex flex-col justify-between">
|
||||
<div className="flex items-center gap-2 text-indigo-500 mb-2">
|
||||
<Layers size={14} />
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest">Clusters Actifs</span>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xl sm:text-2xl font-serif font-semibold text-ink dark:text-dark-ink">{clusters.length}</div>
|
||||
<p className="text-[9px] text-concrete font-medium uppercase mt-1">Détectés sans à priori</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-5 rounded-2xl bg-white dark:bg-zinc-800/40 border border-border/40 shadow-sm flex flex-col justify-between">
|
||||
<div className="flex items-center gap-2 text-ochre mb-2">
|
||||
<Trophy size={14} />
|
||||
<span className="text-[10px] font-bold uppercase tracking-widest">Notes-Ponts</span>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-xl sm:text-2xl font-serif font-semibold text-ink dark:text-dark-ink">{bridgeNotes.length}</div>
|
||||
<p className="text-[9px] text-concrete font-medium uppercase mt-1">Passerelles d'idées</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* NEW SECTION: Auto Recalculator Control Dashboard Section */}
|
||||
<section className="p-5 rounded-2xl bg-white dark:bg-zinc-800 border border-border/40 shadow-sm space-y-4">
|
||||
<div className="flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Sliders size={15} className="text-ochre" />
|
||||
<h4 className="text-[11px] font-black uppercase tracking-[0.2em] text-ink dark:text-dark-ink">Système de Recalcul</h4>
|
||||
</div>
|
||||
<span className="flex items-center gap-1 text-[9.5px] font-bold text-emerald-500 uppercase">
|
||||
<CheckCircle2 size={11} /> Synchronisé
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4 pt-2">
|
||||
<div className="space-y-1">
|
||||
<span className="text-[9px] text-concrete block">CRON PLANIFIÉ</span>
|
||||
<p className="text-xs text-ink dark:text-dark-ink font-semibold flex items-center gap-1.5">
|
||||
<Clock size={12} className="opacity-50" /> Quotidien (04:00)
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<span className="text-[9px] text-concrete block">DERNIÈRE SYNCHRONISATION</span>
|
||||
<p className="text-xs text-ink dark:text-dark-ink font-bold font-mono">
|
||||
Aujourd'hui, {lastSyncTime}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Recalcul Trigger Metrics */}
|
||||
<div className="pt-2 border-t border-border/10 space-y-3">
|
||||
<div className="space-y-1">
|
||||
<div className="flex justify-between items-center text-[10px]">
|
||||
<span className="text-concrete">Notes éditées depuis recul :</span>
|
||||
<span className="font-bold font-mono text-ink dark:text-dark-ink">{notesModifiedCount} / 10 modifs</span>
|
||||
</div>
|
||||
<div className="h-1.5 w-full bg-black/5 dark:bg-white/5 rounded-full overflow-hidden">
|
||||
<motion.div
|
||||
className="h-full bg-ochre/70"
|
||||
initial={{ width: 0 }}
|
||||
animate={{ width: `${(notesModifiedCount / 10) * 100}%` }}
|
||||
transition={{ duration: 0.5 }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-[8px] text-concrete italic block">Le recalcul incrémental se déclenche automatiquement si modification de {'>'} 10 notes ou variation d'embeddings {'>'} 5%.</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Isolated Clusters List */}
|
||||
<section className="space-y-4">
|
||||
<div className="flex items-center justify-between gap-4 px-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertCircle size={15} className="text-rose-400 opacity-80" />
|
||||
<h3 className="text-xs font-bold uppercase tracking-[0.2em] text-ink dark:text-dark-ink">Clusters Isolés ({isolatedClusters.length})</h3>
|
||||
</div>
|
||||
<span className="text-[9px] text-concrete italic">Sans points d'accroche</span>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{isolatedClusters.map(c => (
|
||||
<motion.div
|
||||
key={c.id}
|
||||
whileHover={{ y: -1 }}
|
||||
onClick={() => 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"
|
||||
>
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="w-2 h-2 rounded-full" style={{ backgroundColor: c.color }} />
|
||||
<span className="text-xs font-medium text-ink dark:text-dark-ink">{c.name}</span>
|
||||
</div>
|
||||
<span className="text-[10px] text-rose-500 font-semibold uppercase tracking-wider bg-rose-500/5 px-2.5 py-0.5 rounded-full border border-rose-500/10">
|
||||
Non connecté
|
||||
</span>
|
||||
</motion.div>
|
||||
))}
|
||||
{isolatedClusters.length === 0 && (
|
||||
<div className="p-4 bg-white dark:bg-zinc-800 rounded-xl text-xs text-concrete text-center italic border border-border/20">
|
||||
Tous les clusters thématiques sont liés par au moins un point de passage sémantique !
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Bridge Notes Section */}
|
||||
<section className="space-y-4">
|
||||
<div className="flex items-center gap-2 px-1">
|
||||
<Zap size={16} className="text-ochre" />
|
||||
<h3 className="text-xs font-bold uppercase tracking-[0.2em] text-ink dark:text-dark-ink">Notes-Ponts Influentes</h3>
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{bridgeList.map(bridge => (
|
||||
<motion.div
|
||||
key={bridge.noteId}
|
||||
whileHover={{ x: 4 }}
|
||||
onClick={() => onNoteSelect(bridge.noteId)}
|
||||
className="p-4 rounded-xl bg-white dark:bg-zinc-800 border border-border/30 hover:border-ochre/40 hover:shadow-sm transition-all cursor-pointer group"
|
||||
>
|
||||
<div className="flex items-center justify-between mb-2 gap-4">
|
||||
<h4 className="text-xs font-semibold text-ink dark:text-dark-ink truncate flex-1 group-hover:text-ochre transition-colors">{bridge.title}</h4>
|
||||
<span className="text-[9.5px] font-bold text-ochre bg-ochre/5 border border-ochre/10 px-2.5 py-0.5 rounded-full">
|
||||
Lien : {(bridge.bridgeScore * 100).toFixed(0)}%
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 pt-1.5 border-t border-black/5 dark:border-white/5">
|
||||
{bridge.connectedClusterIds.map(cid => {
|
||||
const c = clusters.find(cl => cl.id === cid);
|
||||
return (
|
||||
<div
|
||||
key={cid}
|
||||
onClick={(e) => {
|
||||
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"
|
||||
>
|
||||
<div className="w-1.5 h-1.5 rounded-full" style={{ backgroundColor: c?.color }} />
|
||||
<span className="text-[9.5px] text-concrete font-medium uppercase tracking-wider">{c?.name}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</motion.div>
|
||||
))}
|
||||
{bridgeList.length === 0 && !isCalculating && (
|
||||
<div className="text-xs text-concrete italic text-center p-6 bg-white dark:bg-zinc-800 rounded-xl border border-border/20">
|
||||
Aucune note-pont significative n'a été détectée. Créez des notes transversales pour forger de nouveaux liens créatifs.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Connection Suggestions */}
|
||||
<section className="space-y-4">
|
||||
<div className="flex items-center gap-2 px-1">
|
||||
<Lightbulb size={16} className="text-indigo-500" />
|
||||
<h3 className="text-xs font-bold uppercase tracking-[0.2em] text-ink dark:text-dark-ink">Opportunités de Connexion (Ponts Suggérés)</h3>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
{suggestions.map((s) => (
|
||||
<div key={s.id} className="p-6 rounded-2xl bg-gradient-to-br from-indigo-500/5 via-transparent to-transparent border border-indigo-500/10 hover:border-indigo-500/20 transition-all shadow-sm">
|
||||
<div className="flex items-center gap-3 mb-4">
|
||||
<div className="flex -space-x-2 shrink-0">
|
||||
<div className="w-5 h-5 rounded-full border-2 border-paper bg-indigo-500 flex items-center justify-center text-[9px] text-white font-bold">A</div>
|
||||
<div className="w-5 h-5 rounded-full border-2 border-paper bg-ochre flex items-center justify-center text-[9px] text-white font-bold">B</div>
|
||||
</div>
|
||||
<span className="text-[9px] font-bold uppercase tracking-wider text-indigo-500/70 truncate">
|
||||
Relier {clusters.find(c => c.id === s.clusterAId)?.name} & {clusters.find(c => c.id === s.clusterBId)?.name}
|
||||
</span>
|
||||
</div>
|
||||
<h4 className="text-sm font-semibold text-ink dark:text-dark-ink mb-2">{s.title}</h4>
|
||||
<p className="text-xs text-muted-ink leading-relaxed mb-4">{s.description}</p>
|
||||
<div className="p-3.5 bg-white/60 dark:bg-zinc-800 rounded-xl border border-border/20 text-[10.5px] italic text-concrete flex gap-2">
|
||||
<Zap size={13} className="shrink-0 text-ochre mt-0.5" />
|
||||
<span>{s.reasoning}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{isCalculating && (
|
||||
<div className="animate-pulse space-y-4">
|
||||
{[1, 2].map(i => (
|
||||
<div key={i} className="h-32 bg-indigo-500/5 rounded-2xl border border-indigo-500/10" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{!isCalculating && suggestions.length === 0 && (
|
||||
<div className="text-xs text-concrete text-center italic p-6 border border-border/20 bg-white/40 dark:bg-zinc-800 rounded-xl">
|
||||
Toutes vos thématiques clés sont déjà formidablement interconnectées !
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user