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>
483 lines
28 KiB
TypeScript
483 lines
28 KiB
TypeScript
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>
|
|
);
|
|
};
|